CaiWuXiTong/tests/Feature/Business/CourierStockQuoteBatchTest.php

600 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use Akaunting\Firewall\Middleware\Xss;
use App\Models\Auth\Permission;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierStockQuoteEntry;
use App\Support\CourierStockQuoteBatchService;
use App\Support\CourierQuoteChangeService;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
use Tests\Feature\FeatureTestCase;
class CourierStockQuoteBatchTest extends FeatureTestCase
{
private array $temporaryFiles = [];
private array $sourceImages = [];
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
$this->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'] = '<script>alert(1)</script>';
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);
}
}