供应商对账:条码匹配去掉厂家编号/版本后缀,表头不符提示具体缺哪些列
实测雪翔家居 9 月对账单(我方货号 CJ019312 vs 厂家条码 CJ019312N1097V01)发现后缀导致全部误判为厂家多/厂家少:比对键改为先去掉厂家编号+版本后缀再匹配,页面同时显示厂家原始条码;表头校验失败时直接列出缺少/多出的列名,方便厂家对照修改。
This commit is contained in:
parent
2a80f380e6
commit
2449c48c78
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user