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-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..6da12e89 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,17 @@ 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::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/CourierStockQuoteEntryTest.php b/tests/Feature/Business/CourierStockQuoteEntryTest.php
new file mode 100644
index 00000000..06e605a1
--- /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.create'))->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;
+ }
+}
--
2.47.2
From 03126cbead86a532cb22ae9420b7737172ad0f56 Mon Sep 17 00:00:00 2001
From: limeng <1131177064@qq.com>
Date: Fri, 16 Oct 2026 13:33:05 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat(=E6=8A=A5=E4=BB=B7=E7=AE=A1=E7=90=86):?=
=?UTF-8?q?=20=E6=95=B4=E6=9C=ACExcel=E6=89=B9=E9=87=8F=E5=BD=95=E5=85=A5?=
=?UTF-8?q?=E5=AD=98=E9=87=8F=E6=8A=A5=E4=BB=B7=E5=B9=B6=E5=A4=8D=E7=94=A8?=
=?UTF-8?q?=E8=AE=A1=E8=B4=B9=E8=A7=84=E5=88=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Business/CourierStockQuoteEntries.php | 69 ++++-
app/Support/CourierStockQuoteBatchService.php | 154 ++++++++++
app/Support/CourierStockQuoteEntryService.php | 104 +++++--
.../stock-quote-batch.blade.php | 266 ++++++++++++++++++
routes/admin.php | 4 +
.../Business/CourierStockQuoteBatchTest.php | 227 +++++++++++++++
.../Business/CourierStockQuoteEntryTest.php | 2 +-
7 files changed, 799 insertions(+), 27 deletions(-)
create mode 100644 app/Support/CourierStockQuoteBatchService.php
create mode 100644 resources/views/business/pages/courier-payables/stock-quote-batch.blade.php
create mode 100644 tests/Feature/Business/CourierStockQuoteBatchTest.php
diff --git a/app/Http/Controllers/Business/CourierStockQuoteEntries.php b/app/Http/Controllers/Business/CourierStockQuoteEntries.php
index 5b72fff2..ccdc33fa 100644
--- a/app/Http/Controllers/Business/CourierStockQuoteEntries.php
+++ b/app/Http/Controllers/Business/CourierStockQuoteEntries.php
@@ -3,25 +3,92 @@
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierStockQuoteEntry;
+use App\Support\CourierStockQuoteBatchService;
use App\Support\CourierStockQuoteEntryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierStockQuoteEntries extends Controller
{
- public function __construct(private readonly CourierStockQuoteEntryService $service)
+ public function __construct(private readonly CourierStockQuoteEntryService $service, private readonly CourierStockQuoteBatchService $batches)
{
}
public function create(): View
+ {
+ return view('business.pages.courier-payables.stock-quote-batch', ['batch' => null, 'manifest' => null, 'rows' => [], 'couriers' => [], 'templates' => $this->batches->ruleTemplates()]);
+ }
+
+ public function single(): View
{
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => null, 'couriers' => $this->service->courierOptions()]);
}
+ public function previewBatch(Request $request): RedirectResponse
+ {
+ $data = $request->validate([
+ 'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
+ 'quote_type' => ['required', 'string', 'max:64'],
+ 'effective_date' => ['nullable', 'date_format:Y-m-d'],
+ 'billing_rules' => ['nullable', 'string', 'max:4000'],
+ ]);
+ $batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
+
+ return redirect()->route('business.courier-stock-quotes.batch', $batch);
+ }
+
+ public function showBatch(string $batch): View
+ {
+ $manifest = $this->batches->load($batch, (int) user_id());
+ $couriers = $manifest['result'] === null ? $this->service->courierOptions() : [];
+
+ return view('business.pages.courier-payables.stock-quote-batch', [
+ 'batch' => $batch, 'manifest' => $manifest, 'couriers' => $couriers,
+ 'rows' => $manifest['result'] === null ? $this->batches->rows($manifest, $couriers) : [], 'templates' => [],
+ ]);
+ }
+
+ public function saveBatch(Request $request, string $batch): RedirectResponse
+ {
+ // 单个 JSON 字段避免百家工作表触及 PHP 表单字段数量限制。
+ $request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
+ $selection = json_decode($request->input('selection_json'), true);
+ if (! is_array($selection)) {
+ throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']);
+ }
+ foreach (['quote_type', 'summary'] as $field) {
+ if (is_string($selection[$field] ?? null)) {
+ $selection[$field] = trim($selection[$field]);
+ }
+ }
+ $data = Validator::make($selection, [
+ 'quote_type' => ['required', 'string', 'max:64'],
+ 'effective_date' => ['nullable', 'date_format:Y-m-d'],
+ 'summary' => ['required', 'string', 'min:2', 'max:2000'],
+ 'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
+ 'rule_groups.*' => ['array'],
+ 'rule_groups.*.name' => ['required', 'string', 'max:100'],
+ 'rule_groups.*.rules' => ['nullable', 'string', 'max:4000'],
+ 'entries' => ['required', 'array', 'max:200'],
+ 'entries.*' => ['array'],
+ 'entries.*.selected' => ['required', 'boolean'],
+ 'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'],
+ 'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
+ 'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
+ 'submit_for_review' => ['required', 'boolean'],
+ 'confirmed' => ['accepted_if:submit_for_review,true'],
+ ])->validate();
+ $this->batches->save($batch, (int) user_id(), $data);
+
+ return redirect()->route('business.courier-stock-quotes.batch', $batch);
+ }
+
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
diff --git a/app/Support/CourierStockQuoteBatchService.php b/app/Support/CourierStockQuoteBatchService.php
new file mode 100644
index 00000000..057a4c06
--- /dev/null
+++ b/app/Support/CourierStockQuoteBatchService.php
@@ -0,0 +1,154 @@
+whereNotNull('billing_rules')->latest('id')->limit(100)->get()
+ ->filter(fn ($version) => filled($version->billing_rules))
+ ->unique('billing_rules')->map(fn ($version) => [
+ 'name' => $version->carrier_name . ' · ' . $version->quote_type,
+ 'rules' => $version->billing_rules,
+ ])->values()->all();
+ }
+
+ public function preview(UploadedFile $file, array $defaults, int $userId): string
+ {
+ $sheets = $this->entries->readBatchWorkbook($file);
+ $batch = (string) Str::uuid();
+ $disk = Storage::disk('uploads');
+ $originalPath = $disk->putFileAs($this->directory($batch, $userId), $file, 'original.' . $file->getClientOriginalExtension(), 'private');
+ if ($originalPath === false) {
+ throw ValidationException::withMessages(['source_quote' => '报价原文件保存失败,请重试。']);
+ }
+ $manifest = [
+ 'company_id' => (int) company_id(), 'user_id' => $userId, 'created_at' => now()->toIso8601String(),
+ 'defaults' => $defaults, 'sheets' => $sheets, 'result' => null,
+ 'source_file' => ['kind' => 'source_quote', 'path' => $originalPath, 'name' => $file->getClientOriginalName(), 'sha256' => hash_file('sha256', $file->getRealPath())],
+ ];
+ $this->write($batch, $userId, $manifest);
+
+ return $batch;
+ }
+
+ public function load(string $batch, int $userId): array
+ {
+ abort_unless(Str::isUuid($batch), 404);
+ $disk = Storage::disk('uploads');
+ $path = $this->directory($batch, $userId) . '/manifest.json';
+ abort_unless($disk->exists($path), 404, '导入预览不存在,请重新上传报价。');
+ $manifest = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR);
+ abort_unless($manifest['company_id'] === (int) company_id() && $manifest['user_id'] === $userId, 404);
+
+ return $manifest;
+ }
+
+ public function rows(array $manifest, array $couriers): array
+ {
+ $existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type'])
+ ->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]);
+
+ return array_map(function (array $sheet) use ($manifest, $couriers, $existing): array {
+ $key = mb_strtolower(trim($sheet['sheet_name']));
+ $matches = array_values(array_filter($couriers, static fn ($courier) => in_array($key, [
+ mb_strtolower($courier['code']), mb_strtolower(trim($courier['name'])),
+ mb_strtolower($courier['name'] . ' · ' . $courier['code']),
+ ], true)));
+ $code = count($matches) === 1 ? $matches[0]['code'] : '';
+ $duplicate = $code !== '' && isset($existing[$code . '|' . $manifest['defaults']['quote_type']]);
+
+ return [...$sheet, 'courier_expcode' => $code, 'duplicate' => $duplicate,
+ 'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称重复,请选编号' : '待匹配,请选编号'))),
+ ];
+ }, $manifest['sheets']);
+ }
+
+ public function save(string $batch, int $userId, array $data): array
+ {
+ $manifest = $this->load($batch, $userId);
+ if ($manifest['result'] !== null) {
+ return $manifest['result'];
+ }
+ $couriers = collect($this->entries->courierOptions())->keyBy('code');
+ $selected = array_filter($data['entries'], static fn ($row) => (bool) ($row['selected'] ?? false));
+ if ($selected === []) {
+ throw ValidationException::withMessages(['entries' => '请至少选择一家需要录入的快递。']);
+ }
+ $errors = $prepared = $identities = [];
+ $existing = CourierStockQuoteEntry::withTrashed()->get(['courier_expcode', 'quote_type'])
+ ->mapWithKeys(fn ($entry) => [$entry->courier_expcode . '|' . $entry->quote_type => true]);
+ foreach ($selected as $index => $row) {
+ $sheet = $manifest['sheets'][$index] ?? null;
+ $courier = $couriers->get(trim($row['courier_expcode'] ?? ''));
+ $rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
+ $metadata = [
+ 'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
+ 'billing_rules' => trim($rules), 'summary' => $data['summary'],
+ ];
+ $validation = Validator::make($metadata, [
+ 'effective_date' => ['required', 'date_format:Y-m-d'],
+ 'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
+ ], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
+ $reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
+ $reason ??= $courier === null ? '请选择已有快递资料中的唯一编号。' : null;
+ $reason ??= $validation->fails() ? $validation->errors()->first() : null;
+ $identity = ($courier['code'] ?? '') . '|' . $metadata['quote_type'];
+ $reason ??= isset($existing[$identity]) ? '此快递编号的同类报价已录入,请取消选择;后续调价使用变价。' : null;
+ $reason ??= isset($identities[$identity]) ? '与本批其他工作表匹配了同一快递编号,请检查或取消选择。' : null;
+ if ($reason !== null) {
+ $errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;
+ } else {
+ $prepared[] = compact('metadata', 'courier', 'sheet');
+ }
+ $identities[$identity] = true;
+ }
+ if ($errors !== []) {
+ throw ValidationException::withMessages($errors);
+ }
+ abort_unless(Storage::disk('uploads')->exists($manifest['source_file']['path']), 404, '报价原文件不存在,请重新上传。');
+ // 一批同进同退;校验失败时保留上传原件和所有已选配置,便于修正重试。
+ return DB::transaction(function () use ($prepared, $manifest, $userId, $data, $batch): array {
+ $created = [];
+ foreach ($prepared as $item) {
+ $entry = $this->entries->createPrepared($item['metadata'], $userId, $item['courier'], $item['sheet'], [$manifest['source_file']]);
+ if ($data['submit_for_review'] ?? false) {
+ $this->changes->submit($entry->change, $userId);
+ }
+ $created[] = ['id' => $entry->id, 'change_id' => $entry->source_change_id, 'sheet_name' => $entry->sheet_name, 'courier_name' => $entry->courier_name, 'courier_expcode' => $entry->courier_expcode];
+ }
+
+ $result = ['created' => $created, 'skipped' => count($manifest['sheets']) - count($created), 'submitted' => (bool) ($data['submit_for_review'] ?? false)];
+ $manifest['result'] = $result;
+ $this->write($batch, $userId, $manifest);
+
+ return $result;
+ });
+ }
+
+ private function directory(string $batch, int $userId): string
+ {
+ return 'courier-stock-quote-batches/' . company_id() . '/' . $userId . '/' . $batch;
+ }
+
+ private function write(string $batch, int $userId, array $manifest): void
+ {
+ if (! Storage::disk('uploads')->put($this->directory($batch, $userId) . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'private')) {
+ throw ValidationException::withMessages(['source_quote' => '导入预览保存失败,请重试。']);
+ }
+ }
+}
diff --git a/app/Support/CourierStockQuoteEntryService.php b/app/Support/CourierStockQuoteEntryService.php
index 16d4f580..a38acfc0 100644
--- a/app/Support/CourierStockQuoteEntryService.php
+++ b/app/Support/CourierStockQuoteEntryService.php
@@ -13,6 +13,7 @@ use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class CourierStockQuoteEntryService
{
@@ -67,29 +68,7 @@ final class CourierStockQuoteEntryService
$reader->setLoadSheetsOnly([$sheetName]);
$book = $reader->load($file->getRealPath());
$sheet = $book->getSheetByName($sheetName);
- $lastRow = $sheet->getHighestDataRow();
- $lastColumn = $sheet->getHighestDataColumn();
- $columns = Coordinate::columnIndexFromString($lastColumn);
- $values = $formulas = [];
- $hasContent = false;
- for ($row = 1; $row <= $lastRow; $row++) {
- $valueRow = $formulaRow = [];
- for ($column = 1; $column <= $columns; $column++) {
- $cell = $sheet->getCell([$column, $row]);
- $formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null;
- $value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue();
- $valueRow[] = is_object($value) ? (string) $value : $value;
- $formulaRow[] = $formula;
- $hasContent = $hasContent || ($value !== null && $value !== '');
- }
- $values[] = $valueRow;
- $formulas[] = $formulaRow;
- }
- if (! $hasContent) {
- throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']);
- }
-
- return ['sheet_name' => $sheetName, 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas];
+ return $this->readSheet($sheet);
} catch (ValidationException $exception) {
throw $exception;
} catch (\Throwable $exception) {
@@ -99,6 +78,74 @@ final class CourierStockQuoteEntryService
}
}
+ /** 一次加载整本文件;各工作表保持独立,不计算公式。 */
+ public function readBatchWorkbook(UploadedFile $file): array
+ {
+ $book = null;
+ try {
+ $reader = IOFactory::createReaderForFile($file->getRealPath());
+ $info = $reader->listWorksheetInfo($file->getRealPath());
+ if (count($info) > 200) {
+ throw ValidationException::withMessages(['source_quote' => '一次最多导入 200 张工作表,请分批上传。']);
+ }
+ $cells = 0;
+ foreach ($info as $sheet) {
+ $cells += $sheet['totalRows'] * $sheet['totalColumns'];
+ if ($sheet['totalRows'] > 2000 || $sheet['totalColumns'] > 100 || $sheet['totalRows'] * $sheet['totalColumns'] > 50000) {
+ throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet['worksheetName'] . '」超过 2000 行、100 列或 50000 格,请缩小范围后上传。']);
+ }
+ }
+ if ($cells > 500000) {
+ throw ValidationException::withMessages(['source_quote' => '整本报价最多 500000 个单元格,请分批上传。']);
+ }
+ $reader->setReadDataOnly(false);
+ $book = $reader->load($file->getRealPath());
+ $sheets = [];
+ foreach ($book->getWorksheetIterator() as $sheet) {
+ try {
+ $sheets[] = [...$this->readSheet($sheet), 'error' => null];
+ } catch (ValidationException $exception) {
+ $sheets[] = ['sheet_name' => $sheet->getTitle(), 'error' => '空工作表,请取消选择。'];
+ }
+ }
+
+ return $sheets;
+ } catch (ValidationException $exception) {
+ throw $exception;
+ } catch (\Throwable $exception) {
+ throw ValidationException::withMessages(['source_quote' => '无法读取报价 Excel,请确认文件未损坏、未加密,且为 xlsx 或 xls 格式。']);
+ } finally {
+ $book?->disconnectWorksheets();
+ }
+ }
+
+ private function readSheet(Worksheet $sheet): array
+ {
+ $lastRow = $sheet->getHighestDataRow();
+ $lastColumn = $sheet->getHighestDataColumn();
+ $columns = Coordinate::columnIndexFromString($lastColumn);
+ $values = $formulas = [];
+ $hasContent = false;
+ for ($row = 1; $row <= $lastRow; $row++) {
+ $valueRow = $formulaRow = [];
+ for ($column = 1; $column <= $columns; $column++) {
+ $cell = $sheet->getCell([$column, $row]);
+ $formula = $cell->getDataType() === DataType::TYPE_FORMULA ? $cell->getValue() : null;
+ $value = $formula !== null ? ($cell->getOldCalculatedValue() ?? $formula) : $cell->getValue();
+ $valueRow[] = is_object($value) ? (string) $value : $value;
+ $formulaRow[] = $formula;
+ $hasContent = $hasContent || ($value !== null && $value !== '');
+ }
+ $values[] = $valueRow;
+ $formulas[] = $formulaRow;
+ }
+ if (! $hasContent) {
+ throw ValidationException::withMessages(['source_quote' => '所选工作表为空,请上传实际报价。']);
+ }
+
+ return ['sheet_name' => $sheet->getTitle(), 'source_range' => "A1:{$lastColumn}{$lastRow}", 'values' => $values, 'formulas' => $formulas];
+ }
+
public function create(array $data, int $userId): CourierStockQuoteEntry
{
$data['quote_type'] = trim($data['quote_type']);
@@ -110,9 +157,16 @@ final class CourierStockQuoteEntryService
$this->duplicateError();
}
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
+
+ return $this->createPrepared($data, $userId, $courier, $matrix);
+ }
+
+ /** 批量入口只接收服务器预览保存的原文与文件,不接受浏览器提供的金额。 */
+ public function createPrepared(array $data, int $userId, array $courier, array $matrix, array $sourceFiles = []): CourierStockQuoteEntry
+ {
$savedPaths = [];
try {
- return DB::transaction(function () use ($data, $userId, $courier, $matrix, &$savedPaths): CourierStockQuoteEntry {
+ return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry {
$entry = CourierStockQuoteEntry::create([
'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'],
'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'],
@@ -120,7 +174,7 @@ final class CourierStockQuoteEntryService
'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'files' => [], 'created_by' => $userId,
]);
- $files = $this->storeFiles($entry, $data, $savedPaths);
+ $files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)];
$entry->update(['files' => $files]);
$change = $this->changes->create([
'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价',
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..e0d95395
--- /dev/null
+++ b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php
@@ -0,0 +1,266 @@
+
选择已有快递资料,保存原报价并核对后提交审核。后续调价沿用报价版本、审核和签署流程。
+ @if($errors->any()) +
+ @foreach($errors->all() as $error)
+ @endif
+
+ {{ $error }}
@endforeach
+ 提交失败时请重新选择附件,其他已填内容会保留。
+完整报价规则
+@if(! empty($billingRules)){!! $mixedText($billingRules) !!}
@endif| 原工作表 | 快递及编号 | 查看 |
|---|---|---|
| {{ $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 +
@foreach($stockEntry->files as $index => $file)
{{ ['source_quote' => '原报价', 'previous_source_quote' => '历次原报价', 'confirmation' => '报价依据', 'signed' => '历史签署资料'][$file['kind']] }} · {{ $file['name'] }}
+ @if(in_array($file['mime_type'] ?? '', ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true))
+ 查看原图 · {{ $file['sheet_name'] ?? $file['name'] }} · {{ $file['anchor'] ?? '' }}
+ @endif
@endforeach
+ @if(collect($stockEntry->files)->contains('source', 'workbook_embedded'))
+ 工作表内嵌图片已按原始字节独立留存,未裁剪、未压缩。提取成功不代表已核对一致;后续调价仍保留本版原件。
+ @endif @if($canReview && $change->status === 'pending_review')
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
index e0d95395..a9e86ce6 100644
--- a/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php
+++ b/resources/views/business/pages/courier-payables/stock-quote-batch.blade.php
@@ -73,7 +73,7 @@
@endif
@if(!$batch)
- 一个 Excel,每家一张工作表。整本上传一次,共用计费规则只填一次,各家的报价金额仍以各自原表为准。
+一个 Excel,每家一张工作表,电子报价旁附签署原件图片,整本上传一次。各家的价格、计费条件和执行日期分别核对。