fix: 供应商对账按商品编码汇总净差异 (#1026)

Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
苏木 2026-11-10 10:17:05 +08:00 committed by sumu
parent ef33a32b88
commit f8d6d4e7ae
5 changed files with 196 additions and 32 deletions

View File

@ -36,6 +36,8 @@ class SupplierStatementMatcher
private const LAYER_EXPRESS = 4;
private const LAYER_BARCODE_BALANCE = 5;
/**
* @param list<array<string,mixed>> $mine 我方数据(expressNo/purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号)
* @param list<array<string,mixed>> $theirs 厂家数据(expressNo/purchaseNo/createdAt/barcode/unitPrice/qty/amount)
@ -73,18 +75,7 @@ class SupplierStatementMatcher
}
$rows = [];
$summary = [
'match' => 0,
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'settleQty' => 0.0,
'settleAmount' => 0.0,
];
$summary = $this->initialSummary();
foreach ($pairs as $pair) {
$rows[] = $this->comparisonRow($pair['mine'], $pair['theirs'], $pair['layer'], $summary);
@ -111,15 +102,48 @@ class SupplierStatementMatcher
$b['barcode'],
]);
$summary['mineAmount'] = round($summary['mineAmount'], 2);
$summary['theirsAmount'] = round($summary['theirsAmount'], 2);
$summary['supplierOverQty'] = round($summary['supplierOverQty'], 4);
$summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2);
$summary['settleQty'] = round($summary['settleQty'], 4);
$summary['settleAmount'] = round($summary['settleAmount'], 2);
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
return ['rows' => $rows, 'summary' => $this->finalizeSummary($summary)];
}
return ['rows' => $rows, 'summary' => $summary];
/**
* 四层明细匹配后,财务端再按商品编码汇总双方的净数量和净金额。
* 同一商品在不同日期、采购单拆行时,正负抵消项不会再显示为多条假差异。
*
* @param list<array<string,mixed>> $mine
* @param list<array<string,mixed>> $theirs
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function netByBarcode(array $mine, array $theirs): array
{
$mineGroups = $this->groupForLayer($mine, true, self::LAYER_BARCODE_BALANCE, true);
$theirsGroups = $this->groupForLayer($theirs, false, self::LAYER_BARCODE_BALANCE, true);
$keys = array_values(array_unique(array_merge(array_keys($mineGroups), array_keys($theirsGroups))));
sort($keys, SORT_NATURAL);
$rows = [];
$summary = $this->initialSummary();
foreach ($keys as $key) {
$mineRow = $mineGroups[$key] ?? null;
$theirsRow = $theirsGroups[$key] ?? null;
$matchedLayer = $mineRow !== null && $theirsRow !== null
? self::LAYER_BARCODE_BALANCE
: null;
$rows[] = $this->comparisonRow($mineRow, $theirsRow, $matchedLayer, $summary);
}
usort($rows, static fn (array $a, array $b): int => [
$a['type'] === self::TYPE_MATCH ? 1 : 0,
$a['date'],
$a['purchaseNo'],
$a['barcode'],
] <=> [
$b['type'] === self::TYPE_MATCH ? 1 : 0,
$b['date'],
$b['purchaseNo'],
$b['barcode'],
]);
return ['rows' => $rows, 'summary' => $this->finalizeSummary($summary)];
}
/**
@ -137,7 +161,7 @@ class SupplierStatementMatcher
if (! $singletonFallback) {
continue;
}
$key = "source\0" . $layer . "\0" . $index;
$key = "source\0" . ($mineSide ? 'mine' : 'theirs') . "\0" . $layer . "\0" . $index;
}
$barcode = $this->barcodeValue($row, $mineSide ? ['productCode', 'skuCode'] : ['barcode']);
@ -257,10 +281,48 @@ class SupplierStatementMatcher
self::LAYER_EXPRESS => $expressNo !== ''
? "express\0" . $expressNo . "\0" . $barcodeKey
: null,
self::LAYER_BARCODE_BALANCE => "barcode-balance\0" . $barcodeKey,
default => null,
};
}
/** @return array<string,int|float> */
private function initialSummary(): array
{
return [
'match' => 0,
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'settleQty' => 0.0,
'settleAmount' => 0.0,
];
}
/**
* @param array<string,int|float> $summary
* @return array<string,int|float>
*/
private function finalizeSummary(array $summary): array
{
$summary['mineAmount'] = round((float) $summary['mineAmount'], 2);
$summary['theirsAmount'] = round((float) $summary['theirsAmount'], 2);
$summary['supplierOverQty'] = round((float) $summary['supplierOverQty'], 4);
$summary['supplierOverAmount'] = round((float) $summary['supplierOverAmount'], 2);
$summary['settleQty'] = round((float) $summary['settleQty'], 4);
$summary['settleAmount'] = round((float) $summary['settleAmount'], 2);
$summary['differenceAmount'] = round(
(float) $summary['mineAmount'] - (float) $summary['settleAmount'],
2,
);
return $summary;
}
/** @param array<string,mixed> $row @param list<string> $fields */
private function barcodeValue(array $row, array $fields): string
{
@ -435,6 +497,7 @@ class SupplierStatementMatcher
: ''
) . '商品编码+数量匹配,已忽略日期差异' . $dateNote . ';',
self::LAYER_EXPRESS => '快递单号+商品编码匹配;',
self::LAYER_BARCODE_BALANCE => '四层明细匹配后,按商品编码汇总净数量、净金额;',
default => '',
};
}

View File

@ -610,7 +610,9 @@ class SupplierReconciliationPageData
];
}
$compared = $this->matcher->compare($mineRows, $theirRows);
$detailed = $this->matcher->compare($mineRows, $theirRows);
$compared = $this->matcher->netByBarcode($mineRows, $theirRows);
$compared['detailedRows'] = $detailed['rows'];
$compared['sourceTotals'] = $sourceTotals;
$compared['mineRows'] = count($mineRows);
$compared['theirsRows'] = count($theirRows);

View File

@ -184,10 +184,10 @@
<details class="sr-guide">
<summary>
<b>比对口径</b>
<span>依次按「采购单号 + 商品编码 + 日期」→「采购单号 + 商品编码」→「商品编码 + 数量」→「快递单号 + 商品编码」匹配;后三层允许日期差异。</span>
<span>依次按「采购单号 + 商品编码 + 日期」→「采购单号 + 商品编码」→「商品编码 + 数量」→「快递单号 + 商品编码」匹配,最后按商品编码汇总净差异;后三层允许日期差异。</span>
</summary>
<div class="sr-guide-body">
第一层先对有采购单号的数据按「采购单号 + 商品编码 + 日期」精确匹配;剩余数据再按「采购单号 + 商品编码」匹配,日期可不同且不限制天数;仍剩余的再按「商品编码 + 数量」匹配,日期可不同;最后仍未匹配且双方都有快递单号的,按「快递单号 + 商品编码」匹配。每层只处理上一层剩余数据,同一匹配键的多行先合并数量与金额。<br>
第一层先对有采购单号的数据按「采购单号 + 商品编码 + 日期」精确匹配;剩余数据再按「采购单号 + 商品编码」匹配,日期可不同且不限制天数;仍剩余的再按「商品编码 + 数量」匹配,日期可不同;最后仍未匹配且双方都有快递单号的,按「快递单号 + 商品编码」匹配。每层只处理上一层剩余数据,同一匹配键的多行先合并数量与金额;四层完成后,再按商品编码汇总双方净数量、净金额,同一商品只显示一条最终结果,避免日期或采购单拆行产生正负抵消的假差异。<br>
页面“双方原始合计”直接按筛选后的原始行汇总,不受匹配分组影响;匹配过程不会删除或改写我方终版货款对账原始行。<br>
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
@ -658,6 +658,9 @@
</td>
<td>
{{ $row['date'] !== '' ? $row['date'] : '—' }}
@if ((int) ($row['dateCount'] ?? 0) > 1 && ! empty($row['dateEnd']))
<div class="sr-muted" style="font-size:.7rem">至 {{ $row['dateEnd'] }}</div>
@endif
@if (! empty($row['dateDifference']))
<div class="sr-muted" style="font-size:.7rem">厂家:{{ $row['theirsDate'] }}</div>
@endif

View File

@ -101,9 +101,9 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame(0, $detail['summary']['supplierExtra']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame(1, $detail['summary']['mismatch']);
$this->assertSame('SF123456', $detail['rows'][0]['expressNo']);
$this->assertSame(4, $detail['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('快递单号+商品编码匹配', $detail['rows'][0]['note']);
$this->assertSame('SF123456', $detail['detailedRows'][0]['expressNo']);
$this->assertSame(4, $detail['detailedRows'][0]['matchedByLayer']);
$this->assertStringContainsString('快递单号+商品编码匹配', $detail['detailedRows'][0]['note']);
}
public function test_finance_supplier_list_shows_return_confirmation_progress(): void
@ -380,8 +380,9 @@ class SupplierReconciliationTest extends FeatureTestCase
$this->assertSame(2, $detail['mineRows']);
$this->assertSame(1, $detail['theirsRows']);
$this->assertSame(['mineQty' => 60.0, 'mineAmount' => 5010.0, 'theirsQty' => 30.0, 'theirsAmount' => 2505.0], $detail['sourceTotals']);
$this->assertSame(1, $detail['summary']['match']);
$this->assertSame(1, $detail['summary']['supplierMissing']);
$this->assertSame(0, $detail['summary']['match']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame(1, $detail['summary']['mismatch']);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
@ -400,6 +401,71 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertSeeText('厂家少');
}
public function test_finance_comparison_consolidates_split_rows_by_barcode_net_totals(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
['key' => 'net:a1', 'purchaseNo' => 'PO-A', 'date' => '2026-10-01', 'productCode' => 'NET-A-N1065V01', 'qty' => '14', 'amount' => '140'],
['key' => 'net:a2', 'purchaseNo' => 'PO-A', 'date' => '2026-10-02', 'productCode' => 'NET-A-N1065V01', 'qty' => '6', 'amount' => '60'],
['key' => 'net:b1', 'purchaseNo' => 'PO-B', 'date' => '2026-10-03', 'productCode' => 'NET-B-N1065V01', 'qty' => '20', 'amount' => '200'],
] as $row) {
$store->save(
'final:supplier:payment-reconciliation',
$row['key'],
[
'summary_month' => '2026-10',
'purchaseNo' => $row['purchaseNo'],
'date' => $row['date'],
'supplier' => '简阔',
'skuCode' => substr($row['productCode'], 0, -8),
'productCode' => $row['productCode'],
'qty' => $row['qty'],
'unitPrice' => '10',
'amount' => $row['amount'],
'reconciler' => '陈亲园',
],
$companyId,
null,
);
}
$this->seedStatement('简阔', [
array_merge($this->statementRow('NET-A-N1065V01', '10.00', '17', '170.00'), [
'purchaseNo' => 'PO-A',
'createdAt' => '2026-10-02',
'supplier' => '简阔',
]),
array_merge($this->statementRow('NET-A-N1065V01', '10.00', '3', '30.00'), [
'purchaseNo' => 'PO-A',
'createdAt' => '2026-10-03',
'supplier' => '简阔',
]),
array_merge($this->statementRow('NET-B-N1065V01', '10.00', '17', '170.00'), [
'purchaseNo' => 'PO-B',
'createdAt' => '2026-10-03',
'supplier' => '简阔',
]),
]);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'简阔',
$companyId,
['month' => '2026-10', 'reconciler' => '陈亲园'],
);
$rows = collect($detail['rows'])->keyBy('barcode');
$this->assertSame(3, $detail['mineRows']);
$this->assertSame(3, $detail['theirsRows']);
$this->assertSame(2, $detail['total_rows']);
$this->assertSame(1, $detail['summary']['match']);
$this->assertSame(1, $detail['summary']['mismatch']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame('一致', $rows['NET-A-N1065V01']['type']);
$this->assertSame('数量/金额不一致', $rows['NET-B-N1065V01']['type']);
$this->assertSame(-3.0, $rows['NET-B-N1065V01']['qtyDifference']);
$this->assertSame(-30.0, $rows['NET-B-N1065V01']['amountDifference']);
}
public function test_finance_comparison_matches_crossed_purchase_numbers_by_barcode_and_quantity(): void
{
$companyId = (int) $this->company->id;
@ -445,7 +511,7 @@ class SupplierReconciliationTest extends FeatureTestCase
$companyId,
['month' => '2026-10', 'reconciler' => '陈亲园'],
);
$rows = collect($detail['rows'])->keyBy('minePurchaseNo');
$rows = collect($detail['detailedRows'])->keyBy('minePurchaseNo');
$this->assertSame(2, $detail['summary']['match']);
$this->assertSame(0, $detail['summary']['supplierExtra']);
@ -466,8 +532,7 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertOk()
->assertSeeText('厂家:972032')
->assertSeeText('厂家:958052')
->assertSeeText('采购单号不一致')
->assertSeeText('商品编码+数量匹配');
->assertSeeText('四层明细匹配后,按商品编码汇总净数量、净金额');
}
public function test_supplier_without_upload_does_not_generate_or_push_missing_differences(): void

View File

@ -66,6 +66,37 @@ class SupplierStatementMatcherTest extends TestCase
$this->assertSame(8, $result['rows'][0]['dateDifferenceDays']);
}
public function test_net_by_barcode_offsets_split_date_rows_and_keeps_only_real_difference(): void
{
$mine = [
['purchaseNo' => 'PO-A', 'date' => '2026-10-01', 'productCode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '14', 'amount' => '140'],
['purchaseNo' => 'PO-A', 'date' => '2026-10-02', 'productCode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '6', 'amount' => '60'],
['purchaseNo' => 'PO-B', 'date' => '2026-10-03', 'productCode' => 'B-1N1065V01', 'unitPrice' => '10', 'qty' => '20', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => 'PO-A', 'createdAt' => '2026-10-02', 'barcode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '17', 'amount' => '170'],
['purchaseNo' => 'PO-A', 'createdAt' => '2026-10-03', 'barcode' => 'A-1N1065V01', 'unitPrice' => '10', 'qty' => '3', 'amount' => '30'],
['purchaseNo' => 'PO-B', 'createdAt' => '2026-10-03', 'barcode' => 'B-1N1065V01', 'unitPrice' => '10', 'qty' => '17', 'amount' => '170'],
];
$result = app(SupplierStatementMatcher::class)->netByBarcode($mine, $theirs);
$byBarcode = collect($result['rows'])->keyBy('barcode');
$this->assertCount(2, $result['rows']);
$this->assertSame(SupplierStatementMatcher::TYPE_MATCH, $byBarcode['A-1N1065V01']['type']);
$this->assertSame(20.0, $byBarcode['A-1N1065V01']['mineQty']);
$this->assertSame(20.0, $byBarcode['A-1N1065V01']['theirsQty']);
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $byBarcode['B-1N1065V01']['type']);
$this->assertSame(-3.0, $byBarcode['B-1N1065V01']['qtyDifference']);
$this->assertSame(-30.0, $byBarcode['B-1N1065V01']['amountDifference']);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(1, $result['summary']['mismatch']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(400.0, $result['summary']['mineAmount']);
$this->assertSame(370.0, $result['summary']['theirsAmount']);
$this->assertSame(370.0, $result['summary']['settleAmount']);
}
public function test_purchase_and_barcode_match_then_reports_quantity_and_amount_difference(): void
{
$mine = [[