- 快递公司支持输入模糊匹配或从下拉建议中选择;状态、变更类型下拉筛选,可组合,提供「清空」。 - 分页保留筛选条件;批量审核后回到同一筛选结果页。 - 无匹配记录时给出空状态提示;补分页样式(原分页按竖排列表显示)。 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
398 lines
19 KiB
PHP
398 lines
19 KiB
PHP
<?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);
|
||
}
|
||
}
|