CaiWuXiTong/app/Support/ReverseLogisticsQuoteSnapshot.php

713 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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,
],
];
}
}