CaiWuXiTong/app/Http/Controllers/Business/CourierOnboardings.php
limeng d19409627a feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。

- 新增三张表 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
2026-10-12 17:37:44 +08:00

176 lines
7.1 KiB
PHP

<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierOnboarding;
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\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([
'invite_label' => ['nullable', 'string', 'max:100'],
'valid_days' => ['nullable', 'integer', 'min:1', 'max:180'],
]);
$onboarding = $this->service->invite(
$data['invite_label'] ?? null,
(int) ($data['valid_days'] ?? CourierOnboardingService::DEFAULT_VALID_DAYS),
(int) user_id(),
);
return redirect()->route('business.courier-onboardings.show', $onboarding)
->with('success', '入驻二维码已生成,发给快递方扫码填写即可。');
}
public function show(CourierOnboarding $onboarding): View
{
$this->ensureReady();
$onboarding->load(['files', 'events.actor', 'quoteVersion']);
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),
'canFinalReview' => $this->isAdmin() && (int) $onboarding->first_reviewed_by !== (int) user_id(),
'isAdmin' => $this->isAdmin(),
]);
}
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, '内部信息已保存。');
}
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$data = $request->validate([
'courier_name' => ['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());
return $this->back($onboarding, '财务复审通过。下一步:到协议签署页发起合作协议,签署完成后回来确认开通。');
}
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
{
return route('courier-onboarding.form', ['token' => $onboarding->token]);
}
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, '财务复审和开通需要管理员账号。');
}
}