CaiWuXiTong/modules/EcommerceFinance/Services/SupplierStatementMatcher.php

328 lines
14 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;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
*/
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';
/**
* @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
{
$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);
$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);
$fallbackNote = '';
if (($theirsRow['matchedByFallback'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
} 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;
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
$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,
'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>> $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;
}
/** @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 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);
}
}