快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,协议签署完成后开通。
- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 财务复审:确认系统快递名称(唯一),快递所填报价写成报价管理的 CourierQuoteVersion,
可直接在协议签署页发起报价确认单;初审人不能复审
- 开通:需匹配到已签署的协议签署任务,或填写线下签署说明
- 快递资料页新增「入驻快递」页签,列出已开通的入驻快递
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
615 lines
26 KiB
PHP
615 lines
26 KiB
PHP
<?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\CourierQuoteVersion;
|
||
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 = 6;
|
||
|
||
/** 提交审核前快递方必须填写的字段。 */
|
||
private const REQUIRED_FIELDS = [
|
||
'company_full_name' => '公司全称',
|
||
'unified_social_credit_code' => '统一社会信用代码',
|
||
'carrier_brand' => '快递品牌',
|
||
'network_name' => '网点名称',
|
||
'service_type' => '业务类型',
|
||
'contact_name' => '业务对接人',
|
||
'contact_phone' => '对接人手机',
|
||
'bank_account_name' => '收款户名',
|
||
'bank_name' => '开户行',
|
||
'bank_account_number' => '银行账号',
|
||
'invoice_type' => '开票类型',
|
||
'settlement_cycle' => '结算周期',
|
||
'quote_mode' => '计费方式',
|
||
'volumetric_rule' => '是否计泡',
|
||
'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): CourierOnboarding
|
||
{
|
||
return DB::transaction(function () use ($label, $validDays, $userId): CourierOnboarding {
|
||
$onboarding = CourierOnboarding::create([
|
||
'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, $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) {
|
||
throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']);
|
||
}
|
||
|
||
$courierName = trim((string) $data['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], '只有复审通过的申请可以开通。');
|
||
|
||
$signature = $this->signedAgreement($onboarding);
|
||
if ($signature === null && blank($offlineNote)) {
|
||
throw ValidationException::withMessages(['offline_note' => '未找到已签署的电子协议;如已线下签署纸质协议,请填写说明后再开通。']);
|
||
}
|
||
|
||
$note = $signature !== null
|
||
? '电子协议已签署:' . $signature->business_no
|
||
: '线下签署:' . trim((string) $offlineNote);
|
||
|
||
return $this->transition($onboarding, CourierOnboarding::STATUS_ACTIVATED, 'activated', $note, $userId, [
|
||
'activated_by' => $userId,
|
||
'activated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 与该快递相关的协议签署任务(按系统快递名称或公司全称匹配),最近的在前。
|
||
*
|
||
* @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', 'bank_account_name', 'bank_name', 'invoice_type', 'settlement_cycle',
|
||
'quote_mode', 'volumetric_rule', 'quote_note', '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);
|
||
$attributes['tax_rate'] = is_numeric($data['tax_rate'] ?? null) ? (float) $data['tax_rate'] : null;
|
||
$attributes['volumetric_divisor'] = $attributes['volumetric_rule'] === '计泡' && is_numeric($data['volumetric_divisor'] ?? null)
|
||
? (int) $data['volumetric_divisor']
|
||
: null;
|
||
$attributes['quote_effective_date'] = $text('quote_effective_date');
|
||
$attributes['quote_rows'] = $this->normalizeQuoteRows((array) ($data['quote_rows'] ?? []));
|
||
|
||
return $attributes;
|
||
}
|
||
|
||
/**
|
||
* @param array<int|string,mixed> $rows
|
||
* @return list<array{region:string,first_weight:?float,first_price:?float,rate:?float}>
|
||
*/
|
||
private function normalizeQuoteRows(array $rows): array
|
||
{
|
||
$number = static fn ($value): ?float => is_numeric($value) ? round((float) $value, 4) : null;
|
||
$result = [];
|
||
|
||
foreach ($rows as $row) {
|
||
if (! is_array($row)) {
|
||
continue;
|
||
}
|
||
$region = trim((string) ($row['region'] ?? ''));
|
||
$values = [
|
||
'first_weight' => $number($row['first_weight'] ?? null),
|
||
'first_price' => $number($row['first_price'] ?? null),
|
||
'rate' => $number($row['rate'] ?? null),
|
||
];
|
||
if ($region === '' || array_filter($values, static fn ($value): bool => $value !== null) === []) {
|
||
continue;
|
||
}
|
||
$result[] = ['region' => mb_substr($region, 0, 50)] + $values;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function storeFile(CourierOnboarding $onboarding, string $kind, UploadedFile $upload): void
|
||
{
|
||
if (! array_key_exists($kind, CourierOnboardingFile::KINDS)) {
|
||
return;
|
||
}
|
||
if ($onboarding->files()->where('kind', $kind)->count() >= self::MAX_FILES_PER_KIND) {
|
||
throw ValidationException::withMessages(["files.{$kind}" => CourierOnboardingFile::KINDS[$kind] . '最多上传 ' . self::MAX_FILES_PER_KIND . ' 个文件。']);
|
||
}
|
||
|
||
$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 = [];
|
||
foreach (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)。';
|
||
}
|
||
|
||
$kinds = $onboarding->files()->pluck('kind')->unique()->all();
|
||
foreach (CourierOnboardingFile::REQUIRED_KINDS as $kind) {
|
||
if (! in_array($kind, $kinds, true)) {
|
||
$errors["files.{$kind}"] = '请上传' . CourierOnboardingFile::KINDS[$kind] . '。';
|
||
}
|
||
}
|
||
|
||
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)
|
||
->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})。",
|
||
'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>>
|
||
*/
|
||
private function quoteMatrix(CourierOnboarding $onboarding, array $rows, Carbon $effectiveDate): array
|
||
{
|
||
$volumetric = $onboarding->volumetric_rule === '计泡'
|
||
? '计泡:长*宽*高/' . ($onboarding->volumetric_divisor ?: 6000)
|
||
: '不计泡';
|
||
$matrix = [
|
||
[$onboarding->courier_name . ' 报价', null, null, null],
|
||
['执行时间:' . $effectiveDate->format('Y.m.d'), '计费方式:' . $onboarding->quoteModeLabel(), $volumetric, null],
|
||
];
|
||
|
||
$matrix[] = match ($onboarding->quote_mode) {
|
||
'first_additional' => ['省份', '首重(kg)', '首重价(元)', '续重(元/kg)'],
|
||
'flat' => ['省份/重量段', '重量(kg)', '价格(元)', '续重(元/kg)'],
|
||
default => ['省份', '首重(kg)', '面单(元)', '费率(元/kg)'],
|
||
};
|
||
|
||
foreach ($rows as $row) {
|
||
$matrix[] = [$row['region'], $row['first_weight'], $row['first_price'], $row['rate']];
|
||
}
|
||
|
||
$notes = array_filter([
|
||
filled($onboarding->quote_note) ? '备注:' . $onboarding->quote_note : null,
|
||
'适用快递:' . $onboarding->courier_name,
|
||
]);
|
||
foreach ($notes as $line) {
|
||
$matrix[] = [$line, null, null, null];
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|