快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、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
484 lines
23 KiB
PHP
484 lines
23 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use App\Models\Business\CourierOnboarding;
|
||
use App\Models\Business\CourierQuoteChange;
|
||
use App\Models\Business\CourierQuoteChangeEvent;
|
||
use App\Models\Business\CourierQuoteVersion;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
/**
|
||
* 已入驻快递的后台变价:我方在入驻同款报价页里改价格或计费方式,
|
||
* 提交成报价变更单 → 审核通过生成新版本 → 按入驻资料推送给快递 e签宝签署 → 签完回传归档。
|
||
* 同一家快递始终只有一条入驻记录,报价按版本往后接。
|
||
*/
|
||
final class CourierRequoteService
|
||
{
|
||
public const CHANGE_PRICE = '变价';
|
||
|
||
public const CHANGE_STRUCTURE = '重新报价';
|
||
|
||
/** 单项涨跌超过 30% 在审核页提醒。 */
|
||
public const LARGE_CHANGE = 0.3;
|
||
|
||
/** 报价管理里的报价类型:区分入驻首版和后续变价。 */
|
||
public static function quoteTypeFor(string $changeType): string
|
||
{
|
||
return $changeType === self::CHANGE_STRUCTURE ? '快递重新报价' : '快递变价报价';
|
||
}
|
||
|
||
public function __construct(private readonly CourierOnboardingService $onboardings)
|
||
{
|
||
}
|
||
|
||
/** 报价版本属于哪家已入驻快递(按系统快递名称对应)。 */
|
||
public function onboardingForCourier(?string $courierName): ?CourierOnboarding
|
||
{
|
||
if (blank($courierName)) {
|
||
return null;
|
||
}
|
||
|
||
return CourierOnboarding::query()
|
||
->where('courier_name', $courierName)
|
||
->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
|
||
->latest('id')
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* 已入驻快递的系统名称 => 入驻 id,报价管理列表一次取出。
|
||
*
|
||
* @return array<string,int>
|
||
*/
|
||
public function onboardedCourierIds(): array
|
||
{
|
||
return CourierOnboarding::query()
|
||
->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED])
|
||
->whereNotNull('courier_name')
|
||
->orderBy('id')
|
||
->pluck('id', 'courier_name')
|
||
->all();
|
||
}
|
||
|
||
/** 这家快递最新的报价版本(含待签署的)。 */
|
||
public function latestVersion(CourierOnboarding $onboarding): ?CourierQuoteVersion
|
||
{
|
||
return CourierQuoteVersion::query()
|
||
->with('signature')
|
||
->where('company_name', $onboarding->courier_name)
|
||
->where('status', 'approved')
|
||
->latest('id')
|
||
->first();
|
||
}
|
||
|
||
/** @return \Illuminate\Support\Collection<int,CourierQuoteVersion> */
|
||
public function versions(CourierOnboarding $onboarding): \Illuminate\Support\Collection
|
||
{
|
||
return CourierQuoteVersion::query()
|
||
->with('signature')
|
||
->where('company_name', $onboarding->courier_name)
|
||
->where('status', 'approved')
|
||
->latest('id')
|
||
->get();
|
||
}
|
||
|
||
/** 还没审核完的变价单(待审核或已退回),同一时间只允许一张。 */
|
||
public function openChange(CourierOnboarding $onboarding): ?CourierQuoteChange
|
||
{
|
||
return CourierQuoteChange::query()
|
||
->where('carrier_name', $onboarding->courier_name)
|
||
->whereIn('status', [CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_DRAFT])
|
||
->whereNotNull('quote_payload')
|
||
->latest('id')
|
||
->first();
|
||
}
|
||
|
||
/**
|
||
* 当前生效(最新)版本的报价结构,作为变价底稿。
|
||
*
|
||
* @return array<string,mixed>
|
||
*/
|
||
public function currentPayload(CourierOnboarding $onboarding): array
|
||
{
|
||
// 以最新的长期报价为底稿:临时变价到期就作废,不该成为下一次变价的起点。
|
||
$version = $this->versions($onboarding)->first(static fn (CourierQuoteVersion $item): bool => ! $item->isTemporary())
|
||
?? $this->latestVersion($onboarding);
|
||
$change = $version?->source_change_id ? CourierQuoteChange::query()->find($version->source_change_id) : null;
|
||
|
||
return $change?->hasQuotePayload() ? $change->quote_payload : $this->onboardings->quotePayload($onboarding);
|
||
}
|
||
|
||
/** 用报价结构生成一份临时的入驻对象,复用入驻的报价列、规则文字和报价矩阵。 */
|
||
public function draftModel(CourierOnboarding $onboarding, array $payload): CourierOnboarding
|
||
{
|
||
$model = $onboarding->replicate();
|
||
$model->id = $onboarding->id;
|
||
foreach (CourierOnboardingService::QUOTE_FIELDS as $field) {
|
||
$model->setAttribute($field, $payload[$field] ?? null);
|
||
}
|
||
$model->setRelation('files', collect());
|
||
|
||
return $model;
|
||
}
|
||
|
||
/**
|
||
* 提交变价单(新建或更新已退回的那张),进入待审核。
|
||
*
|
||
* @param array<string,mixed> $data
|
||
*/
|
||
public function submit(CourierOnboarding $onboarding, array $data, int $userId): CourierQuoteChange
|
||
{
|
||
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
|
||
throw ValidationException::withMessages(['status' => '快递开通后才能发起变价。']);
|
||
}
|
||
|
||
$attributes = $this->onboardings->quoteAttributes($data);
|
||
$structure = $attributes['quote_structure'];
|
||
if (($structure['mode'] ?? '') === 'other') {
|
||
throw ValidationException::withMessages(['quote_mode' => '后台变价请选择具体的计费方式。']);
|
||
}
|
||
$errors = CourierQuoteStructure::validate($structure, $attributes['quote_rows']);
|
||
if (blank($attributes['quote_effective_date'])) {
|
||
$errors['quote_effective_date'] = '请填写新报价的执行日期。';
|
||
}
|
||
// 结束日期选填:填了就是短期(临时)变价,到期后自动回到原来的长期报价。
|
||
$endDate = filled($data['quote_end_date'] ?? null) ? Carbon::parse((string) $data['quote_end_date'])->startOfDay() : null;
|
||
if ($endDate !== null && filled($attributes['quote_effective_date']) && $endDate->lt(Carbon::parse($attributes['quote_effective_date'])->startOfDay())) {
|
||
$errors['quote_end_date'] = '结束日期不能早于执行日期。';
|
||
}
|
||
if ($errors !== []) {
|
||
throw ValidationException::withMessages($errors);
|
||
}
|
||
|
||
$payload = [];
|
||
foreach (CourierOnboardingService::QUOTE_FIELDS as $field) {
|
||
$payload[$field] = $attributes[$field] ?? null;
|
||
}
|
||
$current = $this->currentPayload($onboarding);
|
||
if ($this->comparable($payload) === $this->comparable($current)) {
|
||
throw ValidationException::withMessages(['quote_rows' => '报价和当前版本完全一样,没有需要变更的内容。']);
|
||
}
|
||
|
||
$open = $this->openChange($onboarding);
|
||
if ($open !== null && $open->status === CourierQuoteChange::STATUS_PENDING_REVIEW) {
|
||
throw ValidationException::withMessages(['status' => "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。"]);
|
||
}
|
||
|
||
$version = $this->latestVersion($onboarding);
|
||
$effectiveDate = Carbon::parse($payload['quote_effective_date'])->startOfDay();
|
||
$draft = $this->draftModel($onboarding, $payload);
|
||
$values = $this->onboardings->quoteMatrix($draft, $draft->quoteRowList(), $effectiveDate);
|
||
$type = $this->sameStructure($payload, $current) ? self::CHANGE_PRICE : self::CHANGE_STRUCTURE;
|
||
|
||
return DB::transaction(function () use ($onboarding, $data, $userId, $payload, $values, $version, $effectiveDate, $endDate, $type, $open): CourierQuoteChange {
|
||
$attributes = [
|
||
'source_quote_id' => $version?->version_code ?? ('RZ-' . $onboarding->id),
|
||
'source_sheet_name' => $version?->sheet_name ?? ($onboarding->courier_name . '报价'),
|
||
'carrier_name' => $onboarding->courier_name,
|
||
'quote_type' => $version?->quote_type ?? '快递报价',
|
||
'change_type' => $type,
|
||
'status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
|
||
'effective_date' => $effectiveDate,
|
||
'end_date' => $endDate,
|
||
'is_retrospective' => $effectiveDate->lt(today()),
|
||
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
||
'summary' => trim((string) $data['summary']),
|
||
'submitted_by' => $userId,
|
||
'submitted_at' => now(),
|
||
'quote_payload' => $payload,
|
||
'proposed_values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||
];
|
||
|
||
if ($open !== null) {
|
||
$from = $open->status;
|
||
$open->update($attributes);
|
||
$change = $open;
|
||
} else {
|
||
$from = null;
|
||
$change = CourierQuoteChange::create([
|
||
'company_id' => company_id(),
|
||
'number' => $this->nextChangeNumber(),
|
||
'created_by' => $userId,
|
||
...$attributes,
|
||
]);
|
||
}
|
||
|
||
CourierQuoteChangeEvent::create([
|
||
'company_id' => company_id(),
|
||
'quote_change_id' => $change->id,
|
||
'event_type' => 'submitted',
|
||
'from_status' => $from,
|
||
'to_status' => CourierQuoteChange::STATUS_PENDING_REVIEW,
|
||
'note' => "后台{$type}:{$onboarding->courier_name},自 {$effectiveDate->toDateString()} 起执行。",
|
||
'created_by' => $userId,
|
||
]);
|
||
|
||
return $change->fresh();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 新旧报价对比:同一计费方式时逐格列出变动;计费方式变了则说明结构变化。
|
||
*
|
||
* @return array{same_structure:bool,old_mode:string,new_mode:string,old_columns:list<string>,new_columns:list<string>,cells:list<array{region:string,label:string,old:?float,new:?float}>,added:list<string>,removed:list<string>,rules:list<array{old:string,new:string}>}
|
||
*/
|
||
public function diff(CourierOnboarding $onboarding, array $old, array $new): array
|
||
{
|
||
$oldModel = $this->draftModel($onboarding, $old);
|
||
$newModel = $this->draftModel($onboarding, $new);
|
||
$oldColumns = $oldModel->quoteColumns();
|
||
$newColumns = $newModel->quoteColumns();
|
||
$oldRows = collect($oldModel->quoteRowList())->keyBy('region');
|
||
$newRows = collect($newModel->quoteRowList())->keyBy('region');
|
||
$same = $this->sameStructure($new, $old);
|
||
|
||
$cells = [];
|
||
if ($same) {
|
||
foreach ($newRows as $region => $row) {
|
||
if (! $oldRows->has($region)) {
|
||
continue;
|
||
}
|
||
$before = CourierQuoteStructure::rowValues($oldRows->get($region), $oldColumns);
|
||
$after = CourierQuoteStructure::rowValues($row, $newColumns);
|
||
foreach ($newColumns as $index => $column) {
|
||
if (round((float) ($before[$index] ?? 0), 4) !== round((float) ($after[$index] ?? 0), 4) || ($before[$index] === null) !== ($after[$index] === null)) {
|
||
$cells[] = ['region' => (string) $region, 'label' => $column['label'], 'old' => $before[$index], 'new' => $after[$index]];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$oldRules = $oldModel->quoteRuleLines();
|
||
$newRules = $newModel->quoteRuleLines();
|
||
$rules = [];
|
||
foreach (array_unique([...array_keys($oldRules), ...array_keys($newRules)]) as $index) {
|
||
if (($oldRules[$index] ?? '') !== ($newRules[$index] ?? '')) {
|
||
$rules[] = ['old' => (string) ($oldRules[$index] ?? '—'), 'new' => (string) ($newRules[$index] ?? '—')];
|
||
}
|
||
}
|
||
if (($old['quote_note'] ?? null) !== ($new['quote_note'] ?? null)) {
|
||
$rules[] = ['old' => '备注:' . ($old['quote_note'] ?: '无'), 'new' => '备注:' . ($new['quote_note'] ?: '无')];
|
||
}
|
||
|
||
return [
|
||
'same_structure' => $same,
|
||
'old_mode' => CourierQuoteStructure::describe($oldModel->quoteStructure()),
|
||
'new_mode' => CourierQuoteStructure::describe($newModel->quoteStructure()),
|
||
'old_columns' => array_column($oldColumns, 'label'),
|
||
'new_columns' => array_column($newColumns, 'label'),
|
||
'cells' => $cells,
|
||
'added' => $newRows->keys()->diff($oldRows->keys())->values()->all(),
|
||
'removed' => $oldRows->keys()->diff($newRows->keys())->values()->all(),
|
||
'rules' => $rules,
|
||
];
|
||
}
|
||
|
||
/** 变价单对应的底稿(变价前的版本)报价结构。 */
|
||
public function basePayload(CourierOnboarding $onboarding, CourierQuoteChange $change): array
|
||
{
|
||
$source = CourierQuoteVersion::query()->where('version_code', $change->source_quote_id)->first();
|
||
$sourceChange = $source?->source_change_id ? CourierQuoteChange::query()->find($source->source_change_id) : null;
|
||
|
||
return $sourceChange?->hasQuotePayload() ? $sourceChange->quote_payload : $this->onboardings->quotePayload($onboarding);
|
||
}
|
||
|
||
/**
|
||
* 作废发错的报价版本。还没签完的同时撤回 e签宝 签署任务;已签完的只做内部作废(原件、回签单保留),
|
||
* 需另发一版正确报价让快递重新签。
|
||
*
|
||
* @return string 给操作人看的结果说明
|
||
*/
|
||
public function voidVersion(CourierQuoteVersion $version, string $reason, int $userId): string
|
||
{
|
||
if ($version->isVoided()) {
|
||
throw ValidationException::withMessages(['void_reason' => "{$version->version_code} 已经作废。"]);
|
||
}
|
||
|
||
$signature = $version->signature()->first();
|
||
$signed = $signature !== null && in_array($signature->status, [
|
||
\App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED,
|
||
\App\Models\Business\CourierQuoteSignature::STATUS_COMPLETED,
|
||
], true);
|
||
$note = '';
|
||
|
||
if ($signature !== null && ! $signed && filled($signature->esign_sign_flow_id)
|
||
&& $signature->status === \App\Models\Business\CourierQuoteSignature::STATUS_SIGNING) {
|
||
try {
|
||
app(\App\Services\Esign\EsignClient::class)->revokeSignFlow((string) $signature->esign_sign_flow_id, '报价发错作废:' . $reason);
|
||
} catch (\Throwable $exception) {
|
||
throw ValidationException::withMessages(['void_reason' => '撤回 e签宝 签署任务失败:' . mb_substr($exception->getMessage(), 0, 200) . '。可先点「同步签署状态」确认快递是否已签。']);
|
||
}
|
||
$signature->update(['status' => \App\Models\Business\CourierQuoteSignature::STATUS_REVOKED, 'updated_by' => $userId]);
|
||
$note = '已撤回快递 e签宝 里的待签任务。';
|
||
} elseif ($signed) {
|
||
$note = '快递已签署,回签单保留;请另发一版正确报价让快递重新签署。';
|
||
}
|
||
|
||
DB::transaction(function () use ($version, $reason, $userId, $signature, $signed, $note): void {
|
||
$version->update([
|
||
'status' => CourierQuoteVersion::STATUS_VOIDED,
|
||
'signature_status' => $signed ? $version->signature_status : ($signature !== null ? '已撤销' : $version->signature_status),
|
||
'void_reason' => $reason,
|
||
'voided_at' => now(),
|
||
'voided_by' => $userId,
|
||
]);
|
||
|
||
if ($version->source_change_id) {
|
||
CourierQuoteChangeEvent::create([
|
||
'company_id' => $version->company_id,
|
||
'quote_change_id' => $version->source_change_id,
|
||
'event_type' => 'voided',
|
||
'from_status' => null,
|
||
'to_status' => null,
|
||
'note' => "报价版本 {$version->version_code} 已作废:{$reason}。{$note}",
|
||
'created_by' => $userId,
|
||
]);
|
||
}
|
||
});
|
||
|
||
return "{$version->version_code} 已作废,不再参与生效。" . $note;
|
||
}
|
||
|
||
/** 这家快递当前生效的版本:已签署、未作废、执行日期已到中最新的一版。 */
|
||
/**
|
||
* 某一天(默认今天)执行的报价版本:已签署、未作废、执行期间覆盖这一天,取开始日期最晚的一版。
|
||
* 短期变价期间用临时价,到期后自动回到原来的长期报价。
|
||
*/
|
||
public function effectiveVersion(string $courierName, ?\DateTimeInterface $date = null): ?CourierQuoteVersion
|
||
{
|
||
$versions = CourierQuoteVersion::query()
|
||
->with('signature')
|
||
->where('company_name', $courierName)
|
||
->where('status', CourierQuoteVersion::STATUS_APPROVED)
|
||
->get();
|
||
|
||
return self::resolveAt($versions, $date ?? today());
|
||
}
|
||
|
||
/**
|
||
* @param \Illuminate\Support\Collection<int,CourierQuoteVersion> $versions 同一家快递的版本
|
||
* @param bool $includeTemporary false 时只看长期报价(算「临时价到期后回到哪一版」)
|
||
*/
|
||
public static function resolveAt(\Illuminate\Support\Collection $versions, \DateTimeInterface $date, bool $includeTemporary = true): ?CourierQuoteVersion
|
||
{
|
||
return $versions
|
||
->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided()
|
||
&& self::isSignedVersion($version)
|
||
&& ($includeTemporary || ! $version->isTemporary())
|
||
&& $version->coversDate($date))
|
||
->sortBy([
|
||
static fn ($a, $b) => $b->effective_date <=> $a->effective_date,
|
||
static fn ($a, $b) => $b->id <=> $a->id,
|
||
])
|
||
->first();
|
||
}
|
||
|
||
public static function isSignedVersion(CourierQuoteVersion $version): bool
|
||
{
|
||
return $version->signature_status === '已签署并归档'
|
||
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
|
||
}
|
||
|
||
/**
|
||
* 审核提醒:倒签、价格为 0、涨跌幅过大。
|
||
*
|
||
* @param array<string,mixed>|null $diff
|
||
* @return list<string>
|
||
*/
|
||
public function reviewWarnings(CourierQuoteChange $change, ?array $diff): array
|
||
{
|
||
$warnings = [];
|
||
$current = $this->effectiveVersion((string) $change->carrier_name);
|
||
if ($current !== null && $change->effective_date->lt($current->effective_date)) {
|
||
$warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于当前生效版本 {$current->version_code}({$current->effective_date->toDateString()} 起),属于倒签,会改动已发生运单的运费。";
|
||
} elseif ($change->effective_date->lt(today())) {
|
||
$warnings[] = "执行日期 {$change->effective_date->toDateString()} 早于今天,属于倒签,会改动已发生运单的运费。";
|
||
}
|
||
|
||
if ($change->end_date !== null) {
|
||
$others = CourierQuoteVersion::query()
|
||
->where('company_name', (string) $change->carrier_name)
|
||
->where('status', CourierQuoteVersion::STATUS_APPROVED)
|
||
->where(static fn ($query) => $query->whereNull('source_change_id')->orWhere('source_change_id', '!=', $change->id))
|
||
->get();
|
||
$base = self::resolveAt($others, $change->end_date->copy()->addDay(), false);
|
||
$warnings[] = $base !== null
|
||
? "这是短期变价:{$change->effective_date->toDateString()} 至 {$change->end_date->toDateString()} 执行,{$change->end_date->copy()->addDay()->toDateString()} 起自动恢复 {$base->version_code}({$base->periodLabel()})。"
|
||
: "这是短期变价,但到期后({$change->end_date->copy()->addDay()->toDateString()} 起)没有可以恢复的已签署长期报价,到期后这家快递将没有生效报价。";
|
||
$overlap = $others->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $version->isTemporary()
|
||
&& $version->effective_date->lte($change->end_date) && $version->end_date->gte($change->effective_date));
|
||
if ($overlap->isNotEmpty()) {
|
||
$warnings[] = '和已有的短期变价执行期间重叠:' . $overlap->map(static fn (CourierQuoteVersion $version): string => $version->version_code . '(' . $version->periodLabel() . ')')->implode('、') . ',重叠的日期以开始日期较晚的一份为准。';
|
||
}
|
||
}
|
||
|
||
$big = [];
|
||
foreach ($diff['cells'] ?? [] as $cell) {
|
||
$old = $cell['old'];
|
||
$new = $cell['new'];
|
||
if ($old !== null && $new !== null && (float) $old > 0 && abs((float) $new - (float) $old) / (float) $old > self::LARGE_CHANGE) {
|
||
$big[] = $cell['region'] . ' ' . $cell['label'] . ' ' . CourierQuoteStructure::kg((float) $old) . '→' . CourierQuoteStructure::kg((float) $new);
|
||
}
|
||
}
|
||
if ($big !== []) {
|
||
$warnings[] = '涨跌超过 ' . (int) (self::LARGE_CHANGE * 100) . '%:' . implode(';', array_slice($big, 0, 8)) . (count($big) > 8 ? ' 等 ' . count($big) . ' 处' : '') . ',请核对是否多打或少打了位数。';
|
||
}
|
||
|
||
$zeros = [];
|
||
$table = \App\Http\Controllers\Business\CourierQuoteSignatures::quoteTable(json_decode((string) $change->proposed_values_json, true) ?: []);
|
||
foreach (array_slice($table, 1) as $row) {
|
||
foreach (array_slice($row, 1, null, true) as $index => $value) {
|
||
if (is_numeric($value) && (float) $value === 0.0) {
|
||
$zeros[] = $row[0] . ' ' . ($table[0][$index] ?? '');
|
||
}
|
||
}
|
||
}
|
||
if ($zeros !== []) {
|
||
$warnings[] = '价格为 0:' . implode('、', array_slice($zeros, 0, 8)) . (count($zeros) > 8 ? ' 等 ' . count($zeros) . ' 处' : '') . '。';
|
||
}
|
||
|
||
return $warnings;
|
||
}
|
||
|
||
private function sameStructure(array $a, array $b): bool
|
||
{
|
||
$shape = static fn (array $payload): array => CourierQuoteStructure::columns(
|
||
is_array($payload['quote_structure'] ?? null) ? $payload['quote_structure'] : []
|
||
);
|
||
|
||
return $shape($a) === $shape($b);
|
||
}
|
||
|
||
private function comparable(array $payload): string
|
||
{
|
||
$rows = array_map(static fn (array $row): array => [
|
||
'region' => $row['region'] ?? '',
|
||
'flat' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['flat'] ?? [])),
|
||
'base' => isset($row['base']) ? round((float) $row['base'], 4) : null,
|
||
'rates' => array_map(static fn ($v) => $v === null ? null : round((float) $v, 4), (array) ($row['rates'] ?? [])),
|
||
], array_values(array_filter((array) ($payload['quote_rows'] ?? []), 'is_array')));
|
||
$copy = $payload;
|
||
$copy['quote_rows'] = $rows;
|
||
unset($copy['quote_effective_date']);
|
||
foreach (['min_weight', 'per_ticket_surcharge'] as $key) {
|
||
$copy[$key] = isset($copy[$key]) ? round((float) $copy[$key], 4) : null;
|
||
}
|
||
$copy['volumetric_divisor'] = isset($copy['volumetric_divisor']) ? (int) $copy['volumetric_divisor'] : null;
|
||
ksort($copy);
|
||
|
||
return json_encode($copy, JSON_UNESCAPED_UNICODE);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|