快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
340 lines
16 KiB
PHP
340 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Business;
|
||
|
||
use App\Models\Business\CourierOnboarding;
|
||
use App\Models\Business\CourierOnboardingEvent;
|
||
use App\Models\Business\CourierOnboardingFile;
|
||
use App\Support\CourierOnboardingService;
|
||
use App\Support\QrCode;
|
||
use App\Utilities\SystemAccess;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Routing\Controller;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Illuminate\View\View;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
|
||
class CourierOnboardings extends Controller
|
||
{
|
||
public function __construct(private readonly CourierOnboardingService $service)
|
||
{
|
||
}
|
||
|
||
public function store(Request $request): RedirectResponse
|
||
{
|
||
$this->ensureReady();
|
||
$data = $request->validate([
|
||
'courier_name' => ['required', 'string', 'max:100'],
|
||
'invite_label' => ['nullable', 'string', 'max:100'],
|
||
'valid_days' => ['nullable', 'integer', 'min:1', 'max:180'],
|
||
'workplatform_expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/'],
|
||
], [
|
||
'courier_name.required' => '请先定好系统快递名称(需与报价名、发货数据里的快递名称一致)。',
|
||
'workplatform_expcode.required' => '请填写快递公司代码(与快递资料「快递公司代码」一致)。',
|
||
'workplatform_expcode.regex' => '快递公司代码只能包含字母、数字、下划线和短横线。',
|
||
]);
|
||
$code = strtoupper(trim((string) $data['workplatform_expcode']));
|
||
$taken = CourierOnboarding::query()
|
||
->where('workplatform_expcode', $code)
|
||
->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED])
|
||
->exists();
|
||
if ($taken) {
|
||
throw ValidationException::withMessages(['workplatform_expcode' => "快递公司代码「{$code}」已被其他入驻申请使用。"]);
|
||
}
|
||
|
||
$onboarding = $this->service->invite(
|
||
$data['invite_label'] ?? null,
|
||
(int) ($data['valid_days'] ?? CourierOnboardingService::DEFAULT_VALID_DAYS),
|
||
(int) user_id(),
|
||
$data['courier_name'],
|
||
);
|
||
$onboarding->update(['workplatform_expcode' => $code]);
|
||
|
||
return redirect()->route('business.courier-onboardings.show', $onboarding)
|
||
->with('success', '入驻二维码已生成,发给快递方扫码填写即可。');
|
||
}
|
||
|
||
public function show(CourierOnboarding $onboarding): View
|
||
{
|
||
$this->ensureReady();
|
||
try {
|
||
app(\App\Support\CourierQuoteSignatureService::class)->refreshPending();
|
||
} catch (\Throwable $exception) {
|
||
report($exception);
|
||
}
|
||
$onboarding->load(['files', 'events.actor', 'quoteVersion.signature']);
|
||
|
||
return view('business.pages.courier-payables.courier-onboarding-show', [
|
||
'onboarding' => $onboarding,
|
||
'formUrl' => $this->formUrl($onboarding),
|
||
'signatures' => $this->service->agreementSignatures($onboarding),
|
||
'signedAgreement' => $this->service->signedAgreement($onboarding),
|
||
'quoteSignature' => $onboarding->quoteSignature(),
|
||
'esignReady' => $this->service->esignReady(),
|
||
'canFinalReview' => $this->isAdmin() && (
|
||
(int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer')
|
||
),
|
||
'isAdmin' => $this->isAdmin(),
|
||
'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(),
|
||
]);
|
||
}
|
||
|
||
/** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */
|
||
public function requote(CourierOnboarding $onboarding): View|RedirectResponse
|
||
{
|
||
$this->ensureReady();
|
||
$quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
|
||
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
|
||
return redirect()->to($quoteManagement)->with('success', '这家快递还没开通,开通后才能变价。');
|
||
}
|
||
$requotes = $this->requotes();
|
||
$open = $requotes->openChange($onboarding);
|
||
if ($open !== null && $open->status === \App\Models\Business\CourierQuoteChange::STATUS_PENDING_REVIEW) {
|
||
return redirect()->route('business.courier-quote-changes.show', $open)
|
||
->with('success', "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。");
|
||
}
|
||
|
||
$version = $requotes->latestVersion($onboarding);
|
||
$payload = $open?->quote_payload ?? $requotes->currentPayload($onboarding);
|
||
if ($open === null) {
|
||
// 新变价默认从明天起执行。
|
||
$payload['quote_effective_date'] = today()->addDay()->toDateString();
|
||
}
|
||
|
||
return view('onboarding.courier-form', [
|
||
'onboarding' => $requotes->draftModel($onboarding, $payload),
|
||
'token' => '',
|
||
'provinces' => CourierOnboarding::PROVINCES,
|
||
'fileKinds' => CourierOnboardingFile::KINDS,
|
||
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
|
||
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
|
||
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
|
||
'requote' => [
|
||
'action' => route('business.courier-onboardings.requote.store', $onboarding),
|
||
'back' => $quoteManagement,
|
||
'courier' => $onboarding->courier_name,
|
||
'signer' => trim(($onboarding->isPerson() ? '' : $onboarding->company_full_name . ' · ') . $onboarding->esign_operator_name . ' ' . $onboarding->esign_account),
|
||
'version' => $version,
|
||
'change' => $open,
|
||
'summary' => old('summary', $open?->summary),
|
||
],
|
||
]);
|
||
}
|
||
|
||
public function storeRequote(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$this->ensureReady();
|
||
$data = $request->validate([
|
||
...\App\Http\Controllers\Onboarding\CourierOnboardingForms::quoteRules(),
|
||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||
'quote_end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:quote_effective_date'],
|
||
], [
|
||
'summary.required' => '请写一下这次变价的原因或依据,审核和对账时要看。',
|
||
'quote_end_date.after_or_equal' => '结束日期不能早于执行日期。',
|
||
]);
|
||
$change = $this->requotes()->submit($onboarding, $data, (int) user_id());
|
||
|
||
return redirect()->route('business.courier-quote-changes.show', $change)
|
||
->with('success', "变价单 {$change->number} 已提交审核。审核通过后会自动推送到快递的 e签宝签署。");
|
||
}
|
||
|
||
private function requotes(): \App\Support\CourierRequoteService
|
||
{
|
||
return app(\App\Support\CourierRequoteService::class);
|
||
}
|
||
|
||
public function qr(Request $request, CourierOnboarding $onboarding): Response
|
||
{
|
||
$this->ensureReady();
|
||
$svg = QrCode::svg($this->formUrl($onboarding), 8);
|
||
$headers = ['Content-Type' => 'image/svg+xml; charset=utf-8', 'Cache-Control' => 'private, no-store'];
|
||
if ($request->boolean('download')) {
|
||
$headers['Content-Disposition'] = 'attachment; filename="courier-onboarding-' . $onboarding->number . '.svg"';
|
||
}
|
||
|
||
return response($svg, 200, $headers);
|
||
}
|
||
|
||
public function file(CourierOnboarding $onboarding, CourierOnboardingFile $file): StreamedResponse
|
||
{
|
||
$this->ensureReady();
|
||
abort_unless((int) $file->onboarding_id === (int) $onboarding->id, 404);
|
||
abort_unless(Storage::disk('uploads')->exists($file->path), 404, '附件不存在。');
|
||
|
||
return Storage::disk('uploads')->response($file->path, $file->original_name, [
|
||
'Content-Type' => $file->mime ?: 'application/octet-stream',
|
||
], request()->boolean('download') ? 'attachment' : 'inline');
|
||
}
|
||
|
||
public function firstApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$note = $request->validate(['note' => ['nullable', 'string', 'max:1000']])['note'] ?? null;
|
||
$this->service->firstApprove($onboarding, (int) user_id(), $note);
|
||
|
||
return $this->back($onboarding, '初审通过,已转财务复审。');
|
||
}
|
||
|
||
public function returnForChanges(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请写明需要快递方补充或修改的内容。'])['reason'];
|
||
$this->service->returnForChanges($onboarding, (int) user_id(), $reason);
|
||
|
||
return $this->back($onboarding, '已退回补件,快递方用原二维码即可修改后重新提交。');
|
||
}
|
||
|
||
public function reject(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请填写不予合作的原因。'])['reason'];
|
||
$this->service->reject($onboarding, (int) user_id(), $reason);
|
||
|
||
return $this->back($onboarding, '已标记为不予合作。');
|
||
}
|
||
|
||
public function cancel(CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$this->service->cancel($onboarding, (int) user_id());
|
||
|
||
return $this->back($onboarding, '邀请已作废,原二维码不能再打开。');
|
||
}
|
||
|
||
public function extend(CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$this->service->extend($onboarding, (int) user_id());
|
||
|
||
return $this->back($onboarding, '链接有效期已延长 30 天。');
|
||
}
|
||
|
||
public function updateInternal(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$data = $request->validate([
|
||
'courier_name' => ['nullable', 'string', 'max:100'],
|
||
'reconciler' => ['nullable', 'string', 'max:100'],
|
||
]);
|
||
$this->service->updateInternal($onboarding, $data, (int) user_id());
|
||
|
||
return $this->back($onboarding, '内部信息已保存。');
|
||
}
|
||
|
||
/**
|
||
* 手动补录 ERP 开通信息(钉钉流程同步不到、或名称对不上时用)。
|
||
* 快递资料按快递代码把入驻和工作平台老数据合成一行。
|
||
*/
|
||
public function erpOpening(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$data = $request->validate([
|
||
'workplatform_expcode' => ['nullable', 'string', 'max:64'],
|
||
'erp_account_id' => ['nullable', 'string', 'max:64'],
|
||
]);
|
||
// 快递公司代码在发码时已必填;这里留空表示沿用原代码。填了代码或 ERP账户ID 视为已开通,都空则恢复「待 ERP 开通」。
|
||
$code = filled($data['workplatform_expcode'] ?? null) ? strtoupper(trim((string) $data['workplatform_expcode'])) : null;
|
||
$accountId = filled($data['erp_account_id'] ?? null) ? trim((string) $data['erp_account_id']) : null;
|
||
$opened = $code !== null || $accountId !== null;
|
||
$onboarding->update([
|
||
'workplatform_expcode' => $code ?? $onboarding->workplatform_expcode,
|
||
'erp_account_id' => $accountId,
|
||
'erp_opened_at' => $opened ? ($onboarding->erp_opened_at ?? now()) : null,
|
||
'erp_opened_source' => $opened ? 'manual' : null,
|
||
'updated_by' => (int) user_id(),
|
||
]);
|
||
|
||
return $this->back($onboarding, $opened ? 'ERP 开通信息已保存(快递公司代码 ' . $onboarding->workplatform_expcode . ')。' : '已清空 ERP 开通信息,恢复为「待 ERP 开通」。');
|
||
}
|
||
|
||
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$this->ensureAdmin();
|
||
$data = $request->validate([
|
||
'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'],
|
||
'reconciler' => ['nullable', 'string', 'max:100'],
|
||
'note' => ['nullable', 'string', 'max:1000'],
|
||
], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
|
||
$this->service->finalApprove($onboarding, $data, (int) user_id());
|
||
|
||
if (! $onboarding->quote_version_id) {
|
||
return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。');
|
||
}
|
||
if (! $this->service->esignReady()) {
|
||
return $this->back($onboarding, '财务复审通过,首版报价已生成。E 签宝尚未配置,报价确认单暂不能推送;线下签署后可直接开通。');
|
||
}
|
||
|
||
try {
|
||
$this->service->pushQuote($onboarding->fresh(), (int) user_id());
|
||
} catch (ValidationException $exception) {
|
||
$message = collect($exception->errors())->flatten()->first() ?: '推送失败';
|
||
CourierOnboardingEvent::create([
|
||
'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'event_type' => 'quote_push_failed',
|
||
'from_status' => $onboarding->status, 'to_status' => $onboarding->status, 'note' => $message,
|
||
'actor_type' => 'user', 'actor_id' => (int) user_id(),
|
||
]);
|
||
|
||
return $this->back($onboarding, '财务复审通过,首版报价已生成,但报价确认单推送失败:' . $message . '。可在右侧重新推送。');
|
||
}
|
||
|
||
return $this->back($onboarding, '财务复审通过,报价确认单已推送到快递的 e签宝,等待对方签署。');
|
||
}
|
||
|
||
public function pushQuote(CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$this->ensureAdmin();
|
||
$this->service->pushQuote($onboarding, (int) user_id());
|
||
|
||
return $this->back($onboarding, '报价确认单已推送到快递的 e签宝,等待对方签署。');
|
||
}
|
||
|
||
public function syncQuote(CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$signature = $this->service->syncQuote($onboarding, (int) user_id());
|
||
|
||
return $this->back($onboarding, '已同步 e签宝状态:' . $signature->statusLabel() . '。');
|
||
}
|
||
|
||
public function signUrl(CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$url = $this->service->quoteSignUrl($onboarding);
|
||
|
||
return $this->back($onboarding, '已获取签署链接,签署人用手机打开或扫码即可签署。')->with('quote_sign_url', $url);
|
||
}
|
||
|
||
public function activate(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||
{
|
||
$this->ensureAdmin();
|
||
$note = $request->validate(['offline_note' => ['nullable', 'string', 'max:1000']])['offline_note'] ?? null;
|
||
$this->service->activate($onboarding, (int) user_id(), $note);
|
||
|
||
return $this->back($onboarding, '快递已开通,快递资料「入驻快递」里可以查到。');
|
||
}
|
||
|
||
private function formUrl(CourierOnboarding $onboarding): string
|
||
{
|
||
$base = trim((string) config('courier_onboarding.public_url', ''));
|
||
if ($base === '') {
|
||
return route('courier-onboarding.form', ['token' => $onboarding->token]);
|
||
}
|
||
|
||
return rtrim($base, '/') . route('courier-onboarding.form', ['token' => $onboarding->token], false);
|
||
}
|
||
|
||
private function back(CourierOnboarding $onboarding, string $message): RedirectResponse
|
||
{
|
||
return redirect()->route('business.courier-onboardings.show', $onboarding)->with('success', $message);
|
||
}
|
||
|
||
private function ensureReady(): void
|
||
{
|
||
abort_unless($this->service->ready(), 503, '快递入驻数据表尚未完成迁移,请联系管理员执行数据库迁移。');
|
||
}
|
||
|
||
private function isAdmin(): bool
|
||
{
|
||
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);
|
||
}
|
||
|
||
private function ensureAdmin(): void
|
||
{
|
||
abort_unless($this->isAdmin(), 403, '财务复审和开通需要管理员账号。');
|
||
}
|
||
}
|