快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
294 lines
12 KiB
PHP
294 lines
12 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)',
|
||
];
|
||
|
||
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' => '特殊计费,见附件报价表',
|
||
default => '面单+重量*费率' . $tierNote,
|
||
};
|
||
}
|
||
|
||
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'), '.');
|
||
}
|
||
}
|