CaiWuXiTong/app/Http/Controllers/Business/CourierQuoteChanges.php
limeng 1b37df18cf feat(报价管理): 报价变更记录页增加快递公司/状态/变更类型筛选 (#783)
- 快递公司支持输入模糊匹配或从下拉建议中选择;状态、变更类型下拉筛选,可组合,提供「清空」。
- 分页保留筛选条件;批量审核后回到同一筛选结果页。
- 无匹配记录时给出空状态提示;补分页样式(原分页按竖排列表显示)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf

> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: #783
2026-10-30 14:31:33 +08:00

398 lines
19 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\Http\Controllers\Business;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierStockQuoteEntry;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierOnboardingService;
use App\Support\CourierQuoteSignatureService;
use App\Support\CourierRequoteService;
use App\Support\QuoteManagementSnapshot;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
class CourierQuoteChanges extends Controller
{
public function __construct(
private readonly CourierQuoteChangeService $service,
private readonly QuoteManagementSnapshot $snapshot,
private readonly CourierQuoteSignatureService $signatureService,
private readonly CourierRequoteService $requotes,
) {
}
public function index(Request $request): View
{
$filters = $this->historyFilters($request);
$changes = CourierQuoteChange::query()
->with(['items', 'version'])
->when($filters['carrier'] !== '', fn ($q) => $q->where('carrier_name', 'like', '%' . addcslashes($filters['carrier'], '%_\\') . '%'))
->when($filters['status'] !== '', fn ($q) => $q->where('status', $filters['status']))
->when($filters['change_type'] !== '', fn ($q) => $q->where('change_type', $filters['change_type']))
->latest('updated_at')
->paginate(20)
->withQueryString();
return view('business.pages.courier-payables.quote-change-history', [
'changes' => $changes,
'canReview' => $this->canReview(),
'filters' => $filters,
'carrierOptions' => CourierQuoteChange::query()->whereNotNull('carrier_name')->distinct()->orderBy('carrier_name')->pluck('carrier_name')->all(),
'changeTypeOptions' => CourierQuoteChange::query()->whereNotNull('change_type')->distinct()->orderBy('change_type')->pluck('change_type')->all(),
'statusOptions' => [
CourierQuoteChange::STATUS_DRAFT => '草稿',
CourierQuoteChange::STATUS_PENDING_REVIEW => '待审核',
CourierQuoteChange::STATUS_RETURNED => '已退回',
CourierQuoteChange::STATUS_APPROVED => '已审核',
],
]);
}
/** @return array{carrier:string,status:string,change_type:string} */
private function historyFilters(Request $request): array
{
$status = (string) $request->input('status', '');
$allowed = [CourierQuoteChange::STATUS_DRAFT, CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_APPROVED];
return [
'carrier' => mb_substr(trim((string) $request->input('carrier', '')), 0, 100),
'status' => in_array($status, $allowed, true) ? $status : '',
'change_type' => mb_substr(trim((string) $request->input('change_type', '')), 0, 50),
];
}
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']));
$back = array_filter($this->historyFilters($request), static fn ($v) => $v !== '');
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))] + $back)
->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);
}
}