CaiWuXiTong/modules/EcommerceFinance/Services/SupplierStatementMatcher.php
苏木 f8d6d4e7ae fix: 供应商对账按商品编码汇总净差异 (#1026)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-10 10:17:05 +08:00

589 lines
23 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;
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)
* @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 = $this->initialSummary();
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'],
]);
return ['rows' => $rows, 'summary' => $this->finalizeSummary($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)];
}
/**
* 按当前匹配层对原始行归组;同键数量、金额相加,但仍记录来源行数和全部日期/采购单号。
*
* @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" . ($mineSide ? 'mine' : 'theirs') . "\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,
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
{
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;
$settleQty = 0.0;
$settle = 0.0;
$note = '厂家整条多报:' . $quantityDifference . ';四层匹配后仍未找到我方对应记录,不结算';
$summary['supplierExtra']++;
} elseif ($theirsRow === null) {
$type = self::TYPE_SUPPLIER_MISSING;
$settleQty = 0.0;
$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;
$settleQty = $mineQty;
$settle = $mineAmount;
$note = $matchNote . '数量、金额一致,正常结算';
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
if ($mineAmount <= $theirsAmount) {
$settleQty = $mineQty;
$settle = $mineAmount;
} else {
$settleQty = $theirsQty;
$settle = $theirsAmount;
}
$note = $matchNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
$summary['mismatch']++;
}
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
$summary['settleQty'] += $settleQty;
$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,
'settleQty' => $settleQty,
'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 => '快递单号+商品编码匹配;',
self::LAYER_BARCODE_BALANCE => '四层明细匹配后,按商品编码汇总净数量、净金额;',
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);
}
}