> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号) * @param list> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount) * @return array{rows:list>,summary:array} */ 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> $rows * @param list $barcodeFields * @return array> */ 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 $row @param list $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> $groups * @param array> $fallbackIndex * @return array> */ 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 $left @param array $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 $group @return array */ 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); } }