328 lines
14 KiB
PHP
328 lines
14 KiB
PHP
<?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);
|
||
}
|
||
}
|