with(['items', 'version']) ->latest('updated_at') ->paginate(20); return view('business.pages.courier-payables.quote-change-history', [ 'changes' => $changes, 'canReview' => $this->canReview(), ]); } public function create(Request $request): View|RedirectResponse { $quotes = $this->snapshot->load()['quotes'] ?? []; abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。'); $selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']); $source = $this->service->findSourceQuote($selectedId); // 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。 if (empty($source['courier_expcode']) && $request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) { return redirect()->route('business.courier-onboardings.requote', $onboarding); } return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source)); } public function store(Request $request): RedirectResponse { $change = $this->service->create($this->validatedChange($request, true), user_id()); return redirect()->route('business.courier-quote-changes.edit', $change) ->with('success', "已保存变更草稿 {$change->number}。原始报价未被修改。"); } public function show(CourierQuoteChange $quoteChange): View { $this->refreshSignatures(); $quoteChange->load(['items', 'events.actor', 'version.signature']); $stockEntry = $quoteChange->isStockQuote() ? CourierStockQuoteEntry::query()->where('source_change_id', $quoteChange->id)->firstOrFail() : null; $onboarding = $stockEntry === null && $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null; $requoteDiff = $onboarding !== null ? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload) : null; return view('business.pages.courier-payables.quote-change-show', [ 'change' => $quoteChange, 'stockEntry' => $stockEntry, 'onboarding' => $onboarding, 'requoteDiff' => $requoteDiff, 'reviewWarnings' => $stockEntry === null && $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED ? $this->requotes->reviewWarnings($quoteChange, $requoteDiff) : [], 'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null, 'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')), 'canManageSignature' => $this->canReview(), 'esignConfigured' => EsignClient::configured(), 'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'), ]); } public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse { abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403); if ($quoteChange->isStockQuote()) { return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id')); } if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) { return redirect()->route('business.courier-onboardings.requote', $onboarding); } $quotes = $this->snapshot->load()['quotes'] ?? []; $source = $this->service->findSourceQuote($quoteChange->source_quote_id); $quoteChange->load('items'); return view('business.pages.courier-payables.quote-change-editor', $this->editorData($quoteChange, $quotes, $source)); } public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { abort_if($quoteChange->isStockQuote(), 422, '请使用存量报价草稿入口修改。'); $change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id()); return redirect()->route('business.courier-quote-changes.edit', $change) ->with('success', "已保存变更草稿 {$change->number}。"); } public function submit(CourierQuoteChange $quoteChange): RedirectResponse { $change = $this->service->submit($quoteChange, user_id()); return redirect()->route('business.courier-quote-changes.show', $change) ->with('success', "{$change->number} 已提交审核。"); } public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); if ($quoteChange->isStockQuote()) { $request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']); } $change = $this->service->approve($quoteChange, user_id(), $request->boolean('stock_signature_verified')); $message = "审核完成,已生成新报价版本 {$change->version->version_code}。"; if ($change->isStockQuote()) { return redirect()->route('business.courier-quote-changes.show', $change) ->with('success', $message . ' ' . $change->version->signature_status . '。原报价已保留,未自动发起电子签署或发送提醒。'); } try { $this->signatureService->prepareVersion($change->version, user_id()); $message .= ' 签署 PDF 已生成并固定校验值。'; } catch (\Throwable $exception) { report($exception); $message .= ' 签署 PDF 生成失败,可在详情页重试。'; } if ($change->isRetrospective()) { $message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。'; } // 已入驻快递:按入驻资料直接推送到快递的 e签宝。 if ($change->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($change->carrier_name))) { try { app(CourierOnboardingService::class)->pushVersion($onboarding, $change->version, (int) user_id()); $message .= " 已推送到快递 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account}),等待签署。"; } catch (\Throwable $exception) { report($exception); $reason = $exception instanceof \Illuminate\Validation\ValidationException ? collect($exception->errors())->flatten()->first() : '推送失败'; $message .= " 推送 e签宝失败:{$reason},可在下方重新推送。"; } } return redirect()->route('business.courier-quote-changes.show', $change) ->with('success', $message); } public function batchApprove(Request $request): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); $input = $request->validate([ 'ids' => ['required', 'array', 'min:1', 'max:100'], 'ids.*' => ['required', 'integer', 'min:1', 'distinct'], 'stock_rules_confirmed' => ['accepted'], ], ['stock_rules_confirmed.accepted' => '请先核对选中报价的原报价、计费规则及实际执行日期。']); $results = []; // 历史签署资料需要逐份核对,不接受批量标记已经核验。 $singleRequest = $request->duplicate(); $singleRequest->merge(['stock_signature_verified' => false]); foreach ($input['ids'] as $id) { $change = CourierQuoteChange::query()->where('company_id', company_id())->find($id); if ($change === null) { $results[] = ['number' => '所选记录', 'success' => false, 'message' => '记录已不存在或不属于当前公司。']; continue; } try { // 复用单条入口,权限、校验、版本、日志及后续签署流程保持一致。 $response = $this->approve($singleRequest, $change); $results[] = ['number' => $change->number, 'success' => true, 'message' => $response->getSession()->get('success')]; } catch (\Illuminate\Validation\ValidationException $exception) { $results[] = ['number' => $change->number, 'success' => false, 'message' => collect($exception->errors())->flatten()->implode(';')]; } catch (\Throwable $exception) { report($exception); $results[] = ['number' => $change->number, 'success' => false, 'message' => '处理失败,请查看该单状态并重试。']; } } $passed = count(array_filter($results, static fn ($result) => $result['success'])); return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))]) ->with('success', '批量审核完成:成功 ' . $passed . ' 条,未通过 ' . (count($results) - $passed) . ' 条。') ->with('batch_review_results', $results); } /** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */ public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。'); $onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name); abort_if($onboarding === null || $quoteChange->version === null, 404); app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id()); return redirect()->route('business.courier-quote-changes.show', $quoteChange) ->with('success', "{$quoteChange->version->version_code} 已推送到快递 e签宝,等待签署。"); } /** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */ public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); abort_if($quoteChange->version === null, 404); $data = $request->validate( ['void_reason' => ['required', 'string', 'min:2', 'max:500']], ['void_reason.required' => '请写明作废原因,例如:江苏面单多打了一位。'], ); $message = $this->requotes->voidVersion($quoteChange->version, trim($data['void_reason']), (int) user_id()); return redirect()->route('business.courier-quote-changes.show', $quoteChange)->with('success', $message); } public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); $validated = $request->validate([ 'review_note' => ['required', 'string', 'min:2', 'max:1000'], ]); $change = $this->service->returnForChanges($quoteChange, $validated['review_note'], user_id()); return redirect()->route('business.courier-quote-changes.show', $change) ->with('success', "{$change->number} 已退回发起人修改。"); } private function validatedChange(Request $request, bool $requiresSource): array { $rules = [ 'change_type' => ['required', 'in:费率调整,面单调整,规则备注,执行日期,其他'], 'effective_date' => ['required', 'date_format:Y-m-d'], 'summary' => ['required', 'string', 'min:2', 'max:2000'], 'items' => ['required', 'array', 'min:1', 'max:500'], 'items.*.source_cell' => ['required', 'regex:/^[A-Z]+[1-9][0-9]*$/'], 'items.*.source_row' => ['required', 'integer', 'min:0'], 'items.*.source_column' => ['required', 'integer', 'min:0'], 'items.*.region' => ['nullable', 'string', 'max:255'], 'items.*.weight_band' => ['nullable', 'string', 'max:255'], 'items.*.charge_item' => ['nullable', 'string', 'max:255'], 'items.*.original_amount' => ['required', 'numeric', 'min:0', 'decimal:0,4'], 'items.*.new_amount' => ['required', 'numeric', 'min:0', 'decimal:0,4'], 'items.*.unit' => ['nullable', 'string', 'max:32'], 'items.*.note' => ['nullable', 'string', 'max:500'], ]; if ($requiresSource) { $rules['source_quote_id'] = ['required', 'string', 'max:32']; } return $request->validate($rules); } private function editorData(?CourierQuoteChange $change, array $quotes, array $source): array { $existingItems = $change?->items->keyBy('source_cell') ?? collect(); return [ 'change' => $change, 'quotes' => $quotes, 'source' => $source, 'editableCells' => $this->editableCells($source, $existingItems), ]; } private function editableCells(array $source, $existingItems): array { $values = $source['values']; $cells = []; foreach ($values as $rowIndex => $row) { foreach ($row as $columnIndex => $value) { if (! is_numeric($value)) { continue; } $formula = $source['formulas'][$rowIndex][$columnIndex] ?? null; if (is_string($formula) && trim($formula) !== '') { continue; } $cell = $this->columnName($columnIndex) . ($rowIndex + 1); $existing = $existingItems->get($cell); $cells[] = [ 'source_cell' => $cell, 'source_row' => $rowIndex, 'source_column' => $columnIndex, 'region' => $this->nearestText($row, $columnIndex - 1) ?? $this->nearestText($row, 0), 'weight_band' => $this->headerText($values, $rowIndex, $columnIndex), 'charge_item' => $this->headerText($values, $rowIndex, $columnIndex), 'original_amount' => number_format((float) $value, 4, '.', ''), 'new_amount' => $existing?->new_amount ?? number_format((float) $value, 4, '.', ''), 'unit' => $existing?->unit, 'note' => $existing?->note, 'selected' => $existing !== null, ]; } } return $cells; } private function headerText(array $values, int $row, int $column): ?string { for ($index = $row - 1; $index >= 0; $index--) { $candidate = $values[$index][$column] ?? null; if (is_string($candidate) && trim($candidate) !== '') { return trim($candidate); } } return null; } private function nearestText(array $row, int $column): ?string { for ($index = $column; $index >= 0; $index--) { $candidate = $row[$index] ?? null; if (is_string($candidate) && trim($candidate) !== '') { return trim($candidate); } } return null; } private function columnName(int $column): string { $name = ''; do { $name = chr(65 + ($column % 26)) . $name; $column = intdiv($column, 26) - 1; } while ($column >= 0); return $name; } private function refreshSignatures(): void { try { $this->signatureService->refreshPending(); } catch (\Throwable $exception) { report($exception); } } private function canReview(): bool { return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true); } }