368 lines
18 KiB
PHP
368 lines
18 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\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, '财务复审和开通需要管理员账号。');
|
||
}
|
||
}
|