Integrate feature/limeng-stock-quote-billing-rules at bca3c074 and port the snapshot purpose of feature/jushuitan-cost-price-snapshot at 4c2c422c onto current main. Keep authoritative operation history and accounting readers separate from observations; retain deployment guards and existing source data.
411 lines
18 KiB
PHP
411 lines
18 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
/**
|
||
* 快递入驻报价的计费结构。
|
||
*
|
||
* 由现有约 110 张报价表归纳出 4 种常规计费方式,另有「其他」(按条码、全包单价、
|
||
* 城市流向等)由快递上传报价表、我方录入:
|
||
*
|
||
* - waybill_rate 面单 + 费率×重量(费率可按总重量所在区间分段)
|
||
* - first_additional 首重 + 续重×(重量−首重)(续重可分段)
|
||
* - flat_bands 按重量段一口价(只设一段即纯一口价)
|
||
* - flat_then 小重量段一口价,超出部分按「面单+费率」或「首重+续重」
|
||
* - other 其他,上传报价表
|
||
*
|
||
* 每个省份一行,列由结构决定:一口价各段 flat[]、面单/首重价 base、费率/续重各段 rates[]。
|
||
*/
|
||
final class CourierQuoteStructure
|
||
{
|
||
public const MODES = [
|
||
'waybill_rate' => '面单 + 费率×重量',
|
||
'first_additional' => '首重 + 续重',
|
||
'flat_bands' => '按重量段一口价',
|
||
'flat_then' => '小重量一口价 + 超出另算',
|
||
'other' => '其他(上传报价表)',
|
||
];
|
||
|
||
public const MODE_HINTS = [
|
||
'waybill_rate' => '运费 = 面单 + 重量 × 费率;费率可按重量区间分段,如 0-35kg / 35-50kg / 50kg 以上',
|
||
'first_additional' => '运费 = 首重价 +(重量 − 首重公斤数)× 续重;首重内按首重价',
|
||
'flat_bands' => '可设多档,如 0-1kg、1-2kg、2-3kg 各一个固定价;只设一档就是纯一口价,最后一档以上不收',
|
||
'flat_then' => '如 0-1、1-2、2-3kg 各档一口价,3kg 以上改按 面单+费率×重量 或 首重+续重',
|
||
'other' => '按条码、含货全包、按城市流向等特殊报价:直接上传报价表(Excel 或照片),由我方录入',
|
||
];
|
||
|
||
public const ROUNDING = [
|
||
'none' => '不取整',
|
||
'ceil' => '向上取整(如 2.1kg 按 3kg)',
|
||
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
|
||
];
|
||
|
||
/**
|
||
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
|
||
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
|
||
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
|
||
*/
|
||
public const SEGMENTS = 'segments';
|
||
|
||
public const MAX_BANDS = 12;
|
||
|
||
public const MAX_TIERS = 6;
|
||
|
||
/**
|
||
* @param array<string,mixed> $input
|
||
* @return array{mode:string,flat_bands:list<float>,then_mode:string,first_weight:?float,rate_tiers:list<float>}
|
||
*/
|
||
public static function normalize(array $input): array
|
||
{
|
||
$mode = array_key_exists((string) ($input['quote_mode'] ?? ''), self::MODES) ? (string) $input['quote_mode'] : 'waybill_rate';
|
||
$thenMode = ($input['quote_then_mode'] ?? '') === 'first_additional' ? 'first_additional' : 'waybill_rate';
|
||
|
||
return [
|
||
'mode' => $mode,
|
||
'flat_bands' => in_array($mode, ['flat_bands', 'flat_then'], true)
|
||
? array_slice(self::parseList($input['quote_flat_bands'] ?? ''), 0, self::MAX_BANDS)
|
||
: [],
|
||
'then_mode' => $mode === 'flat_then' ? $thenMode : '',
|
||
'first_weight' => self::usesFirstWeight($mode, $thenMode) && is_numeric($input['quote_first_weight'] ?? null)
|
||
? round((float) $input['quote_first_weight'], 2)
|
||
: null,
|
||
'rate_tiers' => self::usesRates($mode)
|
||
? array_slice(self::parseList($input['quote_rate_tiers'] ?? ''), 0, self::MAX_TIERS)
|
||
: [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析「3,4,5」「3、4、5」这类重量节点,去重并升序。
|
||
*
|
||
* @return list<float>
|
||
*/
|
||
public static function parseList(mixed $value): array
|
||
{
|
||
if (is_array($value)) {
|
||
$parts = $value;
|
||
} else {
|
||
$parts = preg_split('/[\s,,、;;\/]+/u', (string) $value) ?: [];
|
||
}
|
||
|
||
$numbers = [];
|
||
foreach ($parts as $part) {
|
||
$part = preg_replace('/(kg|KG|公斤)$/u', '', trim((string) $part));
|
||
if (is_numeric($part) && (float) $part > 0) {
|
||
$numbers[] = round((float) $part, 2);
|
||
}
|
||
}
|
||
$numbers = array_values(array_unique($numbers, SORT_REGULAR));
|
||
sort($numbers);
|
||
|
||
return $numbers;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $structure
|
||
* @return list<array{key:string,label:string}>
|
||
*/
|
||
public static function columns(array $structure): array
|
||
{
|
||
$mode = $structure['mode'] ?? 'waybill_rate';
|
||
if ($mode === 'other') {
|
||
return [];
|
||
}
|
||
|
||
$columns = [];
|
||
$bands = $structure['flat_bands'] ?? [];
|
||
if (in_array($mode, ['flat_bands', 'flat_then'], true)) {
|
||
$lower = 0.0;
|
||
foreach ($bands as $index => $upper) {
|
||
$columns[] = ['key' => "flat.{$index}", 'label' => self::kg($lower) . '-' . self::kg($upper) . 'kg(含' . self::kg($upper) . ') 一口价'];
|
||
$lower = $upper;
|
||
}
|
||
}
|
||
|
||
if (! self::usesRates($mode)) {
|
||
return $columns;
|
||
}
|
||
|
||
$thenMode = $mode === 'flat_then' ? ($structure['then_mode'] ?? 'waybill_rate') : $mode;
|
||
$prefix = $mode === 'flat_then' && $bands !== [] ? self::kg((float) end($bands)) . 'kg 以上 ' : '';
|
||
if ($thenMode === 'first_additional') {
|
||
$columns[] = ['key' => 'base', 'label' => $prefix . '首重' . self::kg((float) ($structure['first_weight'] ?? 0)) . 'kg(元)'];
|
||
$rateName = '续重';
|
||
} else {
|
||
$columns[] = ['key' => 'base', 'label' => $prefix . '面单'];
|
||
$rateName = '费率';
|
||
}
|
||
|
||
$tiers = $structure['rate_tiers'] ?? [];
|
||
if ($tiers === []) {
|
||
$columns[] = ['key' => 'rates.0', 'label' => "{$rateName}(元/kg)"];
|
||
|
||
return $columns;
|
||
}
|
||
|
||
foreach (array_values($tiers) as $index => $edge) {
|
||
$lower = $index === 0 ? ($mode === 'flat_then' && $bands !== [] ? (float) end($bands) : 0.0) : (float) $tiers[$index - 1];
|
||
$label = self::kg($lower) . '-' . self::kg($edge) . 'kg';
|
||
$columns[] = ['key' => "rates.{$index}", 'label' => "{$rateName} {$label}"];
|
||
}
|
||
$columns[] = ['key' => 'rates.' . count($tiers), 'label' => "{$rateName} " . self::kg((float) end($tiers)) . 'kg以上'];
|
||
|
||
return $columns;
|
||
}
|
||
|
||
/**
|
||
* 按列顺序取一行的值。
|
||
*
|
||
* @param array<string,mixed> $row
|
||
* @param list<array{key:string,label:string}> $columns
|
||
* @return list<?float>
|
||
*/
|
||
public static function rowValues(array $row, array $columns): array
|
||
{
|
||
return array_map(static function (array $column) use ($row): ?float {
|
||
$value = data_get($row, $column['key']);
|
||
|
||
return is_numeric($value) ? (float) $value : null;
|
||
}, $columns);
|
||
}
|
||
|
||
/**
|
||
* 只保留填了价格的省份行,并按当前结构裁剪列。
|
||
*
|
||
* @param array<int|string,mixed> $rows
|
||
* @param array<string,mixed> $structure
|
||
* @return list<array{region:string,flat:list<?float>,base:?float,rates:list<?float>}>
|
||
*/
|
||
public static function normalizeRows(array $rows, array $structure): array
|
||
{
|
||
$flatCount = in_array($structure['mode'] ?? '', ['flat_bands', 'flat_then'], true) ? count($structure['flat_bands'] ?? []) : 0;
|
||
$rateCount = self::usesRates($structure['mode'] ?? '') ? count($structure['rate_tiers'] ?? []) + 1 : 0;
|
||
$number = static fn ($value): ?float => is_numeric($value) && (float) $value >= 0 ? round((float) $value, 4) : null;
|
||
$result = [];
|
||
|
||
foreach ($rows as $row) {
|
||
if (! is_array($row)) {
|
||
continue;
|
||
}
|
||
$region = mb_substr(trim((string) ($row['region'] ?? '')), 0, 50);
|
||
$flat = [];
|
||
for ($i = 0; $i < $flatCount; $i++) {
|
||
$flat[] = $number(data_get($row, "flat.{$i}"));
|
||
}
|
||
$rates = [];
|
||
for ($i = 0; $i < $rateCount; $i++) {
|
||
$rates[] = $number(data_get($row, "rates.{$i}"));
|
||
}
|
||
$base = $rateCount > 0 ? $number($row['base'] ?? null) : null;
|
||
|
||
$filled = array_filter([...$flat, $base, ...$rates], static fn ($value): bool => $value !== null);
|
||
if ($region === '' || $filled === []) {
|
||
continue;
|
||
}
|
||
$result[] = ['region' => $region, 'flat' => $flat, 'base' => $base, 'rates' => $rates];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 提交前的结构和逐行完整性检查,返回 字段 => 错误信息。
|
||
*
|
||
* @param array<string,mixed> $structure
|
||
* @param list<array<string,mixed>> $rows
|
||
* @return array<string,string>
|
||
*/
|
||
public static function validate(array $structure, array $rows): array
|
||
{
|
||
$mode = $structure['mode'] ?? '';
|
||
if ($mode === 'other') {
|
||
return [];
|
||
}
|
||
|
||
$errors = [];
|
||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && ($structure['flat_bands'] ?? []) === []) {
|
||
$errors['quote_flat_bands'] = '请至少设置一档一口价重量,如 0-3kg,或 0-1、1-2、2-3kg 三档。';
|
||
}
|
||
if (self::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! ((float) ($structure['first_weight'] ?? 0) > 0)) {
|
||
$errors['quote_first_weight'] = '请填写首重公斤数,如 3 或 5。';
|
||
}
|
||
if ($mode === 'flat_then' && ($structure['flat_bands'] ?? []) !== [] && ($structure['rate_tiers'] ?? []) !== []
|
||
&& min($structure['rate_tiers']) <= max($structure['flat_bands'])) {
|
||
$errors['quote_rate_tiers'] = '超出部分的费率分段应大于一口价的最后一段重量。';
|
||
}
|
||
if ($errors !== []) {
|
||
return $errors;
|
||
}
|
||
|
||
if ($rows === []) {
|
||
return ['quote_rows' => '请至少填写一个省份的报价,系统会据此生成报价确认单发到 e签宝签署。'];
|
||
}
|
||
|
||
$columns = self::columns($structure);
|
||
foreach ($rows as $row) {
|
||
$values = self::rowValues($row, $columns);
|
||
$missing = [];
|
||
foreach ($values as $index => $value) {
|
||
if ($value === null) {
|
||
$missing[] = $columns[$index]['label'];
|
||
}
|
||
}
|
||
if ($missing !== []) {
|
||
$errors['quote_rows'] = "{$row['region']}还有价格没填:" . implode('、', $missing) . '。不发的省份整行留空即可。';
|
||
break;
|
||
}
|
||
}
|
||
|
||
return $errors;
|
||
}
|
||
|
||
/**
|
||
* 计费方式的文字说明,用于报价矩阵、PDF 和后台展示。
|
||
*
|
||
* @param array<string,mixed> $structure
|
||
*/
|
||
public static function describe(array $structure): string
|
||
{
|
||
$mode = $structure['mode'] ?? 'waybill_rate';
|
||
$bands = $structure['flat_bands'] ?? [];
|
||
$tierNote = ($structure['rate_tiers'] ?? []) !== [] ? '(按总重量所在区间取费率)' : '';
|
||
$fw = self::kg((float) ($structure['first_weight'] ?? 0));
|
||
|
||
return match ($mode) {
|
||
'first_additional' => "首重{$fw}kg内按首重价,超出:首重价+(重量-{$fw})*续重{$tierNote}",
|
||
'flat_bands' => '按重量段一口价,' . self::kg((float) (end($bands) ?: 0)) . 'kg以上不在报价范围',
|
||
'flat_then' => self::kg((float) (end($bands) ?: 0)) . 'kg以内按段一口价,超出:'
|
||
. (($structure['then_mode'] ?? '') === 'first_additional'
|
||
? "首重{$fw}kg价+(重量-{$fw})*续重"
|
||
: '面单+重量*费率') . $tierNote,
|
||
'other' => '特殊计费,见附件报价表',
|
||
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
|
||
default => '面单+重量*费率' . $tierNote,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
|
||
*
|
||
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
|
||
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
|
||
*
|
||
* @param array<string,mixed> $structure
|
||
* @param array<string,mixed> $row
|
||
* @return array{amount:?float,formula:?string,reason:?string}
|
||
*/
|
||
public static function freight(array $structure, array $row, float $weight): array
|
||
{
|
||
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
|
||
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
|
||
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
|
||
if (! is_finite($weight) || $weight <= 0) {
|
||
return $none('缺少有效重量,无法计算运费');
|
||
}
|
||
$w = self::kg($weight);
|
||
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
|
||
|
||
if ($mode === self::SEGMENTS) {
|
||
foreach ((array) ($row['segments'] ?? []) as $segment) {
|
||
$lo = (float) ($segment['lo'] ?? 0);
|
||
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
|
||
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
|
||
continue;
|
||
}
|
||
$type = (string) ($segment['type'] ?? '');
|
||
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
|
||
return $none('不支持的分段计费方式,需人工核算');
|
||
}
|
||
if ($type === 'flat') {
|
||
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
|
||
}
|
||
if (! isset($segment['base'], $segment['rate'])) {
|
||
return $none('该重量段不发');
|
||
}
|
||
$base = (float) $segment['base'];
|
||
$rate = (float) $segment['rate'];
|
||
if ($type === 'first_add') {
|
||
$fw = (float) ($segment['fw'] ?? 0);
|
||
|
||
return $within($weight, $fw)
|
||
? $ok($base, '首重' . self::kg($base))
|
||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||
}
|
||
|
||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||
}
|
||
|
||
return $none('超出报价重量范围');
|
||
}
|
||
|
||
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
|
||
return $none('特殊计费,需人工核算');
|
||
}
|
||
|
||
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
|
||
$flat = array_values((array) ($row['flat'] ?? []));
|
||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
|
||
foreach ($bands as $index => $edge) {
|
||
if ($within($weight, $edge)) {
|
||
$price = $flat[$index] ?? null;
|
||
|
||
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
|
||
}
|
||
}
|
||
|
||
return $none('超出报价重量范围');
|
||
}
|
||
|
||
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
|
||
$index = count($tiers);
|
||
foreach ($tiers as $i => $edge) {
|
||
if ($within($weight, $edge)) {
|
||
$index = $i;
|
||
break;
|
||
}
|
||
}
|
||
$rate = $row['rates'][$index] ?? null;
|
||
$base = $row['base'] ?? null;
|
||
if (! is_numeric($rate) || ! is_numeric($base)) {
|
||
return $none('该重量段不发');
|
||
}
|
||
$rate = (float) $rate;
|
||
$base = (float) $base;
|
||
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
|
||
if ($then === 'first_additional') {
|
||
$fw = (float) ($structure['first_weight'] ?? 0);
|
||
|
||
return $within($weight, $fw)
|
||
? $ok($base, '首重' . self::kg($base))
|
||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||
}
|
||
|
||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||
}
|
||
|
||
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
|
||
public static function roundWeight(float $weight, string $rounding): float
|
||
{
|
||
return match ($rounding) {
|
||
'ceil' => (float) ceil(round($weight, 6)),
|
||
'round' => (float) floor($weight + 0.5),
|
||
default => $weight,
|
||
};
|
||
}
|
||
|
||
public static function usesRates(string $mode): bool
|
||
{
|
||
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
|
||
}
|
||
|
||
public static function usesFirstWeight(string $mode, string $thenMode = ''): bool
|
||
{
|
||
return $mode === 'first_additional' || ($mode === 'flat_then' && $thenMode === 'first_additional');
|
||
}
|
||
|
||
public static function kg(float $value): string
|
||
{
|
||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||
}
|
||
}
|