From fa2a3c39775ce0d8c862c9076e8bd2ed5cea1a8d Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sat, 7 Nov 2026 15:53:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8E=82=E5=AE=B6=E8=AE=B0=E8=B4=A6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=8D=95=E5=8F=B7=E9=99=84=E5=8A=A0=E7=AC=A6?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E5=95=86=E5=93=81=E6=88=90=E6=9C=AC=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E7=BC=BA=E5=A4=B1=E8=BF=90=E8=B4=B9=20(#978)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/厂家记账金额规则.md | 7 +- ...SupplierBookkeepingValuationRepository.php | 12 +++- .../Services/SupplierBookkeepingValuation.php | 50 +++++++++---- .../View/SupplierBookkeepingPageData.php | 2 +- .../manufacturer-bookkeeping.blade.php | 2 +- .../Business/SupplierBookkeepingPageTest.php | 34 +++++++++ ...upplierBookkeepingShippingImporterTest.php | 18 +++++ ...lierBookkeepingValuationRepositoryTest.php | 11 ++- .../SupplierBookkeepingValuationTest.php | 72 +++++++++++++++++-- 9 files changed, 182 insertions(+), 26 deletions(-) diff --git a/docs/厂家记账金额规则.md b/docs/厂家记账金额规则.md index 604015e9..3234d053 100644 --- a/docs/厂家记账金额规则.md +++ b/docs/厂家记账金额规则.md @@ -7,8 +7,8 @@ 1. 对“补偿款项”“记账全款”(兼容“记账全额”),原金额大于0且小于50元,先判断原金额÷商品买家实付合计是否小于40%。命中后金额为0,不扣款,追加备注 `小于50且小于0.4,记账改0`。 2. 未命中的,再用工单 `actual_paid_amount`(页面“实付金额”)作分母,独立判断同样的严格边界。任一轮实付缺失、无效或为0时,只跳过该轮;另一轮仍可正常判断。多商品买家实付缺一项时,不把部分金额当作完整合计。 3. 保留厂家记账配置、代发仓发错货限制和多供应商待确认规则。只有已判定可扣款的剩余工单进入核价,不用核价推翻“不扣款”或“待确认”。 -4. 补偿、全款按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本为当前 `cost_price`,不代表历史月份成本。 -5. 运费损耗按实际运费记账,不受原金额限制;原金额为0也需要匹配。匹配结果确为0时不扣款,不要求商品成本和数量。 +4. 补偿、全款按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本按商品编码精确匹配商品资料的当前 `cost_price`,不代表历史月份成本。 +5. 运费损耗优先按正数实际运费记账,不受原金额限制;原金额为0也需要匹配。原单包裹运费为空或0时,按对应工单商品的当前成本单价×实际发货数量×0.2替代。多个包裹中只替代缺运费的包裹,其余仍取实际运费。替代需要有效商品成本与逐商品发货数量,缺失或数量含糊时待确认;已有正数实际运费不要求商品成本和数量。 金额等于50元或比例等于40%均不满足小额免记账。金额计算使用十进制运算,在最终金额处四舍五入到分。 @@ -17,11 +17,12 @@ - 运费取工作平台月度导出“运费”;5月“系统运费”经用户确认是相同口径。不使用“结算运费”,不借用覆盖不足的现有发货表或其他年份数据。 - 查找时间为工单发起月份及前五个月,右端不含下月1日。例如10月工单读取5月1日至11月1日前的发货记录。六个月未完整导入时待确认。 - 有物流单号时精确匹配该单号或换单前单号,同时核对工单提供的线上订单号;无物流单号时按线上订单号匹配全部相关包裹。合并订单按拆分后的完整单号建立索引。 +- 单号及商品编码匹配前清理前后空白、不可见复制符、单引号、引号、`@` 及其全角形式;保留原始展示值及单号前导0,保留编码内部的 `-`、`+` 等合法内容,不做模糊匹配。 - 不以平均运费替代原单运费,不借同订单其他快递单的运费;多个单号只匹配部分时待确认。 - “未发出”辅助列按用户确认:`#N/A` 表示未命中未发出名单,可正常匹配;返回单号的整个包裹排除,成本、数量和运费均不累计。按订单匹配时仅核算剩余已发出包裹;原单全部未发出或工单明确列出的单号被部分排除时待确认、不计扣款,保留原金额。其他辅助列错误待确认。 - 工单商品须全部匹配。一个单元格合并多个商品编码、仅有总数量时,不推测每个商品的数量,标为待确认。 - 包裹运费只取一次;同包裹同商品的重复行不重复累计数量。运费或数量冲突时待确认,不相加、不擅自选值。 -- 缺发货、有效数量、成本价或运费时待确认,保留原金额,不计扣款合计。明确0运费有效,空运费不能当0。 +- 缺发货、有效数量、成本价或运费时待确认,保留原金额,不计扣款合计;运费损耗的空/0运费按第5条替代,判定说明及备注明确记录“按对应商品成本合计×0.2替代运费”,成本与数量列展示替代部分的依据。补偿/全款仍将明确0运费视为有效,空运费待确认。 - 原记账金额、匹配成本/运费/数量/快递号/期间供页面核对与导出。 ## 月度发货导出镜像 diff --git a/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php index d73894a0..af4444af 100644 --- a/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php @@ -57,7 +57,7 @@ class SupplierBookkeepingValuationRepository $shipment['id'] = $record->generation . ':' . $record->source_row; $shipment['tracking_numbers'] = array_values(array_unique(array_merge(self::numbers($record->express_no), self::numbers($record->old_express_no)))); $shipment['order_numbers'] = self::numbers($record->trade_no); - $shipment['source_id'] = hash('sha256', $record->express_no . '|' . $record->package_no); + $shipment['source_id'] = hash('sha256', self::identifier($record->express_no) . '|' . $record->package_no); $found[$shipment['id']] = $shipment; if (count($found) > 20000) throw new RuntimeException('匹配发货记录过多,须缩小工单范围'); } @@ -121,11 +121,17 @@ class SupplierBookkeepingValuationRepository public static function numbers(mixed $value): array { - return array_values(array_unique(array_filter(array_map('trim', preg_split('/[\r\n,,、;;]+/u', (string) $value) ?: []), static fn ($v) => $v !== ''))); + return array_values(array_unique(array_filter(array_map(static fn ($v) => self::identifier($v), preg_split('/[\r\n,,、;;]+/u', (string) $value) ?: []), static fn ($v) => $v !== ''))); } public static function sku(mixed $value): string { - return str_replace('_X002B_', '+', mb_strtoupper(trim((string) $value))); + return str_replace('_X002B_', '+', mb_strtoupper(self::identifier($value))); + } + + /** 只移除单号/商品编码外围的复制附加符,保留内部符号及前导0。 */ + private static function identifier(mixed $value): string + { + return preg_replace('/^[\s\p{Cf}\'"‘’“”'"@@]+|[\s\p{Cf}\'"‘’“”'"@@]+$/u', '', (string) $value) ?? trim((string) $value); } } diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php index 4b99dd3b..5c362ee9 100644 --- a/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php @@ -27,12 +27,14 @@ class SupplierBookkeepingValuation $byTracking = []; $byOrder = []; foreach ($shipments as $shipment) { - foreach ($shipment['tracking_numbers'] ?? [(string) $shipment['express_no']] as $reference) $byTracking[$reference][] = $shipment; - foreach ($shipment['order_numbers'] ?? [(string) $shipment['trade_no']] as $reference) $byOrder[$reference][] = $shipment; + $shipment['tracking_numbers'] ??= array_values(array_unique(array_merge(Repository::numbers($shipment['express_no']), Repository::numbers($shipment['old_express_no'] ?? '')))); + $shipment['order_numbers'] ??= Repository::numbers($shipment['trade_no']); + foreach ($shipment['tracking_numbers'] as $reference) $byTracking[$reference][] = $shipment; + foreach ($shipment['order_numbers'] as $reference) $byOrder[$reference][] = $shipment; } $skus = []; foreach ($candidates as $row) { - if ($row['bookkeeping_type'] !== '运费损耗') array_push($skus, ...Repository::numbers($row['product_code'] ?? '')); + array_push($skus, ...Repository::numbers($row['product_code'] ?? '')); } try { $costs = $this->repository->costsFor(array_values(array_unique($skus))); @@ -116,11 +118,22 @@ class SupplierBookkeepingValuation $packages[$package]['amount'] = (string) $shipment['freight']; $packages[$package]['has_freight'] = true; } + } + $estimatedPackages = $freightOnly ? array_filter($packages, static fn ($package) => !$package['has_freight'] || bccomp($package['amount'], '0', 6) === 0) : []; + if ($estimatedPackages !== []) { + $shippedSkus = []; + foreach ($pool as $shipment) array_push($shippedSkus, ...array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode']))); + if (array_diff(array_keys($wanted), $shippedSkus) !== []) return $this->review($row, '原单发货明细未匹配全部工单商品,无法估算运费'); + } + $packageCosts = []; + foreach ($pool as $shipment) { + $package = (string) $shipment['source_id']; + if ($freightOnly && !isset($estimatedPackages[$package])) continue; + if ($wanted === []) return $this->review($row, '工单没有商品编码,无法匹配替代运费所需成本'); $sku = Repository::sku($shipment['barcode']); - if ($freightOnly) continue; $components = array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode'])); if (count($components) > 1 && array_intersect(array_keys($wanted), $components) !== []) return $this->review($row, '多商品包裹只有合计数量,缺少逐商品发货数量'); - if (!$freightOnly && !array_key_exists($sku, $wanted)) continue; + if (!array_key_exists($sku, $wanted)) continue; if (!self::decimal($shipment['qty']) || bccomp((string) $shipment['qty'], '0', 6) <= 0) { return $this->review($row, '匹配发货数量缺失或无效'); } @@ -131,25 +144,36 @@ class SupplierBookkeepingValuation } $lines[$lineKey] = (string) $shipment['qty']; $quantity = bcadd($quantity, (string) $shipment['qty'], 6); - if (!$freightOnly) { - $price = $costs[$sku] ?? null; - if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku); - $cost = bcadd($cost, bcmul((string) $price, (string) $shipment['qty'], 6), 6); - $wanted[$sku] = true; - } + $price = $costs[$sku] ?? null; + if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku); + $lineCost = bcmul((string) $price, (string) $shipment['qty'], 6); + $cost = bcadd($cost, $lineCost, 6); + $packageCosts[$package] = bcadd($packageCosts[$package] ?? '0', $lineCost, 6); + $wanted[$sku] = true; } if (!$freightOnly && in_array(false, $wanted, true)) return $this->review($row, '原单发货明细未匹配全部工单商品'); + foreach (array_keys($estimatedPackages) as $package) { + if (!isset($packageCosts[$package])) return $this->review($row, '原单发货商品未匹配替代运费所需成本'); + $packages[$package]['amount'] = bcmul($packageCosts[$package], '0.2', 6); + $packages[$package]['has_freight'] = true; + } $freight = '0'; foreach ($packages as $package) { if (!$package['has_freight']) return $this->review($row, '原单运费尚未补齐,不能按0记账'); $freight = bcadd($freight, $package['amount'], 6); } $row['matched_freight'] = self::money($freight); - $row['matched_quantity'] = $freightOnly ? '' : rtrim(rtrim($quantity, '0'), '.'); + $row['matched_quantity'] = $freightOnly && $estimatedPackages === [] ? '' : rtrim(rtrim($quantity, '0'), '.'); $row['matched_tracking_no'] = implode("\n", array_values(array_unique(array_column($pool, 'express_no')))); if ($freightOnly) { $row['amount'] = self::money($freight); - $basis = '运费损耗按原单实际运费记账'; + if ($estimatedPackages !== []) { + $row['matched_cost'] = self::money($cost); + $basis = '运费损耗:原单运费缺失或为0,按对应商品成本合计×0.2替代运费'; + if (count($estimatedPackages) < count($packages)) $basis .= ',另加其余包裹实际运费'; + } else { + $basis = '运费损耗按原单实际运费记账'; + } } else { $row['matched_cost'] = self::money($cost); $ceiling = self::money(bcadd($cost, $freight, 6)); diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php index 5f059f16..9c9bc791 100644 --- a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php +++ b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php @@ -29,7 +29,7 @@ class SupplierBookkeepingPageData 'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项', 'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额', 'original_amount' => '原记账金额', - 'matched_cost' => '匹配商品成本', 'matched_freight' => '匹配实际运费', 'matched_quantity' => '匹配发货数量', + 'matched_cost' => '匹配商品成本', 'matched_freight' => '核算运费', 'matched_quantity' => '匹配发货数量', 'matched_tracking_no' => '匹配快递单号', 'valuation_window' => '发货匹配期间', 'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明', 'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号', diff --git a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php index 620ef721..35c0063b 100644 --- a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php +++ b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php @@ -54,7 +54,7 @@
接入口径

与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。

厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。

小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。

-

剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本取普通商品资料成本单价×匹配商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值,“运费损耗”按实际运费记账。包裹运费只累计一次;发货、数量、成本或运费缺失时标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。

+

单号匹配前自动清理前后的单引号、@和空格。剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本按商品编码匹配普通商品资料成本单价,再乘对应商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值。“运费损耗”优先取实际运费,包裹运费为空或0时按对应商品成本合计×20%替代,并在判定说明中标注。包裹运费只累计一次;缺发货、成本或逐商品数量等无法核算的工单标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。

@if($bookkeepingError)@endif
diff --git a/tests/Feature/Business/SupplierBookkeepingPageTest.php b/tests/Feature/Business/SupplierBookkeepingPageTest.php index 8bf2822c..6310b9bf 100644 --- a/tests/Feature/Business/SupplierBookkeepingPageTest.php +++ b/tests/Feature/Business/SupplierBookkeepingPageTest.php @@ -212,4 +212,38 @@ class SupplierBookkeepingPageTest extends FeatureTestCase ]))->assertOk()->streamedContent(); $this->assertStringContainsString('GD-ROUND-TWO,0.00,30.00,', $csv); } + + public function test_estimated_freight_matches_page_totals_filters_and_export_with_cost_basis(): void + { + $this->mockRepository([ + $this->row(['work_order_no' => 'GD-ESTIMATE', 'bookkeeping_type' => '运费损耗', 'amount' => '100.00', 'product_code' => 'SKU-A', 'tracking_no' => 'EXP-ZERO']), + $this->row(['work_order_no' => 'GD-ACTUAL', 'bookkeeping_type' => '运费损耗', 'amount' => '100.00', 'product_code' => 'SKU-A', 'tracking_no' => 'EXP-ACTUAL']), + $this->row(['work_order_no' => 'GD-NO-COST', 'bookkeeping_type' => '运费损耗', 'amount' => '100.00', 'product_code' => 'SKU-MISSING', 'tracking_no' => 'EXP-MISSING']), + $this->row(['work_order_no' => 'GD-SKIP', 'bookkeeping_type' => '运费损耗', 'amount' => '100.00', 'supplier' => '尊享', 'product_code' => 'SKU-A', 'tracking_no' => 'EXP-ZERO']), + ], 'ready', true); + $repository = \Mockery::mock(\Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository::class); + $repository->shouldReceive('readyMonths')->andReturn(['2026-04', '2026-05', '2026-06', '2026-07', '2026-08', '2026-09']); + $shipments = []; + foreach (['EXP-ZERO' => ['SKU-A', '0'], 'EXP-ACTUAL' => ['SKU-A', '8.50'], 'EXP-MISSING' => ['SKU-MISSING', null]] as $tracking => [$sku, $freight]) { + $shipments[] = ['id' => $tracking, 'source_id' => $tracking, 'express_no' => $tracking, 'trade_no' => '5127787476014026744', + 'barcode' => $sku, 'qty' => '2', 'freight' => $freight, 'ship_time' => '2026-08-01 00:00:00']; + } + $repository->shouldReceive('shipmentsFor')->andReturn($shipments); + $repository->shouldReceive('costsFor')->andReturn(['SKU-A' => '20.00']); + $this->app->instance(\Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository::class, $repository); + $this->loginAs()->get($this->url())->assertOk()->assertSee('成本合计×0.2替代运费')->assertSee('核算运费')->assertSee('包裹运费为空或0'); + $data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(); + $this->assertSame(1650, $data['bookkeepingSummary']['deduction_cents']); + $this->assertSame(2, $data['bookkeepingSummary']['deduction_count']); + $this->assertSame(1, $data['bookkeepingSummary']['review']); + $this->get($this->url(['deduction' => '扣款']))->assertOk()->assertSee('GD-ESTIMATE')->assertSee('GD-ACTUAL')->assertDontSee('GD-NO-COST')->assertDontSee('GD-SKIP'); + $csv = $this->get(route('business.supplier-bookkeeping.export', [ + 'company_id' => $this->company->id, 'deduction' => '扣款', + 'columns' => 'work_order_no,amount,original_amount,matched_cost,matched_freight,matched_quantity,remark', + ]))->assertOk()->streamedContent(); + $this->assertStringContainsString('GD-ESTIMATE,8.00,100.00,40.00,8.00,2,', $csv); + $this->assertStringContainsString('成本合计×0.2替代运费', $csv); + $this->assertStringNotContainsString('GD-NO-COST', $csv); + $this->assertStringNotContainsString('GD-SKIP', $csv); + } } diff --git a/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php b/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php index 0ba5094b..b1d81090 100644 --- a/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php +++ b/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php @@ -83,6 +83,24 @@ class SupplierBookkeepingShippingImporterTest extends TestCase $this->assertSame(501, $db->table(Importer::ROWS)->where('generation',$state->active_generation)->count()); } + public function test_indexes_clean_references_while_retaining_original_source_values(): void + { + [$file, $manifest] = $this->input(1); + $out = fopen('compress.zlib://' . $file, 'wb'); + fputcsv($out, Importer::HEADERS); + fputcsv($out, [2, " '@0001234567890123456789 ", '@OLD-1’', " '@ORDER-1,@ORDER-2’", '', 'SKU-A+B', '2', '8.50', '2026/10/1 1:02:03', '', '']); + fclose($out); + $meta = json_decode(file_get_contents($manifest), true, 512, JSON_THROW_ON_ERROR); + $meta['data_sha256'] = hash_file('sha256', $file); + file_put_contents($manifest, json_encode($meta, JSON_THROW_ON_ERROR)); + $this->assertSame('succeeded', app(Importer::class)->import($file, $manifest)['status']); + $db = DB::connection('business_mirror'); + $this->assertSame(['0001234567890123456789', 'OLD-1'], $db->table(Importer::REFS)->where('kind', 'tracking')->orderBy('reference')->pluck('reference')->all()); + $this->assertSame(['ORDER-1', 'ORDER-2'], $db->table(Importer::REFS)->where('kind', 'order')->orderBy('reference')->pluck('reference')->all()); + $this->assertSame(" '@0001234567890123456789 ", $db->table(Importer::ROWS)->value('express_no')); + $this->assertSame('SKU-A+B', $db->table(Importer::ROWS)->value('barcode')); + } + public function test_rejects_wrong_column_hash_month_and_incomplete_row_count(): void { foreach ([['freight_column' => '结算运费'], ['data_sha256' => str_repeat('b',64)], ['source_month' => '2026-09'], ['row_count' => 3]] as $patch) { diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php index 6dae6c5d..cc5964f6 100644 --- a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php @@ -55,9 +55,11 @@ class SupplierBookkeepingValuationRepositoryTest extends TestCase $this->assertCount(1, $rows); $this->assertSame('EXP-1', $rows[0]['express_no']); $this->assertCount(1, $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => 'OLD-EXP-1']])); + $this->assertCount(1, $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => " '@EXP-1 ", 'online_order_no' => 'ORDER-1']])); $this->assertSame(['2026-10'], $repo->readyMonths()); $all = $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => '', 'online_order_no' => 'ORDER-1']]); $this->assertCount(2, $all); + $this->assertCount(2, $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => " '@ ", 'online_order_no' => '@ORDER-1’']])); $this->assertSame([], $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => '', 'online_order_no' => '']])); } @@ -69,12 +71,19 @@ class SupplierBookkeepingValuationRepositoryTest extends TestCase ['autoid' => 3, '_mirror_key' => '3', 'sku_id' => 'SKU-A', 'sku_type' => 'combination', 'cost_price' => '90.00'], ['autoid' => 4, '_mirror_key' => '4', 'sku_id' => 'SKU_X002B_B', 'sku_type' => 'normal', 'cost_price' => '15.00'], ]); - $costs = app(Repository::class)->costsFor(['SKU-A', 'SKU+B', 'MISSING']); + $costs = app(Repository::class)->costsFor([" '@sku-a ", '@SKU+B’', 'MISSING']); $this->assertSame('20.00', $costs['SKU-A']); $this->assertSame('15.00', $costs['SKU+B']); $this->assertArrayNotHasKey('MISSING', $costs); } + public function test_cleans_only_outer_copy_symbols_and_preserves_identifier_content(): void + { + $this->assertSame(['JDVB001', '0001234567890123456789', 'SKU-A+B', 'A@B'], Repository::numbers("\u{FEFF}'@JDVB001’ , '@0001234567890123456789";\u{200B}@SKU-A+B\u{00A0}\nA@B")); + $this->assertSame('SKU-A+B', Repository::sku(" ’@sku-a_x002b_b' ")); + $this->assertSame([], Repository::numbers(" '@,@’ ")); + } + public function test_latest_cost_wins_when_encoded_sku_variant_falls_in_another_query_batch(): void { DB::connection('business_mirror')->table('fixture_valuation_skus')->insert([ diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php index 6b8afae9..f2bf1b54 100644 --- a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php @@ -43,6 +43,17 @@ class SupplierBookkeepingValuationTest extends TestCase $this->assertSame('扣款', $row['deduction_status']); } + public function test_copied_reference_and_sku_wrappers_are_cleaned_before_matching_and_costing(): void + { + $service = $this->service([$this->shipment(['express_no' => " '@EXP-1", 'trade_no' => '@ORDER-1’', 'barcode' => " '@sku-a "])]); + foreach ([" '@EXP-1 ", ''] as $tracking) { + $row = $service->apply([$this->row(['tracking_no' => $tracking, 'online_order_no' => " '@ORDER-1 ", 'product_code' => ''@SKU-A’'])])[0]; + $this->assertSame('48.50', $row['amount']); + $this->assertSame('40.00', $row['matched_cost']); + $this->assertSame('扣款', $row['deduction_status']); + } + } + public function test_keeps_lower_or_equal_bookkeeping_amount_and_rounds_ceiling_once(): void { $service = $this->service([$this->shipment()]); @@ -76,10 +87,6 @@ class SupplierBookkeepingValuationTest extends TestCase ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '0.00'])])[0]; $this->assertSame('8.50', $originalZero['amount']); $this->assertSame('扣款', $originalZero['deduction_status']); - $zero = $this->service([$this->shipment(['freight' => '0.00'])], []) - ->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; - $this->assertSame('0.00', $zero['amount']); - $this->assertSame('不扣款', $zero['deduction_status']); } public function test_repeated_package_freight_is_counted_once_for_multiple_skus(): void @@ -238,4 +245,61 @@ class SupplierBookkeepingValuationTest extends TestCase $this->assertSame('待确认', $row['deduction_status']); $this->assertStringContainsString('未识别错误', $row['deduction_basis']); } + + public function test_freight_loss_missing_or_zero_freight_uses_cost_times_quantity_times_twenty_percent(): void + { + foreach ([null, '', '0', '0.00'] as $freight) { + $row = $this->service([$this->shipment(['freight' => $freight])])->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; + $this->assertSame('8.00', $row['amount']); + $this->assertSame('40.00', $row['matched_cost']); + $this->assertSame('2', $row['matched_quantity']); + $this->assertSame('8.00', $row['matched_freight']); + $this->assertSame('扣款', $row['deduction_status']); + $this->assertSame('100.00', $row['original_amount']); + $this->assertStringContainsString('成本合计×0.2替代运费', $row['deduction_basis']); + $this->assertStringStartsWith("原备注\n", $row['remark']); + } + foreach (['0.00', '1.00'] as $amount) { + $row = $this->service([$this->shipment(['freight' => '0'])])->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => $amount])])[0]; + $this->assertSame('8.00', $row['amount']); + } + } + + public function test_estimates_missing_package_only_and_deduplicates_its_goods_cost(): void + { + $service = $this->service([ + $this->shipment(), + $this->shipment(['id' => 2, 'source_id' => 20, 'express_no' => 'EXP-2', 'qty' => '3', 'freight' => '0']), + $this->shipment(['id' => 3, 'source_id' => 20, 'express_no' => 'EXP-2', 'qty' => '3', 'freight' => '0']), + $this->shipment(['id' => 4, 'source_id' => 20, 'express_no' => 'EXP-2', 'barcode' => 'SKU-B', 'qty' => '1', 'freight' => null]), + ], ['SKU-A' => '20', 'SKU-B' => '5']); + $row = $service->apply([$this->row(['bookkeeping_type' => '运费损耗', 'tracking_no' => '', 'product_code' => "SKU-A\nSKU-B"])])[0]; + $this->assertSame('21.50', $row['amount']); + $this->assertSame('65.00', $row['matched_cost']); + $this->assertSame('4', $row['matched_quantity']); + $this->assertStringContainsString('另加其余包裹实际运费', $row['deduction_basis']); + } + + public function test_actual_positive_freight_wins_and_missing_cost_or_uncertain_quantity_cannot_be_estimated(): void + { + $actual = $this->service([$this->shipment(['freight' => '0.01', 'qty' => null])], [])->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; + $this->assertSame('0.01', $actual['amount']); + foreach ([ + [['freight' => null], []], [['freight' => '0'], ['SKU-A' => '0']], + [['freight' => '0', 'qty' => null], ['SKU-A' => '20']], + [['freight' => '0', 'barcode' => 'SKU-A,SKU-B'], ['SKU-A' => '20']], + [['freight' => '-1'], ['SKU-A' => '20']], + ] as [$shipment, $costs]) { + $row = $this->service([$this->shipment($shipment)], $costs)->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; + $this->assertSame('待确认', $row['deduction_status']); + $this->assertSame('100.00', $row['amount']); + } + $rounded = $this->service([$this->shipment(['freight' => '0', 'qty' => '1'])], ['SKU-A' => '12.375']) + ->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0]; + $this->assertSame('2.48', $rounded['amount']); + $incomplete = $this->service([$this->shipment(['freight' => '0'])], ['SKU-A' => '20', 'SKU-B' => '10']) + ->apply([$this->row(['bookkeeping_type' => '运费损耗', 'product_code' => "SKU-A\nSKU-B"])])[0]; + $this->assertSame('待确认', $incomplete['deduction_status']); + $this->assertStringContainsString('未匹配全部工单商品', $incomplete['deduction_basis']); + } }