'存量报价请先完成首版审核,再从正式报价版本发起变价。']); } $source = $this->findSourceQuote($data['source_quote_id']); return DB::transaction(function () use ($data, $userId, $source): CourierQuoteChange { $retrospective = ($data['change_type'] ?? '') !== '存量报价' && $this->isRetrospectiveDate($data['effective_date']); $change = CourierQuoteChange::create([ 'company_id' => company_id(), 'number' => $this->nextChangeNumber(), 'source_quote_id' => $source['id'], 'source_sheet_name' => $source['sheet_name'], 'carrier_name' => $source['company_name'], 'quote_type' => $source['quote_type'], 'change_type' => $data['change_type'], 'status' => CourierQuoteChange::STATUS_DRAFT, 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, 'is_retrospective' => $retrospective, 'recalculation_status' => $retrospective ? CourierQuoteChange::RECALCULATION_PENDING : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, 'recalculation_note' => null, 'summary' => $data['summary'], 'created_by' => $userId, 'quote_payload' => $data['quote_payload'] ?? null, 'proposed_values_json' => $data['proposed_values_json'] ?? null, ]); if (! $change->isStockQuote()) { $this->replaceItems($change, $data['items'], $source); } $this->recordEvent($change, 'created', null, CourierQuoteChange::STATUS_DRAFT, '创建报价变更草稿。', $userId, $this->changeDetails($change)); return $change->load('items', 'events'); }); } public function update(CourierQuoteChange $change, array $data, int $userId): CourierQuoteChange { $this->ensureEditableBy($change, $userId); $source = $this->findSourceQuote($change->source_quote_id); return DB::transaction(function () use ($change, $data, $userId, $source): CourierQuoteChange { $from = $change->status; $retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($data['effective_date']); $change->update([ 'change_type' => $data['change_type'], 'effective_date' => $data['effective_date'], 'end_date' => $change->isStockQuote() ? (($data['end_date'] ?? null) ?: null) : $change->end_date, 'is_retrospective' => $retrospective, 'recalculation_status' => $retrospective ? CourierQuoteChange::RECALCULATION_PENDING : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, 'recalculation_note' => null, 'summary' => $data['summary'], 'status' => CourierQuoteChange::STATUS_DRAFT, 'quote_payload' => $change->isStockQuote() ? [...$change->quote_payload, 'billing_rules' => $data['billing_rules']] : $change->quote_payload, ]); if (! $change->isStockQuote()) { $this->replaceItems($change, $data['items'], $source); } $this->recordEvent($change, 'saved', $from, CourierQuoteChange::STATUS_DRAFT, '保存报价变更草稿。', $userId, $this->changeDetails($change)); return $change->fresh(['items', 'events']); }); } public function submit(CourierQuoteChange $change, int $userId): CourierQuoteChange { $this->ensureEditableBy($change, $userId); if (! ($change->isStockQuote() && filled($change->proposed_values_json)) && $change->items()->count() === 0) { throw ValidationException::withMessages(['items' => '至少需要一条发生变化的报价明细。']); } return DB::transaction(function () use ($change, $userId): CourierQuoteChange { $from = $change->status; $retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date); $change->update([ 'status' => CourierQuoteChange::STATUS_PENDING_REVIEW, 'is_retrospective' => $retrospective, 'recalculation_status' => $retrospective ? CourierQuoteChange::RECALCULATION_PENDING : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, 'recalculation_note' => null, 'submitted_by' => $userId, 'submitted_at' => now(), ]); $this->recordEvent($change, 'submitted', $from, CourierQuoteChange::STATUS_PENDING_REVIEW, '提交报价变更审核。', $userId, $this->changeDetails($change)); return $change->fresh(['items', 'events']); }); } public function returnForChanges(CourierQuoteChange $change, string $note, int $userId): CourierQuoteChange { if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { throw ValidationException::withMessages(['status' => '只有待审核的变更单可以退回。']); } return DB::transaction(function () use ($change, $note, $userId): CourierQuoteChange { $change->update(['status' => CourierQuoteChange::STATUS_RETURNED]); $this->recordEvent($change, 'returned', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, $note, $userId); return $change->fresh(['items', 'events']); }); } public function approve(CourierQuoteChange $change, int $userId, bool $historicalDocumentsConfirmed = false): CourierQuoteChange { if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']); } // 测试环境可用 COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 放开(一人走完流程),正式环境保持发起人不能自审。 if ($change->created_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) { throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']); } $source = $this->findSourceQuote($change->source_quote_id); return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange { $change = CourierQuoteChange::query()->lockForUpdate()->findOrFail($change->id); if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过,请刷新状态。']); } if ($change->isStockQuote()) { \App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail(); $entry = CourierStockQuoteEntry::findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id')); app(CourierStockQuoteEntryService::class)->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $change->effective_date->toDateString(), $change->end_date?->toDateString(), $entry->id); } $hasHistoricalFiles = $change->isStockQuote() && collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files)->contains('kind', 'signed'); if ($change->isStockQuote() && $historicalDocumentsConfirmed && ! $hasHistoricalFiles) { throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或取消历史资料核验;不影响存量报价审核生效。']); } $historicalSigned = $hasHistoricalFiles && $historicalDocumentsConfirmed; $values = $source['values']; $formulas = $source['formulas']; // 后台变价 / 重新报价:变价单里已经是完整的新报价矩阵。 if ($change->hasQuotePayload() && filled($change->proposed_values_json)) { $values = json_decode((string) $change->proposed_values_json, true, 512, JSON_THROW_ON_ERROR); $formulas = $change->isStockQuote() ? $source['formulas'] : []; } foreach ($change->hasQuotePayload() ? [] : $change->items as $item) { if (! array_key_exists($item->source_row, $values) || ! array_key_exists($item->source_column, $values[$item->source_row])) { throw ValidationException::withMessages(['items' => "报价单元格 {$item->source_cell} 已不存在,请退回重新核对。"]); } $values[$item->source_row][$item->source_column] = $item->new_amount; } $importedVersionCount = collect($this->snapshot->load()['quotes'] ?? []) ->whereNull('source_change_id') ->count(); $version = CourierQuoteVersion::create([ 'company_id' => company_id(), 'version_code' => 'PENDING-' . $change->id, 'source_quote_id' => $source['id'], 'source_change_id' => $change->id, 'sheet_name' => $source['sheet_name'], 'company_name' => $source['company_name'], 'carrier_name' => $source['company_name'], 'courier_expcode' => $source['courier_expcode'] ?? null, 'billing_rules' => $change->isStockQuote() ? data_get($change->quote_payload, 'billing_rules') : ($source['billing_rules'] ?? null), 'quote_type' => $source['quote_type'], 'effective_date' => $change->effective_date, 'end_date' => $change->end_date, 'source_range' => $source['source_range'], 'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'status' => 'approved', 'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '存量报价,审核生效(无需签章)') : '未签署', 'created_by' => $change->created_by, 'approved_by' => $userId, ]); $version->update([ 'version_code' => sprintf('QB-%03d', $importedVersionCount + $version->id), ]); $retrospective = ! $change->isStockQuote() && $this->isRetrospectiveDate($change->effective_date); $recalculationNote = $retrospective ? $this->retrospectiveBlockReason() : null; $change->update([ 'status' => CourierQuoteChange::STATUS_APPROVED, 'is_retrospective' => $retrospective, 'recalculation_status' => $retrospective ? CourierQuoteChange::RECALCULATION_BLOCKED : CourierQuoteChange::RECALCULATION_NOT_REQUIRED, 'recalculation_note' => $recalculationNote, 'approved_by' => $userId, 'approved_at' => now(), 'new_quote_version_id' => $version->id, ]); $eventNote = "审核通过并生成新版本 {$version->version_code}。"; if ($retrospective) { $eventNote .= ' 该报价为历史生效;补差明细尚未生成,原运费保持不变。'; } $this->recordEvent($change, 'approved', CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_APPROVED, $eventNote, $userId, [ ...$this->changeDetails($change), 'version_code' => $version->version_code, 'historical_documents_confirmed' => $historicalSigned, ]); return $change->fresh(['items', 'events', 'version']); }); } public function findSourceQuote(string $id): array { if (preg_match('/^STOCK-([1-9][0-9]*)$/', $id, $match)) { $entry = CourierStockQuoteEntry::query()->where('company_id', company_id())->find($match[1]); if ($entry !== null) { return $entry->sourceQuote(); } } $quote = collect($this->snapshot->load()['quotes'] ?? [])->firstWhere('id', $id); if (! is_array($quote)) { throw ValidationException::withMessages(['source_quote_id' => '选择的原报价版本不存在或已不可用。']); } return $quote; } private function replaceItems(CourierQuoteChange $change, array $items, array $source): void { $change->items()->withTrashed()->forceDelete(); foreach (array_values($items) as $position => $item) { $row = (int) $item['source_row']; $column = (int) $item['source_column']; $sourceValue = $source['values'][$row][$column] ?? null; $formula = $source['formulas'][$row][$column] ?? null; if (is_string($formula) && trim($formula) !== '') { throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 是公式单元格,不能直接改价。"]); } if (! is_numeric($sourceValue)) { throw ValidationException::withMessages(["items.{$position}.source_cell" => "{$item['source_cell']} 不是可调整的数值单元格。"]); } if (abs((float) $sourceValue - (float) $item['original_amount']) > 0.00005) { throw ValidationException::withMessages(["items.{$position}.original_amount" => "{$item['source_cell']} 的原价格已经变化,请重新载入后再保存。"]); } if ((float) $item['new_amount'] < 0) { throw ValidationException::withMessages(["items.{$position}.new_amount" => '新价格不能小于 0。']); } if (round((float) $item['new_amount'], 4) === round((float) $item['original_amount'], 4)) { continue; } $change->items()->create([ 'company_id' => company_id(), 'position' => $position, 'source_cell' => strtoupper($item['source_cell']), 'source_row' => $row, 'source_column' => $column, 'region' => $item['region'] ?? null, 'weight_band' => $item['weight_band'] ?? null, 'charge_item' => $item['charge_item'] ?? null, 'original_amount' => number_format((float) $item['original_amount'], 4, '.', ''), 'new_amount' => number_format((float) $item['new_amount'], 4, '.', ''), 'unit' => $item['unit'] ?? null, 'note' => $item['note'] ?? null, ]); } if ($change->items()->count() === 0) { throw ValidationException::withMessages(['items' => '至少需要一条原价格与新价格不同的变价明细。']); } } private function ensureEditableBy(CourierQuoteChange $change, int $userId): void { if (! $change->isEditable()) { throw ValidationException::withMessages(['status' => '当前状态不允许继续修改。']); } if ($change->created_by !== $userId) { abort(403, '只有草稿发起人可以修改或提交该变更单。'); } } private function recordEvent(CourierQuoteChange $change, string $type, ?string $from, ?string $to, string $note, int $userId, array $details = []): void { CourierQuoteChangeEvent::create([ 'company_id' => company_id(), 'quote_change_id' => $change->id, 'event_type' => $type, 'from_status' => $from, 'to_status' => $to, 'note' => $note, 'details_json' => $details === [] ? null : json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_by' => $userId, ]); } private function changeDetails(CourierQuoteChange $change): array { return [ ...($change->isStockQuote() ? [ 'stock_quote_entry_id' => data_get($change->quote_payload, 'stock_quote_entry_id'), 'billing_rules' => data_get($change->quote_payload, 'billing_rules'), 'files' => collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files) ->map(static fn (array $file): array => \Illuminate\Support\Arr::only($file, ['kind', 'name', 'sha256']))->all(), ] : []), 'change_type' => $change->change_type, 'carrier_name' => $change->carrier_name, 'effective_date' => $change->effective_date->toDateString(), 'end_date' => $change->end_date?->toDateString(), 'is_retrospective' => $change->isRetrospective(), 'recalculation_status' => $change->recalculation_status, 'recalculation_note' => $change->recalculation_note, 'summary' => $change->summary, 'items' => $change->items()->get()->map(static fn ($item): array => [ 'source_cell' => $item->source_cell, 'original_amount' => $item->original_amount, 'new_amount' => $item->new_amount, 'note' => $item->note, ])->all(), ]; } private function nextChangeNumber(): string { $prefix = 'BG-' . now()->format('Ymd') . '-'; $last = CourierQuoteChange::withTrashed()->where('number', 'like', $prefix . '%')->lockForUpdate()->max('number'); $sequence = $last ? ((int) substr($last, -3)) + 1 : 1; return $prefix . str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); } private function isRetrospectiveDate(string|Carbon $effectiveDate): bool { return Carbon::parse($effectiveDate)->startOfDay()->lt(today()); } private function retrospectiveBlockReason(): string { return '未生成补差明细:当前报价未提供可执行的运费计算方式,历史运费也未记录所用报价版本。补差前需建立逐票原报价版本关联和可验证的报价计算规则;原运费及原始 Excel 保持不变。'; } }