CaiWuXiTong/app/Http/Controllers/Business/CourierOnboardings.php

368 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\CourierOnboardingReviewers;
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_\-]+$/'],
...CourierOnboarding::profileRules(),
], [
'courier_name.required' => '请先定好系统快递名称(需与报价名、发货数据里的快递名称一致)。',
'workplatform_expcode.required' => '请填写快递公司代码(与快递资料「快递公司代码」一致)。',
'workplatform_expcode.regex' => '快递公司代码只能包含字母、数字、下划线和短横线。',
...CourierOnboarding::profileMessages(),
], CourierOnboarding::profileAttributes());
$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, ...$this->profileValues($data)]);
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']);
$reviewers = app(CourierOnboardingReviewers::class);
$reviewAccess = in_array($onboarding->status, ['submitted', 'first_approved', 'returned'], true)
? $reviewers->access($onboarding, (int) user_id()) : ['allowed' => false, 'message' => '当前申请无需审核。'];
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(),
'canFirstReview' => $reviewAccess['allowed'],
'canFinalReview' => $reviewAccess['allowed'],
'reviewAccess' => $reviewAccess,
'reviewerSettings' => $reviewers->settings(),
'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签宝签署。");
}
/** @return array<string,?string> */
private function profileValues(array $data): array
{
$values = [];
foreach (array_keys(CourierOnboarding::PROFILE_FIELDS) as $field) {
$values[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
}
return $values;
}
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'],
...CourierOnboarding::profileRules(true),
], CourierOnboarding::profileMessages(), CourierOnboarding::profileAttributes());
if ($request->hasAny(['courier_name', 'reconciler'])) {
// 只提交了其中一项时,另一项沿用原值。
$data['courier_name'] = $request->has('courier_name') ? ($data['courier_name'] ?? null) : $onboarding->courier_name;
$data['reconciler'] = $request->has('reconciler') ? ($data['reconciler'] ?? null) : $onboarding->reconciler;
$this->service->updateInternal($onboarding, $data, (int) user_id());
}
// 表单里带了快递资料字段才更新(只改名称/对账人的旧表单不会清空它们)。
$profile = array_intersect_key($this->profileValues($data), $request->only(array_keys(CourierOnboarding::PROFILE_FIELDS)));
if ($profile !== []) {
$onboarding->update([...$profile, 'updated_by' => (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
{
$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' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
$onboarding = $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, '财务复审和开通需要管理员账号。');
}
}