feat: 逆向物流报价页接入计费规则与费率表

This commit is contained in:
zhoushengchen 2026-10-06 14:02:43 +08:00
parent 4b79370fae
commit ce2d3fee9e
4 changed files with 1861 additions and 7 deletions

View File

@ -0,0 +1,712 @@
<?php
namespace App\Support;
use JsonException;
/**
* 逆向物流报价快照:从本机私有 JSON(由逆向物流报价.xlsx 生成)读取报价版本,
* 并把三种原表版式(矩阵、单一到达省、重量段)拆分为可用于运费计算的运价明细。
*
* 与 QuoteManagementSnapshot 同构:只读展示,不重新计算金额。
*/
final class ReverseLogisticsQuoteSnapshot
{
public const RELATIVE_PATH = 'app/private/quote-management/reverse-logistics-quotes.json';
/** 出发省份 × 到达省份矩阵,单元格为面单价格 + 续重价格(如申通退货)。 */
private const LAYOUT_MATRIX = 'matrix';
/** 单一到达省份,每行一个出发省份(如常州德邦逆向)。 */
private const LAYOUT_SINGLE_DESTINATION = 'single-destination';
/** 单一到达仓,每行一个出发省份,按重量段给出费率(如中通快运逆向)。 */
private const LAYOUT_WEIGHT_BAND = 'weight-band';
public function __construct(private readonly ?string $path = null)
{
}
public function load(): array
{
$path = $this->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<string,mixed>,rates:list<array<string,mixed>>}|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<array<int,mixed>> $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<array<int,mixed>> $values
* @param array<string,mixed> $context
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
*/
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<array<int,mixed>> $values
* @param array<string,mixed> $context
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
*/
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<array<int,mixed>> $values
* @param array<string,mixed> $context
* @return array{rule:array<string,mixed>,rates:list<array<string,mixed>>}
*/
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<string,mixed> $context
* @param array<string,mixed> $values
* @return array<string,mixed>
*/
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<string,mixed> $context
* @param array<string,mixed> $values
* @return array<string,mixed>
*/
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<array<int,mixed>> $values
* @return list<string>
*/
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<string> $notes @return list<string> */
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<string> $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,
],
];
}
}

View File

@ -1,7 +1,943 @@
<div class="quote-empty" role="status" data-reverse-logistics-quotes>
<div class="quote-empty__body">
<span class="material-icons-outlined" aria-hidden="true">request_quote</span>
<div class="quote-empty__title">逆向物流报价尚未维护</div>
<div class="quote-empty__text">确认报价字段和计费规则后,可在此建立报价。</div>
@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
<div data-reverse-logistics-quotes>
<style>
.reverse-quote-rules {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
padding: 1rem;
}
.reverse-quote-rule {
display: grid;
align-content: start;
gap: 0.6rem;
border: 1px solid var(--quote-border);
border-radius: 0.75rem;
padding: 0.85rem 0.9rem;
background: #FFFDF8;
}
.reverse-quote-rule__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.reverse-quote-rule__carrier {
font-weight: 600;
font-size: 0.875rem;
}
.reverse-quote-rule__meta {
display: grid;
gap: 0.3rem;
margin: 0;
}
.reverse-quote-rule__meta > div {
display: grid;
grid-template-columns: 5.5rem 1fr;
gap: 0.5rem;
align-items: start;
}
.reverse-quote-rule__meta dt {
color: var(--quote-muted);
font-size: 0.75rem;
}
.reverse-quote-rule__meta dd {
margin: 0;
word-break: break-word;
}
.reverse-quote-rule__notes {
border-top: 1px dashed var(--quote-border);
padding-top: 0.5rem;
}
.reverse-quote-rule__notes summary {
cursor: pointer;
color: var(--vip-content-accent-dark, #856632);
font-size: 0.75rem;
font-weight: 600;
}
.reverse-quote-rule__notes ul {
display: grid;
gap: 0.3rem;
margin: 0.5rem 0 0;
padding-left: 1.1rem;
color: var(--quote-muted);
}
.reverse-rate-band {
color: var(--quote-muted);
}
.reverse-rate-missing {
color: var(--vip-content-warning, #935F07);
font-weight: 600;
}
.quote-empty[hidden] {
display: none;
}
.reverse-calc-best td {
background: var(--vip-content-accent-soft, #F7F0DE);
font-weight: 600;
}
</style>
<div class="quote-summary" aria-label="逆向物流报价数据概览">
<div class="quote-source">
<span class="quote-source__icon" aria-hidden="true">
<span class="material-icons-outlined">assignment_return</span>
</span>
<div class="quote-source__body">
<div class="quote-source__file" title="{{ $reverseQuoteSnapshot['source_file'] }}">{{ $reverseQuoteSnapshot['source_file'] }}</div>
<div class="quote-source__state">本机私有报价源 · 原始工作簿未修改 · 金额按原表数值直读</div>
</div>
</div>
<div class="quote-metric">
<div class="quote-metric__label">报价版本</div>
<div class="quote-metric__value">{{ $reverseQuoteSnapshot['summary']['quote_count'] }}</div>
</div>
<div class="quote-metric">
<div class="quote-metric__label">运价明细</div>
<div class="quote-metric__value" data-reverse-rate-total>{{ $reverseRateSummary['rate_count'] }}</div>
</div>
<div class="quote-metric">
<div class="quote-metric__label">可计算运价</div>
<div class="quote-metric__value">{{ $reverseRateSummary['available_count'] }}</div>
</div>
<div class="quote-metric">
<div class="quote-metric__label">无报价行</div>
<div class="quote-metric__value">{{ $reverseRateSummary['missing_count'] }}</div>
</div>
<div class="quote-metric">
<div class="quote-metric__label">已标执行日期</div>
<div class="quote-metric__value">{{ $reverseQuoteSnapshot['summary']['dated_count'] }}</div>
</div>
</div>
@if ($reverseQuoteSnapshot['available'])
<div class="quote-workspace">
<div class="quote-results">
<span class="quote-results__count">计费规则</span>
<span class="quote-results__notice">
<span class="material-icons-outlined" aria-hidden="true">rule</span>
<span>每种承运商的原表口径(面单、续重、重量段、限重、计泡)</span>
</span>
</div>
<div class="reverse-quote-rules" data-reverse-rule-list>
@foreach ($reverseRules as $rule)
<article class="reverse-quote-rule" data-reverse-rule="{{ $rule['carrier'] }}">
<header class="reverse-quote-rule__head">
<span class="reverse-quote-rule__carrier">{{ $rule['carrier'] }}</span>
<span class="quote-badge quote-badge--ready">{{ $rule['sheet_name'] }}</span>
</header>
<dl class="reverse-quote-rule__meta">
<div><dt>计费方式</dt><dd>{{ $rule['pricing_label'] }}</dd></div>
<div><dt>生效日期</dt><dd>{{ $rule['effective_date'] ?? '未标注' }}</dd></div>
<div><dt>取价方向</dt><dd>{{ $rule['route_label'] ?? '出发省份 → 到达省份' }}</dd></div>
<div><dt>面单口径</dt><dd>{{ $rule['base_label'] }}({{ $rule['base_unit'] }})</dd></div>
<div><dt>续重口径</dt><dd>{{ $rule['increment_label'] }}({{ $rule['increment_unit'] }})</dd></div>
<div><dt>重量段</dt><dd>{{ $rule['weight_bands'] === [] ? '首重 1kg + 续重' : implode(' / ', $rule['weight_bands']) }}</dd></div>
<div><dt>运价覆盖</dt><dd>{{ $rule['rate_count'] }} 条(可用 {{ $rule['available_count'] }})· 出发 {{ $rule['origin_count'] }} 省 · 到达 {{ $rule['destination_count'] }} 处</dd></div>
</dl>
@if ($rule['notes'] !== [])
<details class="reverse-quote-rule__notes">
<summary>原表备注与限制({{ count($rule['notes']) }} 条)</summary>
<ul>
@foreach ($rule['notes'] as $note)
<li>{{ $note }}</li>
@endforeach
</ul>
</details>
@endif
</article>
@endforeach
</div>
</div>
<div class="quote-workspace" data-reverse-calculator>
<div class="quote-results">
<span class="quote-results__count">运费计算器</span>
<span class="quote-results__notice">
<span class="material-icons-outlined" aria-hidden="true">calculate</span>
<span>计泡 6000;按各承运商原表口径计算,附加费按原表备注</span>
</span>
</div>
<div class="quote-toolbar" aria-label="运费计算条件">
<label class="quote-field">
<span class="quote-field__label">承运商</span>
<select class="quote-control" name="calc_carrier" data-reverse-calc="carrier">
<option value="">自动试算(全部)</option>
@foreach ($rateCarriers as $carrier)
<option value="{{ $carrier }}">{{ $carrier }}</option>
@endforeach
</select>
</label>
<label class="quote-field">
<span class="quote-field__label">出发省份</span>
<select class="quote-control" name="calc_origin" data-reverse-calc="origin">
@foreach ($calcOrigins as $origin)
<option value="{{ $origin }}" @selected($origin === '广东')>{{ $origin }}</option>
@endforeach
</select>
</label>
<label class="quote-field">
<span class="quote-field__label">到达省份</span>
<select class="quote-control" name="calc_destination" data-reverse-calc="destination">
@foreach ($calcDestinations as $destination)
<option value="{{ $destination }}" @selected($destination === '江苏')>{{ $destination }}</option>
@endforeach
</select>
</label>
<label class="quote-field">
<span class="quote-field__label">结算重量(kg)</span>
<input class="quote-control" type="number" min="0" step="0.01" value="10" name="calc_weight" data-reverse-calc="weight">
</label>
<label class="quote-field">
<span class="quote-field__label">长(cm)</span>
<input class="quote-control" type="number" min="0" step="1" placeholder="选填" name="calc_length" data-reverse-calc="length">
</label>
<label class="quote-field">
<span class="quote-field__label">宽(cm)</span>
<input class="quote-control" type="number" min="0" step="1" placeholder="选填" name="calc_width" data-reverse-calc="width">
</label>
<label class="quote-field">
<span class="quote-field__label">高(cm)</span>
<input class="quote-control" type="number" min="0" step="1" placeholder="选填" name="calc_height" data-reverse-calc="height">
</label>
<div class="quote-toolbar__actions">
<button class="quote-button" type="button" data-reverse-calc-reset>
<span class="material-icons-outlined" aria-hidden="true">restart_alt</span>
<span>重置</span>
</button>
</div>
</div>
<div class="quote-results">
<span class="quote-results__count" data-reverse-calc-summary>按条件自动试算</span>
<span class="quote-results__notice">
<span class="material-icons-outlined" aria-hidden="true">straighten</span>
<span data-reverse-calc-volume>未填写长宽高,按结算重量计算</span>
</span>
</div>
<div class="quote-table-wrap">
<table class="quote-table">
<thead>
<tr>
<th scope="col">承运商</th>
<th scope="col">线路</th>
<th scope="col">计费重量(kg)</th>
<th scope="col">计算式</th>
<th scope="col">运费(元)</th>
<th scope="col">加收(元)</th>
<th scope="col">附加费 / 说明</th>
</tr>
</thead>
<tbody data-reverse-calc-body></tbody>
</table>
</div>
</div>
<div class="quote-workspace" data-reverse-rate-panel>
<div class="quote-toolbar" aria-label="运价筛选">
<label class="quote-field">
<span class="quote-field__label">承运商</span>
<select class="quote-control" name="rate_carrier" data-reverse-rate-filter="carrier">
<option value="">全部承运商</option>
@foreach ($rateCarriers as $carrier)
<option value="{{ $carrier }}">{{ $carrier }}</option>
@endforeach
</select>
</label>
<label class="quote-field">
<span class="quote-field__label">出发省份</span>
<select class="quote-control" name="rate_origin" data-reverse-rate-filter="origin">
<option value="">全部出发省</option>
@foreach ($rateOrigins as $origin)
<option value="{{ $origin }}">{{ $origin }}</option>
@endforeach
</select>
</label>
<label class="quote-field">
<span class="quote-field__label">到达省份</span>
<select class="quote-control" name="rate_destination" data-reverse-rate-filter="destination">
<option value="">全部到达省</option>
@foreach ($rateDestinations as $destination)
<option value="{{ $destination }}">{{ $destination }}</option>
@endforeach
</select>
</label>
<label class="quote-field">
<span class="quote-field__label">搜索</span>
<input class="quote-control" type="search" name="rate_keyword" placeholder="省份或来源单元格" autocomplete="off" data-reverse-rate-filter="keyword">
</label>
<div class="quote-toolbar__actions">
<button class="quote-icon-button" type="button" title="重置运价筛选" aria-label="重置运价筛选" data-reverse-rate-reset>
<span class="material-icons-outlined" aria-hidden="true">filter_alt_off</span>
</button>
</div>
</div>
<div class="quote-results">
<span class="quote-results__count" data-reverse-rate-count>共 {{ count($reverseRates) }} 条运价</span>
<span class="quote-results__notice">
<span class="material-icons-outlined" aria-hidden="true">calculate</span>
<span>面单价格与续重/费率直接取自原表单元格,页面不重新折算</span>
</span>
</div>
<div class="quote-table-wrap">
<table class="quote-table">
<thead>
<tr>
<th scope="col">承运商</th>
<th scope="col">出发省份</th>
<th scope="col">到达省份</th>
<th scope="col">计费方式</th>
<th scope="col">面单 / 首重(元/票)</th>
<th scope="col">续重 / 重量段费率(元/kg)</th>
<th scope="col">重量段</th>
<th scope="col">生效日期</th>
<th scope="col">来源</th>
</tr>
</thead>
<tbody data-reverse-rate-body></tbody>
</table>
</div>
<div class="quote-empty" role="status" data-reverse-rate-empty hidden>
<div class="quote-empty__body">
<span class="material-icons-outlined" aria-hidden="true">search_off</span>
<div class="quote-empty__title">没有符合筛选条件的运价</div>
<div class="quote-empty__text">调整承运商、出发省份或到达省份后重试。</div>
</div>
</div>
</div>
<div class="quote-workspace">
<div class="quote-results">
<span class="quote-results__count" data-reverse-result-count>报价来源 · 共 {{ count($reverseQuotes) }} 个版本</span>
<span class="quote-results__notice">
<span class="material-icons-outlined" aria-hidden="true">verified_user</span>
<span>原始报价规则保留,可查看每个工作表的完整单元格</span>
</span>
</div>
<div class="quote-table-wrap">
<table class="quote-table">
<thead>
<tr>
<th scope="col">报价版本</th>
<th scope="col">快递公司</th>
<th scope="col">报价类型</th>
<th scope="col">原始范围</th>
<th scope="col">数据规模</th>
<th scope="col">执行日期</th>
<th scope="col">签章</th>
<th scope="col">资料状态</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody data-reverse-table-body></tbody>
</table>
</div>
</div>
@else
<div class="quote-workspace">
<div class="quote-empty" role="status">
<div class="quote-empty__body">
<span class="material-icons-outlined" aria-hidden="true">assignment_return</span>
<div class="quote-empty__title">{{ $reverseQuoteSnapshot['message'] }}</div>
<div class="quote-empty__text">导入本机逆向物流报价文件后,会在此显示各承运商的报价版本和运价明细。</div>
</div>
</div>
</div>
@endif
@if ($reverseQuoteSnapshot['available'])
<div class="quote-modal" role="dialog" aria-modal="true" aria-labelledby="reverse-quote-detail-title" data-reverse-modal>
<div class="quote-modal__panel">
<header class="quote-modal__header">
<div class="quote-modal__title-wrap">
<div class="quote-modal__title" id="reverse-quote-detail-title" data-reverse-detail="title"></div>
<div class="quote-modal__subtitle">原工作表内容 · 只读</div>
</div>
<button class="quote-modal__close" type="button" aria-label="关闭报价明细" title="关闭" data-reverse-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<dl class="quote-detail-meta">
<div><dt>版本号</dt><dd data-reverse-detail="id"></dd></div>
<div><dt>快递公司</dt><dd data-reverse-detail="carrier"></dd></div>
<div><dt>报价类型</dt><dd data-reverse-detail="type"></dd></div>
<div><dt>原始范围</dt><dd data-reverse-detail="range"></dd></div>
<div><dt>执行日期</dt><dd data-reverse-detail="date"></dd></div>
<div><dt>公式单元格</dt><dd data-reverse-detail="formulas"></dd></div>
</dl>
<div class="quote-detail-table-wrap">
<table class="quote-detail-table" data-reverse-detail-table></table>
</div>
</div>
</div>
<script>
(function () {
var root = document.querySelector('[data-reverse-logistics-quotes]');
if (!root || root.dataset.initialized === 'true') {
return;
}
root.dataset.initialized = 'true';
var quotes = @json($reverseQuotes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
var rates = @json($reverseRates, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
var rules = @json($reverseRules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
var body = root.querySelector('[data-reverse-table-body]');
var rateBody = root.querySelector('[data-reverse-rate-body]');
var rateCount = root.querySelector('[data-reverse-rate-count]');
var rateEmpty = root.querySelector('[data-reverse-rate-empty]');
var rateTableWrap = root.querySelector('[data-reverse-rate-panel] .quote-table-wrap');
var rateFilters = {
carrier: root.querySelector('[data-reverse-rate-filter="carrier"]'),
origin: root.querySelector('[data-reverse-rate-filter="origin"]'),
destination: root.querySelector('[data-reverse-rate-filter="destination"]'),
keyword: root.querySelector('[data-reverse-rate-filter="keyword"]')
};
var ruleByCarrier = {};
var modal = root.querySelector('[data-reverse-modal]');
var lastFocusedElement = null;
rules.forEach(function (rule) {
ruleByCarrier[rule.carrier] = rule;
});
function text(value, fallback) {
if (value === null || value === undefined || value === '') {
return fallback || '';
}
return String(value);
}
function cellText(value) {
if (typeof value === 'boolean') {
return value ? 'TRUE' : 'FALSE';
}
return text(value, '');
}
function amountText(value) {
if (value === null || value === undefined || value === '') {
return '—';
}
var number = Number(value);
if (!isFinite(number)) {
return String(value);
}
return String(Math.round(number * 10000) / 10000);
}
function create(tag, className, value) {
var element = document.createElement(tag);
if (className) {
element.className = className;
}
if (value !== undefined) {
element.textContent = value;
}
return element;
}
function appendCell(row, value, className) {
var cell = create('td', className || '', value);
row.appendChild(cell);
return cell;
}
function renderRateRow(rate) {
var rule = ruleByCarrier[rate.carrier] || {};
var row = create('tr');
appendCell(row, rate.carrier);
appendCell(row, rate.origin);
appendCell(row, rate.destination);
appendCell(row, text(rule.pricing_label, '-'));
appendCell(row, amountText(rate.base_amount));
appendCell(row, amountText(rate.increment_amount), rate.available ? '' : 'reverse-rate-missing');
appendCell(row, text(rate.weight_band, '首重 + 续重'), 'reverse-rate-band');
appendCell(row, text(rule.effective_date, '未标注'));
appendCell(row, text(rate.source, '-'), 'quote-table__secondary');
return row;
}
function applyRateFilters() {
if (!rateBody) {
return;
}
var carrier = rateFilters.carrier.value;
var origin = rateFilters.origin.value;
var destination = rateFilters.destination.value;
var keyword = rateFilters.keyword.value.trim().toLowerCase();
var filtered = rates.filter(function (rate) {
if (carrier && rate.carrier !== carrier) {
return false;
}
if (origin && rate.origin !== origin) {
return false;
}
if (destination && rate.destination !== destination) {
return false;
}
if (keyword) {
var haystack = (rate.origin + ' ' + rate.destination + ' ' + rate.carrier + ' ' + text(rate.source, '')).toLowerCase();
if (haystack.indexOf(keyword) === -1) {
return false;
}
}
return true;
});
rateBody.replaceChildren();
filtered.forEach(function (rate) {
rateBody.appendChild(renderRateRow(rate));
});
if (rateCount) {
rateCount.textContent = '共 ' + filtered.length + ' 条运价(全部 ' + rates.length + ' 条)';
}
if (rateEmpty) {
rateEmpty.hidden = filtered.length > 0;
}
if (rateTableWrap) {
rateTableWrap.hidden = filtered.length === 0;
}
}
function renderRow(quote) {
var row = create('tr');
var nameCell = appendCell(row, '');
nameCell.appendChild(create('span', 'quote-table__name', quote.id));
appendCell(row, quote.company_name);
appendCell(row, quote.quote_type);
appendCell(row, text(quote.source_range, '-'));
appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列');
appendCell(row, text(quote.effective_date, '-'));
appendCell(row, quote.signature_status, 'quote-table__secondary');
var statusCell = appendCell(row, '');
statusCell.appendChild(create(
'span',
'quote-badge ' + (quote.effective_date ? 'quote-badge--ready' : 'quote-badge--attention'),
quote.status
));
var actionCell = appendCell(row, '');
var action = create('button', 'quote-action', '查看');
action.type = 'button';
action.dataset.reverseOpen = quote.id;
action.setAttribute('aria-label', '查看 ' + quote.sheet_name);
actionCell.appendChild(action);
return row;
}
function setDetail(name, value) {
root.querySelector('[data-reverse-detail="' + name + '"]').textContent = value;
}
function excelColumn(index) {
var label = '';
var value = index + 1;
while (value > 0) {
value--;
label = String.fromCharCode(65 + (value % 26)) + label;
value = Math.floor(value / 26);
}
return label;
}
function openDetail(quote, trigger) {
lastFocusedElement = trigger;
setDetail('title', quote.sheet_name);
setDetail('id', quote.id);
setDetail('carrier', quote.company_name);
setDetail('type', quote.quote_type);
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
var table = root.querySelector('[data-reverse-detail-table]');
var head = create('thead');
var headerRow = create('tr');
headerRow.appendChild(create('th', 'quote-detail-table__row-number', '#'));
for (var column = 0; column < quote.column_count; column++) {
headerRow.appendChild(create('th', '', excelColumn(column)));
}
head.appendChild(headerRow);
var tableBody = create('tbody');
quote.values.forEach(function (values, rowIndex) {
var row = create('tr');
row.appendChild(create('th', 'quote-detail-table__row-number', String(rowIndex + 1)));
for (var columnIndex = 0; columnIndex < quote.column_count; columnIndex++) {
var cell = create('td', '', cellText(values[columnIndex]));
var formula = quote.formulas[rowIndex] && quote.formulas[rowIndex][columnIndex];
if (formula) {
cell.appendChild(create('span', 'quote-detail-table__formula', '=' + String(formula).replace(/^=/, '')));
}
row.appendChild(cell);
}
tableBody.appendChild(row);
});
table.replaceChildren(head, tableBody);
modal.classList.add('is-open');
document.body.style.overflow = 'hidden';
root.querySelector('[data-reverse-close]').focus();
}
function closeDetail() {
modal.classList.remove('is-open');
document.body.style.overflow = '';
if (lastFocusedElement) {
lastFocusedElement.focus();
}
}
quotes.forEach(function (quote) {
body.appendChild(renderRow(quote));
});
Object.keys(rateFilters).forEach(function (key) {
if (!rateFilters[key]) {
return;
}
rateFilters[key].addEventListener('input', applyRateFilters);
rateFilters[key].addEventListener('change', applyRateFilters);
});
var rateReset = root.querySelector('[data-reverse-rate-reset]');
if (rateReset) {
rateReset.addEventListener('click', function () {
Object.keys(rateFilters).forEach(function (key) {
if (rateFilters[key]) {
rateFilters[key].value = '';
}
});
applyRateFilters();
});
}
var calcFields = {
carrier: root.querySelector('[data-reverse-calc="carrier"]'),
origin: root.querySelector('[data-reverse-calc="origin"]'),
destination: root.querySelector('[data-reverse-calc="destination"]'),
weight: root.querySelector('[data-reverse-calc="weight"]'),
length: root.querySelector('[data-reverse-calc="length"]'),
width: root.querySelector('[data-reverse-calc="width"]'),
height: root.querySelector('[data-reverse-calc="height"]')
};
var calcBody = root.querySelector('[data-reverse-calc-body]');
var calcSummary = root.querySelector('[data-reverse-calc-summary]');
var calcVolume = root.querySelector('[data-reverse-calc-volume]');
var calcCarriers = rules.map(function (rule) { return rule.carrier; });
var rateIndex = {};
rates.forEach(function (rate) {
rateIndex[rate.carrier] = rateIndex[rate.carrier] || {};
var key = rate.origin_key + '|' + rate.destination_key;
rateIndex[rate.carrier][key] = rateIndex[rate.carrier][key] || [];
rateIndex[rate.carrier][key].push(rate);
});
function calcNumber(field) {
if (!field) {
return 0;
}
var value = parseFloat(field.value);
return isFinite(value) && value > 0 ? value : 0;
}
function round2(value) {
return Math.round(value * 100) / 100;
}
function pickBand(bucket, band) {
for (var index = 0; index < bucket.length; index++) {
if (bucket[index].weight_band === band) {
return bucket[index];
}
}
return null;
}
function computeFreight(carrier, context) {
var rule = ruleByCarrier[carrier] || {};
var bucket = (rateIndex[carrier] || {})[context.key] || [];
var row = {
carrier: carrier,
route: context.origin + ' → ' + context.destination,
weight: '—',
formula: '',
total: null,
surcharge: 0,
notes: []
};
if (!bucket.length) {
row.notes.push('原表未覆盖该线路');
return row;
}
var rate = bucket[0];
var billable = 0;
var total = 0;
if (rule.pricing_mode === 'first_weight_increment') {
billable = Math.max(1, Math.ceil(context.chargeable));
total = Number(rate.base_amount) + Number(rate.increment_amount) * (billable - 1);
row.formula = '面单 ' + rate.base_amount + ' + 续重 ' + rate.increment_amount + ' ×(' + billable + ' − 1)';
row.notes.push('续重按结算重量取整(原表备注 3)');
} else if (rule.weight_bands && rule.weight_bands.length > 0) {
billable = Math.max(15, Math.ceil(context.chargeable));
var band = billable > 30 ? '31-60kg' : '0-30kg';
rate = pickBand(bucket, band) || rate;
if (!rate.available) {
row.notes.push('该线路重量段无报价');
return row;
}
total = Number(rate.base_amount) + Number(rate.increment_amount) * billable;
row.formula = '面单 ' + rate.base_amount + ' + 费率 ' + rate.increment_amount + ' × ' + billable + '(' + band + ')';
row.notes.push('不足 15kg 按 15kg 计费并向上取整(原表备注 2)');
if (billable > 60 || context.maxSide > 200) {
var surcharge = Math.max(20, billable * 1);
row.surcharge = round2(surcharge);
row.notes.push('超长超重附加费 ' + round2(surcharge) + ' 元(最长边>2m 或 计费重量>60kg,1 元/kg 最低 20 元)');
}
} else {
billable = round2(context.chargeable);
total = Number(rate.base_amount) + Number(rate.increment_amount) * billable;
row.formula = '面单 ' + rate.base_amount + ' + 费率 ' + rate.increment_amount + ' × ' + billable;
if (context.maxSide > 160 || context.sideSum > 250) {
row.surcharge = 7;
row.notes.push('单边>1.6m 或 三边>2.5m,加收 7 元/单(原表备注 1)');
}
}
row.weight = billable;
row.total = round2(total);
return row;
}
function calculate() {
if (!calcBody) {
return;
}
var settlement = calcNumber(calcFields.weight);
var length = calcNumber(calcFields.length);
var width = calcNumber(calcFields.width);
var height = calcNumber(calcFields.height);
var hasSize = length > 0 && width > 0 && height > 0;
var volumeWeight = hasSize ? (length * width * height / 6000) : 0;
var chargeable = Math.max(settlement, volumeWeight);
var origin = calcFields.origin.value;
var destination = calcFields.destination.value;
var selected = calcFields.carrier.value;
var context = {
key: origin + '|' + destination,
origin: origin,
destination: destination,
settlement: settlement,
chargeable: chargeable,
maxSide: Math.max(length, width, height),
sideSum: length + width + height
};
var carriers = selected ? [selected] : calcCarriers;
var results = carriers.map(function (carrier) { return computeFreight(carrier, context); });
var bestTotal = null;
var bestCarrier = null;
results.forEach(function (item) {
if (item.total === null) {
return;
}
var itemTotal = round2(item.total + (item.surcharge || 0));
if (bestTotal === null || itemTotal < bestTotal) {
bestTotal = itemTotal;
bestCarrier = item.carrier;
}
});
calcBody.replaceChildren();
results.forEach(function (item) {
var row = create('tr');
if (item.total !== null && round2(item.total + (item.surcharge || 0)) === bestTotal && carriers.length > 1) {
row.className = 'reverse-calc-best';
}
appendCell(row, item.carrier);
appendCell(row, item.route);
appendCell(row, item.weight === '—' ? '—' : String(item.weight));
appendCell(row, item.formula || '—');
appendCell(row, item.total === null ? '—' : item.total.toFixed(2));
appendCell(row, (item.surcharge || 0) > 0 ? Number(item.surcharge).toFixed(2) : '—');
appendCell(row, item.notes.join(';') || '按原表价格计算', 'quote-table__secondary');
calcBody.appendChild(row);
});
if (calcVolume) {
calcVolume.textContent = hasSize
? '体积重 ' + round2(volumeWeight) + ' kg(' + length + '×' + width + '×' + height + '÷6000),结算重量取较大值 ' + round2(chargeable) + ' kg'
: '未填写长宽高,按结算重量计算';
}
if (calcSummary) {
calcSummary.textContent = bestTotal === null
? origin + ' → ' + destination + ':所选承运商无可用报价'
: origin + ' → ' + destination + ' 最低总价 ' + bestCarrier + ' ' + bestTotal.toFixed(2) + ' 元(含加收,结算重量 ' + round2(chargeable) + ' kg)';
}
}
Object.keys(calcFields).forEach(function (key) {
if (!calcFields[key]) {
return;
}
calcFields[key].addEventListener('input', calculate);
calcFields[key].addEventListener('change', calculate);
});
var calcReset = root.querySelector('[data-reverse-calc-reset]');
if (calcReset) {
calcReset.addEventListener('click', function () {
if (calcFields.carrier) {
calcFields.carrier.value = '';
}
if (calcFields.origin) {
calcFields.origin.value = '广东';
}
if (calcFields.destination) {
calcFields.destination.value = '江苏';
}
if (calcFields.weight) {
calcFields.weight.value = '10';
}
['length', 'width', 'height'].forEach(function (key) {
if (calcFields[key]) {
calcFields[key].value = '';
}
});
calculate();
});
}
body.addEventListener('click', function (event) {
var trigger = event.target.closest('[data-reverse-open]');
if (!trigger) {
return;
}
var quote = quotes.find(function (item) { return item.id === trigger.dataset.reverseOpen; });
if (quote) {
openDetail(quote, trigger);
}
});
root.querySelector('[data-reverse-close]').addEventListener('click', closeDetail);
modal.addEventListener('click', function (event) {
if (event.target === modal) {
closeDetail();
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
closeDetail();
}
});
applyRateFilters();
calculate();
}());
</script>
@endif
</div>

View File

@ -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);
}

View File

@ -0,0 +1,155 @@
<?php
namespace Tests\Unit\Business;
use App\Support\ReverseLogisticsQuoteSnapshot;
use Tests\TestCase;
/** 逆向物流报价拆分:矩阵、单一到达省、重量段三种版式都要还原成可计算运价。 */
final class ReverseLogisticsQuoteSnapshotTest extends TestCase
{
private string $fixturePath;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}