快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
108 lines
4.6 KiB
PHP
108 lines
4.6 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use App\Models\Business\CourierQuoteVersion;
|
||
|
||
final class CourierQuoteConfirmation
|
||
{
|
||
public function data(CourierQuoteVersion $version): array
|
||
{
|
||
$version->loadMissing(['change.items']);
|
||
$values = $this->decodeMatrix($version->values_json);
|
||
$nextVersion = CourierQuoteVersion::query()
|
||
->where('carrier_name', $version->carrier_name)
|
||
->where('quote_type', $version->quote_type)
|
||
->where('status', 'approved')
|
||
->where('effective_date', '>', $version->effective_date)
|
||
->whereNull('end_date')
|
||
->orderBy('effective_date')
|
||
->first();
|
||
// 短期变价到期后恢复的长期报价(打印在确认单上,让快递知道到期后按哪份执行)。
|
||
$restore = $version->isTemporary()
|
||
? \App\Support\CourierRequoteService::resolveAt(
|
||
CourierQuoteVersion::query()->with('signature')->where('company_name', $version->company_name)->where('status', 'approved')->where('id', '!=', $version->id)->get(),
|
||
$version->end_date->copy()->addDay(),
|
||
false,
|
||
)
|
||
: null;
|
||
|
||
return [
|
||
'version_id' => $version->id,
|
||
'version_code' => $version->version_code,
|
||
'party_a' => company((int) $version->company_id)?->name ?: '当前公司',
|
||
'party_b' => $version->carrier_name,
|
||
'carrier_name' => $version->carrier_name,
|
||
'quote_type' => $version->quote_type,
|
||
'effective_date' => $version->effective_date?->toDateString(),
|
||
'expiry_date' => $version->isTemporary()
|
||
? $version->end_date->toDateString()
|
||
: ($nextVersion?->effective_date?->copy()->subDay()->toDateString() ?: '长期'),
|
||
'temporary_note' => $version->isTemporary()
|
||
? '本报价为短期(临时)报价,仅在 ' . $version->effective_date->toDateString() . ' 至 ' . $version->end_date->toDateString() . ' 期间执行;'
|
||
. $version->end_date->copy()->addDay()->toDateString() . ' 起自动恢复' . ($restore ? '原报价 ' . $restore->version_code . '(' . $restore->effective_date->toDateString() . ' 起执行)' : '原长期报价') . ',无需另行签署。'
|
||
: null,
|
||
'face_fee' => $this->matrixMetric($values, ['面单费', '面单费用', '面单']),
|
||
'bubble_coefficient' => $this->matrixMetric($values, ['计泡系数', '泡系数', '计泡']),
|
||
'values' => $values,
|
||
'formulas' => $this->decodeMatrix($version->formulas_json),
|
||
];
|
||
}
|
||
|
||
private function decodeMatrix(?string $json): array
|
||
{
|
||
if (blank($json)) {
|
||
return [];
|
||
}
|
||
|
||
$decoded = json_decode($json, true);
|
||
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
|
||
private function matrixMetric(array $values, array $labels): string
|
||
{
|
||
foreach ($values as $rowIndex => $row) {
|
||
if (! is_array($row)) {
|
||
continue;
|
||
}
|
||
|
||
foreach (array_values($row) as $columnIndex => $cell) {
|
||
$text = trim((string) $cell);
|
||
foreach ($labels as $label) {
|
||
if ($text !== $label && ! str_contains($text, $label)) {
|
||
continue;
|
||
}
|
||
|
||
$inline = trim((string) preg_replace('/^.*?' . preg_quote($label, '/') . '\\s*[::=]?\\s*/u', '', $text));
|
||
if ($inline !== '' && $inline !== $text && $inline !== $label) {
|
||
return $inline;
|
||
}
|
||
|
||
$columnValues = [];
|
||
foreach (array_slice($values, $rowIndex + 1) as $nextRow) {
|
||
$value = is_array($nextRow) ? ($nextRow[$columnIndex] ?? null) : null;
|
||
if ($value !== null && trim((string) $value) !== '') {
|
||
$columnValues[] = trim((string) $value);
|
||
}
|
||
}
|
||
$columnValues = array_values(array_unique($columnValues));
|
||
if (count($columnValues) === 1) {
|
||
return $columnValues[0];
|
||
}
|
||
if (count($columnValues) > 1) {
|
||
return '详见报价明细';
|
||
}
|
||
|
||
$adjacent = $row[$columnIndex + 1] ?? null;
|
||
if ($adjacent !== null && trim((string) $adjacent) !== '') {
|
||
return trim((string) $adjacent);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return '按报价明细';
|
||
}
|
||
}
|