fix: 供应商对账按商品编码汇总净差异 (#1026)
Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
parent
ef33a32b88
commit
f8d6d4e7ae
@ -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 => '',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user