path ?? storage_path(self::RELATIVE_PATH); if (! is_file($path)) { return $this->emptySnapshot('missing', '尚未导入本机逆向物流报价文件。'); } try { $payload = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { return $this->emptySnapshot('invalid', '本机逆向物流报价快照无法读取,请重新导入源文件。'); } if (! is_array($payload) || ! is_array($payload['sheets'] ?? null)) { return $this->emptySnapshot('invalid', '本机逆向物流报价快照结构不完整,请重新导入源文件。'); } $quotes = []; foreach (array_values($payload['sheets']) as $position => $sheet) { if (! is_array($sheet) || trim((string) ($sheet['name'] ?? '')) === '') { continue; } $quotes[] = $this->normalizeSheet($sheet, $position); } $rates = []; $rateGroups = []; $unparsedSheets = []; $originKeys = []; $destinationKeys = []; foreach ($quotes as $quote) { $extracted = $this->extractRates($quote); if ($extracted === null) { $unparsedSheets[] = $quote['sheet_name']; continue; } $rateGroups[] = $extracted['rule']; foreach ($extracted['rates'] as $rate) { $rates[] = $rate; if (! in_array($rate['origin_key'], $originKeys, true)) { $originKeys[] = $rate['origin_key']; } if (! in_array($rate['destination_key'], $destinationKeys, true)) { $destinationKeys[] = $rate['destination_key']; } } } return [ 'available' => true, 'state' => 'ready', 'message' => null, 'source_file' => basename(str_replace('\\', '/', (string) ($payload['sourcePath'] ?? '逆向物流报价.xlsx'))), 'generated_at' => (string) ($payload['generatedAt'] ?? ''), 'quotes' => $quotes, 'rates' => $rates, 'rate_groups' => $rateGroups, 'unparsed_sheets' => $unparsedSheets, 'provinces' => [ 'origins' => $originKeys, 'destinations' => $destinationKeys, ], 'summary' => [ 'quote_count' => count($quotes), 'carrier_count' => count(array_unique(array_column($quotes, 'carrier'))), 'dated_count' => count(array_filter($quotes, fn (array $quote) => $quote['effective_date'] !== null)), 'formula_count' => array_sum(array_column($quotes, 'formula_count')), ], 'rate_summary' => [ 'rate_count' => count($rates), 'carrier_count' => count($rateGroups), 'origin_count' => count(array_unique(array_column($rates, 'origin'))), 'destination_count' => count(array_unique(array_column($rates, 'destination'))), 'weight_band_count' => count(array_unique(array_filter( array_column($rates, 'weight_band'), static fn ($band) => $band !== null && $band !== '' ))), 'available_count' => count(array_filter($rates, static fn (array $rate) => $rate['available'])), 'missing_count' => count(array_filter($rates, static fn (array $rate) => ! $rate['available'])), ], ]; } /** * 把一个报价工作表拆分为运价明细;无法识别版式时返回 null。 * * @return array{rule:array,rates:list>}|null */ private function extractRates(array $quote): ?array { $values = $quote['values']; $layout = $this->detectLayout($values); if ($layout === null) { return null; } $notes = $this->noteLines($values, $layout); $context = [ 'carrier' => $quote['carrier'], 'sheet_name' => $quote['sheet_name'], 'quote_type' => $quote['quote_type'], 'effective_date' => $quote['effective_date'], 'layout' => $layout, 'notes' => $notes, ]; return match ($layout) { self::LAYOUT_MATRIX => $this->extractMatrixRates($values, $context), self::LAYOUT_SINGLE_DESTINATION => $this->extractSingleDestinationRates($values, $context), self::LAYOUT_WEIGHT_BAND => $this->extractWeightBandRates($values, $context), default => null, }; } /** @param list> $values */ private function detectLayout(array $values): ?string { $header = $values[1] ?? []; foreach ($header as $index => $label) { if (! is_string($label)) { continue; } if ($index >= 3 && preg_match('/^\d+\s*-\s*\d+\s*kg$/u', trim($label)) === 1) { return self::LAYOUT_WEIGHT_BAND; } } if (isset($header[0], $header[1]) && trim((string) $header[0]) === '出发省份' && trim((string) $header[1]) === '到达省份') { return self::LAYOUT_MATRIX; } if (isset($header[1]) && is_string($header[1]) && str_starts_with(trim($header[1]), '到达省份')) { return self::LAYOUT_SINGLE_DESTINATION; } return null; } /** * 矩阵版式:列方向按“到达省份”成组,每组两列(面单价格、续重价格),行方向为出发省份。 * * @param list> $values * @param array $context * @return array{rule:array,rates:list>} */ private function extractMatrixRates(array $values, array $context): array { $destinations = []; $subHeader = $values[2] ?? []; foreach ($subHeader as $index => $label) { if (! is_string($label) || trim($label) !== '面单价格') { continue; } $name = trim((string) ($values[1][$index] ?? '')); if ($name === '' || $name === '到达省份') { continue; } $destinations[$index] = ['name' => $name, 'increment' => $index + 1]; } $rates = []; $position = 0; foreach ($values as $rowIndex => $row) { if ($rowIndex < 3) { continue; } $origin = trim((string) ($row[1] ?? '')); if ($origin === '') { continue; } foreach ($destinations as $baseIndex => $destination) { $position++; $baseAmount = $this->amount($row[$baseIndex] ?? null); $incrementAmount = $this->amount($row[$destination['increment']] ?? null); $rates[] = $this->rateRow($context, $position, [ 'origin' => $origin, 'destination' => $destination['name'], 'weight_band' => null, 'base_amount' => $baseAmount, 'increment_amount' => $incrementAmount, 'source' => $this->cellRef($baseIndex, $rowIndex) . ':' . $this->cellRef($destination['increment'], $rowIndex), ]); } } return [ 'rule' => $this->rateRule($context, [ 'pricing_mode' => 'first_weight_increment', 'pricing_label' => '首重 + 续重 × (结算重量 - 1)', 'base_label' => '面单价格(首重 1kg)', 'increment_label' => '续重价格', 'route_label' => sprintf('行=出发省份,列=到达省份(%d 个到达省)', count($destinations)), 'weight_bands' => [], 'rates' => $rates, ]), 'rates' => $rates, ]; } /** * 单一到达省版式:A 列出发省份,随后两列为面单价格、续重价格。 * * @param list> $values * @param array $context * @return array{rule:array,rates:list>} */ private function extractSingleDestinationRates(array $values, array $context): array { $destination = trim((string) ($values[1][1] ?? '')); $destination = trim((string) preg_replace('/^到达省份/u', '', $destination)); if ($destination === '') { $destination = '江苏'; } $rates = []; $position = 0; foreach ($values as $rowIndex => $row) { if ($rowIndex < 3) { continue; } $origin = trim((string) ($row[0] ?? '')); if ($origin === '') { continue; } $baseAmount = $this->amount($row[1] ?? null); $incrementAmount = $this->amount($row[2] ?? null); if ($baseAmount === null && $incrementAmount === null) { continue; } $position++; $rates[] = $this->rateRow($context, $position, [ 'origin' => $origin, 'destination' => $destination, 'weight_band' => null, 'base_amount' => $baseAmount, 'increment_amount' => $incrementAmount, 'source' => $this->cellRef(1, $rowIndex) . ':' . $this->cellRef(2, $rowIndex), ]); } return [ 'rule' => $this->rateRule($context, [ 'pricing_mode' => 'base_plus_weight_rate', 'pricing_label' => '面单 + 重量 × 费率', 'base_label' => '面单价格', 'increment_label' => '续重价格', 'route_label' => '行=出发省份,到达固定为 ' . $destination, 'weight_bands' => [], 'rates' => $rates, ]), 'rates' => $rates, ]; } /** * 重量段版式:A 列出发省份、B 列面单费,其后每列一个重量段费率。 * * @param list> $values * @param array $context * @return array{rule:array,rates:list>} */ private function extractWeightBandRates(array $values, array $context): array { $bands = []; $header = $values[1] ?? []; foreach ($header as $index => $label) { if ($index < 3 || ! is_string($label)) { continue; } $label = trim($label); if ($label === '' || $label === '备注') { continue; } $bands[$index] = $label; } $destination = $this->weightBandDestination($context['notes']); $rates = []; $position = 0; foreach ($values as $rowIndex => $row) { if ($rowIndex < 2) { continue; } $origin = trim((string) ($row[0] ?? '')); if ($origin === '') { continue; } $baseAmount = $this->amount($row[1] ?? null); foreach ($bands as $index => $band) { $position++; $rates[] = $this->rateRow($context, $position, [ 'origin' => $origin, 'destination' => $destination, 'weight_band' => $band, 'base_amount' => $baseAmount, 'increment_amount' => $this->amount($row[$index] ?? null), 'source' => $this->cellRef($index, $rowIndex), ]); } } return [ 'rule' => $this->rateRule($context, [ 'pricing_mode' => 'base_plus_weight_rate', 'pricing_label' => '面单 + 重量段费率 × 结算重量(不足 15kg 按 15kg 计费,向上取整)', 'base_label' => '面单费', 'increment_label' => '重量段费率', 'route_label' => '行=出发省份,到达固定为 ' . $destination, 'weight_bands' => array_values($bands), 'rates' => $rates, ]), 'rates' => $rates, ]; } /** * @param array $context * @param array $values * @return array */ private function rateRow(array $context, int $position, array $values): array { return [ 'id' => sprintf('%s-%03d', $context['sheet_name'], $position), 'carrier' => $context['carrier'], 'sheet_name' => $context['sheet_name'], 'quote_type' => $context['quote_type'], 'origin' => $values['origin'], 'destination' => $values['destination'], 'origin_key' => self::provinceKey($values['origin']), 'destination_key' => self::provinceKey($values['destination']), 'weight_band' => $values['weight_band'], 'base_amount' => $values['base_amount'], 'increment_amount' => $values['increment_amount'], 'available' => $values['base_amount'] !== null || $values['increment_amount'] !== null, 'source' => $values['source'], ]; } /** * @param array $context * @param array $values * @return array */ private function rateRule(array $context, array $values): array { $rates = $values['rates']; return [ 'carrier' => $context['carrier'], 'sheet_name' => $context['sheet_name'], 'quote_type' => $context['quote_type'], 'layout' => $context['layout'], 'pricing_mode' => $values['pricing_mode'], 'pricing_label' => $values['pricing_label'], 'base_label' => $values['base_label'], 'increment_label' => $values['increment_label'], 'route_label' => $values['route_label'], 'base_unit' => '元/票', 'increment_unit' => '元/kg', 'weight_bands' => $values['weight_bands'], 'effective_date' => $context['effective_date'], 'constraints' => $this->constraintLines($context['notes']), 'notes' => $context['notes'], 'rate_count' => count($rates), 'available_count' => count(array_filter($rates, static fn (array $rate) => $rate['available'])), 'origin_count' => count(array_unique(array_column($rates, 'origin'))), 'destination_count' => count(array_unique(array_column($rates, 'destination'))), ]; } /** * 备注列:矩阵版式取 A 列尾部说明行,其余版式取最后一列说明。 * * @param list> $values * @return list */ private function noteLines(array $values, string $layout): array { [$startRow, $column] = match ($layout) { self::LAYOUT_MATRIX => [3, 0], self::LAYOUT_SINGLE_DESTINATION => [3, 3], self::LAYOUT_WEIGHT_BAND => [2, 5], default => [null, null], }; if ($column === null) { return []; } $lines = []; foreach ($values as $rowIndex => $row) { if ($rowIndex < $startRow) { continue; } $cell = $row[$column] ?? null; if (! is_string($cell) || trim($cell) === '') { continue; } foreach (preg_split('/\r\n|\r|\n/u', $cell) ?: [] as $line) { $line = trim($line); if ($line !== '') { $lines[] = $line; } } } return $lines; } /** @param list $notes @return list */ private function constraintLines(array $notes): array { $keywords = ['限重', '限长', '超标', '超长超重', '保丢', '计泡', '记泡', '抛比']; return array_values(array_filter( $notes, static function (string $line) use ($keywords): bool { foreach ($keywords as $keyword) { if (str_contains($line, $keyword)) { return true; } } return false; } )); } /** @param list $notes */ private function weightBandDestination(array $notes): string { foreach ($notes as $line) { if (str_contains($line, '常州仓')) { return '常州仓(江苏)'; } } return '江苏'; } private function amount(mixed $value): ?float { if (is_int($value) || is_float($value)) { return round((float) $value, 4); } if (! is_string($value)) { return null; } $value = trim($value); if ($value === '' || in_array($value, ['/', '-', '—', '无'], true)) { return null; } return is_numeric($value) ? round((float) $value, 4) : null; } private function cellRef(int $columnIndex, int $rowIndex): string { $label = ''; $value = $columnIndex + 1; while ($value > 0) { $value--; $label = chr(65 + ($value % 26)) . $label; $value = intdiv($value, 26); } return $label . ($rowIndex + 1); } /** 省份归一口径:去掉“省/市/自治区”后缀,仓名取括号内省份(如“常州仓(江苏)”)。 */ public static function provinceKey(string $name): string { $text = trim($name); if (preg_match('/^[^((]*[((]([^))]+)[))]$/u', $text, $matches) === 1) { $text = trim($matches[1]); } $text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text); return $text === '内蒙' ? '内蒙古' : $text; } private function normalizeSheet(array $sheet, int $position): array { $name = trim((string) $sheet['name']); $values = $this->normalizeMatrix($sheet['values'] ?? []); $formulas = $this->normalizeMatrix($sheet['formulas'] ?? []); $columnCount = max(array_map('count', $values ?: [[]])); return [ 'id' => sprintf('RL-%03d', $position + 1), 'sheet_index' => (int) ($sheet['index'] ?? $position), 'sheet_name' => $name, 'company_name' => $this->inferCompanyName($name), 'carrier' => $this->inferCarrier($name), 'quote_type' => $this->inferQuoteType($name), 'effective_date' => $this->extractEffectiveDate($values), 'source_range' => trim((string) ($sheet['address'] ?? '')), 'row_count' => count($values), 'column_count' => $columnCount, 'formula_count' => $this->formulaCount($formulas), 'status' => $this->extractEffectiveDate($values) === null ? '未标执行日期' : '已标执行日期', 'signature_status' => 'Excel 未提供', 'values' => $values, 'formulas' => $formulas, ]; } private function inferCompanyName(string $name): string { $companyName = trim((string) preg_replace('/(?:逆向|退货|报价表|报价)$/u', '', $name)); return $companyName !== '' ? $companyName : $name; } private function inferCarrier(string $name): string { foreach ([ '中通快运', '顺心捷达', '安能快运', '京广', 'EMS', '中通', '圆通', '德邦', '申通', '极兔', '安能', '邮政', '韵达', '顺丰', ] as $carrier) { if (str_contains($name, $carrier)) { return $carrier; } } return '其他快递'; } private function inferQuoteType(string $name): string { if (str_contains($name, '退货')) { return '退货报价'; } if (str_contains($name, '快运')) { return '逆向快运报价'; } return '逆向报价'; } private function extractEffectiveDate(array $values): ?string { foreach ($values as $row) { foreach ($row as $cell) { if (! is_string($cell)) { continue; } if (preg_match('/(20\d{2})[.\/\-年]\s*(\d{1,2})[.\/\-月]\s*(\d{1,2})日?/u', $cell, $match) === 1) { return sprintf('%04d-%02d-%02d', (int) $match[1], (int) $match[2], (int) $match[3]); } } } return null; } private function normalizeMatrix(mixed $matrix): array { if (! is_array($matrix)) { return []; } return array_map(function ($row) { if (! is_array($row)) { return [$this->normalizeCell($row)]; } return array_map(fn ($cell) => $this->normalizeCell($cell), array_values($row)); }, array_values($matrix)); } private function normalizeCell(mixed $cell): string|int|float|bool|null { if ($cell === null || is_string($cell) || is_int($cell) || is_float($cell) || is_bool($cell)) { return $cell; } return (string) $cell; } private function formulaCount(array $formulas): int { $count = 0; foreach ($formulas as $row) { foreach ($row as $formula) { if (is_string($formula) && trim($formula) !== '') { $count++; } } } return $count; } private function emptySnapshot(string $state, string $message): array { return [ 'available' => false, 'state' => $state, 'message' => $message, 'source_file' => '逆向物流报价.xlsx', 'generated_at' => '', 'quotes' => [], 'rates' => [], 'rate_groups' => [], 'unparsed_sheets' => [], 'provinces' => [ 'origins' => [], 'destinations' => [], ], 'summary' => [ 'quote_count' => 0, 'carrier_count' => 0, 'dated_count' => 0, 'formula_count' => 0, ], 'rate_summary' => [ 'rate_count' => 0, 'carrier_count' => 0, 'origin_count' => 0, 'destination_count' => 0, 'weight_band_count' => 0, 'available_count' => 0, 'missing_count' => 0, ], ]; } }