diff --git a/app/Support/ReverseLogisticsQuoteSnapshot.php b/app/Support/ReverseLogisticsQuoteSnapshot.php new file mode 100644 index 00000000..a88b0987 --- /dev/null +++ b/app/Support/ReverseLogisticsQuoteSnapshot.php @@ -0,0 +1,712 @@ +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, + ], + ]; + } +} diff --git a/resources/views/business/pages/courier-payables/reverse-logistics-quotes.blade.php b/resources/views/business/pages/courier-payables/reverse-logistics-quotes.blade.php index 4ac6f541..c1e8ad36 100644 --- a/resources/views/business/pages/courier-payables/reverse-logistics-quotes.blade.php +++ b/resources/views/business/pages/courier-payables/reverse-logistics-quotes.blade.php @@ -1,7 +1,943 @@ -
-
- -
逆向物流报价尚未维护
-
确认报价字段和计费规则后,可在此建立报价。
+@php + $reverseQuoteSnapshot = app(\App\Support\ReverseLogisticsQuoteSnapshot::class)->load(); + $reverseQuotes = $reverseQuoteSnapshot['quotes']; + $reverseRates = $reverseQuoteSnapshot['rates'] ?? []; + $reverseRules = $reverseQuoteSnapshot['rate_groups'] ?? []; + $reverseRateSummary = $reverseQuoteSnapshot['rate_summary'] ?? [ + 'rate_count' => 0, + 'carrier_count' => 0, + 'origin_count' => 0, + 'destination_count' => 0, + 'weight_band_count' => 0, + 'available_count' => 0, + 'missing_count' => 0, + ]; + $rateCarriers = collect($reverseRules)->pluck('carrier')->unique()->values()->all(); + $rateOrigins = collect($reverseRates)->pluck('origin')->unique()->sort()->values()->all(); + $rateDestinations = collect($reverseRates)->pluck('destination')->unique()->sort()->values()->all(); + $calcOrigins = $reverseQuoteSnapshot['provinces']['origins'] ?? []; + $calcDestinations = $reverseQuoteSnapshot['provinces']['destinations'] ?? []; +@endphp + +
+ + +
+
+ +
+
{{ $reverseQuoteSnapshot['source_file'] }}
+
本机私有报价源 · 原始工作簿未修改 · 金额按原表数值直读
+
+
+
+
报价版本
+
{{ $reverseQuoteSnapshot['summary']['quote_count'] }}
+
+
+
运价明细
+
{{ $reverseRateSummary['rate_count'] }}
+
+
+
可计算运价
+
{{ $reverseRateSummary['available_count'] }}
+
+
+
无报价行
+
{{ $reverseRateSummary['missing_count'] }}
+
+
+
已标执行日期
+
{{ $reverseQuoteSnapshot['summary']['dated_count'] }}
+
+ + @if ($reverseQuoteSnapshot['available']) +
+
+ 计费规则 + + + 每种承运商的原表口径(面单、续重、重量段、限重、计泡) + +
+ +
+ @foreach ($reverseRules as $rule) +
+
+ {{ $rule['carrier'] }} + {{ $rule['sheet_name'] }} +
+
+
计费方式
{{ $rule['pricing_label'] }}
+
生效日期
{{ $rule['effective_date'] ?? '未标注' }}
+
取价方向
{{ $rule['route_label'] ?? '出发省份 → 到达省份' }}
+
面单口径
{{ $rule['base_label'] }}({{ $rule['base_unit'] }})
+
续重口径
{{ $rule['increment_label'] }}({{ $rule['increment_unit'] }})
+
重量段
{{ $rule['weight_bands'] === [] ? '首重 1kg + 续重' : implode(' / ', $rule['weight_bands']) }}
+
运价覆盖
{{ $rule['rate_count'] }} 条(可用 {{ $rule['available_count'] }})· 出发 {{ $rule['origin_count'] }} 省 · 到达 {{ $rule['destination_count'] }} 处
+
+ @if ($rule['notes'] !== []) +
+ 原表备注与限制({{ count($rule['notes']) }} 条) +
    + @foreach ($rule['notes'] as $note) +
  • {{ $note }}
  • + @endforeach +
+
+ @endif +
+ @endforeach +
+
+ +
+
+ 运费计算器 + + + 计泡 6000;按各承运商原表口径计算,附加费按原表备注 + +
+ +
+ + + + + + + +
+ +
+
+ +
+ 按条件自动试算 + + + 未填写长宽高,按结算重量计算 + +
+ +
+ + + + + + + + + + + + + +
承运商线路计费重量(kg)计算式运费(元)加收(元)附加费 / 说明
+
+
+ +
+
+ + + + +
+ +
+
+ +
+ 共 {{ count($reverseRates) }} 条运价 + + + 面单价格与续重/费率直接取自原表单元格,页面不重新折算 + +
+ +
+ + + + + + + + + + + + + + + +
承运商出发省份到达省份计费方式面单 / 首重(元/票)续重 / 重量段费率(元/kg)重量段生效日期来源
+
+ + +
+ +
+
+ 报价来源 · 共 {{ count($reverseQuotes) }} 个版本 + + + 原始报价规则保留,可查看每个工作表的完整单元格 + +
+ +
+ + + + + + + + + + + + + + + +
报价版本快递公司报价类型原始范围数据规模执行日期签章资料状态操作
+
+
+ @else +
+
+
+ +
{{ $reverseQuoteSnapshot['message'] }}
+
导入本机逆向物流报价文件后,会在此显示各承运商的报价版本和运价明细。
+
+
+
+ @endif + + @if ($reverseQuoteSnapshot['available']) + + + + @endif
diff --git a/tests/Feature/Business/QuoteManagementPageTest.php b/tests/Feature/Business/QuoteManagementPageTest.php index 7ab6e501..09c93fcf 100644 --- a/tests/Feature/Business/QuoteManagementPageTest.php +++ b/tests/Feature/Business/QuoteManagementPageTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; use App\Support\QuoteManagementSnapshot; +use App\Support\ReverseLogisticsQuoteSnapshot; use Illuminate\Http\Exceptions\HttpResponseException; use Symfony\Component\HttpKernel\Exception\HttpException; use Tests\Feature\FeatureTestCase; @@ -12,6 +13,8 @@ class QuoteManagementPageTest extends FeatureTestCase { private string $fixturePath; + private string $reverseFixturePath; + protected function setUp(): void { parent::setUp(); @@ -66,6 +69,31 @@ class QuoteManagementPageTest extends FeatureTestCase ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath)); + + $this->reverseFixturePath = storage_path('framework/testing/reverse-logistics-' . uniqid('', true) . '.json'); + file_put_contents($this->reverseFixturePath, json_encode([ + 'sourcePath' => 'C:/private/逆向物流报价.xlsx', + 'sheets' => [ + [ + 'index' => 0, + 'name' => '申通退货', + 'address' => 'A1:D5', + 'values' => [ + ['报价单', null, null, null], + ['出发省份', '到达省份', '江苏', null], + [null, null, '面单价格', '续重价格'], + [null, '广东', 9, 1.75], + ['1、该价格不含税,生效日期:2026年5月1日', null, null, null], + ], + 'formulas' => [[], [], [], [], []], + ], + ], + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + + app()->instance( + ReverseLogisticsQuoteSnapshot::class, + new ReverseLogisticsQuoteSnapshot($this->reverseFixturePath) + ); } protected function tearDown(): void @@ -74,6 +102,10 @@ class QuoteManagementPageTest extends FeatureTestCase unlink($this->fixturePath); } + if (isset($this->reverseFixturePath) && is_file($this->reverseFixturePath)) { + unlink($this->reverseFixturePath); + } + parent::tearDown(); } @@ -92,7 +124,8 @@ class QuoteManagementPageTest extends FeatureTestCase ->assertSeeText('报价管理') ->assertSee('data-quote-view="reverse-logistics"', false) ->assertSee('data-quote-panel="reverse-logistics"', false) - ->assertSeeText('逆向物流报价尚未维护') + ->assertSeeText('计费规则') + ->assertSee('data-reverse-rate-body', false) ->assertSeeText('报价单合并.xlsx') ->assertSeeText('报价名称') ->assertSeeText('快递公司') @@ -147,7 +180,18 @@ class QuoteManagementPageTest extends FeatureTestCase ->assertOk() ->assertSee('data-quote-view="reverse-logistics" aria-controls="quote-reverse-logistics-panel" aria-selected="true"', false) ->assertSee('id="quote-reverse-logistics-panel" role="tabpanel" data-quote-panel="reverse-logistics"', false) - ->assertSeeText('逆向物流报价尚未维护'); + ->assertSeeText('计费规则') + ->assertSeeText('申通退货') + ->assertSee('共 1 条运价', false) + ->assertSee('首重 + 续重 × (结算重量 - 1)', false) + ->assertSee('2026-05-01', false) + ->assertSee('"origin":"广东"', false) + ->assertSee('"destination":"江苏"', false) + ->assertSee('"increment_amount":1.75', false) + ->assertSee('data-reverse-calc-body', false) + ->assertSeeText('运费计算器') + ->assertSee('data-reverse-calc="weight"', false) + ->assertSee('data-reverse-rate-body', false); } public function testQuoteManagementPageKeepsTheShellWhenThePrivateSnapshotIsMissing(): void @@ -157,13 +201,20 @@ class QuoteManagementPageTest extends FeatureTestCase new QuoteManagementSnapshot(storage_path('framework/testing/missing-quote-snapshot.json')) ); + app()->instance( + ReverseLogisticsQuoteSnapshot::class, + new ReverseLogisticsQuoteSnapshot(storage_path('framework/testing/missing-reverse-logistics-snapshot.json')) + ); + $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'quote-management', + 'quote_view' => 'reverse-logistics', ])) ->assertOk() ->assertSeeText('尚未导入本机报价文件。') + ->assertSeeText('尚未导入本机逆向物流报价文件。') ->assertSeeText('报价页面仍可访问') ->assertDontSee('class="business-blank-page"', false); } diff --git a/tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php b/tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php new file mode 100644 index 00000000..8822744c --- /dev/null +++ b/tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php @@ -0,0 +1,155 @@ +fixturePath = storage_path('framework/testing/reverse-logistics-' . uniqid('', true) . '.json'); + file_put_contents($this->fixturePath, json_encode([ + 'sourcePath' => 'C:/private/逆向物流报价.xlsx', + 'generatedAt' => '2026-09-29 12:00:00', + 'sheets' => [ + [ + 'index' => 0, + 'name' => '申通退货', + 'address' => 'A1:F8', + 'values' => [ + ['报价单', null, null, null, null, null], + ['出发省份', '到达省份', '江苏', null, '安徽', null], + [null, null, '面单价格', '续重价格', '面单价格', '续重价格'], + [null, '江苏', 9, 1, 9, 1], + [null, '广东', 9, 1.75, 9, 1.75], + ['1、该价格不含税,生效日期:2026年5月1日', null, null, null, null, null], + ['4、计泡标准:长*宽*高/6000', null, null, null, null, null], + ['7、结算方式:总价=首重+续重*(货物结算重量-1)', null, null, null, null, null], + ], + 'formulas' => [[], [], [], [], []], + ], + [ + 'index' => 1, + 'name' => '常州德邦逆向', + 'address' => 'A1:D5', + 'values' => [ + ['常州德邦逆向报价2026.4.7', null, null, null], + ['出发省份', '到达省份江苏', null, '备注:'], + [null, '面单价格', '续重价格', null], + ['江苏', 9, 1, '1、此价格单件限重60KG以内 单边超1.6米或三边超2.5米加收7元每单'], + ['新疆', 18, 5.5, '6.计算方式:面单+重量*费率'], + ], + 'formulas' => [[], [], [], [], []], + ], + [ + 'index' => 2, + 'name' => '中通快运逆向', + 'address' => 'A1:F4', + 'values' => [ + ['中通快运逆向2026.4.21起', null, null, null, null, null], + ['省份', '面单费', '省份', '0-30kg', '31-60kg', '备注'], + ['安徽省', 9, '安徽省', 0.95, 0.95, '1、目前该报价仅支持常州仓收件地址。'], + ['新疆', '/', '新疆', '/', '/', null], + ], + 'formulas' => [[], [], [], []], + ], + [ + 'index' => 3, + 'name' => '未识别报价表', + 'address' => 'A1:B2', + 'values' => [['随便', '什么'], ['没有报价结构', null]], + 'formulas' => [[], []], + ], + ], + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + protected function tearDown(): void + { + if (isset($this->fixturePath) && is_file($this->fixturePath)) { + unlink($this->fixturePath); + } + + parent::tearDown(); + } + + public function testItSplitsEverySupportedSheetLayoutIntoRates(): void + { + $snapshot = (new ReverseLogisticsQuoteSnapshot($this->fixturePath))->load(); + + $this->assertTrue($snapshot['available']); + $this->assertSame(['未识别报价表'], $snapshot['unparsed_sheets']); + $this->assertSame(['申通', '德邦', '中通快运'], array_column($snapshot['rate_groups'], 'carrier')); + $this->assertSame(10, $snapshot['rate_summary']['rate_count']); + $this->assertSame(8, $snapshot['rate_summary']['available_count']); + $this->assertSame(2, $snapshot['rate_summary']['missing_count']); + $this->assertSame(2, $snapshot['rate_summary']['weight_band_count']); + $this->assertSame(['江苏', '广东', '新疆', '安徽'], $snapshot['provinces']['origins']); + $this->assertSame(['江苏', '安徽'], $snapshot['provinces']['destinations']); + + $rules = collect($snapshot['rate_groups'])->keyBy('carrier'); + + $this->assertSame(4, $rules['申通']['rate_count']); + $this->assertSame('2026-05-01', $rules['申通']['effective_date']); + $this->assertSame('首重 + 续重 × (结算重量 - 1)', $rules['申通']['pricing_label']); + $this->assertSame('行=出发省份,列=到达省份(2 个到达省)', $rules['申通']['route_label']); + $this->assertContains('4、计泡标准:长*宽*高/6000', $rules['申通']['constraints']); + + $this->assertSame(2, $rules['德邦']['rate_count']); + $this->assertSame('2026-04-07', $rules['德邦']['effective_date']); + $this->assertSame('面单 + 重量 × 费率', $rules['德邦']['pricing_label']); + + $this->assertSame(4, $rules['中通快运']['rate_count']); + $this->assertSame(2, $rules['中通快运']['available_count']); + $this->assertSame('2026-04-21', $rules['中通快运']['effective_date']); + $this->assertSame(['0-30kg', '31-60kg'], $rules['中通快运']['weight_bands']); + $this->assertSame(1, $rules['中通快运']['destination_count']); + + $rates = collect($snapshot['rates']); + + $shentong = $rates->firstWhere(fn (array $rate) => $rate['carrier'] === '申通' + && $rate['origin'] === '广东' + && $rate['destination'] === '江苏'); + $this->assertNotNull($shentong); + $this->assertSame(9.0, $shentong['base_amount']); + $this->assertSame(1.75, $shentong['increment_amount']); + $this->assertSame('C5:D5', $shentong['source']); + $this->assertSame('广东', $shentong['origin_key']); + $this->assertSame('江苏', $shentong['destination_key']); + $this->assertTrue($shentong['available']); + + $deppon = $rates->firstWhere(fn (array $rate) => $rate['carrier'] === '德邦' && $rate['origin'] === '新疆'); + $this->assertNotNull($deppon); + $this->assertSame('江苏', $deppon['destination']); + $this->assertSame(18.0, $deppon['base_amount']); + $this->assertSame(5.5, $deppon['increment_amount']); + + $bandRates = $rates->filter(fn (array $rate) => $rate['carrier'] === '中通快运' && $rate['origin'] === '安徽省'); + $this->assertCount(2, $bandRates); + $this->assertSame(['0-30kg', '31-60kg'], array_column($bandRates->values()->all(), 'weight_band')); + $this->assertSame('安徽', $bandRates->first()['origin_key']); + $this->assertSame('江苏', $bandRates->first()['destination_key']); + + $missing = $rates->filter(fn (array $rate) => ! $rate['available']); + $this->assertCount(2, $missing); + $this->assertSame(['新疆', '新疆'], $missing->pluck('origin')->values()->all()); + } + + public function testItKeepsTheShellWhenTheSnapshotIsMissing(): void + { + $snapshot = (new ReverseLogisticsQuoteSnapshot(storage_path('framework/testing/missing-reverse-logistics.json')))->load(); + + $this->assertFalse($snapshot['available']); + $this->assertSame('missing', $snapshot['state']); + $this->assertSame('尚未导入本机逆向物流报价文件。', $snapshot['message']); + $this->assertSame([], $snapshot['rates']); + $this->assertSame([], $snapshot['rate_groups']); + } +}