CaiWuXiTong/app/Support/CourierOnboardingService.php
limeng 71652c5a64 feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。

- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
  链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
  复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
  不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
  财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
  点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
  按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
2026-10-14 13:47:00 +08:00

801 lines
37 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\Support;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Models\Business\CourierOnboardingFile;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteChangeEvent;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteStructure;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class CourierOnboardingService
{
public const DEFAULT_VALID_DAYS = 30;
public const MAX_FILES_PER_KIND = 1;
/** 提交审核前快递方必须填写的字段。 */
private const COMPANY_REQUIRED_FIELDS = [
'company_full_name' => '公司全称',
'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<string,mixed> $data
* @param array<string,list<UploadedFile>> $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
{
$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
{
$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
{
$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
{
$this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。');
if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) {
throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']);
}
// 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。
$courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name);
$this->assertCourierNameAvailable($onboarding, $courierName);
return DB::transaction(function () use ($onboarding, $data, $courierName, $userId): CourierOnboarding {
$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);
});
}
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<int,CourierAgreementSignature>
*/
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<string,int>
*/
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<string,mixed> $data
* @return array<string,mixed>
*/
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<string,mixed> $data
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<array{region:string,first_weight:?float,first_price:?float,rate:?float}> $rows
* @return list<list<string|int|float|null>>
*/
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<string> $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<string,mixed> $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;
}
}