CaiWuXiTong/app/Support/CourierRequoteService.php

488 lines
24 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\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)
->whereNull('courier_expcode')
->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)
->whereNull('courier_expcode')
->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')
->where('change_type', '!=', '存量报价')
->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)
->whereNull('courier_expcode')
->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()
&& ($version->isApprovedStockQuote() || 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 in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|| $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);
}
}