where('courier_name', $courierName) ->whereIn('status', [CourierOnboarding::STATUS_APPROVED, CourierOnboarding::STATUS_ACTIVATED]) ->latest('id') ->first(); } /** * 已入驻快递的系统名称 => 入驻 id,报价管理列表一次取出。 * * @return array */ 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 */ 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 */ 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 $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,new_columns:list,cells:list,added:list,removed:list,rules:list} */ 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 $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 in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true) || $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED; } /** * 审核提醒:倒签、价格为 0、涨跌幅过大。 * * @param array|null $diff * @return list */ 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); } }