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_batch_accepts_same_courier_in_disjoint_periods_and_rejects_overlaps_atomically(): void { $batch = $this->preview(['EXP-001', 'EXP-002']); $this->loginAs()->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSee('data-row-end', false)->assertSee('data-common-end', false); $selection = $this->selection(['EXP-001', 'EXP-002']); $selection['entries'][1]['courier_expcode'] = 'EXP-001'; $selection['entries'][0]['effective_date'] = '2025-12-01'; $selection['entries'][0]['end_date'] = '2026-09-30'; $selection['entries'][1]['effective_date'] = '2026-09-30'; $url = route('business.courier-stock-quotes.batch-save', $batch); $this->withExceptionHandling()->loginAs()->postJson($url, $selection)->assertUnprocessable(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); $selection['entries'][1]['effective_date'] = '2026-10-01'; $this->loginAs()->postJson($url, $selection)->assertOk(); $entries = CourierStockQuoteEntry::orderBy('effective_date')->get(); $this->assertCount(2, $entries); $this->assertSame(['EXP-001', 'EXP-001'], $entries->pluck('courier_expcode')->all()); $this->assertSame('2026-09-30', $entries[0]->end_date->toDateString()); $this->assertSame('2026-10-01', $entries[1]->change->effective_date->toDateString()); } public function test_returned_open_ended_quote_does_not_block_four_new_batch_periods(): void { $oldBatch = $this->preview(['EXP-001']); $this->postJson(route('business.courier-stock-quotes.batch-save', $oldBatch), $this->selection(['EXP-001']))->assertOk(); $old = CourierStockQuoteEntry::firstOrFail(); $files = $old->files; $changes = app(CourierQuoteChangeService::class); $changes->submit($old->change, (int) $this->user->id); $changes->returnForChanges($old->change->fresh(), '退回后按实际区间重新录入', (int) $this->user->id); $batch = $this->preview(['EXP-001']); $service = app(CourierStockQuoteBatchService::class); $rows = $service->rows($service->load($batch, (int) $this->user->id), app(\App\Support\CourierStockQuoteEntryService::class)->courierOptions()); $this->assertFalse($rows[0]['duplicate']); $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertDontSeeText('已录入,默认跳过'); $selection = $this->selection(['EXP-001']); $selection['submit_for_review'] = $selection['confirmed'] = true; $selection['entries'][0]['periods'] = [ ['effective_date' => '2026-10-01', 'end_date' => '2026-10-04'], ['effective_date' => '2026-10-11', 'end_date' => '2026-10-14'], ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'], ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ]; $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection)->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 5); $this->assertSame(4, CourierQuoteChange::where('status', CourierQuoteChange::STATUS_PENDING_REVIEW)->count()); $this->assertSame(CourierQuoteChange::STATUS_RETURNED, $old->change->fresh()->status); $this->assertSame($files, $old->fresh()->files); Storage::disk('uploads')->assertExists($files[0]['path']); $this->assertDatabaseCount('business_courier_quote_versions', 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_one_sheet_with_three_periods_reuses_originals_and_only_executes_within_each_period(): void { $batch = $this->preview(['EXP-001', '说明'], [1]); $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('添加执行区间'); $selection = $this->selection(['EXP-001', '']); $selection['entries'][1]['selected'] = false; $periods = [ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'], ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'], ]; $selection['entries'][0]['periods'] = $periods; $selection['submit_for_review'] = $selection['confirmed'] = true; $url = route('business.courier-stock-quotes.batch-save', $batch); $this->postJson($url, $selection)->assertOk(); $entries = CourierStockQuoteEntry::with('change')->orderBy('id')->get(); $this->assertCount(3, $entries); $this->assertCount(1, $entries->pluck('values_json')->unique()); $this->assertCount(1, $entries->pluck('formulas_json')->unique()); $this->assertCount(1, $entries->pluck('files')->map(fn ($files) => json_encode($files))->unique()); $result = app(CourierStockQuoteBatchService::class)->load($batch, (int) $this->user->id)['result']; $this->assertSame(1, $result['selected_sheets']); $this->assertSame(1, $result['skipped']); $this->assertSame($periods, array_map(fn ($row) => \Illuminate\Support\Arr::only($row, ['effective_date', 'end_date']), $result['created'])); $reviewer = user_model_class()::create([ 'name' => '区间审核员', 'email' => 'period-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); $versions = $entries->map(fn ($entry) => app(CourierQuoteChangeService::class)->approve($entry->change, (int) $reviewer->id)->version); foreach ($periods as $index => $period) { foreach ($period as $date) { $this->assertSame($versions[$index]->id, \App\Support\CourierRequoteService::resolveAt($versions, \Carbon\Carbon::parse($date))?->id); } } foreach (['2026-10-20', '2026-10-25', '2026-11-05', '2026-11-10', '2026-11-15'] as $date) { $this->assertNull(\App\Support\CourierRequoteService::resolveAt($versions, \Carbon\Carbon::parse($date))); } $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('3')->assertSeeText('2026-11-14'); $this->postJson($url, $selection)->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 3); } public function test_multi_period_validation_rejects_overlaps_missing_dates_and_bad_ranges_without_partial_imports(): void { $batch = $this->preview(['EXP-001', 'EXP-003']); $selection = $this->selection(['EXP-001', 'EXP-003']); $url = route('business.courier-stock-quotes.batch-save', $batch); $this->withExceptionHandling(); foreach ([ [], [['effective_date' => '', 'end_date' => '2026-11-04']], [['effective_date' => '2026-02-30', 'end_date' => null]], [['effective_date' => '2026-11-04', 'end_date' => '2026-11-01']], array_fill(0, 21, ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04']), [['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ['effective_date' => '2026-11-04', 'end_date' => '2026-11-08']], [['effective_date' => '2026-11-01', 'end_date' => null], ['effective_date' => '2026-11-11', 'end_date' => null]], ] as $periods) { $selection['entries'][0]['periods'] = $periods; $this->postJson($url, $selection)->assertUnprocessable(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); $this->assertDatabaseCount('business_courier_quote_changes', 0); } $selection['entries'][0]['periods'] = [['effective_date' => '2026-11-01', 'end_date' => '2026-11-01']]; $selection['entries'][1]['periods'] = [['effective_date' => '2026-11-11', 'end_date' => null]]; $this->postJson($url, $selection)->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); // 新批次任意一段与已有区间重叠时,所有新段都不写入。 $nextBatch = $this->preview(['EXP-001']); $next = $this->selection(['EXP-001']); $next['entries'][0]['periods'] = [ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'], ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ]; $this->postJson(route('business.courier-stock-quotes.batch-save', $nextBatch), $next)->assertUnprocessable() ->assertJsonValidationErrors('entries.0.periods.1'); $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_structured_json_save_passes_real_xss_checks_and_returns_correctable_errors(): void { $batch = $this->preview(['EXP-001', 'EXP-002']); $selection = $this->selection(['EXP-001', 'EXP-002']); // 合法名称中出现 JavaScript,但没有协议或标签;序列化后会与后续字段的冒号串联误匹配。 $selection['summary'] = 'JavaScript物流报价补录'; $middleware = new Xss(); $middleware->prepare(\Illuminate\Http\Request::create('/', 'POST')); $this->assertSame(1, $middleware->match($middleware->getPatterns()[3], ['selection_json' => json_encode($selection)])); config(['firewall.middleware.xss.enabled' => true]); $url = route('business.courier-stock-quotes.batch-save', $batch); $selection['entries'][1]['courier_expcode'] = 'EXP-001'; $this->withExceptionHandling()->loginAs()->postJson($url, $selection) ->assertUnprocessable()->assertJsonValidationErrors('entries.1'); $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); $selection['entries'][1]['courier_expcode'] = 'EXP-002'; $selection['submit_for_review'] = $selection['confirmed'] = true; $this->postJson($url, $selection)->assertOk() ->assertJsonPath('redirect', route('business.courier-stock-quotes.batch', $batch)); $this->assertSame([CourierQuoteChange::STATUS_PENDING_REVIEW], CourierQuoteChange::pluck('status')->unique()->all()); $this->postJson($url, $selection)->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); $this->assertDatabaseCount('business_courier_quote_versions', 0); } public function test_structured_json_keeps_malicious_fields_blocked_by_real_xss_middleware(): void { $batch = $this->preview(['EXP-001']); $selection = $this->selection(['EXP-001']); $selection['rule_groups'][0]['rules'] = ''; config(['firewall.middleware.xss.enabled' => true, 'firewall.logs.enabled' => false]); $this->withExceptionHandling()->loginAs()->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection) ->assertForbidden(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); } public function test_unknown_names_and_couriers_without_codes_can_be_imported_without_creating_directory_records(): void { $this->mirror([['code' => '', 'name' => '资料缺编号快递']]); $batch = $this->preview(['老表名称', '资料缺编号快递']); $selection = $this->selection(['', '']); $selection['entries'][0]['courier_name'] = '发货数据中的完整快递名称'; $selection['entries'][1]['courier_name'] = '资料缺编号快递'; $url = route('business.courier-stock-quotes.batch-save', $batch); config(['firewall.middleware.xss.enabled' => true]); $this->loginAs()->postJson($url, $selection)->assertOk(); $entries = CourierStockQuoteEntry::orderBy('id')->get(); $this->assertSame(['发货数据中的完整快递名称', '资料缺编号快递'], $entries->pluck('courier_name')->all()); $this->assertSame([null, null], $entries->pluck('courier_expcode')->all()); $this->assertSame('老表名称', $entries[0]->sheet_name); $this->assertNotSame($entries[0]->courier_name_key, $entries[1]->courier_name_key); $this->assertEquals(5, $entries[0]->sourceQuote()['values'][1][1]); $this->assertSame('=B2/2', $entries[0]->sourceQuote()['formulas'][1][2]); $this->assertDatabaseCount('business_courier_onboardings', 0); $this->postJson($url, $selection)->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); $duplicate = $this->preview(['发货数据中的完整快递名称']); $this->get(route('business.courier-stock-quotes.batch', $duplicate))->assertSeeText('已录入,默认跳过'); $payload = $this->selection(['']); $payload['entries'][0]['courier_name'] = '发货数据中的完整快递名称'; $this->withExceptionHandling()->postJson(route('business.courier-stock-quotes.batch-save', $duplicate), $payload) ->assertUnprocessable()->assertJsonValidationErrors('entries.0'); $this->assertDatabaseCount('business_courier_stock_quote_entries', 2); } public function test_name_input_does_not_silently_merge_same_named_codes_and_rejects_repeated_manual_names(): void { $batch = $this->preview(['原表一', '原表二']); $selection = $this->selection(['', '']); foreach ($selection['entries'] as &$row) { $row['courier_name'] = '未建档快递'; } unset($row); $url = route('business.courier-stock-quotes.batch-save', $batch); $this->withExceptionHandling()->loginAs()->postJson($url, $selection)->assertUnprocessable()->assertJsonValidationErrors('entries.1'); $this->assertDatabaseCount('business_courier_stock_quote_entries', 0); $selection['entries'][1]['courier_name'] = '同名网点'; $this->postJson($url, $selection)->assertOk(); $this->assertSame([null, null], CourierStockQuoteEntry::pluck('courier_expcode')->all()); $this->assertSame(['未建档快递', '同名网点'], CourierStockQuoteEntry::pluck('courier_name')->all()); } 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_with_three_periods_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), 32); $this->get(route('business.courier-stock-quotes.batch', $batch))->assertOk()->assertSeeText('120'); config(['firewall.middleware.xss.enabled' => true]); $selection = $this->selection($names); foreach ($selection['entries'] as &$row) { $row['periods'] = [ ['effective_date' => '2026-10-21', 'end_date' => '2026-10-24'], ['effective_date' => '2026-11-01', 'end_date' => '2026-11-04'], ['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'], ]; } unset($row); $this->postJson(route('business.courier-stock-quotes.batch-save', $batch), $selection)->assertOk(); $this->assertDatabaseCount('business_courier_stock_quote_entries', 360); $this->assertDatabaseCount('business_courier_quote_changes', 360); $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 = [], int $regionRows = 1): string { $response = $this->loginAs()->post(route('business.courier-stock-quotes.batch-preview'), [ 'source_quote' => $this->workbook($names, $imageCounts, 'Xlsx', $regionRows), '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', int $regionRows = 1): 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 ($region = 2; $region <= $regionRows; $region++) { $sheet->setCellValue('A' . ($region + 1), '测试地区' . $region); $sheet->setCellValue('B' . ($region + 1), 5 + $index); $sheet->setCellValue('C' . ($region + 1), '=B' . ($region + 1) . '/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); } }