供应商对账:条码匹配去掉厂家编号/版本后缀,表头不符提示具体缺哪些列

实测雪翔家居 9 月对账单(我方货号 CJ019312 vs 厂家条码 CJ019312N1097V01)发现后缀导致全部误判为厂家多/厂家少:比对键改为先去掉厂家编号+版本后缀再匹配,页面同时显示厂家原始条码;表头校验失败时直接列出缺少/多出的列名,方便厂家对照修改。
This commit is contained in:
苏木 2026-10-17 09:06:46 +08:00
parent 2a80f380e6
commit 2449c48c78
4 changed files with 103 additions and 5 deletions

View File

@ -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<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $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;
}
}

View File

@ -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<string>|null $actual 标准化后的实际表头;@param list<string> $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
{

View File

@ -148,7 +148,12 @@
@endphp
<tr class="{{ $tagClass }}">
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
<td>{{ $row['barcode'] }}</td>
<td>
{{ $row['barcode'] }}
@if (! empty($row['supplierBarcode']))
<span class="sr-muted" style="font-size:.7rem">(厂家:{{ $row['supplierBarcode'] }})</span>
@endif
</td>
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>

View File

@ -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 [