CaiWuXiTong/app/Support/CourierQuoteStructure.php
WuYongJie 342cdd1b99 整合报价计费规则与独立成本价观测,保留现有发布保护
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.
2026-10-20 19:07:57 +08:00

411 lines
18 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;
/**
* 快递入驻报价的计费结构。
*
* 由现有约 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'), '.');
}
}