'面单 + 费率×重量', '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 $input * @return array{mode:string,flat_bands:list,then_mode:string,first_weight:?float,rate_tiers:list} */ 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 */ 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 $structure * @return list */ 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 $row * @param list $columns * @return list */ 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 $rows * @param array $structure * @return list,base:?float,rates:list}> */ 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 $structure * @param list> $rows * @return array */ 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 $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 $structure * @param array $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'), '.'); } }