快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。
- 新增三张表 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
176 lines
7.1 KiB
PHP
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, '财务复审和开通需要管理员账号。');
|
|
}
|
|
}
|