arrow_back返回报价历史
+ @if($change->isEditable() && $change->created_by===user_id())edit编辑草稿@endif @if($canReview && $change->status==='pending_review')@endif
+ arrow_back返回报价历史
+
@if(is_string(session('success')))
+ @if($change->isEditable() && $change->created_by===user_id())
+ edit编辑草稿
+
+ @endif
+ @if($canReview && $change->status==='pending_review')
+
+ @endif
+
+ {{ session('success') }}
@endif
@if($errors->any()){{ $errors->first() }}
@endif
@if($reviewWarnings)report
@endif
@if($change->isRetrospective())审核提醒(请重点核对)@foreach($reviewWarnings as $warning)
· {{ $warning }}@endforeach
· {{ $warning }}@endforeach
history
@endif
- 追溯变价 · {{ $change->recalculationStatusLabel() }}
自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}
自 {{ $change->effective_date->toDateString() }} 起生效。{{ $change->recalculation_note ?: '审核完成后应生成补款或扣款明细,原运费和原始 Excel 保持不变。' }}
assignment{{ $change->number }} · {{ $change->statusLabel() }}
- 原报价版本
- {{ $change->source_quote_id }}
- 快递公司
- {{ $change->carrier_name }}
- 报价类型
- {{ $change->quote_type }}
- 变更类型
- {{ $change->change_type }}
- 执行期间
- {{ $change->periodLabel() }}
- 生效方式
- {{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}
- 追溯核算
- {{ $change->recalculationStatusLabel() }}
- 变价明细
- {{ $requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项' }}
- 新报价版本
- {{ $change->version?->version_code ?? '尚未生成' }}
- 签署状态
- {{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
{{ $change->summary }}
assignment{{ $change->number }} · {{ $change->statusLabel() }}
+
+ -
+
- 原报价版本
- {{ $change->source_quote_id }}
- 快递公司
- {{ $change->carrier_name }}
- 报价类型
- {{ $change->quote_type }}
- 变更类型
- {{ $change->change_type }}
- 执行期间
- {{ $change->periodLabel() }}
- 生效方式
- {{ $stockEntry ? '存量报价补录' : ($change->isRetrospective() ? '追溯变价' : '正常生效') }}
- 追溯核算
- {{ $change->recalculationStatusLabel() }}
- {{ $stockEntry ? '录入范围' : '变价明细' }}
- {{ $stockEntry ? $stockEntry->source_range : ($requoteDiff ? ($requoteDiff['same_structure'] ? count($requoteDiff['cells']) . ' 处价格变动' : '计费方式调整') : $change->items->count() . ' 项') }}
- 新报价版本
- {{ $change->version?->version_code ?? '尚未生成' }}
- 签署状态
- {{ $change->version?->signature_status ?? '未进入签署' }}
变更依据与说明
{{ $change->summary }}
+ {{ $change->summary }}
difference新旧报价对比 · {{ $change->change_type }}
历史签署资料已核验归档,可从上方附件下载。尚无本系统电子签署记录。
+ @elseif(!$quoteSignature)该历史报价版本尚未生成签署原文。
@if($canManageSignature)@endif
@else
diff --git a/resources/views/business/pages/courier-payables/quote-management.blade.php b/resources/views/business/pages/courier-payables/quote-management.blade.php
index 384b107b..a40cea8c 100644
--- a/resources/views/business/pages/courier-payables/quote-management.blade.php
+++ b/resources/views/business/pages/courier-payables/quote-management.blade.php
@@ -762,6 +762,9 @@
+
+ upload_file录入存量报价
+
@@ -986,7 +989,7 @@
var row = create('tr');
var nameCell = appendCell(row, '');
nameCell.appendChild(create('span', 'quote-table__name', quote.id));
- appendCell(row, quote.company_name);
+ appendCell(row, quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : ''));
appendCell(row, quote.quote_type);
appendCell(row, text(quote.source_range, '-'));
appendCell(row, quote.row_count + ' 行 × ' + quote.column_count + ' 列');
@@ -994,9 +997,9 @@
var signCell = appendCell(row, quote.signature_status, 'quote-table__secondary');
if (quote.signed_files && quote.signed_files.signed) {
signCell.appendChild(document.createElement('br'));
- var signedLink = create('a', 'quote-action', '回签单');
+ var signedLink = create('a', 'quote-action', quote.signature_status === '历史签署资料已归档' ? '历史签署资料' : '回签单');
signedLink.href = quote.signed_files.signed;
- signedLink.title = '下载 ' + quote.id + ' 已签署的回签单 PDF' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : '');
+ signedLink.title = '下载 ' + quote.id + ' 签署资料' + (quote.signed_at ? '(' + quote.signed_at + ' 签署)' : '');
signedLink.setAttribute('aria-label', '下载回签单 ' + quote.id);
signCell.appendChild(signedLink);
}
@@ -1046,7 +1049,7 @@
root.querySelector('[data-quote-result-count]').textContent = '共 ' + filteredQuotes.length + ' 个报价版本';
var exportLink = root.querySelector('[data-quote-export]');
if (exportLink) {
- var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.signed; })
+ var signedCodes = filteredQuotes.filter(function (quote) { return quote.signed_files && quote.signed_files.export; })
.map(function (quote) { return quote.id; });
exportLink.href = exportBase + (signedCodes.length ? '?codes=' + encodeURIComponent(signedCodes.join(',')) : '');
exportLink.setAttribute('aria-disabled', signedCodes.length ? 'false' : 'true');
@@ -1068,7 +1071,7 @@
var keyword = fields.keyword.value.trim().toLocaleLowerCase('zh-CN');
filteredQuotes = quotes.filter(function (quote) {
- var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier)
+ var matchesKeyword = !keyword || (quote.company_name + ' ' + quote.sheet_name + ' ' + quote.id + ' ' + quote.carrier + ' ' + (quote.courier_expcode || ''))
.toLocaleLowerCase('zh-CN')
.includes(keyword);
@@ -1106,7 +1109,7 @@
lastFocusedElement = trigger;
setDetail('title', quote.sheet_name);
setDetail('id', quote.id);
- setDetail('carrier', quote.company_name);
+ setDetail('carrier', quote.company_name + (quote.courier_expcode ? ' · ' + quote.courier_expcode : ''));
setDetail('type', quote.quote_type);
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
diff --git a/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php
new file mode 100644
index 00000000..a9e86ce6
--- /dev/null
+++ b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php
@@ -0,0 +1,284 @@
+
+ 批量录入存量报价
+
+
+ @php
+ $result = $manifest['result'] ?? null;
+ $previous = json_decode(old('selection_json', '{}'), true) ?: [];
+ $defaults = $manifest['defaults'] ?? [];
+ $groups = $previous['rule_groups'] ?? [['name' => '共用计费规则', 'rules' => $defaults['billing_rules'] ?? '']];
+ @endphp
+
+
+
+
+
+
+ @else
+
+
+
+
diff --git a/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php b/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php
new file mode 100644
index 00000000..d24fcd0d
--- /dev/null
+++ b/resources/views/business/pages/courier-payables/stock-quote-entry.blade.php
@@ -0,0 +1,101 @@
+
+ {{ $entry ? '编辑存量报价' : '录入存量报价' }}
+
+
+
+ 返回报价管理
+
+
+
+
diff --git a/resources/views/business/pdf/courier-quote.blade.php b/resources/views/business/pdf/courier-quote.blade.php
index 61498f3b..82bd66ec 100644
--- a/resources/views/business/pdf/courier-quote.blade.php
+++ b/resources/views/business/pdf/courier-quote.blade.php
@@ -117,6 +117,7 @@
@endif
@forelse($values as $rowIndex => $row)
diff --git a/routes/admin.php b/routes/admin.php
index bacdbd28..e9ac2459 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -3,6 +3,7 @@
use App\Http\Controllers\Business\AccountMappingActions;
use App\Http\Controllers\Business\CourierProfiles;
use App\Http\Controllers\Business\CourierQuoteChanges;
+use App\Http\Controllers\Business\CourierStockQuoteEntries;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use App\Http\Controllers\Business\CourierAgreementSignatures;
use App\Http\Controllers\Business\CourierOnboardings;
@@ -123,6 +124,22 @@ Route::prefix('workspace-data/supplier-payables')
* @see \modules\OfflinePayments\Routes\admin.php for module example
*/
+Route::prefix('workspace/courier-payables/quote-management/stock-entries')
+ ->name('business.courier-stock-quotes.')
+ ->middleware('business.page:courier-payables,quote-management')
+ ->group(function () {
+ Route::get('/', [CourierStockQuoteEntries::class, 'create'])->name('create');
+ Route::get('single', [CourierStockQuoteEntries::class, 'single'])->name('single');
+ Route::post('batch-preview', [CourierStockQuoteEntries::class, 'previewBatch'])->name('batch-preview');
+ Route::get('batches/{batch}', [CourierStockQuoteEntries::class, 'showBatch'])->name('batch');
+ Route::get('batches/{batch}/images/{sheet}/{file}', [CourierStockQuoteEntries::class, 'batchImage'])->whereNumber(['sheet', 'file'])->name('batch-image');
+ Route::post('batches/{batch}', [CourierStockQuoteEntries::class, 'saveBatch'])->name('batch-save');
+ Route::post('/', [CourierStockQuoteEntries::class, 'store'])->name('store');
+ Route::get('{stockQuoteEntry}/edit', [CourierStockQuoteEntries::class, 'edit'])->name('edit');
+ Route::patch('{stockQuoteEntry}', [CourierStockQuoteEntries::class, 'update'])->name('update');
+ Route::get('{stockQuoteEntry}/files/{file}', [CourierStockQuoteEntries::class, 'download'])->whereNumber('file')->name('download');
+ });
+
Route::prefix('workspace/courier-payables/quote-management/changes')
->name('business.courier-quote-changes.')
->middleware('business.page:courier-payables,quote-management')
diff --git a/tests/Feature/Business/CourierStockQuoteBatchTest.php b/tests/Feature/Business/CourierStockQuoteBatchTest.php
new file mode 100644
index 00000000..1dc819cf
--- /dev/null
+++ b/tests/Feature/Business/CourierStockQuoteBatchTest.php
@@ -0,0 +1,369 @@
+user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
+ $this->mirror([
+ ['code' => 'EXP-001', 'name' => '同名网点'], ['code' => 'EXP-002', 'name' => '同名网点'],
+ ['code' => 'EXP-003', 'name' => '第三网点'],
+ ]);
+ app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-batch-quotes.json'));
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->temporaryFiles as $path) {
+ if (is_file($path)) {
+ unlink($path);
+ }
+ }
+ parent::tearDown();
+ }
+
+ public function test_preview_keeps_workbook_without_creating_quotes_and_never_guesses_ambiguous_names(): void
+ {
+ $this->loginAs()->get(route('business.courier-stock-quotes.create'))->assertOk()
+ ->assertSeeText('共用计费规则')->assertSeeText('只补录一家');
+ $batch = $this->preview(['EXP-001', '同名网点', '第三网点', '说明']);
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()
+ ->assertSeeText('名称重复,请选编号')->assertSeeText('保存并提交审核');
+ $service = app(CourierStockQuoteBatchService::class);
+ $manifest = $service->load($batch, (int) $this->user->id);
+ $rows = $service->rows($manifest, app(\App\Support\CourierStockQuoteEntryService::class)->courierOptions());
+ $this->assertSame(['EXP-001', '', 'EXP-003', ''], array_column($rows, 'courier_expcode'));
+ $this->assertSame('=B2/2', $manifest['sheets'][0]['formulas'][1][2]);
+ Storage::disk('uploads')->assertExists($manifest['source_file']['path']);
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $this->assertDatabaseCount('business_courier_quote_changes', 0);
+ }
+
+ public function test_bulk_save_reuses_rule_groups_overrides_dates_and_preserves_separate_prices(): void
+ {
+ $batch = $this->preview(['EXP-001', 'EXP-002', '说明']);
+ $selection = $this->selection(['EXP-001', 'EXP-002', '']);
+ $selection['entries'][2]['selected'] = false;
+ $selection['rule_groups'][] = ['name' => '单独规则', 'rules' => '超长件按实际重量计费'];
+ $selection['entries'][1]['rule_group'] = 1;
+ $selection['entries'][1]['effective_date'] = '2025-11-01';
+ $url = route('business.courier-stock-quotes.batch-save', $batch);
+ $this->loginAs()->post($url, ['selection_json' => json_encode($selection)])->assertRedirect();
+ $entries = CourierStockQuoteEntry::with('change')->orderBy('id')->get();
+ $this->assertCount(2, $entries);
+ $this->assertSame('按原表重量段计费,面单另计', $entries[0]->change->quote_payload['billing_rules']);
+ $this->assertSame('超长件按实际重量计费', $entries[1]->change->quote_payload['billing_rules']);
+ $this->assertSame('2025-11-01', $entries[1]->change->effective_date->toDateString());
+ $this->assertEquals(5, $entries[0]->sourceQuote()['values'][1][1]);
+ $this->assertEquals(6, $entries[1]->sourceQuote()['values'][1][1]);
+ $this->assertSame($entries[0]->files[0]['path'], $entries[1]->files[0]['path']);
+ $this->assertSame(CourierQuoteChange::STATUS_DRAFT, $entries[0]->change->status);
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('已录入');
+ // 同一预览重发只返回原结果,不生成重复记录。
+ $this->post($url, ['selection_json' => json_encode($selection)])->assertRedirect();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ }
+
+ public function test_bulk_submit_requires_confirmation_and_does_not_approve_or_fake_signatures(): void
+ {
+ $batch = $this->preview(['EXP-001', 'EXP-002']);
+ $selection = [...$this->selection(['EXP-001', 'EXP-002']), 'submit_for_review' => true, 'confirmed' => false];
+ $url = route('business.courier-stock-quotes.batch-save', $batch);
+ $this->withExceptionHandling()->loginAs()->post($url, ['selection_json' => json_encode($selection)])->assertSessionHasErrors('confirmed');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $selection['confirmed'] = true;
+ $this->post($url, ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors()->assertRedirect();
+ $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all());
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ $this->assertDatabaseCount('business_courier_quote_signatures', 0);
+ }
+
+ public function test_row_errors_are_atomic_keep_preview_and_do_not_override_existing_quotes(): void
+ {
+ $batch = $this->preview(['EXP-001', 'EXP-002']);
+ $selection = $this->selection(['EXP-001', 'EXP-001']);
+ $this->withExceptionHandling()->loginAs()->from(route('business.courier-stock-quotes.batch', $batch))
+ ->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
+ ->assertSessionHasErrors('entries.1')->assertSessionHasInput('selection_json');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $selection['entries'][1]['courier_expcode'] = 'EXP-002';
+ $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors();
+ $another = $this->preview(['EXP-001']);
+ $this->post(route('business.courier-stock-quotes.batch-save', $another), ['selection_json' => json_encode($this->selection(['EXP-001']))])
+ ->assertSessionHasErrors('entries.0');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ $this->get(route('business.courier-stock-quotes.batch', $another))->assertOk()->assertSeeText('已录入,默认跳过');
+ }
+
+ public function test_real_date_and_known_courier_are_required_but_notes_can_be_skipped(): void
+ {
+ $batch = $this->preview(['EXP-001', '说明']);
+ $selection = $this->selection(['EXP-001', 'UNKNOWN']);
+ $selection['effective_date'] = '';
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
+ ->assertSessionHasErrors(['entries.0', 'entries.1']);
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $selection['entries'][0]['effective_date'] = '2024-06-01';
+ $selection['entries'][1]['selected'] = false;
+ $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 1);
+ }
+
+ public function test_preview_is_scoped_to_company_and_uploader_and_rejects_fake_sheet_indices(): void
+ {
+ $batch = $this->preview(['EXP-001']);
+ $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
+ $manifestPath = dirname($manifest['source_file']['path']) . '/manifest.json';
+ $selection = $this->selection(['EXP-001']);
+ $selection['entries'] = [50 => $selection['entries'][0]];
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
+ ->assertSessionHasErrors('entries.50');
+ $manifest['user_id'] = (int) $this->user->id + 1;
+ Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
+ $this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
+ $manifest['user_id'] = (int) $this->user->id;
+ $manifest['company_id'] = (int) company_id() + 1;
+ Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
+ $this->get(route('business.courier-stock-quotes.batch', $batch), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
+ }
+
+ public function test_one_hundred_twenty_sheets_preview_and_save_as_one_batch(): void
+ {
+ $couriers = $names = [];
+ for ($index = 1; $index <= 120; $index++) {
+ $names[] = $code = sprintf('BULK-%03d', $index);
+ $couriers[] = ['code' => $code, 'name' => '批量网点' . $index];
+ }
+ $this->mirror($couriers);
+ $batch = $this->preview($names, array_fill(0, 120, 1));
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120');
+ $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($this->selection($names))])->assertRedirect();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 120);
+ $this->assertDatabaseCount('business_courier_quote_changes', 120);
+ $this->assertCount(1, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[0]['path'])->unique());
+ $this->assertCount(120, CourierStockQuoteEntry::all()->pluck('files')->map(fn ($files) => $files[1]['path'])->unique());
+ }
+
+ public function test_empty_sheet_can_be_skipped_and_failed_result_storage_rolls_back_the_entire_batch(): void
+ {
+ $batch = $this->preview(['EXP-001', '空表']);
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('空工作表,请取消选择');
+ $selection = $this->selection(['EXP-001', 'EXP-002']);
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
+ ->assertSessionHasErrors('entries.1');
+ $selection['entries'][1]['selected'] = false;
+ $disk = Storage::disk('uploads');
+ $failingDisk = \Mockery::mock($disk);
+ $failingDisk->shouldReceive('put')->once()->andReturn(false);
+ Storage::shouldReceive('disk')->with('uploads')->andReturn($failingDisk);
+ $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])
+ ->assertSessionHasErrors('source_quote');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $this->assertDatabaseCount('business_courier_quote_changes', 0);
+ }
+
+ public function test_embedded_images_are_archived_byte_for_byte_per_sheet_and_never_auto_verified(): void
+ {
+ $file = $this->workbook(['EXP-001', 'EXP-002'], [2, 1]);
+ $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
+ 'source_quote' => $file, 'quote_type' => '标准快递报价',
+ ])->assertSessionHasNoErrors()->assertRedirect();
+ $batch = basename($response->headers->get('Location'));
+ $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
+ $this->assertCount(2, $manifest['sheets'][0]['signed_images']);
+ $this->assertCount(1, $manifest['sheets'][1]['signed_images']);
+ foreach ($manifest['sheets'] as $sheetIndex => $sheet) {
+ foreach ($sheet['signed_images'] as $imageIndex => $image) {
+ $this->assertSame($this->sourceImages[$sheetIndex][$imageIndex], Storage::disk('uploads')->get($image['path']));
+ $this->assertSame(hash('sha256', $this->sourceImages[$sheetIndex][$imageIndex]), $image['sha256']);
+ $this->assertSame($sheet['sheet_name'], $image['sheet_name']);
+ $this->assertSame('G' . (2 + $imageIndex * 20), $image['anchor']);
+ $this->assertSame('image/png', $image['mime_type']);
+ $url = route('business.courier-stock-quotes.batch-image', [$batch, $sheetIndex, $imageIndex]);
+ $download = $this->get($url)->assertOk()->assertDownload()->assertStreamedContent($this->sourceImages[$sheetIndex][$imageIndex]);
+ $this->assertStringContainsString(rawurlencode($image['name']), $download->headers->get('Content-Disposition'));
+ $this->get($url . '?preview=1')->assertOk()->assertHeader('Content-Type', 'image/png')
+ ->assertHeader('X-Content-Type-Options', 'nosniff')->assertStreamedContent($this->sourceImages[$sheetIndex][$imageIndex]);
+ }
+ }
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('已留存 2 张 · 待核对');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $selection = [...$this->selection(['EXP-001', 'EXP-002']), 'submit_for_review' => true, 'confirmed' => true];
+ $this->post(route('business.courier-stock-quotes.batch-save', $batch), ['selection_json' => json_encode($selection)])->assertSessionHasNoErrors();
+ $entries = CourierStockQuoteEntry::orderBy('id')->get();
+ $this->assertSame([3, 2], $entries->map(fn ($entry) => count($entry->files))->all());
+ $this->assertSame($manifest['sheets'][0]['signed_images'][0]['path'], $entries[0]->files[1]['path']);
+ $this->assertSame($manifest['sheets'][1]['signed_images'][0]['path'], $entries[1]->files[1]['path']);
+ $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all());
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ $this->assertDatabaseCount('business_courier_quote_signatures', 0);
+ $this->get(route('business.courier-quote-changes.show', $entries[0]->change))->assertOk()->assertSeeText('查看原图');
+ $this->get(route('business.courier-stock-quotes.download', [$entries[0], 1, 'preview' => 1]))->assertOk()
+ ->assertStreamedContent($this->sourceImages[0][0]);
+ }
+
+ public function test_image_storage_failure_does_not_publish_a_preview_or_create_quotes(): void
+ {
+ $file = $this->workbook(['EXP-001'], [1]);
+ $disk = Storage::disk('uploads');
+ $failingDisk = \Mockery::mock($disk);
+ $failingDisk->shouldReceive('put')->withArgs(fn ($path) => str_contains($path, '/images/'))->once()->andReturn(false);
+ Storage::shouldReceive('disk')->with('uploads')->andReturn($failingDisk);
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
+ 'source_quote' => $file, 'quote_type' => '标准快递报价',
+ ])->assertSessionHasErrors('source_quote');
+ $this->assertSame([], $disk->allFiles());
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ }
+
+ public function test_missing_archived_image_blocks_the_entire_save_and_image_routes_are_private(): void
+ {
+ $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
+ 'source_quote' => $this->workbook(['EXP-001', 'EXP-002'], [1, 1]), 'quote_type' => '标准快递报价',
+ ])->assertRedirect();
+ $batch = basename($response->headers->get('Location'));
+ $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
+ Storage::disk('uploads')->delete($manifest['sheets'][1]['signed_images'][0]['path']);
+ $this->withExceptionHandling()->post(route('business.courier-stock-quotes.batch-save', $batch), [
+ 'selection_json' => json_encode($this->selection(['EXP-001', 'EXP-002'])),
+ ])->assertSessionHasErrors('entries.1');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $url = route('business.courier-stock-quotes.batch-image', [$batch, 0, 0, 'preview' => 1]);
+ $this->get(route('business.courier-stock-quotes.batch-image', [$batch, 0, 99]), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
+ $manifestPath = dirname($manifest['source_file']['path']) . '/manifest.json';
+ $manifest['user_id']++;
+ Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
+ $this->get($url, ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
+ $manifest['user_id']--;
+ $manifest['company_id']++;
+ Storage::disk('uploads')->put($manifestPath, json_encode($manifest));
+ $this->get($url, ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
+ }
+
+ public function test_followup_price_change_keeps_all_original_images_and_unverified_images_do_not_activate_quote(): void
+ {
+ $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
+ 'source_quote' => $this->workbook(['EXP-001'], [2]), 'quote_type' => '标准快递报价',
+ ])->assertRedirect();
+ $batch = basename($response->headers->get('Location'));
+ $this->post(route('business.courier-stock-quotes.batch-save', $batch), [
+ 'selection_json' => json_encode([...$this->selection(['EXP-001']), 'submit_for_review' => true, 'confirmed' => true]),
+ ])->assertSessionHasNoErrors();
+ $entry = CourierStockQuoteEntry::firstOrFail();
+ $reviewer = user_model_class()::create([
+ 'name' => '原件核验员', 'email' => 'image-reviewer@example.com', 'password' => 'local-test-password',
+ 'locale' => 'zh-CN', 'enabled' => 1, 'landing_page' => 'dashboard', 'created_from' => 'core::test',
+ ]);
+ $reviewer->companies()->attach($this->company->id);
+ $changes = app(CourierQuoteChangeService::class);
+ $approved = $changes->approve($entry->change, (int) $reviewer->id);
+ $this->assertSame('待确认/待补签', $approved->version->signature_status);
+ $change = $changes->create([
+ 'source_quote_id' => $approved->version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16',
+ 'summary' => '基于存量版本调价,原件继续保留',
+ 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]],
+ ], (int) $this->user->id);
+ $changes->submit($change, (int) $this->user->id);
+ $changes->approve($change->fresh(), (int) $reviewer->id);
+ $this->assertCount(3, $entry->fresh()->files);
+ foreach ([1, 2] as $index) {
+ $this->get(route('business.courier-stock-quotes.download', [$entry, $index]))->assertOk()
+ ->assertStreamedContent($this->sourceImages[0][$index - 1]);
+ }
+ \DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
+ $this->withExceptionHandling()->get(route('business.courier-stock-quotes.download', [$entry, 1, 'preview' => 1]), ['X-Requested-With' => 'XMLHttpRequest'])->assertNotFound();
+ }
+
+ public function test_legacy_xls_images_are_not_reencoded_and_falsely_labeled_originals(): void
+ {
+ $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
+ 'source_quote' => $this->workbook(['EXP-001'], [1], 'Xls'), 'quote_type' => '标准快递报价',
+ ])->assertSessionHasNoErrors()->assertRedirect();
+ $batch = basename($response->headers->get('Location'));
+ $manifest = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id);
+ $this->assertSame([], $manifest['sheets'][0]['signed_images']);
+ $this->assertNotEmpty($manifest['sheets'][0]['image_warnings']);
+ Storage::disk('uploads')->assertExists($manifest['source_file']['path']);
+ $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('无法无损提取');
+ }
+
+ private function preview(array $names, array $imageCounts = []): string
+ {
+ $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [
+ 'source_quote' => $this->workbook($names, $imageCounts), 'quote_type' => '标准快递报价',
+ 'effective_date' => '2025-12-01', 'billing_rules' => '按原表重量段计费,面单另计',
+ ])->assertRedirect();
+
+ return basename($response->headers->get('Location'));
+ }
+
+ private function selection(array $codes): array
+ {
+ return ['quote_type' => '标准快递报价', 'effective_date' => '2025-12-01', 'summary' => '整本报价历史补录',
+ 'rule_groups' => [['name' => '共用规则', 'rules' => '按原表重量段计费,面单另计']],
+ 'submit_for_review' => false, 'confirmed' => false,
+ 'entries' => array_map(fn ($code) => ['selected' => true, 'courier_expcode' => $code, 'rule_group' => 0, 'effective_date' => ''], $codes),
+ ];
+ }
+
+ private function workbook(array $names, array $imageCounts = [], string $format = 'Xlsx'): UploadedFile
+ {
+ $book = new Spreadsheet();
+ foreach ($names as $index => $name) {
+ $sheet = ($index === 0 ? $book->getActiveSheet() : $book->createSheet())->setTitle($name);
+ if ($name === '空表') {
+ continue;
+ }
+ $sheet->fromArray([['省份', '首重', '续重'], ['江苏省', 5 + $index, null]]);
+ $sheet->setCellValue('C2', '=B2/2');
+ for ($imageIndex = 0; $imageIndex < ($imageCounts[$index] ?? 0); $imageIndex++) {
+ $image = imagecreatetruecolor(24 + $index, 32 + $imageIndex);
+ imagefill($image, 0, 0, imagecolorallocate($image, (80 + $index * 20) % 256, 50, (30 + $imageIndex * 20) % 256));
+ $imagePath = tempnam(sys_get_temp_dir(), 'quote-image-');
+ rename($imagePath, $imagePath . '.png');
+ $imagePath .= '.png';
+ $this->temporaryFiles[] = $imagePath;
+ imagepng($image, $imagePath);
+ imagedestroy($image);
+ $this->sourceImages[$index][$imageIndex] = file_get_contents($imagePath);
+ (new Drawing())->setPath($imagePath)->setCoordinates('G' . (2 + $imageIndex * 20))->setWorksheet($sheet);
+ }
+ }
+ $path = tempnam(sys_get_temp_dir(), 'batch-quote-');
+ $this->temporaryFiles[] = $path;
+ IOFactory::createWriter($book, $format)->save($path);
+ $book->disconnectWorksheets();
+
+ return new UploadedFile($path, '整本存量报价.' . strtolower($format), null, null, true);
+ }
+
+ private function mirror(array $couriers): void
+ {
+ $mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
+ $mirror->shouldReceive('overviewSource')->andReturn(['express' => array_map(fn ($courier) => (object) ['expcode' => $courier['code'], 'expname' => $courier['name']], $couriers)]);
+ app()->instance(CourierInformationMirrorRepository::class, $mirror);
+ }
+}
diff --git a/tests/Feature/Business/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php
new file mode 100644
index 00000000..10340454
--- /dev/null
+++ b/tests/Feature/Business/CourierStockQuoteEntryTest.php
@@ -0,0 +1,239 @@
+user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
+ $mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
+ $mirror->shouldReceive('overviewSource')->andReturn(['express' => [
+ (object) ['expcode' => 'EXP-001', 'expname' => '同名快递网点'],
+ (object) ['expcode' => 'EXP-002', 'expname' => '同名快递网点'],
+ ]]);
+ app()->instance(CourierInformationMirrorRepository::class, $mirror);
+ app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-stock-quote-fixture.json'));
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->temporaryFiles as $path) {
+ if (is_file($path)) {
+ unlink($path);
+ }
+ }
+ parent::tearDown();
+ }
+
+ public function test_entry_uses_existing_courier_and_retains_original_file_formula_and_real_date(): void
+ {
+ $this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk()
+ ->assertSeeText('EXP-001')->assertSeeText('EXP-002')->assertSeeText('实际开始执行日期');
+ $data = $this->payload();
+ $originalHash = hash_file('sha256', $data['source_quote']->getRealPath());
+ $this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect();
+ $entry = CourierStockQuoteEntry::firstOrFail();
+ $this->assertSame('EXP-001', $entry->courier_expcode);
+ $this->assertSame($originalHash, $entry->files[0]['sha256']);
+ $this->assertSame('原始报价.xlsx', $entry->files[0]['name']);
+ $this->assertSame('=B2/2', $entry->sourceQuote()['formulas'][1][2]);
+ $this->assertSame('2025-12-01', $entry->change->effective_date->toDateString());
+ $this->assertFalse($entry->change->isRetrospective());
+ Storage::disk('uploads')->assertExists($entry->files[0]['path']);
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ $this->assertDatabaseCount('business_courier_onboardings', 0);
+ $this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk()
+ ->assertSeeText('存量报价原文与核对')->assertSeeText('=B2/2')->assertSeeText('首重1kg');
+ }
+
+ public function test_review_requires_confirmation_and_never_claims_unsigned_quote_is_signed(): void
+ {
+ $entry = $this->createEntry();
+ $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change))->assertRedirect();
+ $reviewer = $this->reviewer();
+ $this->withExceptionHandling()->loginAs($reviewer, $this->company)
+ ->post(route('business.courier-quote-changes.approve', $entry->change))->assertSessionHasErrors('stock_rules_confirmed');
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ $this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1'])->assertRedirect();
+ $version = CourierQuoteVersion::firstOrFail();
+ $this->assertSame('待确认/待补签', $version->signature_status);
+ $this->assertSame('EXP-001', $version->courier_expcode);
+ $this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $entry->change->fresh()->recalculation_status);
+ $this->assertSame('2025-12-01', $version->effective_date->toDateString());
+ $this->assertDatabaseCount('business_courier_quote_signatures', 0);
+ $this->assertSame('待生效(待签章)', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']);
+ }
+
+ public function test_historical_documents_require_reviewer_verification_and_remain_distinct_from_esign(): void
+ {
+ $entry = $this->createEntry(['signed_files' => [UploadedFile::fake()->create('历史合同.pdf', 5, 'application/pdf')]]);
+ $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
+ $this->loginAs($this->reviewer(), $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), [
+ 'stock_rules_confirmed' => '1', 'stock_signature_verified' => '1',
+ ])->assertRedirect();
+ $version = CourierQuoteVersion::firstOrFail();
+ $this->assertSame('历史签署资料已归档', $version->signature_status);
+ $this->assertDatabaseCount('business_courier_quote_signatures', 0);
+ $snapshot = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
+ $this->assertSame('生效中', $snapshot['status']);
+ $this->assertArrayHasKey('signed', $snapshot['signed_files']);
+ $this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk()
+ ->assertSeeText('尚无本系统电子签署记录')->assertDontSeeText('生成签署 PDF');
+ }
+
+ public function test_claiming_verified_history_without_documents_is_rejected(): void
+ {
+ $entry = $this->createEntry();
+ $this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
+ $this->withExceptionHandling()->loginAs($this->reviewer(), $this->company)
+ ->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1', 'stock_signature_verified' => '1'])
+ ->assertSessionHasErrors('stock_signature_verified');
+ $this->assertDatabaseCount('business_courier_quote_versions', 0);
+ }
+
+ public function test_same_code_and_type_cannot_be_imported_twice_but_same_names_with_different_codes_can(): void
+ {
+ $this->createEntry();
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), $this->payload())
+ ->assertSessionHasErrors('courier_expcode');
+ $this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'EXP-002'])->assertRedirect();
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'UNREGISTERED'])
+ ->assertSessionHasErrors('courier_expcode');
+ }
+
+ public function test_multi_sheet_upload_requires_exact_sheet_and_invalid_workbooks_leave_no_record(): void
+ {
+ $this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true)])
+ ->assertSessionHasErrors('sheet_name');
+ $this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
+ $this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true), 'sheet_name' => '报价'])
+ ->assertRedirect();
+ }
+
+ public function test_draft_replacement_retains_previous_original_and_updates_preview_and_audit(): void
+ {
+ $entry = $this->createEntry();
+ $originalPath = $entry->files[0]['path'];
+ $this->loginAs()->patch(route('business.courier-stock-quotes.update', $entry), [
+ 'effective_date' => '2025-11-01', 'billing_rules' => '修正后首重1kg', 'summary' => '更正原报价附件',
+ 'source_quote' => $this->workbook(false, 8),
+ ])->assertRedirect();
+ $entry->refresh();
+ $this->assertCount(2, $entry->files);
+ $this->assertSame('previous_source_quote', $entry->files[0]['kind']);
+ Storage::disk('uploads')->assertExists($originalPath);
+ $this->assertEquals(8, $entry->sourceQuote()['values'][1][1]);
+ $this->assertEquals(8, json_decode($entry->change->proposed_values_json, true)[1][1]);
+ $details = json_decode($entry->change->events()->where('event_type', 'saved')->firstOrFail()->details_json, true);
+ $this->assertSame('修正后首重1kg', $details['billing_rules']);
+ $this->assertCount(2, $details['files']);
+ }
+
+ public function test_followup_price_change_reuses_version_and_keeps_exact_courier_identity(): void
+ {
+ $entry = $this->createEntry();
+ app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id);
+ app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $this->reviewer()->id);
+ $version = CourierQuoteVersion::firstOrFail();
+ $this->loginAs()->get(route('business.courier-quote-changes.start', ['source_quote_id' => $version->version_code]))
+ ->assertOk()->assertSeeText('编辑新价格');
+ $change = app(CourierQuoteChangeService::class)->create([
+ 'source_quote_id' => $version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16',
+ 'summary' => '存量快递后续变价', 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]],
+ ], (int) $this->user->id);
+ app(CourierQuoteChangeService::class)->submit($change, (int) $this->user->id);
+ $approved = app(CourierQuoteChangeService::class)->approve($change->fresh(), (int) $this->reviewer()->id);
+ $this->assertSame('EXP-001', $approved->version->courier_expcode);
+ $this->assertSame('首重1kg,续重按原报价', $approved->version->billing_rules);
+ $this->assertEquals(6, json_decode($approved->version->values_json, true)[1][1]);
+ $this->assertEquals(5, json_decode($version->fresh()->values_json, true)[1][1]);
+ }
+
+ public function test_other_company_cannot_download_or_edit_stock_quote(): void
+ {
+ $entry = $this->createEntry();
+ \DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
+ $this->withExceptionHandling()->loginAs()
+ ->get(route('business.courier-stock-quotes.download', [$entry, 0]))->assertNotFound();
+ $this->loginAs()->get(route('business.courier-stock-quotes.edit', $entry))->assertNotFound();
+ $this->assertNull(CourierStockQuoteEntry::find($entry->id));
+ }
+
+ public function test_same_name_couriers_keep_separate_effective_versions(): void
+ {
+ $reviewer = $this->reviewer();
+ foreach (['EXP-001', 'EXP-002'] as $code) {
+ $entry = $this->createEntry(['courier_expcode' => $code, 'signed_files' => [UploadedFile::fake()->create('回签.pdf', 1, 'application/pdf')]]);
+ app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id);
+ app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $reviewer->id, true);
+ }
+ $quotes = app(QuoteManagementSnapshot::class)->load()['quotes'];
+ $this->assertCount(2, $quotes);
+ $this->assertSame(['生效中', '生效中'], array_column($quotes, 'status'));
+ $this->assertSame(['EXP-001', 'EXP-002'], array_column($quotes, 'courier_expcode'));
+ }
+
+ private function createEntry(array $overrides = []): CourierStockQuoteEntry
+ {
+ $this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), ...$overrides])->assertRedirect();
+
+ return CourierStockQuoteEntry::latest('id')->firstOrFail();
+ }
+
+ private function payload(): array
+ {
+ return ['courier_expcode' => 'EXP-001', 'quote_type' => '标准快递报价', 'source_quote' => $this->workbook(),
+ 'effective_date' => '2025-12-01', 'billing_rules' => '首重1kg,续重按原报价', 'summary' => '补录历史合作报价'];
+ }
+
+ private function workbook(bool $multipleSheets = false, int $price = 5): UploadedFile
+ {
+ $book = new Spreadsheet();
+ $sheet = $book->getActiveSheet()->setTitle('报价');
+ $sheet->fromArray([['省份', '首重', '续重'], ['江苏省', $price, null]]);
+ $sheet->setCellValue('C2', '=B2/2');
+ if ($multipleSheets) {
+ $book->createSheet()->setTitle('其他网点')->setCellValue('A1', '其他报价');
+ }
+ $path = tempnam(sys_get_temp_dir(), 'stock-quote-');
+ $this->temporaryFiles[] = $path;
+ IOFactory::createWriter($book, 'Xlsx')->save($path);
+ $book->disconnectWorksheets();
+
+ return new UploadedFile($path, '原始报价.xlsx', null, null, true);
+ }
+
+ private function reviewer()
+ {
+ $user = user_model_class()::create([
+ 'name' => '报价审核员', 'email' => 'stock-reviewer-' . uniqid() . '@example.com',
+ 'password' => 'local-test-password', 'locale' => 'zh-CN', 'enabled' => 1,
+ 'landing_page' => 'dashboard', 'created_from' => 'core::test',
+ ]);
+ $user->companies()->attach($this->company->id);
+ $user->attachRole(Role::where('name', 'admin')->firstOrFail());
+ $user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
+
+ return $user;
+ }
+}
-
+
- 1 上传整本报价 +
- 2 匹配快递 · 套用规则 +
- 3 确认批量录入 +
+ 尚未录入,请修正以下问题
+ @foreach($errors->all() as $error)
+ @endif
+ @if(!$batch)
+ {{ $error }}
@endforeach
+ @if($batch)已保留本次上传的 Excel 和选择,无需重新上传。
@endif +一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。
+ + @elseif($result) +已录入 {{ count($result['created']) }} 家报价{{ ($result['submitted'] ?? false) ? '并提交审核' : '草稿' }},跳过 {{ $result['skipped'] }} 张工作表。原 Excel 已留存,各家报价独立管理。
+ {{ ($result['submitted'] ?? false) ? '审核通过后才会生成正式版本。' : '可在报价历史继续检查草稿并提交审核。' }}已提取的原件图片随各家报价保留,可在“查看报价”中查看或下载;缺图时再补齐,核对一致后才能按历史签署资料归档生效。
+本批录入结果
+| 原工作表 | 快递及编号 | 查看 |
|---|---|---|
| {{ $created['sheet_name'] }} | {{ $created['courier_name'] }} · {{ $created['courier_expcode'] }} | 查看报价 |
{{ $manifest['source_file']['name'] }} · 识别到 {{ count($rows) }} 张工作表,{{ count(array_filter($rows, fn ($row) => $row['courier_expcode'] !== '' && !$row['error'] && !$row['duplicate'])) }} 张已匹配。未匹配、重名或重复报价会提示处理,不覆盖已有报价。
+ + @endif +选择已有快递资料,保存原报价并核对后提交审核。后续调价沿用报价版本、审核和签署流程。
+ @if($errors->any()) +
+ @foreach($errors->all() as $error)
+ @endif
+
+ {{ $error }}
@endforeach
+ 提交失败时请重新选择附件,其他已填内容会保留。
+完整报价规则
+@if(! empty($billingRules)){!! $mixedText($billingRules) !!}
@endif