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())@foreach($errors->all() as $error)
{{ $error }}
@endforeach
@endif
+ @if(session('batch_review_results'))
+ 批量审核逐条结果
@foreach(session('batch_review_results') as $result)- {{ $result['number'] }} · {{ $result['success'] ? '审核成功' : '未通过' }} · {{ $result['message'] }}
@endforeach
+ @endif
@if($changes->isEmpty())
difference还没有报价变更记录发起第一张变更单后,草稿和审核进度会显示在这里。
@else
- | 变更单号 | 原报价 | 快递公司 | 变更类型 | 明细 | 生效日期 | 生效方式 | 追溯核算 | 状态 | 新版本 | 更新时间 | 操作 |
+ @if($canReview)
+
+ @if($canReview)
+
+
+ @endif
+
@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());
+ }
+}