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

- 新增三张表 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
2026-10-12 17:37:44 +08:00

615 lines
26 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\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;
}
}