CaiWuXiTong/app/Http/Controllers/Onboarding/CourierOnboardingForms.php
limeng 71652c5a64 feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
2026-10-14 13:47:00 +08:00

181 lines
8.1 KiB
PHP

<?php
namespace App\Http\Controllers\Onboarding;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Models\Common\Company;
use App\Support\CourierOnboardingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 快递方扫码打开的免登录入驻填报页。链接里的随机 token 就是凭证,只能读写这一份申请。
*/
class CourierOnboardingForms extends Controller
{
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function show(string $token): View|Response
{
$onboarding = $this->resolve($token);
if ($onboarding === null) {
return $this->unavailable();
}
$onboarding->load('files');
return view('onboarding.courier-form', [
'onboarding' => $onboarding,
'token' => $token,
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
]);
}
public function update(Request $request, string $token): RedirectResponse|Response
{
$onboarding = $this->resolve($token);
if ($onboarding === null) {
return $this->unavailable();
}
$data = $request->validate($this->rules(), $this->messages());
$files = [];
foreach ((array) $request->file('files', []) as $kind => $uploads) {
$files[(string) $kind] = array_values(array_filter(is_array($uploads) ? $uploads : [$uploads]));
}
$submit = ($data['action'] ?? 'save') === 'submit';
$this->service->saveCourierForm($onboarding, $data, $files, $submit);
return redirect()
->route('courier-onboarding.form', ['token' => $token])
->with('onboarding_success', $submit ? '资料已提交,我们会尽快审核,审核结果请留意对接人通知。' : '资料已暂存,可随时扫码回来继续填写。');
}
private function resolve(string $token): ?CourierOnboarding
{
$onboarding = $this->service->ready() ? $this->service->findByToken($token) : null;
if ($onboarding === null || $onboarding->status === CourierOnboarding::STATUS_CANCELLED) {
return null;
}
// 免登录访问没有当前公司上下文;切到申请所属公司,后续按公司隔离的查询才能命中。
Company::find($onboarding->company_id)?->makeCurrent();
return $onboarding;
}
/**
* 免登录页不走系统的 404 跳转(会跳到登录页),直接给快递方一个能看懂的提示。
*/
private function unavailable(): Response
{
return response()->view('onboarding.courier-unavailable', [], 404);
}
/**
* @return array<string,mixed>
*/
private function rules(): array
{
$text = ['nullable', 'string', 'max:255'];
$phone = ['nullable', 'string', 'regex:/^[0-9+\-\s()]{5,20}$/'];
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'remark' => ['nullable', 'string', 'max:2000'],
'action' => ['required', Rule::in(['save', 'submit'])],
'entity_type' => ['nullable', Rule::in(array_keys(CourierOnboarding::ENTITY_TYPES))],
'id_number' => ['nullable', 'string', 'regex:/^\s*\d{17}[\dXx]\s*$/'],
'company_full_name' => $text,
'unified_social_credit_code' => ['nullable', 'string', 'regex:/^[0-9A-Za-z]{18}$/'],
'legal_representative' => ['nullable', 'string', 'max:100'],
'business_address' => $text,
'carrier_brand' => ['nullable', 'string', 'max:50'],
'network_name' => ['nullable', 'string', 'max:100'],
'service_type' => ['nullable', Rule::in(CourierOnboarding::SERVICE_TYPES)],
'contact_name' => ['nullable', 'string', 'max:100'],
'contact_phone' => $phone,
'finance_contact_name' => ['nullable', 'string', 'max:100'],
'finance_contact_phone' => $phone,
'pickup_warehouses' => ['nullable', 'string', 'max:1000'],
'esign_operator_name' => ['nullable', 'string', 'max:100'],
'esign_account' => ['nullable', 'string', 'max:255'],
'bank_account_name' => $text,
'bank_name' => $text,
'bank_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'private_account_name' => $text,
'private_bank_name' => $text,
'private_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'invoice_type' => ['nullable', Rule::in(CourierOnboarding::INVOICE_TYPES)],
'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:20'],
'settlement_cycle' => ['nullable', Rule::in(CourierOnboarding::SETTLEMENT_CYCLES)],
...self::quoteRules(),
'remove_files' => ['nullable', 'array'],
'remove_files.*' => ['integer'],
'files' => ['nullable', 'array'],
'files.*' => ['array', 'max:' . CourierOnboardingService::MAX_FILES_PER_KIND],
'files.*.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,heic,heif,pdf,xls,xlsx'],
];
}
/**
* 报价部分的校验规则,后台变价页共用。
*
* @return array<string,mixed>
*/
public static function quoteRules(): array
{
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'quote_mode' => ['nullable', Rule::in(array_keys(CourierOnboarding::QUOTE_MODES))],
'volumetric_rule' => ['nullable', Rule::in(CourierOnboarding::VOLUMETRIC_RULES)],
'volumetric_divisor' => ['nullable', 'integer', 'min:1000', 'max:20000'],
'quote_effective_date' => ['nullable', 'date'],
'quote_note' => ['nullable', 'string', 'max:2000'],
'quote_rows' => ['nullable', 'array', 'max:80'],
'quote_rows.*.region' => ['nullable', 'string', 'max:50'],
'quote_rows.*.flat' => ['nullable', 'array', 'max:' . \App\Support\CourierQuoteStructure::MAX_BANDS],
'quote_rows.*.flat.*' => $money,
'quote_rows.*.base' => $money,
'quote_rows.*.rates' => ['nullable', 'array', 'max:' . (\App\Support\CourierQuoteStructure::MAX_TIERS + 1)],
'quote_rows.*.rates.*' => $money,
'quote_flat_bands' => ['nullable', 'string', 'max:200'],
'quote_first_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'quote_rate_tiers' => ['nullable', 'string', 'max:200'],
'quote_then_mode' => ['nullable', Rule::in(['waybill_rate', 'first_additional'])],
'min_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'weight_rounding' => ['nullable', Rule::in(array_keys(\App\Support\CourierQuoteStructure::ROUNDING))],
'per_ticket_surcharge' => ['nullable', 'numeric', 'min:0', 'max:1000'],
];
}
/**
* @return array<string,string>
*/
private function messages(): array
{
return [
'unified_social_credit_code.regex' => '统一社会信用代码应为 18 位数字或字母。',
'contact_phone.regex' => '请填写正确的联系电话。',
'id_number.regex' => '身份证号应为 18 位。',
'finance_contact_phone.regex' => '请填写正确的联系电话。',
'bank_account_number.regex' => '银行账号只能包含数字。',
'private_account_number.regex' => '银行卡号只能包含数字。',
'volumetric_divisor.min' => '抛比系数一般为 5000、6000、8000 等。',
'files.*.*.max' => '单个文件不能超过 10MB。',
'files.*.*.mimes' => '请上传照片、PDF 或 Excel 文件。',
];
}
}