From 6e054e4e8a9d97b0cc1bd21081e20e1361a1b498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Wed, 4 Nov 2026 09:17:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BE=9B=E5=BA=94=E5=95=86=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E6=94=AF=E6=8C=81=E6=97=A5=E6=9C=9F=E4=B8=A4=E5=A4=A9?= =?UTF-8?q?=E5=AE=B9=E5=B7=AE=E5=8C=B9=E9=85=8D=20(#845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 严格匹配未命中时,同采购单号、同商品编码且数量金额一致、日期相差不超过 2 天,按一致处理。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com> --- .../Services/SupplierStatementMatcher.php | 102 +++++++++++++++++- .../finance-data.blade.php | 3 +- .../SupplierStatementMatcherTest.php | 89 +++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php diff --git a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php index ee0c8596..e418c908 100644 --- a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php +++ b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php @@ -2,10 +2,13 @@ namespace Modules\EcommerceFinance\Services; +use Carbon\CarbonImmutable; + /** * 厂家对账单 vs 我方结算数据: * - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀); * - 同一行键的多行先合并数量与金额,再比较双方数量和金额; + * - 严格行键未匹配时,采购单号+商品编码一致、数量金额一致且日期相差不超过 2 天,仍按一致处理; * - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。 */ class SupplierStatementMatcher @@ -21,6 +24,8 @@ class SupplierStatementMatcher /** 厂家条码后缀:供应商编号 + 版本,如 N1097V01、N1269V01。 */ private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i'; + private const DATE_TOLERANCE_DAYS = 2; + /** * @param list> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号) * @param list> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount) @@ -37,6 +42,7 @@ class SupplierStatementMatcher $fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key; } $theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex); + [$mineGrouped, $theirsGrouped] = $this->matchDateToleranceGroups($mineGrouped, $theirsGrouped); $rows = []; $summary = [ @@ -69,6 +75,7 @@ class SupplierStatementMatcher $qtyDifference = $theirsQty - $mineQty; $amountDifference = $theirsAmount - $mineAmount; $quantityDifference = $this->describeDifference($qtyDifference, $amountDifference); + $dateToleranceDays = (int) ($theirsRow['dateToleranceDays'] ?? 0); $fallbackNote = ''; if (($theirsRow['matchedByFallback'] ?? false) === true) { $fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;'; @@ -92,7 +99,10 @@ class SupplierStatementMatcher } elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) { $type = self::TYPE_MATCH; $settle = $mineAmount; - $note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算'; + $note = $fallbackNote . ($dateToleranceDays > 0 + ? '采购单号、商品编码、数量和金额一致,日期相差 ' . $dateToleranceDays + . ' 天(容差不超过 ' . self::DATE_TOLERANCE_DAYS . ' 天),正常结算' + : '采购单号、日期、商品编码、数量和金额一致,正常结算'); $summary['match']++; } else { $type = self::TYPE_MISMATCH; @@ -125,6 +135,7 @@ class SupplierStatementMatcher 'supplierOverAmount' => round(max($amountDifference, 0), 2), 'type' => $type, 'settleAmount' => $settle, + 'dateToleranceDays' => $dateToleranceDays, 'note' => $note, ]; } @@ -244,6 +255,95 @@ class SupplierStatementMatcher return $resolved; } + /** + * 严格键未匹配时,仅对同采购单号、同商品编码、数量金额一致且日期相差 + * 不超过 2 天的记录做一对一容错配对。 + * + * @param array> $mine + * @param array> $theirs + * @return array{0:array>,1:array>} + */ + private function matchDateToleranceGroups(array $mine, array $theirs): array + { + $mineOnly = array_diff_key($mine, $theirs); + $theirsOnly = array_diff_key($theirs, $mine); + $candidates = []; + + foreach ($mineOnly as $mineKey => $mineRow) { + if ((string) $mineRow['purchaseNo'] === '') { + continue; + } + foreach ($theirsOnly as $theirsKey => $theirsRow) { + if ((string) $theirsRow['purchaseNo'] !== (string) $mineRow['purchaseNo'] + || (string) $theirsRow['barcodeKey'] !== (string) $mineRow['barcodeKey']) { + continue; + } + $days = $this->dateDifferenceDays((string) $mineRow['date'], (string) $theirsRow['date']); + if ($days === null || $days < 1 || $days > self::DATE_TOLERANCE_DAYS + || ! $this->groupsHaveEqualTotals($mineRow, $theirsRow)) { + continue; + } + $candidates[] = [ + 'days' => $days, + 'mine_key' => $mineKey, + 'theirs_key' => $theirsKey, + ]; + } + } + + usort($candidates, static fn (array $left, array $right): int => [ + $left['days'], + $left['mine_key'], + $left['theirs_key'], + ] <=> [ + $right['days'], + $right['mine_key'], + $right['theirs_key'], + ]); + + $usedMine = []; + $usedTheirs = []; + foreach ($candidates as $candidate) { + $mineKey = (string) $candidate['mine_key']; + $theirsKey = (string) $candidate['theirs_key']; + if (isset($usedMine[$mineKey]) || isset($usedTheirs[$theirsKey])) { + continue; + } + + $theirsRow = $theirs[$theirsKey]; + $theirsRow['dateToleranceDays'] = (int) $candidate['days']; + $theirsRow['matchedByDateTolerance'] = true; + $theirs[$mineKey] = $theirsRow; + unset($theirs[$theirsKey]); + $usedMine[$mineKey] = true; + $usedTheirs[$theirsKey] = true; + } + + return [$mine, $theirs]; + } + + /** @param array $left @param array $right */ + private function groupsHaveEqualTotals(array $left, array $right): bool + { + return abs((float) $left['qty'] - (float) $right['qty']) < 0.005 + && abs((float) $left['amount'] - (float) $right['amount']) < 0.005; + } + + private function dateDifferenceDays(string $left, string $right): ?int + { + try { + $leftDate = CarbonImmutable::createFromFormat('!Y-m-d', $left); + $rightDate = CarbonImmutable::createFromFormat('!Y-m-d', $right); + } catch (\Throwable) { + return null; + } + if (! $leftDate instanceof CarbonImmutable || ! $rightDate instanceof CarbonImmutable) { + return null; + } + + return (int) $leftDate->diffInDays($rightDate); + } + /** @param array $left @param array $right */ private function mergeGroup(array $left, array $right): array { 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 e45ed042..eb6028ae 100644 --- a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -118,7 +118,8 @@ @endif

- 比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。 + 比对口径:优先按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。 + 日期未对上时,如采购单号+商品编码一致、数量金额一致且日期相差不超过 2 天,仍按一致处理。
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改; 数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算; 厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。
diff --git a/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php b/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php new file mode 100644 index 00000000..a288455e --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php @@ -0,0 +1,89 @@ + '879735', + 'date' => '2026-09-10', + 'skuCode' => 'DNZ033609', + 'productCode' => 'DNZ033609N1178V01', + 'unitPrice' => '100', + 'qty' => '20', + 'amount' => '2000', + ]]; + $theirs = [[ + 'purchaseNo' => '879735', + 'createdAt' => '2026-09-12', + 'barcode' => 'DNZ033609N1097V01', + 'unitPrice' => '100', + 'qty' => '20', + 'amount' => '2000', + ]]; + + $result = (new SupplierStatementMatcher())->compare($mine, $theirs); + + $this->assertSame(1, $result['summary']['match']); + $this->assertSame(0, $result['summary']['supplierExtra']); + $this->assertSame(0, $result['summary']['supplierMissing']); + $this->assertSame(0, $result['summary']['mismatch']); + $this->assertSame(0.0, $result['summary']['differenceAmount']); + $this->assertSame(2000.0, $result['summary']['settleAmount']); + $this->assertSame(2, $result['rows'][0]['dateToleranceDays']); + $this->assertStringContainsString('日期相差 2 天', $result['rows'][0]['note']); + } + + public function test_date_difference_over_two_days_remains_a_difference(): void + { + $mine = [[ + 'purchaseNo' => '879735', + 'date' => '2026-09-10', + 'skuCode' => 'DNZ033609', + 'qty' => '20', + 'amount' => '2000', + ]]; + $theirs = [[ + 'purchaseNo' => '879735', + 'createdAt' => '2026-09-13', + 'barcode' => 'DNZ033609N1097V01', + 'qty' => '20', + 'amount' => '2000', + ]]; + + $result = (new SupplierStatementMatcher())->compare($mine, $theirs); + + $this->assertSame(0, $result['summary']['match']); + $this->assertSame(1, $result['summary']['supplierExtra']); + $this->assertSame(1, $result['summary']['supplierMissing']); + } + + public function test_date_tolerance_still_requires_equal_quantity_and_amount(): void + { + $mine = [[ + 'purchaseNo' => '879735', + 'date' => '2026-09-10', + 'skuCode' => 'DNZ033609', + 'qty' => '20', + 'amount' => '2000', + ]]; + $theirs = [[ + 'purchaseNo' => '879735', + 'createdAt' => '2026-09-12', + 'barcode' => 'DNZ033609N1097V01', + 'qty' => '19', + 'amount' => '1900', + ]]; + + $result = (new SupplierStatementMatcher())->compare($mine, $theirs); + + $this->assertSame(0, $result['summary']['match']); + $this->assertSame(1, $result['summary']['supplierExtra']); + $this->assertSame(1, $result['summary']['supplierMissing']); + } +}