From e428cf5de2012273c4beda8bcb5f2c70e3d371e7 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sun, 18 Oct 2026 14:05:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E5=8E=86=E5=8F=B2=EF=BC=9A?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8B=BE=E9=80=89=E5=B9=B6=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E9=80=9A=E8=BF=87=20(#387)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Business/CourierQuoteChanges.php | 35 ++++++ app/Support/CourierQuoteChangeService.php | 4 + docs/报价历史批量审核.md | 11 ++ .../quote-change-history.blade.php | 48 +++++++- routes/admin.php | 1 + .../Feature/Business/QuoteBatchReviewTest.php | 106 ++++++++++++++++++ 6 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 docs/报价历史批量审核.md create mode 100644 tests/Feature/Business/QuoteBatchReviewTest.php diff --git a/app/Http/Controllers/Business/CourierQuoteChanges.php b/app/Http/Controllers/Business/CourierQuoteChanges.php index b73144bd..e1e617fc 100644 --- a/app/Http/Controllers/Business/CourierQuoteChanges.php +++ b/app/Http/Controllers/Business/CourierQuoteChanges.php @@ -165,6 +165,41 @@ class CourierQuoteChanges extends Controller ->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 { diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index 6aacf26b..b30c0970 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -141,6 +141,10 @@ final class CourierQuoteChangeService $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')); diff --git a/docs/报价历史批量审核.md b/docs/报价历史批量审核.md new file mode 100644 index 00000000..8d63e272 --- /dev/null +++ b/docs/报价历史批量审核.md @@ -0,0 +1,11 @@ +# 报价历史批量审核 + +报价管理 → 报价历史中,有报价审核权限的账号可勾选本页待审核记录,或用表头勾选框全选本页可审核项,点击“批量审核通过”。确认窗口列出变更单号、快递、变更类型和执行期间;核对原报价、计费规则及实际执行日期后提交。已审核和受自审限制的记录不显示勾选框。 + +批量接口沿用当前公司和报价管理页级权限,同时检查管理员/超级管理员审核身份;仅处理明确提交的记录ID,最多100条,不隐式选择其他页或其他公司。逐条调用现有单条审核入口,保留自审限制、报价期间冲突校验、版本生成、审核日志和原签署后续流程。单条失败不撤销其他成功项;完成后显示成功/未通过数量及每条结果,返回原页码。 + +存量报价仍按既有规则审核生效,不自动发起签署;历史签署资料必须逐份核对,不接受批量标记为已经核验。非存量变价沿用原单条审核的签署准备与推送流程。 + +审核服务在事务内锁定记录并重新检查待审核状态,防止过期模型或重复提交再次生成版本。无数据库迁移,无预先审核正式数据。 + +验证:QuoteBatchReviewTest覆盖多条存量报价、未选项保持原状、历史核验标记隔离、部分失败、已审核/自审/跨公司/不存在记录、确认与输入边界、无审核权限、过期模型重复审核;同步回归单条正常审核和自审限制。 diff --git a/resources/views/business/pages/courier-payables/quote-change-history.blade.php b/resources/views/business/pages/courier-payables/quote-change-history.blade.php index df7d005f..95c00b74 100644 --- a/resources/views/business/pages/courier-payables/quote-change-history.blade.php +++ b/resources/views/business/pages/courier-payables/quote-change-history.blade.php @@ -38,19 +38,61 @@
变价请在报价管理的报价列表里,点对应报价那一行的「变价」。
@if(is_string(session('success')))
{{ session('success') }}
@endif + @if($errors->any())@endif + @if(session('batch_review_results')) +
批量审核逐条结果
+ @endif
报价变更记录
草稿、审核和新版本按当前公司隔离保存,历史记录不可覆盖原报价。
@if($changes->isEmpty())
还没有报价变更记录
发起第一张变更单后,草稿和审核进度会显示在这里。
@else -
+ @if($canReview) + + @csrf + +
已选 0 条可全选本页待审核项;逐条校验,失败项保留待处理。
+ @endif +
变更单号原报价快递公司变更类型明细生效日期生效方式追溯核算状态新版本更新时间操作
@if($canReview)@endif @foreach($changes as $change) @php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''}) - + @if($canReview)@endif @endforeach -
变更单号原报价快递公司变更类型明细生效日期生效方式追溯核算状态新版本更新时间操作
{{ $change->number }}{{ $change->source_quote_id }}{{ $change->carrier_name }}{{ $change->change_type }}{{ $change->items->count() }} 项{{ $change->effective_date->toDateString() }}{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}{{ $change->recalculationStatusLabel() }}{{ $change->statusLabel() }}{{ $change->version?->version_code ?? '—' }}{{ $change->updated_at?->format('Y-m-d H:i') }}查看@if($change->isEditable() && $change->created_by===user_id()) · 编辑@endif
@if($change->status === 'pending_review' && ($change->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')))@else—@endif{{ $change->number }}{{ $change->source_quote_id }}{{ $change->carrier_name }}{{ $change->change_type }}{{ $change->items->count() }} 项{{ $change->effective_date->toDateString() }}{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}{{ $change->recalculationStatusLabel() }}{{ $change->statusLabel() }}{{ $change->version?->version_code ?? '—' }}{{ $change->updated_at?->format('Y-m-d H:i') }}查看@if($change->isEditable() && $change->created_by===user_id()) · 编辑@endif
{{ $changes->links() }}
+ + @if($canReview) + +

确认批量审核通过

+

将审核下列报价,并按现有流程生成新版本。请先核对原报价、计费规则与实际执行日期。

+ +

存量报价审核后生效,不自动发起签署;其他变价沿用单条审核的签署准备和推送流程。历史签署资料不会在此批量标记为已核验。

+ +
+
+ + @endif +
{{ $changes->links() }}
@endif
+ + diff --git a/routes/admin.php b/routes/admin.php index 2d1e1d9a..982c019c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -152,6 +152,7 @@ Route::prefix('workspace/courier-payables/quote-management/changes') // 不以 create 结尾:Akaunting 的套餐限制中间件会把 …/create 的 GET 请求跳到用户页。 Route::get('new', [CourierQuoteChanges::class, 'create'])->name('start'); Route::post('/', [CourierQuoteChanges::class, 'store'])->name('store'); + Route::post('batch-approve', [CourierQuoteChanges::class, 'batchApprove'])->name('batch-approve'); Route::get('{quoteChange}', [CourierQuoteChanges::class, 'show'])->name('show'); Route::get('{quoteChange}/edit', [CourierQuoteChanges::class, 'edit'])->name('edit'); Route::patch('{quoteChange}', [CourierQuoteChanges::class, 'update'])->name('update'); diff --git a/tests/Feature/Business/QuoteBatchReviewTest.php b/tests/Feature/Business/QuoteBatchReviewTest.php new file mode 100644 index 00000000..59085715 --- /dev/null +++ b/tests/Feature/Business/QuoteBatchReviewTest.php @@ -0,0 +1,106 @@ +instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-batch-review-test.json')); + $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + config(['courier_onboarding.allow_same_reviewer' => false]); + } + + protected function reviewer() + { + $reviewer = user_model_class()::create(['name'=>'批量审核员','email'=>'batch-'.uniqid().'@example.com','password'=>'local-test-password','locale'=>'zh-CN','enabled'=>1,'landing_page'=>'dashboard','created_from'=>'core::test']); + $reviewer->companies()->attach($this->company->id); + $reviewer->attachRole(Role::where('name','admin')->firstOrFail()); + $reviewer->attachPermission(Permission::where('name','read-admin-panel')->firstOrFail()); + return $reviewer; + } + + protected function pending(string $name, ?int $creator = null): CourierQuoteChange + { + $creator ??= $this->user->id; + $entry = app(CourierStockQuoteEntryService::class)->createPrepared( + ['quote_type'=>'标准快递报价','effective_date'=>'2026-08-01','summary'=>'批量审核测试','billing_rules'=>'保留原报价规则'], + $creator, ['code'=>'TEST-'.$name,'name'=>$name], + ['sheet_name'=>'原报价','source_range'=>'A1:B2','values'=>[['省份','运费'],['江苏',5]],'formulas'=>[[],[]]] + ); + return app(CourierQuoteChangeService::class)->submit($entry->change, $creator); + } + + public function test_batch_approves_selected_stock_quotes_and_does_not_mark_historical_files_verified(): void + { + $this->loginAs(); + $one=$this->pending('快递甲');$two=$this->pending('快递乙');$untouched=$this->pending('未选快递'); + $this->loginAs($this->reviewer())->post(route('business.courier-quote-changes.batch-approve'), ['ids'=>[$one->id,$two->id],'stock_rules_confirmed'=>1,'stock_signature_verified'=>1]) + ->assertRedirect(route('business.courier-quote-changes.index',['page'=>1])) + ->assertSessionHas('success','批量审核完成:成功 2 条,未通过 0 条。'); + $this->assertSame('approved',$one->fresh()->status); + $this->assertSame('approved',$two->fresh()->status); + $this->assertSame('pending_review',$untouched->fresh()->status); + $this->assertDatabaseCount('business_courier_quote_versions',2); + foreach(CourierQuoteVersion::all() as $version) $this->assertSame('存量报价,审核生效(无需签章)',$version->signature_status); + $this->assertSame(1,$one->events()->where('event_type','approved')->count()); + $this->assertSame(1,$two->events()->where('event_type','approved')->count()); + Http::assertNothingSent(); + } + + public function test_partial_batch_keeps_self_review_stale_and_other_company_records_unchanged(): void + { + $this->loginAs();$reviewer=$this->reviewer(); + $good=$this->pending('正常');$own=$this->pending('自审',$reviewer->id);$old=$this->pending('已审核');$foreign=$this->pending('外公司'); + app(CourierQuoteChangeService::class)->approve($old,$reviewer->id); + DB::table('business_courier_quote_changes')->where('id',$foreign->id)->update(['company_id'=>$this->company->id+1000]); + $this->loginAs($reviewer)->post(route('business.courier-quote-changes.batch-approve'), ['ids'=>[$good->id,$own->id,$old->id,$foreign->id,99999999],'stock_rules_confirmed'=>1]) + ->assertSessionHas('success','批量审核完成:成功 1 条,未通过 4 条。'); + $this->assertSame('approved',$good->fresh()->status); + $this->assertSame('pending_review',$own->fresh()->status); + $this->assertDatabaseCount('business_courier_quote_versions',2); + $this->assertSame('pending_review',DB::table('business_courier_quote_changes')->where('id',$foreign->id)->value('status')); + } + + public function test_batch_requires_confirmation_nonempty_distinct_bounded_ids(): void + { + $this->loginAs();$change=$this->pending('校验'); + $this->withExceptionHandling()->loginAs($this->reviewer()); + foreach ([['ids'=>[$change->id]],['ids'=>[],'stock_rules_confirmed'=>1],['ids'=>[$change->id,$change->id],'stock_rules_confirmed'=>1],['ids'=>range(1,101),'stock_rules_confirmed'=>1]] as $input) { + $this->post(route('business.courier-quote-changes.batch-approve'),$input)->assertSessionHasErrors(); + } + $this->assertSame('pending_review',$change->fresh()->status); + $this->assertDatabaseCount('business_courier_quote_versions',0); + } + + public function test_non_reviewer_cannot_use_batch_endpoint(): void + { + $this->loginAs();$change=$this->pending('权限');$viewer=$this->reviewer();$viewer->detachRole(Role::where('name','admin')->firstOrFail()); + $this->withExceptionHandling()->loginAs($viewer)->post(route('business.courier-quote-changes.batch-approve'),['ids'=>[$change->id],'stock_rules_confirmed'=>1])->assertForbidden(); + $this->assertSame('pending_review',$change->fresh()->status); + } + + public function test_stale_model_cannot_approve_twice(): void + { + $this->loginAs();$one=$this->pending('并发');$stale=CourierQuoteChange::findOrFail($one->id);$reviewer=$this->reviewer(); + app(CourierQuoteChangeService::class)->approve($one,$reviewer->id); + try { app(CourierQuoteChangeService::class)->approve($stale,$reviewer->id); $this->fail('过期状态不应重复审核'); } + catch(ValidationException $exception){$this->assertArrayHasKey('status',$exception->errors());} + $this->assertDatabaseCount('business_courier_quote_versions',1); + $this->assertSame(1,$one->events()->where('event_type','approved')->count()); + } +}