快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 财务复审:确认系统快递名称(唯一),快递所填报价写成报价管理的 CourierQuoteVersion,
可直接在协议签署页发起报价确认单;初审人不能复审
- 开通:需匹配到已签署的协议签署任务,或填写线下签署说明
- 快递资料页新增「入驻快递」页签,列出已开通的入驻快递
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
147 lines
6.2 KiB
PHP
147 lines
6.2 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,
|
|
]);
|
|
}
|
|
|
|
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 [
|
|
'action' => ['required', Rule::in(['save', 'submit'])],
|
|
'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'],
|
|
'bank_account_name' => $text,
|
|
'bank_name' => $text,
|
|
'bank_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)],
|
|
'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'],
|
|
'remark' => ['nullable', 'string', 'max:2000'],
|
|
'quote_rows' => ['nullable', 'array', 'max:80'],
|
|
'quote_rows.*.region' => ['nullable', 'string', 'max:50'],
|
|
'quote_rows.*.first_weight' => $money,
|
|
'quote_rows.*.first_price' => $money,
|
|
'quote_rows.*.rate' => $money,
|
|
'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,pdf,xls,xlsx,doc,docx'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string,string>
|
|
*/
|
|
private function messages(): array
|
|
{
|
|
return [
|
|
'unified_social_credit_code.regex' => '统一社会信用代码应为 18 位数字或字母。',
|
|
'contact_phone.regex' => '请填写正确的联系电话。',
|
|
'finance_contact_phone.regex' => '请填写正确的联系电话。',
|
|
'bank_account_number.regex' => '银行账号只能包含数字。',
|
|
'volumetric_divisor.min' => '抛比系数一般为 5000、6000、8000 等。',
|
|
'files.*.*.max' => '单个文件不能超过 10MB。',
|
|
'files.*.*.mimes' => '只支持图片、PDF、Excel、Word 文件。',
|
|
];
|
|
}
|
|
}
|