fix: 供应商对账支持日期两天容差匹配 (#845)
严格匹配未命中时,同采购单号、同商品编码且数量金额一致、日期相差不超过 2 天,按一致处理。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
parent
f62ce3fbc6
commit
6e054e4e8a
@ -2,10 +2,13 @@
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* 厂家对账单 vs 我方结算数据:
|
||||
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
|
||||
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
|
||||
* - 严格行键未匹配时,采购单号+商品编码一致、数量金额一致且日期相差不超过 2 天,仍按一致处理;
|
||||
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
|
||||
*/
|
||||
class SupplierStatementMatcher
|
||||
@ -21,6 +24,8 @@ class SupplierStatementMatcher
|
||||
/** 厂家条码后缀:供应商编号 + 版本,如 N1097V01、N1269V01。 */
|
||||
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
|
||||
|
||||
private const DATE_TOLERANCE_DAYS = 2;
|
||||
|
||||
/**
|
||||
* @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)
|
||||
@ -37,6 +42,7 @@ class SupplierStatementMatcher
|
||||
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
|
||||
}
|
||||
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
|
||||
[$mineGrouped, $theirsGrouped] = $this->matchDateToleranceGroups($mineGrouped, $theirsGrouped);
|
||||
|
||||
$rows = [];
|
||||
$summary = [
|
||||
@ -69,6 +75,7 @@ class SupplierStatementMatcher
|
||||
$qtyDifference = $theirsQty - $mineQty;
|
||||
$amountDifference = $theirsAmount - $mineAmount;
|
||||
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
|
||||
$dateToleranceDays = (int) ($theirsRow['dateToleranceDays'] ?? 0);
|
||||
$fallbackNote = '';
|
||||
if (($theirsRow['matchedByFallback'] ?? false) === true) {
|
||||
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
|
||||
@ -92,7 +99,10 @@ class SupplierStatementMatcher
|
||||
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
|
||||
$type = self::TYPE_MATCH;
|
||||
$settle = $mineAmount;
|
||||
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
|
||||
$note = $fallbackNote . ($dateToleranceDays > 0
|
||||
? '采购单号、商品编码、数量和金额一致,日期相差 ' . $dateToleranceDays
|
||||
. ' 天(容差不超过 ' . self::DATE_TOLERANCE_DAYS . ' 天),正常结算'
|
||||
: '采购单号、日期、商品编码、数量和金额一致,正常结算');
|
||||
$summary['match']++;
|
||||
} else {
|
||||
$type = self::TYPE_MISMATCH;
|
||||
@ -125,6 +135,7 @@ class SupplierStatementMatcher
|
||||
'supplierOverAmount' => round(max($amountDifference, 0), 2),
|
||||
'type' => $type,
|
||||
'settleAmount' => $settle,
|
||||
'dateToleranceDays' => $dateToleranceDays,
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
@ -244,6 +255,95 @@ class SupplierStatementMatcher
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格键未匹配时,仅对同采购单号、同商品编码、数量金额一致且日期相差
|
||||
* 不超过 2 天的记录做一对一容错配对。
|
||||
*
|
||||
* @param array<string,array<string,mixed>> $mine
|
||||
* @param array<string,array<string,mixed>> $theirs
|
||||
* @return array{0:array<string,array<string,mixed>>,1:array<string,array<string,mixed>>}
|
||||
*/
|
||||
private function matchDateToleranceGroups(array $mine, array $theirs): array
|
||||
{
|
||||
$mineOnly = array_diff_key($mine, $theirs);
|
||||
$theirsOnly = array_diff_key($theirs, $mine);
|
||||
$candidates = [];
|
||||
|
||||
foreach ($mineOnly as $mineKey => $mineRow) {
|
||||
if ((string) $mineRow['purchaseNo'] === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($theirsOnly as $theirsKey => $theirsRow) {
|
||||
if ((string) $theirsRow['purchaseNo'] !== (string) $mineRow['purchaseNo']
|
||||
|| (string) $theirsRow['barcodeKey'] !== (string) $mineRow['barcodeKey']) {
|
||||
continue;
|
||||
}
|
||||
$days = $this->dateDifferenceDays((string) $mineRow['date'], (string) $theirsRow['date']);
|
||||
if ($days === null || $days < 1 || $days > self::DATE_TOLERANCE_DAYS
|
||||
|| ! $this->groupsHaveEqualTotals($mineRow, $theirsRow)) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = [
|
||||
'days' => $days,
|
||||
'mine_key' => $mineKey,
|
||||
'theirs_key' => $theirsKey,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($candidates, static fn (array $left, array $right): int => [
|
||||
$left['days'],
|
||||
$left['mine_key'],
|
||||
$left['theirs_key'],
|
||||
] <=> [
|
||||
$right['days'],
|
||||
$right['mine_key'],
|
||||
$right['theirs_key'],
|
||||
]);
|
||||
|
||||
$usedMine = [];
|
||||
$usedTheirs = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$mineKey = (string) $candidate['mine_key'];
|
||||
$theirsKey = (string) $candidate['theirs_key'];
|
||||
if (isset($usedMine[$mineKey]) || isset($usedTheirs[$theirsKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$theirsRow = $theirs[$theirsKey];
|
||||
$theirsRow['dateToleranceDays'] = (int) $candidate['days'];
|
||||
$theirsRow['matchedByDateTolerance'] = true;
|
||||
$theirs[$mineKey] = $theirsRow;
|
||||
unset($theirs[$theirsKey]);
|
||||
$usedMine[$mineKey] = true;
|
||||
$usedTheirs[$theirsKey] = true;
|
||||
}
|
||||
|
||||
return [$mine, $theirs];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $left @param array<string,mixed> $right */
|
||||
private function groupsHaveEqualTotals(array $left, array $right): bool
|
||||
{
|
||||
return abs((float) $left['qty'] - (float) $right['qty']) < 0.005
|
||||
&& abs((float) $left['amount'] - (float) $right['amount']) < 0.005;
|
||||
}
|
||||
|
||||
private function dateDifferenceDays(string $left, string $right): ?int
|
||||
{
|
||||
try {
|
||||
$leftDate = CarbonImmutable::createFromFormat('!Y-m-d', $left);
|
||||
$rightDate = CarbonImmutable::createFromFormat('!Y-m-d', $right);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
if (! $leftDate instanceof CarbonImmutable || ! $rightDate instanceof CarbonImmutable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $leftDate->diffInDays($rightDate);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $left @param array<string,mixed> $right */
|
||||
private function mergeGroup(array $left, array $right): array
|
||||
{
|
||||
|
||||
@ -118,7 +118,8 @@
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
|
||||
比对口径:优先按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
|
||||
日期未对上时,如采购单号+商品编码一致、数量金额一致且日期相差不超过 2 天,仍按一致处理。<br>
|
||||
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
|
||||
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
|
||||
|
||||
89
tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php
Normal file
89
tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SupplierStatementMatcherTest extends TestCase
|
||||
{
|
||||
public function test_two_day_date_difference_matches_when_purchase_barcode_quantity_and_amount_match(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => '879735',
|
||||
'date' => '2026-09-10',
|
||||
'skuCode' => 'DNZ033609',
|
||||
'productCode' => 'DNZ033609N1178V01',
|
||||
'unitPrice' => '100',
|
||||
'qty' => '20',
|
||||
'amount' => '2000',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => '879735',
|
||||
'createdAt' => '2026-09-12',
|
||||
'barcode' => 'DNZ033609N1097V01',
|
||||
'unitPrice' => '100',
|
||||
'qty' => '20',
|
||||
'amount' => '2000',
|
||||
]];
|
||||
|
||||
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame(0, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(0, $result['summary']['supplierMissing']);
|
||||
$this->assertSame(0, $result['summary']['mismatch']);
|
||||
$this->assertSame(0.0, $result['summary']['differenceAmount']);
|
||||
$this->assertSame(2000.0, $result['summary']['settleAmount']);
|
||||
$this->assertSame(2, $result['rows'][0]['dateToleranceDays']);
|
||||
$this->assertStringContainsString('日期相差 2 天', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_date_difference_over_two_days_remains_a_difference(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => '879735',
|
||||
'date' => '2026-09-10',
|
||||
'skuCode' => 'DNZ033609',
|
||||
'qty' => '20',
|
||||
'amount' => '2000',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => '879735',
|
||||
'createdAt' => '2026-09-13',
|
||||
'barcode' => 'DNZ033609N1097V01',
|
||||
'qty' => '20',
|
||||
'amount' => '2000',
|
||||
]];
|
||||
|
||||
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(0, $result['summary']['match']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(1, $result['summary']['supplierMissing']);
|
||||
}
|
||||
|
||||
public function test_date_tolerance_still_requires_equal_quantity_and_amount(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => '879735',
|
||||
'date' => '2026-09-10',
|
||||
'skuCode' => 'DNZ033609',
|
||||
'qty' => '20',
|
||||
'amount' => '2000',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => '879735',
|
||||
'createdAt' => '2026-09-12',
|
||||
'barcode' => 'DNZ033609N1097V01',
|
||||
'qty' => '19',
|
||||
'amount' => '1900',
|
||||
]];
|
||||
|
||||
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(0, $result['summary']['match']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(1, $result['summary']['supplierMissing']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user