diff --git a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php index bd23f551..17b34d09 100644 --- a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php +++ b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php @@ -36,6 +36,8 @@ class SupplierStatementMatcher private const LAYER_EXPRESS = 4; + private const LAYER_BARCODE_BALANCE = 5; + /** * @param list> $mine 我方数据(expressNo/purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号) * @param list> $theirs 厂家数据(expressNo/purchaseNo/createdAt/barcode/unitPrice/qty/amount) @@ -73,18 +75,7 @@ class SupplierStatementMatcher } $rows = []; - $summary = [ - 'match' => 0, - 'supplierExtra' => 0, - 'mismatch' => 0, - 'supplierMissing' => 0, - 'mineAmount' => 0.0, - 'theirsAmount' => 0.0, - 'supplierOverQty' => 0.0, - 'supplierOverAmount' => 0.0, - 'settleQty' => 0.0, - 'settleAmount' => 0.0, - ]; + $summary = $this->initialSummary(); foreach ($pairs as $pair) { $rows[] = $this->comparisonRow($pair['mine'], $pair['theirs'], $pair['layer'], $summary); @@ -111,15 +102,48 @@ class SupplierStatementMatcher $b['barcode'], ]); - $summary['mineAmount'] = round($summary['mineAmount'], 2); - $summary['theirsAmount'] = round($summary['theirsAmount'], 2); - $summary['supplierOverQty'] = round($summary['supplierOverQty'], 4); - $summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2); - $summary['settleQty'] = round($summary['settleQty'], 4); - $summary['settleAmount'] = round($summary['settleAmount'], 2); - $summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2); + return ['rows' => $rows, 'summary' => $this->finalizeSummary($summary)]; + } - return ['rows' => $rows, 'summary' => $summary]; + /** + * 四层明细匹配后,财务端再按商品编码汇总双方的净数量和净金额。 + * 同一商品在不同日期、采购单拆行时,正负抵消项不会再显示为多条假差异。 + * + * @param list> $mine + * @param list> $theirs + * @return array{rows:list>,summary:array} + */ + public function netByBarcode(array $mine, array $theirs): array + { + $mineGroups = $this->groupForLayer($mine, true, self::LAYER_BARCODE_BALANCE, true); + $theirsGroups = $this->groupForLayer($theirs, false, self::LAYER_BARCODE_BALANCE, true); + $keys = array_values(array_unique(array_merge(array_keys($mineGroups), array_keys($theirsGroups)))); + sort($keys, SORT_NATURAL); + + $rows = []; + $summary = $this->initialSummary(); + foreach ($keys as $key) { + $mineRow = $mineGroups[$key] ?? null; + $theirsRow = $theirsGroups[$key] ?? null; + $matchedLayer = $mineRow !== null && $theirsRow !== null + ? self::LAYER_BARCODE_BALANCE + : null; + $rows[] = $this->comparisonRow($mineRow, $theirsRow, $matchedLayer, $summary); + } + + usort($rows, static fn (array $a, array $b): int => [ + $a['type'] === self::TYPE_MATCH ? 1 : 0, + $a['date'], + $a['purchaseNo'], + $a['barcode'], + ] <=> [ + $b['type'] === self::TYPE_MATCH ? 1 : 0, + $b['date'], + $b['purchaseNo'], + $b['barcode'], + ]); + + return ['rows' => $rows, 'summary' => $this->finalizeSummary($summary)]; } /** @@ -137,7 +161,7 @@ class SupplierStatementMatcher if (! $singletonFallback) { continue; } - $key = "source\0" . $layer . "\0" . $index; + $key = "source\0" . ($mineSide ? 'mine' : 'theirs') . "\0" . $layer . "\0" . $index; } $barcode = $this->barcodeValue($row, $mineSide ? ['productCode', 'skuCode'] : ['barcode']); @@ -257,10 +281,48 @@ class SupplierStatementMatcher self::LAYER_EXPRESS => $expressNo !== '' ? "express\0" . $expressNo . "\0" . $barcodeKey : null, + self::LAYER_BARCODE_BALANCE => "barcode-balance\0" . $barcodeKey, default => null, }; } + /** @return array */ + private function initialSummary(): array + { + return [ + 'match' => 0, + 'supplierExtra' => 0, + 'mismatch' => 0, + 'supplierMissing' => 0, + 'mineAmount' => 0.0, + 'theirsAmount' => 0.0, + 'supplierOverQty' => 0.0, + 'supplierOverAmount' => 0.0, + 'settleQty' => 0.0, + 'settleAmount' => 0.0, + ]; + } + + /** + * @param array $summary + * @return array + */ + private function finalizeSummary(array $summary): array + { + $summary['mineAmount'] = round((float) $summary['mineAmount'], 2); + $summary['theirsAmount'] = round((float) $summary['theirsAmount'], 2); + $summary['supplierOverQty'] = round((float) $summary['supplierOverQty'], 4); + $summary['supplierOverAmount'] = round((float) $summary['supplierOverAmount'], 2); + $summary['settleQty'] = round((float) $summary['settleQty'], 4); + $summary['settleAmount'] = round((float) $summary['settleAmount'], 2); + $summary['differenceAmount'] = round( + (float) $summary['mineAmount'] - (float) $summary['settleAmount'], + 2, + ); + + return $summary; + } + /** @param array $row @param list $fields */ private function barcodeValue(array $row, array $fields): string { @@ -435,6 +497,7 @@ class SupplierStatementMatcher : '' ) . '商品编码+数量匹配,已忽略日期差异' . $dateNote . ';', self::LAYER_EXPRESS => '快递单号+商品编码匹配;', + self::LAYER_BARCODE_BALANCE => '四层明细匹配后,按商品编码汇总净数量、净金额;', default => '', }; } diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php index 7c462f52..c0c9c42d 100644 --- a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -610,7 +610,9 @@ class SupplierReconciliationPageData ]; } - $compared = $this->matcher->compare($mineRows, $theirRows); + $detailed = $this->matcher->compare($mineRows, $theirRows); + $compared = $this->matcher->netByBarcode($mineRows, $theirRows); + $compared['detailedRows'] = $detailed['rows']; $compared['sourceTotals'] = $sourceTotals; $compared['mineRows'] = count($mineRows); $compared['theirsRows'] = count($theirRows); diff --git a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php index bb35c45c..e2c4a23f 100644 --- a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -184,10 +184,10 @@
比对口径 - 依次按「采购单号 + 商品编码 + 日期」→「采购单号 + 商品编码」→「商品编码 + 数量」→「快递单号 + 商品编码」匹配;后三层允许日期差异。 + 依次按「采购单号 + 商品编码 + 日期」→「采购单号 + 商品编码」→「商品编码 + 数量」→「快递单号 + 商品编码」匹配,最后按商品编码汇总净差异;后三层允许日期差异。
- 第一层先对有采购单号的数据按「采购单号 + 商品编码 + 日期」精确匹配;剩余数据再按「采购单号 + 商品编码」匹配,日期可不同且不限制天数;仍剩余的再按「商品编码 + 数量」匹配,日期可不同;最后仍未匹配且双方都有快递单号的,按「快递单号 + 商品编码」匹配。每层只处理上一层剩余数据,同一匹配键的多行先合并数量与金额。
+ 第一层先对有采购单号的数据按「采购单号 + 商品编码 + 日期」精确匹配;剩余数据再按「采购单号 + 商品编码」匹配,日期可不同且不限制天数;仍剩余的再按「商品编码 + 数量」匹配,日期可不同;最后仍未匹配且双方都有快递单号的,按「快递单号 + 商品编码」匹配。每层只处理上一层剩余数据,同一匹配键的多行先合并数量与金额;四层完成后,再按商品编码汇总双方净数量、净金额,同一商品只显示一条最终结果,避免日期或采购单拆行产生正负抵消的假差异。
页面“双方原始合计”直接按筛选后的原始行汇总,不受匹配分组影响;匹配过程不会删除或改写我方终版货款对账原始行。
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改; 数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算; @@ -658,6 +658,9 @@ {{ $row['date'] !== '' ? $row['date'] : '—' }} + @if ((int) ($row['dateCount'] ?? 0) > 1 && ! empty($row['dateEnd'])) +
至 {{ $row['dateEnd'] }}
+ @endif @if (! empty($row['dateDifference']))
厂家:{{ $row['theirsDate'] }}
@endif diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php index d9253737..6e2db9f7 100644 --- a/tests/Feature/Business/SupplierReconciliationTest.php +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -101,9 +101,9 @@ class SupplierReconciliationTest extends FeatureTestCase $this->assertSame(0, $detail['summary']['supplierExtra']); $this->assertSame(0, $detail['summary']['supplierMissing']); $this->assertSame(1, $detail['summary']['mismatch']); - $this->assertSame('SF123456', $detail['rows'][0]['expressNo']); - $this->assertSame(4, $detail['rows'][0]['matchedByLayer']); - $this->assertStringContainsString('快递单号+商品编码匹配', $detail['rows'][0]['note']); + $this->assertSame('SF123456', $detail['detailedRows'][0]['expressNo']); + $this->assertSame(4, $detail['detailedRows'][0]['matchedByLayer']); + $this->assertStringContainsString('快递单号+商品编码匹配', $detail['detailedRows'][0]['note']); } public function test_finance_supplier_list_shows_return_confirmation_progress(): void @@ -380,8 +380,9 @@ class SupplierReconciliationTest extends FeatureTestCase $this->assertSame(2, $detail['mineRows']); $this->assertSame(1, $detail['theirsRows']); $this->assertSame(['mineQty' => 60.0, 'mineAmount' => 5010.0, 'theirsQty' => 30.0, 'theirsAmount' => 2505.0], $detail['sourceTotals']); - $this->assertSame(1, $detail['summary']['match']); - $this->assertSame(1, $detail['summary']['supplierMissing']); + $this->assertSame(0, $detail['summary']['match']); + $this->assertSame(0, $detail['summary']['supplierMissing']); + $this->assertSame(1, $detail['summary']['mismatch']); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'supplier-reconciliation', @@ -400,6 +401,71 @@ class SupplierReconciliationTest extends FeatureTestCase ->assertSeeText('厂家少'); } + public function test_finance_comparison_consolidates_split_rows_by_barcode_net_totals(): void + { + $companyId = (int) $this->company->id; + $store = app(SupplierPayableOverrideStore::class); + foreach ([ + ['key' => 'net:a1', 'purchaseNo' => 'PO-A', 'date' => '2026-10-01', 'productCode' => 'NET-A-N1065V01', 'qty' => '14', 'amount' => '140'], + ['key' => 'net:a2', 'purchaseNo' => 'PO-A', 'date' => '2026-10-02', 'productCode' => 'NET-A-N1065V01', 'qty' => '6', 'amount' => '60'], + ['key' => 'net:b1', 'purchaseNo' => 'PO-B', 'date' => '2026-10-03', 'productCode' => 'NET-B-N1065V01', 'qty' => '20', 'amount' => '200'], + ] as $row) { + $store->save( + 'final:supplier:payment-reconciliation', + $row['key'], + [ + 'summary_month' => '2026-10', + 'purchaseNo' => $row['purchaseNo'], + 'date' => $row['date'], + 'supplier' => '简阔', + 'skuCode' => substr($row['productCode'], 0, -8), + 'productCode' => $row['productCode'], + 'qty' => $row['qty'], + 'unitPrice' => '10', + 'amount' => $row['amount'], + 'reconciler' => '陈亲园', + ], + $companyId, + null, + ); + } + $this->seedStatement('简阔', [ + array_merge($this->statementRow('NET-A-N1065V01', '10.00', '17', '170.00'), [ + 'purchaseNo' => 'PO-A', + 'createdAt' => '2026-10-02', + 'supplier' => '简阔', + ]), + array_merge($this->statementRow('NET-A-N1065V01', '10.00', '3', '30.00'), [ + 'purchaseNo' => 'PO-A', + 'createdAt' => '2026-10-03', + 'supplier' => '简阔', + ]), + array_merge($this->statementRow('NET-B-N1065V01', '10.00', '17', '170.00'), [ + 'purchaseNo' => 'PO-B', + 'createdAt' => '2026-10-03', + 'supplier' => '简阔', + ]), + ]); + + $detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters( + '简阔', + $companyId, + ['month' => '2026-10', 'reconciler' => '陈亲园'], + ); + $rows = collect($detail['rows'])->keyBy('barcode'); + + $this->assertSame(3, $detail['mineRows']); + $this->assertSame(3, $detail['theirsRows']); + $this->assertSame(2, $detail['total_rows']); + $this->assertSame(1, $detail['summary']['match']); + $this->assertSame(1, $detail['summary']['mismatch']); + $this->assertSame(0, $detail['summary']['supplierMissing']); + $this->assertSame('一致', $rows['NET-A-N1065V01']['type']); + $this->assertSame('数量/金额不一致', $rows['NET-B-N1065V01']['type']); + $this->assertSame(-3.0, $rows['NET-B-N1065V01']['qtyDifference']); + $this->assertSame(-30.0, $rows['NET-B-N1065V01']['amountDifference']); + } + public function test_finance_comparison_matches_crossed_purchase_numbers_by_barcode_and_quantity(): void { $companyId = (int) $this->company->id; @@ -445,7 +511,7 @@ class SupplierReconciliationTest extends FeatureTestCase $companyId, ['month' => '2026-10', 'reconciler' => '陈亲园'], ); - $rows = collect($detail['rows'])->keyBy('minePurchaseNo'); + $rows = collect($detail['detailedRows'])->keyBy('minePurchaseNo'); $this->assertSame(2, $detail['summary']['match']); $this->assertSame(0, $detail['summary']['supplierExtra']); @@ -466,8 +532,7 @@ class SupplierReconciliationTest extends FeatureTestCase ->assertOk() ->assertSeeText('厂家:972032') ->assertSeeText('厂家:958052') - ->assertSeeText('采购单号不一致') - ->assertSeeText('商品编码+数量匹配'); + ->assertSeeText('四层明细匹配后,按商品编码汇总净数量、净金额'); } public function test_supplier_without_upload_does_not_generate_or_push_missing_differences(): void diff --git a/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php b/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php index dd84bede..8e0de5d8 100644 --- a/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php +++ b/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php @@ -66,6 +66,37 @@ class SupplierStatementMatcherTest extends TestCase $this->assertSame(8, $result['rows'][0]['dateDifferenceDays']); } + public function test_net_by_barcode_offsets_split_date_rows_and_keeps_only_real_difference(): void + { + $mine = [ + ['purchaseNo' => 'PO-A', 'date' => '2026-10-01', 'productCode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '14', 'amount' => '140'], + ['purchaseNo' => 'PO-A', 'date' => '2026-10-02', 'productCode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '6', 'amount' => '60'], + ['purchaseNo' => 'PO-B', 'date' => '2026-10-03', 'productCode' => 'B-1N1065V01', 'unitPrice' => '10', 'qty' => '20', 'amount' => '200'], + ]; + $theirs = [ + ['purchaseNo' => 'PO-A', 'createdAt' => '2026-10-02', 'barcode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '17', 'amount' => '170'], + ['purchaseNo' => 'PO-A', 'createdAt' => '2026-10-03', 'barcode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '3', 'amount' => '30'], + ['purchaseNo' => 'PO-B', 'createdAt' => '2026-10-03', 'barcode' => 'B-1N1065V01', 'unitPrice' => '10', 'qty' => '17', 'amount' => '170'], + ]; + + $result = app(SupplierStatementMatcher::class)->netByBarcode($mine, $theirs); + $byBarcode = collect($result['rows'])->keyBy('barcode'); + + $this->assertCount(2, $result['rows']); + $this->assertSame(SupplierStatementMatcher::TYPE_MATCH, $byBarcode['A-1N1065V01']['type']); + $this->assertSame(20.0, $byBarcode['A-1N1065V01']['mineQty']); + $this->assertSame(20.0, $byBarcode['A-1N1065V01']['theirsQty']); + $this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $byBarcode['B-1N1065V01']['type']); + $this->assertSame(-3.0, $byBarcode['B-1N1065V01']['qtyDifference']); + $this->assertSame(-30.0, $byBarcode['B-1N1065V01']['amountDifference']); + $this->assertSame(1, $result['summary']['match']); + $this->assertSame(1, $result['summary']['mismatch']); + $this->assertSame(0, $result['summary']['supplierMissing']); + $this->assertSame(400.0, $result['summary']['mineAmount']); + $this->assertSame(370.0, $result['summary']['theirsAmount']); + $this->assertSame(370.0, $result['summary']['settleAmount']); + } + public function test_purchase_and_barcode_match_then_reports_quantity_and_amount_difference(): void { $mine = [[