feat: 逆向物流报价页接入计费规则与费率表
This commit is contained in:
parent
4b79370fae
commit
ce2d3fee9e
712
app/Support/ReverseLogisticsQuoteSnapshot.php
Normal file
712
app/Support/ReverseLogisticsQuoteSnapshot.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
155
tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php
Normal file
155
tests/Unit/Business/ReverseLogisticsQuoteSnapshotTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user