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 */ 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, '财务复审和开通需要管理员账号。'); } }