报价历史:支持勾选并批量审核通过 (#387)

This commit is contained in:
limeng 2026-10-18 14:05:40 +08:00
parent 2d30aea624
commit e428cf5de2
6 changed files with 202 additions and 3 deletions

View File

@ -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
{

View File

@ -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'));

View File

@ -0,0 +1,11 @@
# 报价历史批量审核
报价管理 → 报价历史中,有报价审核权限的账号可勾选本页待审核记录,或用表头勾选框全选本页可审核项,点击“批量审核通过”。确认窗口列出变更单号、快递、变更类型和执行期间;核对原报价、计费规则及实际执行日期后提交。已审核和受自审限制的记录不显示勾选框。
批量接口沿用当前公司和报价管理页级权限,同时检查管理员/超级管理员审核身份;仅处理明确提交的记录ID,最多100条,不隐式选择其他页或其他公司。逐条调用现有单条审核入口,保留自审限制、报价期间冲突校验、版本生成、审核日志和原签署后续流程。单条失败不撤销其他成功项;完成后显示成功/未通过数量及每条结果,返回原页码。
存量报价仍按既有规则审核生效,不自动发起签署;历史签署资料必须逐份核对,不接受批量标记为已经核验。非存量变价沿用原单条审核的签署准备与推送流程。
审核服务在事务内锁定记录并重新检查待审核状态,防止过期模型或重复提交再次生成版本。无数据库迁移,无预先审核正式数据。
验证:QuoteBatchReviewTest覆盖多条存量报价、未选项保持原状、历史核验标记隔离、部分失败、已审核/自审/跨公司/不存在记录、确认与输入边界、无审核权限、过期模型重复审核;同步回归单条正常审核和自审限制。

View File

@ -38,19 +38,61 @@
<div class="quote-history__actions"><span class="quote-history__hint">变价请在报价管理的报价列表里,点对应报价那一行的「变价」。</span></div>
</div>
@if(is_string(session('success')))<div style="margin-bottom:16px;border:1px solid #BAD0AB;border-radius:8px;background:#F3F8EF;padding:10px 12px;color:#40622F" role="status">{{ session('success') }}</div>@endif
@if($errors->any())<div class="qh-review-error" role="alert">@foreach($errors->all() as $error)<div>{{ $error }}</div>@endforeach</div>@endif
@if(session('batch_review_results'))
<details class="qh-review-results" open><summary>批量审核逐条结果</summary><ul>@foreach(session('batch_review_results') as $result)<li><strong>{{ $result['number'] }}</strong> · {{ $result['success'] ? '审核成功' : '未通过' }} · {{ $result['message'] }}</li>@endforeach</ul></details>
@endif
<section class="quote-history__panel" aria-labelledby="quote-history-title">
<header class="quote-history__header"><div class="quote-history__title" id="quote-history-title"><span class="material-icons-outlined" aria-hidden="true">history</span><span>报价变更记录</span></div><div class="quote-history__hint">草稿、审核和新版本按当前公司隔离保存,历史记录不可覆盖原报价。</div></header>
@if($changes->isEmpty())
<div class="quote-history__empty"><span class="material-icons-outlined" aria-hidden="true">difference</span><strong>还没有报价变更记录</strong><div>发起第一张变更单后,草稿和审核进度会显示在这里。</div></div>
@else
<div class="quote-history__table-wrap"><table><thead><tr><th>变更单号</th><th>原报价</th><th>快递公司</th><th>变更类型</th><th>明细</th><th>生效日期</th><th>生效方式</th><th>追溯核算</th><th>状态</th><th>新版本</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
@if($canReview)
<form method="post" action="{{ route('business.courier-quote-changes.batch-approve') }}" data-batch-review>
@csrf
<input type="hidden" name="page" value="{{ $changes->currentPage() }}">
<div class="qh-review-toolbar"><span>已选 <strong data-review-count>0</strong> 条</span><button type="button" class="quote-history__button quote-history__button--primary" data-review-open disabled>批量审核通过</button><span class="quote-history__hint">可全选本页待审核项;逐条校验,失败项保留待处理。</span></div>
@endif
<div class="quote-history__table-wrap"><table><thead><tr>@if($canReview)<th><input type="checkbox" data-review-all aria-label="全选本页可审核记录"></th>@endif<th>变更单号</th><th>原报价</th><th>快递公司</th><th>变更类型</th><th>明细</th><th>生效日期</th><th>生效方式</th><th>追溯核算</th><th>状态</th><th>新版本</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
@foreach($changes as $change)
@php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''})
<tr><td class="quote-history__number">{{ $change->number }}</td><td>{{ $change->source_quote_id }}</td><td>{{ $change->carrier_name }}</td><td>{{ $change->change_type }}</td><td>{{ $change->items->count() }} 项</td><td>{{ $change->effective_date->toDateString() }}</td><td><span class="quote-history__badge @if($change->isRetrospective()) quote-history__badge--pending @endif">{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</span></td><td><span class="quote-history__badge @if($change->recalculation_status==='blocked') quote-history__badge--returned @elseif($change->recalculation_status==='pending') quote-history__badge--pending @endif">{{ $change->recalculationStatusLabel() }}</span></td><td><span class="quote-history__badge @if($badge) quote-history__badge--{{ $badge }} @endif">{{ $change->statusLabel() }}</span></td><td>{{ $change->version?->version_code ?? '—' }}</td><td>{{ $change->updated_at?->format('Y-m-d H:i') }}</td><td><a class="quote-history__link" href="{{ route('business.courier-quote-changes.show',$change) }}">查看</a>@if($change->isEditable() && $change->created_by===user_id()) · <a class="quote-history__link" href="{{ route('business.courier-quote-changes.edit',$change) }}">编辑</a>@endif</td></tr>
<tr>@if($canReview)<td>@if($change->status === 'pending_review' && ($change->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')))<input type="checkbox" name="ids[]" value="{{ $change->id }}" data-review-choice data-review-label="{{ $change->number }} · {{ $change->carrier_name }} · {{ $change->change_type }} · {{ $change->periodLabel() }}" aria-label="选择 {{ $change->number }}">@else<span class="quote-history__hint" title="{{ $change->status === 'pending_review' ? '发起人不能审核自己提交的报价' : '仅待审核记录可勾选' }}">—</span>@endif</td>@endif<td class="quote-history__number">{{ $change->number }}</td><td>{{ $change->source_quote_id }}</td><td>{{ $change->carrier_name }}</td><td>{{ $change->change_type }}</td><td>{{ $change->items->count() }} 项</td><td>{{ $change->effective_date->toDateString() }}</td><td><span class="quote-history__badge @if($change->isRetrospective()) quote-history__badge--pending @endif">{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}</span></td><td><span class="quote-history__badge @if($change->recalculation_status==='blocked') quote-history__badge--returned @elseif($change->recalculation_status==='pending') quote-history__badge--pending @endif">{{ $change->recalculationStatusLabel() }}</span></td><td><span class="quote-history__badge @if($badge) quote-history__badge--{{ $badge }} @endif">{{ $change->statusLabel() }}</span></td><td>{{ $change->version?->version_code ?? '—' }}</td><td>{{ $change->updated_at?->format('Y-m-d H:i') }}</td><td><a class="quote-history__link" href="{{ route('business.courier-quote-changes.show',$change) }}">查看</a>@if($change->isEditable() && $change->created_by===user_id()) · <a class="quote-history__link" href="{{ route('business.courier-quote-changes.edit',$change) }}">编辑</a>@endif</td></tr>
@endforeach
</tbody></table></div><div class="quote-history__pagination">{{ $changes->links() }}</div>
</tbody></table></div>
@if($canReview)
<dialog class="qh-review-dialog" data-review-dialog aria-labelledby="qh-review-dialog-title">
<h3 id="qh-review-dialog-title">确认批量审核通过</h3>
<p>将审核下列报价,并按现有流程生成新版本。请先核对原报价、计费规则与实际执行日期。</p>
<ul data-review-selection></ul>
<p>存量报价审核后生效,不自动发起签署;其他变价沿用单条审核的签署准备和推送流程。历史签署资料不会在此批量标记为已核验。</p>
<label class="qh-review-confirm"><input type="checkbox" name="stock_rules_confirmed" value="1" required>我已核对所有选中报价的原报价、计费规则及实际执行日期</label>
<div class="qh-review-dialog-actions"><button type="button" class="quote-history__button" data-review-cancel>取消</button><button type="submit" class="quote-history__button quote-history__button--primary" data-review-submit>确认审核通过</button></div>
</dialog>
</form>
@endif
<div class="quote-history__pagination">{{ $changes->links() }}</div>
@endif
</section>
</main>
<style>
.qh-review-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:12px;padding:12px 16px;border-bottom:1px solid #D8D2C7}.qh-review-toolbar strong{color:#856632}.quote-history input[type=checkbox]{width:16px;height:16px;accent-color:#856632;vertical-align:middle}.quote-history__button:disabled{opacity:.55;cursor:not-allowed}.qh-review-dialog{width:min(680px,calc(100vw - 32px));padding:24px;border:1px solid #D8D2C7;border-radius:12px;color:#171717;background:#fff;font:inherit}.qh-review-dialog::backdrop{background:#17171766}.qh-review-dialog h3{margin:0 0 12px;font-size:14px;font-weight:600}.qh-review-dialog p{font-size:12px;line-height:1.7;color:#595959}.qh-review-dialog ul{max-height:35vh;overflow:auto;padding-left:20px;line-height:1.8}.qh-review-confirm{display:flex;gap:8px;align-items:flex-start;font-size:12px;line-height:1.7}.qh-review-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.qh-review-results{padding:12px 16px;margin-bottom:16px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;font-size:12px;line-height:1.7}.qh-review-results ul{max-height:260px;overflow:auto}.qh-review-error{padding:12px;margin-bottom:16px;border:1px solid #E6B7B7;border-radius:8px;background:#FFF4F4;color:#B80000}.quote-history__table-wrap{max-height:60vh;overflow:auto}.quote-history th{position:sticky;top:0;z-index:1}.quote-history :focus-visible{outline:2px solid #856632;outline-offset:2px}
</style>
<script>
(() => {
const form = document.querySelector('[data-batch-review]');
if (!form) return;
const all = form.querySelector('[data-review-all]');
const choices = Array.from(form.querySelectorAll('[data-review-choice]'));
const open = form.querySelector('[data-review-open]');
const dialog = form.querySelector('[data-review-dialog]');
const update = () => { const checked = choices.filter(input => input.checked); form.querySelector('[data-review-count]').textContent = checked.length; open.disabled = checked.length === 0; all.disabled = choices.length === 0; all.checked = choices.length > 0 && checked.length === choices.length; all.indeterminate = checked.length > 0 && checked.length < choices.length; };
all.addEventListener('change', () => { choices.forEach(input => input.checked = all.checked); update(); });
choices.forEach(input => input.addEventListener('change', update));
open.addEventListener('click', () => { const list = form.querySelector('[data-review-selection]'); list.replaceChildren(); choices.filter(input => input.checked).forEach(input => { const item = document.createElement('li'); item.textContent = input.dataset.reviewLabel; list.append(item); }); form.elements.stock_rules_confirmed.checked = false; dialog.showModal(); });
form.querySelector('[data-review-cancel]').addEventListener('click', () => dialog.close());
form.addEventListener('submit', event => { if (!choices.some(input => input.checked)) { event.preventDefault(); return; } form.querySelector('[data-review-submit]').disabled = true; form.querySelector('[data-review-submit]').textContent = '审核中…'; open.disabled = true; });
update();
})();
</script>
</x-slot>
</x-layouts.admin>

View File

@ -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');

View File

@ -0,0 +1,106 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierStockQuoteEntryService;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
use Tests\Feature\FeatureTestCase;
class QuoteBatchReviewTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
app()->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());
}
}