CaiWuXiTong/modules/EcommerceFinance/Services/SupplierStatementMatcher.php
苏木 a9c49c85cb fix: 修复厂家对账异常日期导致批量误报
Squash merge PR #961.
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-06 16:51:02 +08:00

603 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
* - 严格行键未匹配时,采购单号+商品编码一致、数量金额一致且日期相差不超过 7 天,仍按一致处理;
* - 厂家日期无法识别时,仅按采购单号+商品编码唯一且数量金额一致兜底,不猜测多行结果;
* - 我方同日期、条码、数量和金额完全一致但采购单号不同的重复行,优先保留厂家有对应采购单号的一条;
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
*/
class SupplierStatementMatcher
{
public const TYPE_MATCH = '一致';
public const TYPE_SUPPLIER_EXTRA = '厂家多';
public const TYPE_MISMATCH = '数量/金额不一致';
public const TYPE_SUPPLIER_MISSING = '厂家少';
/** 厂家条码后缀:供应商编号 + 版本,如 N1097V01、N1269V01。 */
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
private const DATE_TOLERANCE_DAYS = 7;
/**
* @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
{
$mine = $this->deduplicateMineRows($mine, $theirs);
$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;
}
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
[$mineGrouped, $theirsGrouped] = $this->matchDateToleranceGroups($mineGrouped, $theirsGrouped);
[$mineGrouped, $theirsGrouped] = $this->matchUnrecognizedDateGroups($mineGrouped, $theirsGrouped);
$rows = [];
$summary = [
'match' => 0,
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'settleAmount' => 0.0,
];
foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) {
$mineRow = $mineGrouped[$key] ?? null;
$theirsRow = $theirsGrouped[$key] ?? null;
$purchaseNo = (string) ($mineRow['purchaseNo'] ?? $theirsRow['purchaseNo'] ?? '');
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
$mineUnitPrice = (float) ($mineRow['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($theirsRow['unitPrice'] ?? 0);
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
$mineQty = (float) ($mineRow['qty'] ?? 0);
$mineAmount = (float) ($mineRow['amount'] ?? 0);
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
$qtyDifference = $theirsQty - $mineQty;
$amountDifference = $theirsAmount - $mineAmount;
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
$dateToleranceDays = (int) ($theirsRow['dateToleranceDays'] ?? 0);
$fallbackNote = '';
if (($theirsRow['matchedByFallback'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
} elseif (($theirsRow['matchedByUnrecognizedDate'] ?? false) === true) {
$dateCount = (int) ($mineRow['dateCount'] ?? 0);
$fallbackNote = $dateCount > 1
? '厂家创建时间无法识别,已汇总我方 ' . $dateCount . ' 个日期后按采购单号+商品编码匹配;'
: '厂家创建时间无法识别,已按采购单号+商品编码匹配;';
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,且同日期+商品编码有多条我方数据,无法唯一匹配;';
}
if ($mineRow === null) {
$type = self::TYPE_SUPPLIER_EXTRA;
$settle = 0.0;
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+商品编码,不结算';
$summary['supplierExtra']++;
} elseif ($theirsRow === null) {
$type = self::TYPE_SUPPLIER_MISSING;
$settle = 0.0;
$note = '厂家漏报:我方数量 ' . $this->number($mineQty)
. ',单价 ¥' . number_format($mineUnitPrice, 2)
. ',金额 ¥' . number_format($mineAmount, 2)
. ';按厂家数据结算(不付款)';
$summary['supplierMissing']++;
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
$type = self::TYPE_MATCH;
$settle = $mineAmount;
if (($theirsRow['matchedByUnrecognizedDate'] ?? false) === true) {
$note = $fallbackNote . '采购单号、商品编码、数量和金额一致,正常结算';
} else {
$note = $fallbackNote . ($dateToleranceDays > 0
? '采购单号、商品编码、数量和金额一致,日期相差 ' . $dateToleranceDays
. ' 天(容差不超过 ' . self::DATE_TOLERANCE_DAYS . ' 天),正常结算'
: '采购单号、日期、商品编码、数量和金额一致,正常结算');
}
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
$settle = min($mineAmount, $theirsAmount);
$note = $fallbackNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
$summary['mismatch']++;
}
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
$summary['settleAmount'] += $settle;
$rows[] = [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
'unitPrice' => $unitPrice,
'mineUnitPrice' => $mineUnitPrice,
'theirsUnitPrice' => $theirsUnitPrice,
'mineQty' => $mineQty,
'mineAmount' => $mineAmount,
'theirsQty' => $theirsQty,
'theirsAmount' => $theirsAmount,
'qtyDifference' => round($qtyDifference, 4),
'amountDifference' => round($amountDifference, 2),
'supplierOverQty' => round(max($qtyDifference, 0), 4),
'supplierOverAmount' => round(max($amountDifference, 0), 2),
'type' => $type,
'settleAmount' => $settle,
'dateToleranceDays' => $dateToleranceDays,
'dateCount' => (int) ($mineRow['dateCount'] ?? 1),
'dateEnd' => (string) ($mineRow['dateEnd'] ?? ''),
'note' => $note,
];
}
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'],
]);
$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['settleAmount'] = round($summary['settleAmount'], 2);
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
return ['rows' => $rows, 'summary' => $summary];
}
/**
* 同日期、条码、数量和金额一致,仅采购单号不同的我方记录属于重复来源。
* 厂家数据中有对应采购单号时保留该行;双方都没有明确采购单号时才退回首行。
*
* @param list<array<string,mixed>> $mine
* @param list<array<string,mixed>> $theirs
* @return list<array<string,mixed>>
*/
private function deduplicateMineRows(array $mine, array $theirs): array
{
$supplierPurchaseNumbers = [];
foreach ($theirs as $row) {
$barcodeKey = $this->normalizeBarcode(trim((string) ($row['barcode'] ?? '')));
$purchaseNo = $this->normalizePurchaseNo($row['purchaseNo'] ?? '');
if ($barcodeKey !== '' && $purchaseNo !== '') {
$supplierPurchaseNumbers[$barcodeKey][$purchaseNo] = true;
}
}
$signatureGroups = [];
foreach ($mine as $index => $row) {
$barcode = $this->barcodeValue($row, ['productCode', 'skuCode']);
$barcodeKey = $this->normalizeBarcode($barcode);
if ($barcodeKey === '') {
continue;
}
$purchaseNo = $this->normalizePurchaseNo($row['purchaseNo'] ?? '');
$signature = implode("\0", [
$this->normalizeDate($row['date'] ?? ''),
$barcodeKey,
number_format((float) ($row['qty'] ?? 0), 4, '.', ''),
number_format((float) ($row['amount'] ?? 0), 2, '.', ''),
]);
$signatureGroups[$signature][$purchaseNo ?? ''][] = $index;
}
$keepIndexes = [];
foreach ($signatureGroups as $signature => $purchaseGroups) {
if (count($purchaseGroups) === 1) {
foreach (reset($purchaseGroups) as $index) {
$keepIndexes[$index] = true;
}
continue;
}
$barcodeKey = explode("\0", $signature)[1];
$knownPurchaseGroups = [];
foreach ($purchaseGroups as $purchaseNo => $indexes) {
$purchaseNo = (string) $purchaseNo;
if ($purchaseNo !== '' && isset($supplierPurchaseNumbers[$barcodeKey][$purchaseNo])) {
$knownPurchaseGroups[$purchaseNo] = $indexes;
}
}
if ($knownPurchaseGroups === []) {
$knownPurchaseGroups = [array_key_first($purchaseGroups) => reset($purchaseGroups)];
}
foreach ($knownPurchaseGroups as $indexes) {
foreach ($indexes as $index) {
$keepIndexes[$index] = true;
}
}
}
ksort($keepIndexes);
$deduplicated = [];
foreach (array_keys($keepIndexes) as $index) {
$deduplicated[] = $mine[$index];
}
return $deduplicated;
}
/**
* 按「采购单号 + 日期 + 商品编码」归组,数量与金额相加。
*
* @param list<array<string,mixed>> $rows
* @param list<string> $barcodeFields
* @return array<string,array<string,mixed>>
*/
private function group(array $rows, array $barcodeFields, string $purchaseField, string $dateField): array
{
$grouped = [];
foreach ($rows as $row) {
$barcode = $this->barcodeValue($row, $barcodeFields);
if ($barcode === '') {
continue;
}
$barcodeKey = $this->normalizeBarcode($barcode);
$purchaseNo = $this->normalizePurchaseNo($row[$purchaseField] ?? '');
$date = $this->normalizeDate($row[$dateField] ?? '');
$unitPrice = (float) ($row['unitPrice'] ?? 0);
$key = $this->matchKey($purchaseNo, $date, $barcodeKey);
$grouped[$key] ??= [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'barcodeKey' => $barcodeKey,
'supplierBarcode' => in_array('barcode', $barcodeFields, true) ? $barcode : '',
'unitPrice' => $unitPrice,
'unitPrices' => [],
'qty' => 0.0,
'amount' => 0.0,
'matchedByFallback' => false,
'fallbackAmbiguous' => false,
];
if ($unitPrice > 0) {
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
}
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
}
foreach ($grouped as &$group) {
$group = $this->finalizeGroup($group);
}
unset($group);
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
* @return array<string,array<string,mixed>>
*/
private function resolveMissingPurchaseNumbers(array $groups, array $fallbackIndex): array
{
$resolved = [];
foreach ($groups as $key => $group) {
$targetKey = $key;
if ($group['purchaseNo'] === '') {
$candidates = $fallbackIndex[$group['date'] . "\0" . $group['barcodeKey']] ?? [];
if (count($candidates) === 1) {
$targetKey = $candidates[0];
$group['matchedByFallback'] = true;
} elseif (count($candidates) > 1) {
$group['fallbackAmbiguous'] = true;
}
}
if (isset($resolved[$targetKey])) {
$resolved[$targetKey] = $this->mergeGroup($resolved[$targetKey], $group);
} else {
$resolved[$targetKey] = $group;
}
}
return $resolved;
}
/**
* 严格键未匹配时,仅对同采购单号、同商品编码、数量金额一致且日期相差
* 不超过 7 天的记录做一对一容错配对。
*
* @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,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 matchUnrecognizedDateGroups(array $mine, array $theirs): array
{
$mineIndex = [];
foreach ($mine as $key => $row) {
if ((string) $row['purchaseNo'] === '') {
continue;
}
$lookupKey = $row['purchaseNo'] . "\0" . $row['barcodeKey'];
$mineIndex[$lookupKey] ??= [
'keys' => [],
'qty' => 0.0,
'amount' => 0.0,
'dates' => [],
];
$mineIndex[$lookupKey]['keys'][] = $key;
$mineIndex[$lookupKey]['qty'] += (float) $row['qty'];
$mineIndex[$lookupKey]['amount'] += (float) $row['amount'];
$mineIndex[$lookupKey]['dates'][] = (string) $row['date'];
}
$theirsIndex = [];
foreach ($theirs as $theirsKey => $theirsRow) {
if ((string) $theirsRow['purchaseNo'] === '' || $this->isRecognizedDate((string) $theirsRow['date'])) {
continue;
}
$lookupKey = $theirsRow['purchaseNo'] . "\0" . $theirsRow['barcodeKey'];
$theirsIndex[$lookupKey][] = $theirsKey;
}
foreach ($theirsIndex as $lookupKey => $theirsKeys) {
if (count($theirsKeys) !== 1) {
continue;
}
$mineAggregate = $mineIndex[$lookupKey] ?? null;
$theirsKey = $theirsKeys[0];
$theirsRow = $theirs[$theirsKey];
if ($mineAggregate === null
|| abs((float) $mineAggregate['qty'] - (float) $theirsRow['qty']) >= 0.005
|| abs((float) $mineAggregate['amount'] - (float) $theirsRow['amount']) >= 0.005) {
continue;
}
$mineKeys = $mineAggregate['keys'];
$mineKey = $mineKeys[0];
if (isset($theirs[$mineKey])) {
continue;
}
$combinedMine = $mine[$mineKey];
foreach (array_slice($mineKeys, 1) as $mergeKey) {
$combinedMine = $this->mergeGroup($combinedMine, $mine[$mergeKey]);
unset($mine[$mergeKey]);
}
$dates = array_values(array_unique($mineAggregate['dates']));
sort($dates);
$combinedMine['dateCount'] = count($dates);
if (count($dates) > 1) {
$combinedMine['dateEnd'] = $dates[array_key_last($dates)];
}
$mine[$mineKey] = $combinedMine;
$theirsRow['matchedByUnrecognizedDate'] = true;
$theirs[$mineKey] = $theirsRow;
unset($theirs[$theirsKey]);
}
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
{
$left['qty'] += (float) $right['qty'];
$left['amount'] += (float) $right['amount'];
$left['unitPrices'] += (array) $right['unitPrices'];
$left['matchedByFallback'] = (bool) $left['matchedByFallback'] || (bool) $right['matchedByFallback'];
$left['fallbackAmbiguous'] = (bool) $left['fallbackAmbiguous'] || (bool) $right['fallbackAmbiguous'];
if ($left['supplierBarcode'] === '' && $right['supplierBarcode'] !== '') {
$left['supplierBarcode'] = $right['supplierBarcode'];
}
return $this->finalizeGroup($left);
}
/** @param array<string,mixed> $group @return array<string,mixed> */
private function finalizeGroup(array $group): array
{
$prices = array_keys((array) ($group['unitPrices'] ?? []));
if (count($prices) === 1) {
$group['unitPrice'] = (float) $prices[0];
} elseif ((float) $group['qty'] != 0.0) {
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
}
return $group;
}
private function matchKey(string $purchaseNo, string $date, string $barcode): string
{
return $purchaseNo . "\0" . $date . "\0" . $barcode;
}
private function normalizePurchaseNo(mixed $value): string
{
$purchaseNo = ltrim(trim((string) $value), "'");
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
}
private function normalizeDate(mixed $value): string
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) === 1) {
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
return $raw;
}
private function isRecognizedDate(string $value): bool
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches) !== 1) {
return false;
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]);
}
private function describeDifference(float $qtyDifference, float $amountDifference): string
{
$parts = [];
if (abs($qtyDifference) >= 0.005) {
$parts[] = '厂家数量' . ($qtyDifference > 0 ? '多 ' : '少 ') . $this->number(abs($qtyDifference));
}
if (abs($amountDifference) >= 0.005) {
$parts[] = '厂家金额' . ($amountDifference > 0 ? '多 ¥' : '少 ¥') . number_format(abs($amountDifference), 2);
}
return $parts === [] ? '数量、金额一致' : implode(',', $parts);
}
private function number(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
/** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */
private function normalizeBarcode(string $barcode): string
{
$normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode);
return strtoupper($normalized === null || $normalized === '' ? $barcode : $normalized);
}
}