diff --git a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php index 605dfb4f..2943be61 100644 --- a/modules/EcommerceFinance/Services/SupplierStatementMatcher.php +++ b/modules/EcommerceFinance/Services/SupplierStatementMatcher.php @@ -4,7 +4,8 @@ namespace Modules\EcommerceFinance\Services; /** * 厂家对账单 vs 我方结算数据: - * - 行键 = 条码 + 单价(同一把钥匙的多行先合并:数量、金额相加); + * - 行键 = 条码(去掉厂家编号/版本后缀,如 CJ019312N1097V01 → CJ019312)+ 单价 + * (同一把钥匙的多行先合并:数量、金额相加); * - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。 */ class SupplierStatementMatcher @@ -17,6 +18,9 @@ class SupplierStatementMatcher public const TYPE_SUPPLIER_MISSING = '厂家少'; + /** 厂家条码后缀:供应商编号 + 版本,如 N1097V01、N1269V01。 */ + private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i'; + /** * @param list> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount) * @param list> $theirs 厂家对账单(barcode/unitPrice/qty/amount) @@ -36,6 +40,7 @@ class SupplierStatementMatcher $theirsRow = $theirsGrouped[$key] ?? null; $barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']); + $supplierBarcode = (string) ($theirsRow['barcode'] ?? ''); $unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']); $mineQty = (float) ($mineRow['qty'] ?? 0); $mineAmount = (float) ($mineRow['amount'] ?? 0); @@ -69,6 +74,7 @@ class SupplierStatementMatcher $rows[] = [ 'barcode' => $barcode, + 'supplierBarcode' => $supplierBarcode === $barcode ? '' : $supplierBarcode, 'unitPrice' => $unitPrice, 'mineQty' => $mineQty, 'mineAmount' => $mineAmount, @@ -106,7 +112,7 @@ class SupplierStatementMatcher } $unitPrice = (float) ($row['unitPrice'] ?? 0); - $key = $barcode . '|' . number_format($unitPrice, 4, '.', ''); + $key = $this->normalizeBarcode($barcode) . '|' . number_format($unitPrice, 4, '.', ''); $grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0]; $grouped[$key]['qty'] += (float) ($row['qty'] ?? 0); @@ -115,4 +121,12 @@ class SupplierStatementMatcher return $grouped; } + + /** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */ + private function normalizeBarcode(string $barcode): string + { + $normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode); + + return $normalized === null || $normalized === '' ? $barcode : $normalized; + } } diff --git a/modules/EcommerceFinance/Services/SupplierStatementTemplate.php b/modules/EcommerceFinance/Services/SupplierStatementTemplate.php index 852562f3..2923e6c6 100644 --- a/modules/EcommerceFinance/Services/SupplierStatementTemplate.php +++ b/modules/EcommerceFinance/Services/SupplierStatementTemplate.php @@ -123,6 +123,9 @@ class SupplierStatementTemplate private function locateHeader(PayablesWorkbookStreamReader $reader): array { $expected = array_values(self::COLUMNS); + $expectedNormalized = array_map($this->normalizeLabel(...), $expected); + $bestLabels = null; + $bestScore = -1; foreach (array_keys($reader->sheetNames()) as $sheetIndex) { foreach ($reader->rows($sheetIndex, null, 20) as $rowNumber => $row) { @@ -135,17 +138,54 @@ class SupplierStatementTemplate } ksort($labels); - if (array_values($labels) === array_map($this->normalizeLabel(...), $expected) && array_keys($labels) === range(0, count($expected) - 1)) { + if (array_values($labels) === $expectedNormalized && array_keys($labels) === range(0, count($expected) - 1)) { return ['sheet' => $sheetIndex, 'row' => $rowNumber]; } + + $score = count(array_intersect(array_values($labels), $expectedNormalized)); + if ($score > $bestScore) { + $bestScore = $score; + $bestLabels = array_values($labels); + } } } throw ValidationException::withMessages([ - 'import' => '表头与模板不一致:请下载最新模板填写,不要增删或修改列(列名、顺序必须与模板完全相同)。', + 'import' => '表头与模板不一致:' . $this->describeHeader($bestLabels, $expectedNormalized) . '请下载最新模板填写,不要增删或修改列(列名、顺序必须与模板完全相同)。', ]); } + /** @param list|null $actual 标准化后的实际表头;@param list $expected 标准化后的模板表头 */ + private function describeHeader(?array $actual, array $expected): string + { + if ($actual === null) { + return ''; + } + + $display = []; + foreach (self::COLUMNS as $label) { + $display[$this->normalizeLabel($label)] = $label; + } + + $missing = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($expected, $actual))); + $extra = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($actual, $expected))); + + $parts = []; + if ($missing !== []) { + $parts[] = '缺少「' . implode('」「', $missing) . '」'; + } + if ($extra !== []) { + $parts[] = '多出「' . implode('」「', $extra) . '」'; + } + $commonActual = array_values(array_filter($actual, static fn (string $key): bool => in_array($key, $expected, true))); + $commonExpected = array_values(array_filter($expected, static fn (string $key): bool => in_array($key, $actual, true))); + if ($commonActual !== $commonExpected) { + $parts[] = '列顺序与模板不同'; + } + + return $parts === [] ? '' : implode(',', $parts) . ';'; + } + /** 比较表头时忽略空白与全半角括号差异。 */ private function normalizeLabel(string $label): string { 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 d2ad3748..3700621e 100644 --- a/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/finance-data.blade.php @@ -148,7 +148,12 @@ @endphp {{ $row['type'] }} - {{ $row['barcode'] }} + + {{ $row['barcode'] }} + @if (! empty($row['supplierBarcode'])) + (厂家:{{ $row['supplierBarcode'] }}) + @endif + {{ number_format($row['unitPrice'], 2) }} {{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }} {{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }} diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php index 4b8c095b..3ecbc46c 100644 --- a/tests/Feature/Business/SupplierReconciliationTest.php +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -201,6 +201,45 @@ class SupplierReconciliationTest extends FeatureTestCase $this->assertSame(0.0, $byBarcode['D-1']['settleAmount']); } + public function test_matcher_normalizes_supplier_barcode_suffix(): void + { + // 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01) + $mine = [ + ['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'], + ['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'], + ]; + $theirs = [ + ['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'], + ['barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'], + ]; + + $result = app(SupplierStatementMatcher::class)->compare($mine, $theirs); + + $this->assertSame(2, $result['summary']['match'], '去掉厂家后缀后应全部一致'); + $this->assertSame(0, $result['summary']['supplierExtra']); + $this->assertSame(0, $result['summary']['supplierMissing']); + $this->assertSame(0, $result['summary']['mismatch']); + $this->assertSame(24751.0, $result['summary']['settleAmount']); + $this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null); + } + + public function test_header_mismatch_message_lists_the_differences(): void + { + $file = UploadedFile::fake()->createWithContent( + 'statement.csv', + "\xEF\xBB\xBF单据编号(采购单号),创建时间,供应商,条码,数量,单价,金额\n" + . "编号-1,2026-09-30,晨丰,ZSJ-1,2,128.00,256.00\n", + ); + + try { + app(SupplierStatementTemplate::class)->parse($file, '晨丰'); + $this->fail('表头不一致应当整份失败。'); + } catch (ValidationException $exception) { + $message = $exception->errors()['import'][0]; + $this->assertStringContainsString('缺少「快递名称」「快递单号」「规格名称」', $message); + } + } + private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array { return [