供应商对账:比对汇总改按我方商品编码匹配 (#408)

对照我方货款对账的商品编码匹配厂家条码,页面、CSV导出和钉钉通知统一切换。
This commit is contained in:
renshen 2026-10-20 17:45:55 +08:00
parent db08b2c264
commit a317ab9dea
5 changed files with 67 additions and 26 deletions

View File

@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierComparisonCsvExporter
{
private const HEADERS = [
'采购单号', '日期', '条码', '厂家条码', '比对结果',
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
'我方数量', '我方单价', '我方金额',
'厂家数量', '厂家单价', '厂家金额',
'数量差异', '金额差异', '结算口径', '结算金额',

View File

@ -187,7 +187,7 @@ class SupplierDingTalkNotifier
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
$lines[] = sprintf(
'%d. **%s**|采购单号:%s|日期:%s|条码:%s%s',
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
$rowOffset + $index + 1,
(string) ($row['type'] ?? '差异'),
$purchaseNo !== '' ? $purchaseNo : '—',

View File

@ -4,9 +4,9 @@ namespace Modules\EcommerceFinance\Services;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 采购单号 + 日期 + 条码(条码去掉厂家编号/版本后缀);
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
* - 厂家未填采购单号时,仅在同日期+条码能唯一对应我方一条数据时兜底匹配。
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
*/
class SupplierStatementMatcher
{
@ -22,16 +22,16 @@ class SupplierStatementMatcher
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
/**
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号)
* @param list<array<string,mixed>> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount)
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function compare(array $mine, array $theirs): array
{
$mineGrouped = $this->group($mine, 'skuCode', 'purchaseNo', 'date');
$theirsGrouped = $this->group($theirs, 'barcode', 'purchaseNo', 'createdAt');
$mineGrouped = $this->group($mine, ['productCode', 'skuCode'], 'purchaseNo', 'date');
$theirsGrouped = $this->group($theirs, ['barcode'], 'purchaseNo', 'createdAt');
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+条码唯一时兜底。
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+商品编码唯一时兜底。
$fallbackIndex = [];
foreach ($mineGrouped as $key => $row) {
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
@ -71,15 +71,15 @@ class SupplierStatementMatcher
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
$fallbackNote = '';
if (($theirsRow['matchedByFallback'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,已按日期+条码唯一匹配;';
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,且同日期+条码有多条我方数据,无法唯一匹配;';
$fallbackNote = '厂家未填采购单号,且同日期+商品编码有多条我方数据,无法唯一匹配;';
}
if ($mineRow === null) {
$type = self::TYPE_SUPPLIER_EXTRA;
$settle = 0.0;
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+条码,不结算';
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+商品编码,不结算';
$summary['supplierExtra']++;
} elseif ($theirsRow === null) {
$type = self::TYPE_SUPPLIER_MISSING;
@ -92,7 +92,7 @@ class SupplierStatementMatcher
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
$type = self::TYPE_MATCH;
$settle = $mineAmount;
$note = $fallbackNote . '采购单号、日期、条码、数量和金额一致,正常结算';
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
@ -152,16 +152,17 @@ class SupplierStatementMatcher
}
/**
* 按「采购单号 + 日期 + 条码」归组,数量与金额相加。
* 按「采购单号 + 日期 + 商品编码」归组,数量与金额相加。
*
* @param list<array<string,mixed>> $rows
* @param list<string> $barcodeFields
* @return array<string,array<string,mixed>>
*/
private function group(array $rows, string $barcodeField, string $purchaseField, string $dateField): array
private function group(array $rows, array $barcodeFields, string $purchaseField, string $dateField): array
{
$grouped = [];
foreach ($rows as $row) {
$barcode = trim((string) ($row[$barcodeField] ?? ''));
$barcode = $this->barcodeValue($row, $barcodeFields);
if ($barcode === '') {
continue;
}
@ -175,9 +176,9 @@ class SupplierStatementMatcher
$grouped[$key] ??= [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcodeKey,
'barcode' => $barcode,
'barcodeKey' => $barcodeKey,
'supplierBarcode' => $barcodeField === 'barcode' ? $barcode : '',
'supplierBarcode' => in_array('barcode', $barcodeFields, true) ? $barcode : '',
'unitPrice' => $unitPrice,
'unitPrices' => [],
'qty' => 0.0,
@ -200,6 +201,19 @@ class SupplierStatementMatcher
return $grouped;
}
/** @param array<string,mixed> $row @param list<string> $fields */
private function barcodeValue(array $row, array $fields): string
{
foreach ($fields as $field) {
$value = trim((string) ($row[$field] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param array<string,array<string,mixed>> $groups
* @param array<string,list<string>> $fallbackIndex

View File

@ -111,10 +111,10 @@
</div>
@endif
<p class="sr-hint">
比对口径:按「采购单号 + 日期 + 条码」核对,同一采购单号、日期和条码的多行先合并数量与金额。
比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+条码能唯一匹配我方一条数据时兜底。<br>
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
</p>
@ -280,7 +280,7 @@
<th scope="col">比对结果</th>
<th scope="col">采购单号</th>
<th scope="col">日期</th>
<th scope="col">条码</th>
<th scope="col">我方商品编码 / 厂家条码</th>
<th scope="col">我方数量</th>
<th scope="col">我方单价</th>
<th scope="col">我方金额</th>

View File

@ -46,7 +46,7 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('厂家端数据')
->assertSeeText('厂家多')
->assertSeeText('取金额低的一方结算')
->assertSeeText('采购单号 + 日期 + 条码')
->assertSeeText('采购单号 + 日期 + 商品编码')
->assertSee('list="finance-supplier-options"', false)
->assertSeeText('对账人')
->assertSeeText('下载对账单模板');
@ -251,7 +251,7 @@ class SupplierReconciliationTest extends FeatureTestCase
'厂家多,1',
'数量/金额不一致,1',
'厂家少,0',
'采购单号,日期,条码,厂家条码,比对结果',
'采购单号,日期,我方商品编码,厂家条码,比对结果',
'PO-1',
'PO-2',
'PO-D',
@ -1027,10 +1027,10 @@ class SupplierReconciliationTest extends FeatureTestCase
public function test_matcher_normalizes_supplier_barcode_suffix(): void
{
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
// 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。
$mine = [
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$theirs = [
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
@ -1047,6 +1047,33 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
}
public function test_matcher_uses_product_code_instead_of_sku_code(): void
{
$mine = [[
'purchaseNo' => 'PO-CODE-1',
'date' => '2026-09-30',
'skuCode' => 'SKU-ONLY',
'productCode' => 'CJ019312N1178V01',
'unitPrice' => '182.5',
'qty' => '2',
'amount' => '365',
]];
$theirs = [[
'purchaseNo' => 'PO-CODE-1',
'createdAt' => '2026-09-30',
'barcode' => 'CJ019312N1097V01',
'unitPrice' => '182.5',
'qty' => '2',
'amount' => '365',
]];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']);
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']);
}
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
{
$mine = [
@ -1085,7 +1112,7 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
$this->assertStringContainsString('按日期+条码唯一匹配', $result['rows'][0]['note']);
$this->assertStringContainsString('按日期+商品编码唯一匹配', $result['rows'][0]['note']);
}
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void