CaiWuXiTong/modules/EcommerceFinance/Services/SupplierStatementMatcher.php
苏木 7aacb20058 feat: 供应商对账改为四层顺序匹配
按采购单号+条码+日期、采购单号+条码、条码+数量、快递单号+条码的顺序逐层匹配。

- 每层只处理上一轮剩余原始行
- 后三层允许日期差异
- 跨采购单号匹配时展示双方原单号
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-09 15:23:32 +08:00

513 lines
20 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 我方结算数据:
* 按四层顺序匹配,每层只处理上一层剩余的原始行:
* 1. 采购单号 + 商品编码 + 日期;
* 2. 采购单号 + 商品编码,忽略日期;
* 3. 商品编码 + 数量,忽略日期;
* 4. 快递单号 + 商品编码。
*
* 商品编码会比较我方 productCode/skuCode 与厂家条码去掉厂家编号/版本后缀后的值。
* 匹配过程不删除或改写双方原始行,未匹配行全部保留为厂家多或厂家少。
*/
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 LAYER_PURCHASE_DATE = 1;
private const LAYER_PURCHASE = 2;
private const LAYER_BARCODE_QTY = 3;
private const LAYER_EXPRESS = 4;
/**
* @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)
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function compare(array $mine, array $theirs): array
{
$mineRemaining = array_values($mine);
$theirsRemaining = array_values($theirs);
$pairs = [];
foreach ([
self::LAYER_PURCHASE_DATE,
self::LAYER_PURCHASE,
self::LAYER_BARCODE_QTY,
self::LAYER_EXPRESS,
] as $layer) {
$mineGroups = $this->groupForLayer($mineRemaining, true, $layer);
$theirsGroups = $this->groupForLayer($theirsRemaining, false, $layer);
$matchedKeys = array_intersect_key($mineGroups, $theirsGroups);
if ($matchedKeys === []) {
continue;
}
foreach (array_keys($matchedKeys) as $key) {
$pairs[] = [
'mine' => $mineGroups[$key],
'theirs' => $theirsGroups[$key],
'layer' => $layer,
];
}
$mineRemaining = $this->withoutMatchedLayerRows($mineRemaining, true, $layer, $matchedKeys);
$theirsRemaining = $this->withoutMatchedLayerRows($theirsRemaining, false, $layer, $matchedKeys);
}
$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 ($pairs as $pair) {
$rows[] = $this->comparisonRow($pair['mine'], $pair['theirs'], $pair['layer'], $summary);
}
$mineLeftovers = $this->groupForLayer($mineRemaining, true, self::LAYER_PURCHASE_DATE, true);
$theirsLeftovers = $this->groupForLayer($theirsRemaining, false, self::LAYER_PURCHASE_DATE, true);
foreach ($mineLeftovers as $group) {
$rows[] = $this->comparisonRow($group, null, null, $summary);
}
foreach ($theirsLeftovers as $group) {
$rows[] = $this->comparisonRow(null, $group, null, $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'],
]);
$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>> $rows
* @return array<string,array<string,mixed>>
*/
private function groupForLayer(array $rows, bool $mineSide, int $layer, bool $singletonFallback = false): array
{
$grouped = [];
foreach ($rows as $index => $row) {
$key = $this->matchLayerKey($row, $mineSide, $layer);
if ($key === null) {
if (! $singletonFallback) {
continue;
}
$key = "source\0" . $layer . "\0" . $index;
}
$barcode = $this->barcodeValue($row, $mineSide ? ['productCode', 'skuCode'] : ['barcode']);
$barcodeKey = $this->normalizeBarcode($barcode);
$purchaseNo = $this->normalizePurchaseNo($row['purchaseNo'] ?? '');
$date = $this->normalizeDate($row[$mineSide ? 'date' : 'createdAt'] ?? '');
$expressNo = trim((string) $this->firstValue($row, ['expressNo', 'express_no']));
$unitPrice = (float) ($row['unitPrice'] ?? 0);
$grouped[$key] ??= [
'purchaseNo' => '',
'purchaseNos' => [],
'date' => '',
'dates' => [],
'dateCount' => 0,
'dateEnd' => '',
'barcode' => $barcode,
'barcodeKey' => $barcodeKey,
'supplierBarcode' => $mineSide ? '' : $barcode,
'expressNo' => '',
'expressNos' => [],
'unitPrice' => 0.0,
'unitPrices' => [],
'qty' => 0.0,
'amount' => 0.0,
'sourceRows' => 0,
];
if ($purchaseNo !== '') {
$grouped[$key]['purchaseNos'][$purchaseNo] = true;
}
if ($date !== '') {
$grouped[$key]['dates'][$date] = true;
}
if ($expressNo !== '') {
$grouped[$key]['expressNos'][$expressNo] = true;
}
if ($unitPrice > 0) {
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
}
if ($grouped[$key]['barcode'] === '' && $barcode !== '') {
$grouped[$key]['barcode'] = $barcode;
}
if (! $mineSide && $grouped[$key]['supplierBarcode'] === '' && $barcode !== '') {
$grouped[$key]['supplierBarcode'] = $barcode;
}
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
$grouped[$key]['sourceRows']++;
}
foreach ($grouped as &$group) {
$purchaseNos = array_keys((array) $group['purchaseNos']);
sort($purchaseNos, SORT_NATURAL);
$group['purchaseNos'] = $purchaseNos;
$group['purchaseNo'] = (string) ($purchaseNos[0] ?? '');
$dates = array_keys((array) $group['dates']);
sort($dates);
$group['dates'] = $dates;
$group['date'] = (string) ($dates[0] ?? '');
$group['dateCount'] = count($dates);
$group['dateEnd'] = count($dates) > 1 ? (string) $dates[array_key_last($dates)] : '';
$expressNos = array_keys((array) $group['expressNos']);
sort($expressNos);
$group['expressNos'] = $expressNos;
$group['expressNo'] = (string) ($expressNos[0] ?? '');
$group = $this->finalizeGroup($group);
}
unset($group);
return $grouped;
}
/** @param list<array<string,mixed>> $rows @param array<string,mixed> $matchedKeys */
private function withoutMatchedLayerRows(array $rows, bool $mineSide, int $layer, array $matchedKeys): array
{
$remaining = [];
foreach ($rows as $row) {
$key = $this->matchLayerKey($row, $mineSide, $layer);
if ($key !== null && isset($matchedKeys[$key])) {
continue;
}
$remaining[] = $row;
}
return $remaining;
}
/** @param array<string,mixed> $row */
private function matchLayerKey(array $row, bool $mineSide, int $layer): ?string
{
$barcode = $this->barcodeValue($row, $mineSide ? ['productCode', 'skuCode'] : ['barcode']);
if ($barcode === '') {
return null;
}
$barcodeKey = $this->normalizeBarcode($barcode);
$purchaseNo = $this->normalizePurchaseNo($row['purchaseNo'] ?? '');
$date = $this->normalizeDate($row[$mineSide ? 'date' : 'createdAt'] ?? '');
$qtyRaw = trim((string) ($row['qty'] ?? ''));
$expressNo = $this->normalizeExpressNo($this->firstValue($row, ['expressNo', 'express_no']));
return match ($layer) {
self::LAYER_PURCHASE_DATE => $purchaseNo !== ''
? "purchase-date\0" . $purchaseNo . "\0" . $barcodeKey . "\0" . $date
: null,
self::LAYER_PURCHASE => $purchaseNo !== ''
? "purchase\0" . $purchaseNo . "\0" . $barcodeKey
: null,
self::LAYER_BARCODE_QTY => $qtyRaw !== ''
? "barcode-qty\0" . $barcodeKey . "\0" . number_format((float) $qtyRaw, 4, '.', '')
: null,
self::LAYER_EXPRESS => $expressNo !== ''
? "express\0" . $expressNo . "\0" . $barcodeKey
: null,
default => null,
};
}
/** @param array<string,mixed> $row @param list<string> $fields */
private function barcodeValue(array $row, array $fields): string
{
return $this->firstValue($row, $fields);
}
/** @param array<string,mixed> $row @param list<string> $fields */
private function firstValue(array $row, array $fields): string
{
foreach ($fields as $field) {
$value = trim((string) ($row[$field] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param array<string,mixed>|null $mineRow
* @param array<string,mixed>|null $theirsRow
* @param array<string,int|float> $summary
* @return array<string,mixed>
*/
private function comparisonRow(?array $mineRow, ?array $theirsRow, ?int $matchedLayer, array &$summary): array
{
$minePurchaseNos = array_values((array) ($mineRow['purchaseNos'] ?? []));
$theirsPurchaseNos = array_values((array) ($theirsRow['purchaseNos'] ?? []));
$minePurchaseNo = implode('、', $minePurchaseNos);
$theirsPurchaseNo = implode('、', $theirsPurchaseNos);
$purchaseNo = $minePurchaseNo !== '' ? $minePurchaseNo : $theirsPurchaseNo;
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
$theirsDate = (string) ($theirsRow['date'] ?? '');
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
$expressNo = (string) (
($theirsRow['expressNo'] ?? '') !== ''
? $theirsRow['expressNo']
: ($mineRow['expressNo'] ?? '')
);
$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);
$dateDifferenceDays = $this->dateDifferenceDays($date, $theirsDate);
$matchNote = $matchedLayer === null
? ''
: $this->matchLayerNote(
$matchedLayer,
$minePurchaseNos,
$theirsPurchaseNos,
$date,
$theirsDate,
);
if ($mineRow === null) {
$type = self::TYPE_SUPPLIER_EXTRA;
$settle = 0.0;
$note = '厂家整条多报:' . $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;
$note = $matchNote . '数量、金额一致,正常结算';
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
$settle = min($mineAmount, $theirsAmount);
$note = $matchNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
$summary['mismatch']++;
}
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
$summary['settleAmount'] += $settle;
return [
'purchaseNo' => $purchaseNo,
'minePurchaseNo' => $minePurchaseNo,
'theirsPurchaseNo' => $theirsPurchaseNo,
'purchaseNoDifference' => $minePurchaseNo !== ''
&& $theirsPurchaseNo !== ''
&& $minePurchaseNo !== $theirsPurchaseNo,
'date' => $date,
'mineDate' => (string) ($mineRow['date'] ?? ''),
'theirsDate' => $theirsDate,
'dateDifference' => $date !== '' && $theirsDate !== '' && $date !== $theirsDate,
'barcode' => $barcode,
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
'expressNo' => $expressNo,
'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' => $dateDifferenceDays ?? 0,
'dateDifferenceDays' => $dateDifferenceDays,
'dateCount' => max((int) ($mineRow['dateCount'] ?? 0), (int) ($theirsRow['dateCount'] ?? 0)),
'dateEnd' => (string) ($mineRow['dateEnd'] ?? $theirsRow['dateEnd'] ?? ''),
'matchedByLayer' => $matchedLayer,
'note' => $note,
];
}
/**
* @param list<string> $minePurchaseNos
* @param list<string> $theirsPurchaseNos
*/
private function matchLayerNote(
int $layer,
array $minePurchaseNos,
array $theirsPurchaseNos,
string $mineDate,
string $theirsDate,
): string {
$dateNote = '';
if ($mineDate !== '' || $theirsDate !== '') {
$dateNote = '(我方日期 '
. ($mineDate !== '' ? $mineDate : '空')
. ' / 厂家日期 '
. ($theirsDate !== '' ? $theirsDate : '空')
. ')';
}
return match ($layer) {
self::LAYER_PURCHASE_DATE => '',
self::LAYER_PURCHASE => '采购单号+商品编码匹配,已忽略日期差异' . $dateNote . ';',
self::LAYER_BARCODE_QTY => (
implode('、', $minePurchaseNos) !== implode('、', $theirsPurchaseNos)
? '采购单号不一致(我方 '
. (implode('、', $minePurchaseNos) ?: '空')
. ' / 厂家 '
. (implode('、', $theirsPurchaseNos) ?: '空')
. '),'
: ''
) . '商品编码+数量匹配,已忽略日期差异' . $dateNote . ';',
self::LAYER_EXPRESS => '快递单号+商品编码匹配;',
default => '',
};
}
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> $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 normalizePurchaseNo(mixed $value): string
{
$purchaseNo = ltrim(trim((string) $value), "'");
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
}
private function normalizeExpressNo(mixed $value): string
{
$expressNo = preg_replace('/\s+/u', '', trim((string) $value)) ?? '';
$expressNo = ltrim($expressNo, "'@");
return preg_match('/^(\d+)\.0+$/', $expressNo, $matches) === 1 ? $matches[1] : strtoupper($expressNo);
}
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 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);
}
}