Squash merge PR #961. Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
603 lines
25 KiB
PHP
603 lines
25 KiB
PHP
<?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);
|
||
}
|
||
}
|