'公司全称', 'unified_social_credit_code' => '统一社会信用代码', 'legal_representative' => '法定代表人', 'bank_account_name' => '对公账户户名', 'bank_name' => '对公账户开户行', 'bank_account_number' => '对公账号', ]; private const PERSON_REQUIRED_FIELDS = [ 'company_full_name' => '姓名', 'id_number' => '身份证号', ]; private const REQUIRED_FIELDS = [ 'carrier_brand' => '快递品牌', 'network_name' => '网点名称', 'service_type' => '业务类型', 'contact_name' => '业务对接人', 'contact_phone' => '对接人手机', 'esign_operator_name' => 'e签宝签署人姓名', 'esign_account' => 'e签宝账号', 'private_account_name' => '对私收款人', 'private_bank_name' => '对私账户开户行', 'private_account_number' => '对私银行卡号', 'invoice_type' => '开票类型', 'settlement_cycle' => '结算周期', 'quote_mode' => '计费方式', 'volumetric_rule' => '是否计泡', 'quote_effective_date' => '报价执行日期', ]; public const QUOTE_FIELDS = [ 'quote_mode', 'quote_structure', 'quote_rows', 'volumetric_rule', 'volumetric_divisor', 'min_weight', 'weight_rounding', 'per_ticket_surcharge', 'quote_note', 'quote_effective_date', ]; public function ready(): bool { return Schema::hasTable('business_courier_onboardings') && Schema::hasTable('business_courier_onboarding_files') && Schema::hasTable('business_courier_onboarding_events'); } public function invite(?string $label, int $validDays, int $userId, ?string $courierName = null): CourierOnboarding { $courierName = trim((string) $courierName); if ($courierName !== '') { $this->assertCourierNameAvailable(null, $courierName); } return DB::transaction(function () use ($label, $validDays, $userId, $courierName): CourierOnboarding { $onboarding = CourierOnboarding::create([ 'courier_name' => $courierName !== '' ? $courierName : null, 'company_id' => company_id(), 'number' => $this->nextNumber(), 'token' => $this->newToken(), 'status' => CourierOnboarding::STATUS_INVITED, 'invite_label' => filled($label) ? trim((string) $label) : null, 'expires_at' => now()->addDays(max(1, min($validDays, 180)))->endOfDay(), 'created_by' => $userId, 'updated_by' => $userId, ]); $this->log($onboarding, 'invited', null, CourierOnboarding::STATUS_INVITED, trim('系统快递名称:' . ($onboarding->courier_name ?? '—') . ($onboarding->invite_label ? ';备注:' . $onboarding->invite_label : '')), 'user', $userId); return $onboarding; }); } public function findByToken(string $token): ?CourierOnboarding { if (strlen($token) < 32 || strlen($token) > 64 || ! ctype_alnum($token)) { return null; } return CourierOnboarding::allCompanies()->where('token', $token)->first(); } /** * 快递方保存或提交资料。 * * @param array $data * @param array> $files */ public function saveCourierForm(CourierOnboarding $onboarding, array $data, array $files, bool $submit): CourierOnboarding { if (! $onboarding->courierCanEdit()) { throw ValidationException::withMessages(['form' => $onboarding->linkExpired() ? '入驻链接已过期,请联系对接人重新获取二维码。' : '资料已提交审核,暂不能修改。']); } return DB::transaction(function () use ($onboarding, $data, $files, $submit): CourierOnboarding { $onboarding->fill($this->courierAttributes($data)); $onboarding->save(); foreach ($files as $kind => $uploads) { foreach ($uploads as $upload) { $this->storeFile($onboarding, $kind, $upload); } } $removeIds = array_map('intval', (array) ($data['remove_files'] ?? [])); if ($removeIds !== []) { $onboarding->files()->whereIn('id', $removeIds)->get()->each(function (CourierOnboardingFile $file): void { Storage::disk('uploads')->delete($file->path); $file->delete(); }); } $onboarding->unsetRelation('files'); if (! $submit) { $this->log($onboarding, 'saved', $onboarding->status, $onboarding->status, null, 'courier'); return $onboarding; } $this->assertCompleteForSubmit($onboarding); $from = $onboarding->status; $onboarding->update([ 'status' => CourierOnboarding::STATUS_SUBMITTED, 'submitted_at' => now(), 'return_reason' => null, ]); $this->log($onboarding, 'submitted', $from, CourierOnboarding::STATUS_SUBMITTED, null, 'courier'); return $onboarding; }); } public function firstApprove(CourierOnboarding $onboarding, int $userId, ?string $note): CourierOnboarding { return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $note): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。'); return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [ 'first_reviewed_by' => $userId, 'first_reviewed_at' => now(), ]); }); } public function returnForChanges(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding { return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。'); $expiresAt = $onboarding->expires_at; if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) { $expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay(); } return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [ 'return_reason' => $reason, 'expires_at' => $expiresAt, 'first_reviewed_by' => null, 'first_reviewed_at' => null, ]); }); } public function reject(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding { return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding { $this->assertStatus($onboarding, [ CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_RETURNED, ], '当前状态不能标记为不予合作。'); return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId); }); } public function cancel(CourierOnboarding $onboarding, int $userId): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED], '只有快递方尚未提交的邀请可以作废。'); return $this->transition($onboarding, CourierOnboarding::STATUS_CANCELLED, 'cancelled', null, $userId); } public function extend(CourierOnboarding $onboarding, int $userId): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED], '只有待快递填写的邀请需要延期。'); $expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay(); $onboarding->update(['expires_at' => $expiresAt, 'updated_by' => $userId]); $this->log($onboarding, 'extended', $onboarding->status, $onboarding->status, '有效期至 ' . $expiresAt->format('Y-m-d'), 'user', $userId); return $onboarding; } /** * @param array{courier_name?:?string,reconciler?:?string} $data */ public function updateInternal(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding { $courierName = filled($data['courier_name'] ?? null) ? trim((string) $data['courier_name']) : null; if ($courierName !== null && $onboarding->quote_version_id !== null && $courierName !== $onboarding->courier_name) { throw ValidationException::withMessages(['courier_name' => '已生成首版报价,系统快递名称不能再修改。']); } if ($courierName !== null) { $this->assertCourierNameAvailable($onboarding, $courierName); } $onboarding->update([ 'courier_name' => $courierName, 'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : null, 'updated_by' => $userId, ]); $this->log($onboarding, 'internal_updated', $onboarding->status, $onboarding->status, trim('系统快递名称:' . ($courierName ?? '—') . ';对账人:' . ($onboarding->reconciler ?? '—')), 'user', $userId); return $onboarding; } /** * 财务复审:确认系统快递名称,并把快递填的报价写成「报价管理」里的首版报价版本。 * * @param array{courier_name:string,reconciler?:?string,note?:?string} $data */ public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding { return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($data, $userId): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。'); // 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。 $courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name); $this->assertCourierNameAvailable($onboarding, $courierName); $onboarding->forceFill([ 'courier_name' => $courierName, 'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : $onboarding->reconciler, ])->save(); $note = filled($data['note'] ?? null) ? trim((string) $data['note']) : null; $extra = ['final_reviewed_by' => $userId, 'final_reviewed_at' => now()]; $version = $this->createQuoteVersion($onboarding, $userId); if ($version !== null) { $extra['quote_change_id'] = $version->source_change_id; $extra['quote_version_id'] = $version->id; $note = trim(($note ?? '') . "\n已生成首版报价 {$version->version_code}。"); } else { $note = trim(($note ?? '') . "\n快递未填写报价明细,请在报价管理中补录后再发起报价确认。"); } return $this->transition($onboarding, CourierOnboarding::STATUS_APPROVED, 'final_approved', $note, $userId, $extra); }); } private function review(CourierOnboarding $onboarding, int $userId, \Closure $action): CourierOnboarding { return DB::transaction(function () use ($onboarding, $userId, $action): CourierOnboarding { // Serialize all review outcomes on the current row, including stale/repeated requests. $current = CourierOnboarding::query()->disableCache()->lockForUpdate()->findOrFail($onboarding->id); $this->assertStatus($current, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_RETURNED], '申请已由其他人员处理,请刷新页面查看最新状态。'); $reviewers = app(CourierOnboardingReviewers::class); $reviewers->assertAllowed($current, $userId, $reviewers->stage($current)); return $action($current); }); } public function activate(CourierOnboarding $onboarding, int $userId, ?string $offlineNote): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。'); $quoteSignature = $onboarding->quoteSignature(); $quoteSigned = $quoteSignature?->status === CourierQuoteSignature::STATUS_ARCHIVED; $signature = $this->signedAgreement($onboarding); if (! $quoteSigned && $signature === null && blank($offlineNote)) { throw ValidationException::withMessages(['offline_note' => '报价确认单还没在 e签宝签署完成;如已线下签署,请填写说明后再开通。']); } $note = match (true) { $quoteSigned => '报价确认单已在 e签宝签署:' . $onboarding->quoteVersion->version_code, $signature !== null => '电子协议已签署:' . $signature->business_no, default => '线下签署:' . trim((string) $offlineNote), }; return $this->transition($onboarding, CourierOnboarding::STATUS_ACTIVATED, 'activated', $note, $userId, [ 'activated_by' => $userId, 'activated_at' => now(), ]); } public function esignReady(): bool { return EsignClient::configured(); } /** * 用快递填写的报价生成报价确认单 PDF,推送到快递方的 e签宝账号签署。 */ public function pushQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature { $onboarding->loadMissing('quoteVersion.signature'); if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true) || $onboarding->quoteVersion === null) { throw ValidationException::withMessages(['signature' => '财务复审通过、生成首版报价后才能推送报价确认单。']); } return $this->pushVersion($onboarding, $onboarding->quoteVersion, $userId); } /** * 按入驻资料(公司 / 个人、e签宝签署人和账号)把某个报价版本推送给快递签署。 * 首版报价和后续变价共用。 */ public function pushVersion(CourierOnboarding $onboarding, CourierQuoteVersion $version, int $userId): CourierQuoteSignature { if (! in_array($onboarding->status, [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED], true)) { throw ValidationException::withMessages(['signature' => '这家快递的入驻还没有审核通过,不能推送报价。']); } if ($version->isVoided()) { throw ValidationException::withMessages(['signature' => "{$version->version_code} 已作废,不能推送。"]); } $existing = $version->signature()->first(); if ($existing !== null && ! $existing->canSend()) { throw ValidationException::withMessages(['signature' => "{$version->version_code} 已经推送过,正在签署或已签署。"]); } if (! EsignClient::configured()) { throw ValidationException::withMessages(['signature' => 'E 签宝尚未配置,暂时无法推送;可线下签署后在开通时填写说明。']); } if (blank($onboarding->esign_operator_name) || ! $this->validEsignAccount((string) $onboarding->esign_account)) { throw ValidationException::withMessages(['signature' => '快递方填写的 e签宝签署人或账号不完整,请退回补件或联系快递确认。']); } $quoteSignatures = app(CourierQuoteSignatureService::class); $signature = $quoteSignatures->prepareVersion($version, $userId, today()->toDateString()); $signature = $quoteSignatures->send($signature, [ 'signer_type' => $onboarding->isPerson() ? 'person' : 'organization', 'organization_name' => (string) $onboarding->company_full_name, 'operator_name' => (string) $onboarding->esign_operator_name, 'operator_account' => (string) $onboarding->esign_account, 'notice_type' => $onboarding->esignNoticeType(), 'signature_date' => today()->toDateString(), 'expires_at' => now()->addDays(30)->toDateTimeString(), ], $userId); $this->log($onboarding, 'quote_pushed', $onboarding->status, $onboarding->status, "报价确认单 {$version->version_code} 已推送到 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account})", 'user', $userId); return $signature; } public function syncQuote(CourierOnboarding $onboarding, int $userId): CourierQuoteSignature { $signature = $onboarding->quoteSignature(); if ($signature === null) { throw ValidationException::withMessages(['signature' => '报价确认单还没有推送。']); } return app(CourierQuoteSignatureService::class)->synchronize($signature, $userId); } /** * 取快递签署人的 e签宝 签署链接,可直接发给快递或我方扫码核对。 */ public function quoteSignUrl(CourierOnboarding $onboarding): string { $signature = $onboarding->quoteSignature(); if ($signature === null || blank($signature->esign_sign_flow_id)) { throw ValidationException::withMessages(['signature' => '报价确认单还没有推送到 e签宝。']); } if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) { throw ValidationException::withMessages(['signature' => '报价确认单已签署完成。']); } try { return app(\App\Services\Esign\EsignClient::class)->signUrl( (string) $signature->esign_sign_flow_id, (string) $signature->operator_account, $onboarding->isPerson() ? null : (string) $signature->organization_name, ); } catch (\RuntimeException $exception) { throw ValidationException::withMessages(['signature' => '获取签署链接失败:' . mb_substr($exception->getMessage(), 0, 300)]); } } private function validEsignAccount(string $account): bool { return preg_match('/^1\d{10}$/', $account) === 1 || filter_var($account, FILTER_VALIDATE_EMAIL) !== false; } /** * 与该快递相关的协议签署任务(按系统快递名称或公司全称匹配),最近的在前。 * * @return \Illuminate\Support\Collection */ public function agreementSignatures(CourierOnboarding $onboarding): \Illuminate\Support\Collection { if (! Schema::hasTable('business_courier_agreement_signatures')) { return collect(); } $names = array_values(array_filter([ $onboarding->courier_name, $onboarding->company_full_name, ], 'filled')); if ($names === []) { return collect(); } return CourierAgreementSignature::query() ->where(function ($query) use ($names): void { $query->whereIn('courier_name', $names) ->orWhereIn('company_full_name', $names) ->orWhereIn('organization_name', $names); }) ->latest('id') ->limit(20) ->get(); } public function signedAgreement(CourierOnboarding $onboarding): ?CourierAgreementSignature { return $this->agreementSignatures($onboarding)->first(static fn (CourierAgreementSignature $signature): bool => in_array( $signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true, )); } /** * @return array */ public function statusCounts(): array { return CourierOnboarding::query() ->select('status', DB::raw('count(*) as total')) ->groupBy('status') ->pluck('total', 'status') ->map(static fn ($total): int => (int) $total) ->all(); } /** * @param array $data * @return array */ private function courierAttributes(array $data): array { $text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null; $attributes = []; foreach ([ 'company_full_name', 'legal_representative', 'business_address', 'carrier_brand', 'network_name', 'service_type', 'contact_name', 'contact_phone', 'finance_contact_name', 'finance_contact_phone', 'pickup_warehouses', 'esign_operator_name', 'esign_account', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle', 'private_account_name', 'private_bank_name', 'remark', ] as $key) { $attributes[$key] = $text($key); } $code = $text('unified_social_credit_code'); $attributes['unified_social_credit_code'] = $code === null ? null : strtoupper($code); $account = $text('bank_account_number'); $attributes['bank_account_number'] = $account === null ? null : preg_replace('/\s+/', '', $account); $privateAccount = $text('private_account_number'); $attributes['private_account_number'] = $privateAccount === null ? null : preg_replace('/\s+/', '', $privateAccount); $attributes['tax_rate'] = is_numeric($data['tax_rate'] ?? null) ? (float) $data['tax_rate'] : null; $attributes = [...$attributes, ...$this->quoteAttributes($data)]; $attributes['entity_type'] = ($data['entity_type'] ?? null) === CourierOnboarding::ENTITY_PERSON ? CourierOnboarding::ENTITY_PERSON : CourierOnboarding::ENTITY_COMPANY; if ($attributes['entity_type'] === CourierOnboarding::ENTITY_PERSON) { // 个人以本人身份签署:没有企业信息,e签宝签署人就是本人。 $idNumber = $text('id_number'); $attributes['id_number'] = $idNumber === null ? null : strtoupper((string) preg_replace('/\s+/', '', $idNumber)); $attributes['unified_social_credit_code'] = null; $attributes['legal_representative'] = null; // 个人入驻:对私账户必填,对公账户暂为选填(有个体户/公司账户的可以填)。 $attributes['esign_operator_name'] = $attributes['company_full_name']; } else { $attributes['id_number'] = null; } return $attributes; } /** * 报价相关字段(计费结构、各省价格、附加规则、执行日期),入驻表单和后台变价共用。 * * @param array $data * @return array */ public function quoteAttributes(array $data): array { $text = static fn (string $key): ?string => filled($data[$key] ?? null) ? trim((string) $data[$key]) : null; $structure = CourierQuoteStructure::normalize($data); $volumetricRule = $text('volumetric_rule'); return [ 'volumetric_rule' => $volumetricRule, 'quote_note' => $text('quote_note'), 'volumetric_divisor' => $volumetricRule === '计泡' && is_numeric($data['volumetric_divisor'] ?? null) ? (int) $data['volumetric_divisor'] : null, 'quote_effective_date' => $text('quote_effective_date'), 'quote_mode' => filled($data['quote_mode'] ?? null) ? $structure['mode'] : null, 'quote_structure' => $structure, 'quote_rows' => CourierQuoteStructure::normalizeRows((array) ($data['quote_rows'] ?? []), $structure), 'min_weight' => is_numeric($data['min_weight'] ?? null) && (float) $data['min_weight'] > 0 ? round((float) $data['min_weight'], 2) : null, 'weight_rounding' => array_key_exists((string) ($data['weight_rounding'] ?? ''), CourierQuoteStructure::ROUNDING) ? (string) $data['weight_rounding'] : null, 'per_ticket_surcharge' => is_numeric($data['per_ticket_surcharge'] ?? null) && (float) $data['per_ticket_surcharge'] > 0 ? round((float) $data['per_ticket_surcharge'], 2) : null, ]; } /** * 当前入驻记录里的报价部分,写进报价变更单,后续变价以此为底稿。 * * @return array */ public function quotePayload(CourierOnboarding $onboarding): array { $payload = []; foreach (self::QUOTE_FIELDS as $field) { $payload[$field] = $onboarding->getAttribute($field); } $payload['quote_effective_date'] = $onboarding->quote_effective_date?->toDateString(); $payload['quote_structure'] = $onboarding->quoteStructure(); $payload['quote_rows'] = $onboarding->quoteRowList(); return $payload; } private function storeFile(CourierOnboarding $onboarding, string $kind, UploadedFile $upload): void { if (! array_key_exists($kind, CourierOnboardingFile::allKinds())) { return; } // 每项资料只保留一张,重新上传即替换。 $onboarding->files()->where('kind', $kind)->get()->each(function (CourierOnboardingFile $old): void { Storage::disk('uploads')->delete($old->path); $old->delete(); }); $extension = strtolower($upload->getClientOriginalExtension() ?: ($upload->guessExtension() ?: 'bin')); $path = sprintf('courier-onboarding/%d/%d/%s-%s.%s', $onboarding->company_id, $onboarding->id, $kind, Str::uuid()->toString(), $extension); if (Storage::disk('uploads')->putFileAs(dirname($path), $upload, basename($path)) === false) { throw ValidationException::withMessages(["files.{$kind}" => '文件保存失败,请稍后重试。']); } CourierOnboardingFile::create([ 'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'kind' => $kind, 'original_name' => mb_substr($upload->getClientOriginalName() ?: basename($path), 0, 200), 'path' => $path, 'mime' => $upload->getMimeType(), 'size' => (int) $upload->getSize(), ]); } private function assertCompleteForSubmit(CourierOnboarding $onboarding): void { $errors = []; $subjectFields = $onboarding->isPerson() ? self::PERSON_REQUIRED_FIELDS : self::COMPANY_REQUIRED_FIELDS; foreach ($subjectFields + self::REQUIRED_FIELDS as $field => $label) { if (blank($onboarding->{$field})) { $errors[$field] = "请填写{$label}。"; } } if ($onboarding->volumetric_rule === '计泡' && blank($onboarding->volumetric_divisor)) { $errors['volumetric_divisor'] = '计泡请填写抛比系数(如 6000)。'; } $checks = $onboarding->accountChecks(); if ($checks['corporate'] === false) { $errors['bank_account_name'] = "对公账户户名「{$onboarding->bank_account_name}」与营业执照公司全称「{$onboarding->company_full_name}」不一致,对公账户须为公司本身的账户。"; } if ($checks['private'] === false) { $who = $onboarding->isPerson() ? '本人姓名' : '营业执照法定代表人'; $errors['private_account_name'] = "对私收款人「{$onboarding->private_account_name}」与{$who}「{$onboarding->expectedPrivatePayee()}」不一致,对私账户须为{$who}本人的银行卡。"; } if (filled($onboarding->esign_account) && ! $this->validEsignAccount((string) $onboarding->esign_account)) { $errors['esign_account'] = 'e签宝账号请填写注册时用的 11 位手机号或邮箱。'; } $kinds = $onboarding->files()->pluck('kind')->unique()->all(); $errors += CourierQuoteStructure::validate($onboarding->quoteStructure(), $onboarding->quoteRowList()); if ($onboarding->quote_mode === 'other' && ! in_array(CourierOnboardingFile::QUOTE_SHEET_KIND, $kinds, true)) { $errors['files.' . CourierOnboardingFile::QUOTE_SHEET_KIND] = '计费方式选「其他」时,请上传报价表(Excel 或照片)。'; } if ($onboarding->isPerson() && filled($onboarding->id_number) && preg_match('/^\d{17}[\dX]$/', (string) $onboarding->id_number) !== 1) { $errors['id_number'] = '身份证号应为 18 位。'; } foreach (CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) as $kind) { if (! in_array($kind, $kinds, true)) { $errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::labelFor($kind, $onboarding->entity_type) . '。'; } } if ($errors !== []) { throw ValidationException::withMessages($errors); } } private function assertCourierNameAvailable(?CourierOnboarding $onboarding, string $courierName): void { if ($courierName === '') { throw ValidationException::withMessages(['courier_name' => '请填写系统快递名称。']); } $taken = CourierOnboarding::query() ->where('courier_name', $courierName) ->when($onboarding !== null, static fn ($query) => $query->whereKeyNot($onboarding->id)) ->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]) ->exists(); if ($taken) { throw ValidationException::withMessages(['courier_name' => "系统快递名称「{$courierName}」已被其他入驻申请使用。"]); } } private function createQuoteVersion(CourierOnboarding $onboarding, int $userId): ?CourierQuoteVersion { $rows = $onboarding->quoteRowList(); if ($rows === [] || ! Schema::hasTable('business_courier_quote_versions') || ! Schema::hasTable('business_courier_quote_changes')) { return null; } $effectiveDate = $onboarding->quote_effective_date ?? today(); $sheetName = $onboarding->courier_name . '报价'; $quoteType = '新快递入驻报价'; $values = $this->quoteMatrix($onboarding, $rows, $effectiveDate); $change = CourierQuoteChange::create([ 'company_id' => company_id(), 'number' => $this->nextChangeNumber(), 'source_quote_id' => 'RZ-' . $onboarding->id, 'source_sheet_name' => $sheetName, 'carrier_name' => $onboarding->courier_name, 'quote_type' => $quoteType, 'change_type' => '新快递入驻', 'status' => CourierQuoteChange::STATUS_APPROVED, 'effective_date' => $effectiveDate, 'is_retrospective' => $effectiveDate->copy()->startOfDay()->lt(today()), 'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED, 'summary' => "快递入驻 {$onboarding->number} 首版报价({$onboarding->company_full_name})。", 'quote_payload' => Schema::hasColumn('business_courier_quote_changes', 'quote_payload') ? $this->quotePayload($onboarding) : null, 'created_by' => (int) ($onboarding->first_reviewed_by ?: $userId), 'submitted_by' => (int) ($onboarding->first_reviewed_by ?: $userId), 'submitted_at' => $onboarding->first_reviewed_at ?? now(), 'approved_by' => $userId, 'approved_at' => now(), ]); $importedCount = collect(app(QuoteManagementSnapshot::class)->load()['quotes'] ?? []) ->whereNull('source_change_id') ->count(); $version = CourierQuoteVersion::create([ 'company_id' => company_id(), 'version_code' => 'PENDING-' . $change->id, 'source_quote_id' => $change->source_quote_id, 'source_change_id' => $change->id, 'sheet_name' => $sheetName, 'company_name' => $onboarding->courier_name, 'carrier_name' => $onboarding->courier_name, 'quote_type' => $quoteType, 'effective_date' => $effectiveDate, 'source_range' => null, 'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode([], JSON_THROW_ON_ERROR), 'status' => 'approved', 'signature_status' => '未签署', 'created_by' => $change->created_by, 'approved_by' => $userId, ]); $version->update(['version_code' => sprintf('QB-%03d', $importedCount + $version->id)]); $change->update(['new_quote_version_id' => $version->id]); CourierQuoteChangeEvent::create([ 'company_id' => company_id(), 'quote_change_id' => $change->id, 'event_type' => 'approved', 'from_status' => null, 'to_status' => CourierQuoteChange::STATUS_APPROVED, 'note' => "由快递入驻 {$onboarding->number} 财务复审生成。", 'created_by' => $userId, ]); return $version; } /** * 写成与报价单相同的二维表,报价管理和报价确认单可以直接展示。 * * @param list $rows * @return list> */ public function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array { $columns = $onboarding->quoteColumns(); $width = count($columns) + 1; $line = static fn (string $text): array => array_pad([$text], $width, null); $matrix = [ $line($onboarding->courier_name . ' 报价'), $line('执行时间:' . $effectiveDate->format('Y.m.d')), ]; $matrix[] = ['省份', ...array_column($columns, 'label')]; foreach ($rows as $row) { $matrix[] = [$row['region'], ...CourierQuoteStructure::rowValues($row, $columns)]; } // 规则说明放在价格表下方,避免报价确认单抬头的「面单费」误读说明文字。 foreach ($onboarding->quoteRuleLines() as $rule) { $matrix[] = $line($rule); } if (filled($onboarding->quote_note)) { $matrix[] = $line('备注:' . $onboarding->quote_note); } $matrix[] = $line('适用快递:' . $onboarding->courier_name); return $matrix; } /** * @param list $allowed */ private function assertStatus(CourierOnboarding $onboarding, array $allowed, string $message): void { if (! in_array($onboarding->status, $allowed, true)) { throw ValidationException::withMessages(['status' => $message]); } } /** * @param array $extra */ private function transition(CourierOnboarding $onboarding, string $to, string $event, ?string $note, int $userId, array $extra = []): CourierOnboarding { $from = $onboarding->status; $onboarding->update(['status' => $to, 'updated_by' => $userId] + $extra); $this->log($onboarding, $event, $from, $to, $note, 'user', $userId); return $onboarding; } private function log(CourierOnboarding $onboarding, string $event, ?string $from, ?string $to, ?string $note, string $actorType, ?int $actorId = null): void { CourierOnboardingEvent::create([ 'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'event_type' => $event, 'from_status' => $from, 'to_status' => $to, 'note' => filled($note) ? $note : null, 'actor_type' => $actorType, 'actor_id' => $actorId, ]); } private function nextNumber(): string { $prefix = 'RZ-' . now()->format('Ymd') . '-'; $last = CourierOnboarding::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); $sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1; return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); } private function nextChangeNumber(): string { $prefix = 'BG-' . now()->format('Ymd') . '-'; $last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); $sequence = $last ? ((int) substr((string) $last, -3)) + 1 : 1; return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); } private function newToken(): string { do { $token = Str::random(40); } while (CourierOnboarding::allCompanies()->withTrashed()->where('token', $token)->exists()); return $token; } }