CaiWuXiTong/tests/Feature/Business/CourierQuoteChangeWorkflowTest.php

659 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 App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierQuoteVersion;
use App\Support\QuoteManagementSnapshot;
use Tests\Feature\FeatureTestCase;
class CourierQuoteChangeWorkflowTest extends FeatureTestCase
{
private string $fixturePath;
protected function setUp(): void
{
parent::setUp();
$this->fixturePath = storage_path('framework/testing/courier-quote-change-' . uniqid('', true) . '.json');
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:D3',
'values' => [
['省份', '面单', '续重', '备注'],
['广东省', 5.0, 1.2, null],
['江苏省', 4.0, 1.0, null],
],
'formulas' => [[], [], []],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath));
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
protected function tearDown(): void
{
if (isset($this->fixturePath) && is_file($this->fixturePath)) {
unlink($this->fixturePath);
}
parent::tearDown();
}
public function test_user_can_save_real_quote_change_draft_and_open_history(): void
{
$response = $this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::with(['items', 'events'])->firstOrFail();
$response->assertRedirect(route('business.courier-quote-changes.edit', $change));
$this->assertSame('BG-' . now()->format('Ymd') . '-001', $change->number);
$this->assertSame(CourierQuoteChange::STATUS_DRAFT, $change->status);
$this->assertFalse($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $change->recalculation_status);
$this->assertSame('新北中通', $change->carrier_name);
$this->assertCount(1, $change->items);
$this->assertSame('5.0000', $change->items->first()->original_amount);
$this->assertSame('6.0000', $change->items->first()->new_amount);
$this->assertSame('created', $change->events->first()->event_type);
$this->loginAs()->get(route('business.courier-quote-changes.index'))
->assertOk()
->assertSeeText($change->number)
->assertSeeText('新北中通')
->assertSeeText('草稿');
}
public function test_create_page_shows_structured_price_editor_and_excludes_formula_cells(): void
{
$this->loginAs()->get(route('business.courier-quote-changes.create', ['source_quote_id' => 'QB-001']))
->assertOk()
->assertSeeText('第一步:选择原报价')
->assertSeeText('第三步:编辑新价格')
->assertSeeText('批量方式')
->assertSeeText('原价格')
->assertSeeText('新价格')
->assertSee('data-region-row', false)
->assertSee('name="items[0][new_amount]"', false)
->assertSee('data-effective-date', false)
->assertDontSee('type="date" min=', false)
->assertSeeText('生效日期可以早于今天')
->assertSeeText('保存草稿');
}
public function test_horizontal_editor_saves_the_rendered_cell_coordinates_and_restores_draft_values(): void
{
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:D3',
'values' => [
['省份', '0-3公斤面单费/元', '3公斤以上面单费/元', '0-3公斤费率'],
['江苏省', 0, 1.2, 0.45],
['安徽省', 4, 1, null],
],
'formulas' => [[], ['', '', '=0.6*2'], []],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$response = $this->loginAs()->get(route('business.courier-quote-changes.create', ['source_quote_id' => 'QB-001']))->assertOk();
$document = new \DOMDocument();
@$document->loadHTML('<?xml encoding="UTF-8">' . $response->getContent());
$xpath = new \DOMXPath($document);
$this->assertSame(2, $xpath->query('//tr[@data-region-row]')->length);
$this->assertSame(3, $xpath->query('//input[@data-column-select]')->length);
$this->assertSame(2, $xpath->query('//tr[@data-region-row="1"]/td[@data-price-cell]')->length);
$this->assertSame(2, $xpath->query('//tr[@data-region-row="2"]/td[@data-price-cell]')->length);
// Submit the names/coordinates actually rendered in the rearranged form.
$fields = [];
foreach ($xpath->query('//form[@data-quote-change-form]//input[@name and not(@disabled)]') as $input) {
$fields[] = urlencode($input->getAttribute('name')) . '=' . urlencode($input->getAttribute('value'));
}
parse_str(implode('&', $fields), $payload);
$this->assertSame(['B2', 'D2', 'B3', 'C3'], array_column($payload['items'], 'source_cell'));
$this->assertSame('0.0000', $payload['items'][0]['new_amount']);
$payload['change_type'] = '费率调整';
$payload['effective_date'] = now()->addDay()->toDateString();
$payload['summary'] = '江苏和安徽按区间调整。';
$payload['items'][1]['new_amount'] = '0.5678';
$payload['items'][1]['note'] = '江苏费率调整';
$payload['items'][3]['new_amount'] = '0';
$this->post(route('business.courier-quote-changes.store'), $payload)->assertRedirect();
$change = CourierQuoteChange::with('items')->firstOrFail();
$items = $change->items->keyBy('source_cell');
$this->assertCount(2, $items);
$this->assertSame('江苏省', $items['D2']->region);
$this->assertSame('0.4500', $items['D2']->original_amount);
$this->assertSame('0.5678', $items['D2']->new_amount);
$this->assertSame('江苏费率调整', $items['D2']->note);
$this->assertSame('安徽省', $items['C3']->region);
$this->assertSame('0.0000', $items['C3']->new_amount);
$this->assertEquals(0.45, app(QuoteManagementSnapshot::class)->load()['quotes'][0]['values'][1][3]);
$draft = $this->get(route('business.courier-quote-changes.edit', $change))->assertOk();
@$document->loadHTML('<?xml encoding="UTF-8">' . $draft->getContent());
$xpath = new \DOMXPath($document);
$this->assertSame('0.5678', $xpath->query('//input[@name="items[1][new_amount]"]')->item(0)->getAttribute('value'));
$this->assertSame('江苏费率调整', $xpath->query('//input[@name="items[1][note]"]')->item(0)->getAttribute('value'));
$this->assertTrue($xpath->query('//input[@name="items[1][note]"]/parent::details')->item(0)->hasAttribute('open'));
$this->assertSame('0.0000', $xpath->query('//input[@name="items[3][new_amount]"]')->item(0)->getAttribute('value'));
}
public function test_historical_effective_date_can_be_saved_and_submitted_for_retrospective_recalculation(): void
{
$payload = $this->validPayload();
$payload['effective_date'] = now()->subMonths(2)->startOfMonth()->toDateString();
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->assertSame($payload['effective_date'], $change->effective_date->toDateString());
$this->assertTrue($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_PENDING, $change->recalculation_status);
$this->assertNull($change->recalculation_note);
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status);
$this->assertTrue($change->isRetrospective());
$this->assertSame(CourierQuoteChange::RECALCULATION_PENDING, $change->recalculation_status);
$this->loginAs()->get(route('business.courier-quote-changes.show', $change))
->assertOk()
->assertSeeText('追溯变价 · 待追溯核算')
->assertSeeText('原运费和原始 Excel 保持不变');
}
public function test_invalid_or_unchanged_amount_is_rejected_without_saving_partial_data(): void
{
$payload = $this->validPayload();
$payload['items'][0]['new_amount'] = '5.0000';
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertSessionHasErrors('items');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
$this->assertDatabaseCount('business_courier_quote_change_items', 0);
$payload = $this->validPayload();
$payload['items'][0]['original_amount'] = '4.5000';
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $payload)
->assertSessionHasErrors('items.0.original_amount');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
public function test_formula_cell_cannot_be_changed_directly(): void
{
file_put_contents($this->fixturePath, json_encode([
'sourcePath' => 'C:/private/报价单合并.xlsx',
'sheets' => [[
'index' => 0,
'name' => '新北中通报价',
'address' => 'A1:B2',
'values' => [['省份', '面单'], ['广东省', 5.0]],
'formulas' => [[], ['', '=2+3']],
]],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->withExceptionHandling()->loginAs()
->post(route('business.courier-quote-changes.store'), $this->validPayload())
->assertSessionHasErrors('items.0.source_cell');
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
public function test_submit_locks_draft_and_creator_cannot_approve_own_change(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_PENDING_REVIEW, $change->status);
$this->withExceptionHandling()->loginAs()->get(route('business.courier-quote-changes.edit', $change))->assertForbidden();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.approve', $change))
->assertSessionHasErrors('approval');
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_different_admin_can_approve_and_generate_immutable_new_version(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change));
$reviewer = $this->createReviewer();
$this->loginAs($reviewer, $this->company)
->post(route('business.courier-quote-changes.approve', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change));
$change->refresh();
$version = CourierQuoteVersion::firstOrFail();
$values = json_decode($version->values_json, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame('QB-002', $version->version_code);
$this->assertSame('6.0000', $values[1][1]);
$this->assertEquals(5.0, app(QuoteManagementSnapshot::class)->load()['quotes'][0]['values'][1][1]);
$this->assertSame($reviewer->id, $change->approved_by);
$this->assertSame('QB-002', app(QuoteManagementSnapshot::class)->load()['quotes'][1]['id']);
}
public function test_approving_historical_change_blocks_automatic_compensation_and_records_reason(): void
{
$payload = $this->validPayload();
$payload['effective_date'] = now()->subMonths(2)->startOfMonth()->toDateString();
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload);
$change = CourierQuoteChange::firstOrFail();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $change));
$this->loginAs($this->createReviewer(), $this->company)
->post(route('business.courier-quote-changes.approve', $change))
->assertRedirect(route('business.courier-quote-changes.show', $change))
->assertSessionHas('success', function (string $message): bool {
return str_contains($message, '历史生效已登记') && str_contains($message, '补差核算尚未执行');
});
$change->refresh();
$this->assertSame(CourierQuoteChange::STATUS_APPROVED, $change->status);
$this->assertSame(CourierQuoteChange::RECALCULATION_BLOCKED, $change->recalculation_status);
$this->assertStringContainsString('当前报价未提供可执行的运费计算方式', $change->recalculation_note);
$this->assertStringContainsString('历史运费也未记录所用报价版本', $change->recalculation_note);
$this->assertDatabaseCount('business_courier_quote_versions', 1);
$approvalEvent = $change->events()->where('event_type', 'approved')->firstOrFail();
$this->assertStringContainsString('补差明细尚未生成', $approvalEvent->note);
$this->assertStringContainsString('原运费保持不变', $approvalEvent->note);
$this->assertSame(
CourierQuoteChange::RECALCULATION_BLOCKED,
json_decode($approvalEvent->details_json, true, 512, JSON_THROW_ON_ERROR)['recalculation_status']
);
$this->loginAs()->get(route('business.courier-quote-changes.show', $change))
->assertOk()
->assertSeeText('追溯变价 · 核算受阻')
->assertSeeText('未生成补差明细')
->assertSeeText('原运费保持不变');
$this->loginAs()->get(route('business.courier-quote-changes.index'))
->assertOk()
->assertSeeText('追溯变价')
->assertSeeText('核算受阻');
}
public function test_other_company_cannot_open_or_modify_quote_change(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
\DB::table('business_courier_quote_changes')->where('id', $change->id)->update([
'company_id' => $this->company->id + 999,
]);
$response = $this->withExceptionHandling()->loginAs()
->get(route('business.courier-quote-changes.show', $change->id));
$response->assertRedirect(route('business.courier-quote-changes.index'));
$this->assertNull(CourierQuoteChange::find($change->id));
}
public function test_user_without_quote_page_permission_cannot_use_workflow_routes(): void
{
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode([
'configured' => [$subject],
'subjects' => [$subject => ['courier-payables/freight-reconciliation']],
], JSON_UNESCAPED_UNICODE));
$this->withExceptionHandling()->loginAs()->get(route('business.courier-quote-changes.index'))->assertForbidden();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload())->assertForbidden();
}
public function test_export_preserves_horizontal_coordinates_and_all_500_saved_items_without_mutating_pending_change(): void
{
$values = [['地区', ...array_map(fn ($column) => ($column - 1) . '-' . $column . 'kg', range(1, 10))]];
$payload = $this->validPayload();
$payload['items'] = [];
foreach (range(1, 50) as $row) {
$region = $row <= 2 ? '江苏' : '地区' . $row;
$values[] = [$region, ...array_fill(0, 10, 5.1234)];
foreach (range(1, 10) as $column) {
$payload['items'][] = ['source_cell' => \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($column + 1) . ($row + 1),
'source_row' => $row, 'source_column' => $column, 'region' => $region,
'weight_band' => $values[0][$column], 'charge_item' => $values[0][$column],
'original_amount' => '5.1234', 'new_amount' => $row === 50 && $column === 10 ? '0' : '4.5678',
'unit' => '元', 'note' => '=1+1'];
}
}
file_put_contents($this->fixturePath, json_encode(['sourcePath' => 'private/quote.xlsx', 'sheets' => [[
'index' => 0, 'name' => '测试快递报价', 'address' => 'A1:K51', 'values' => $values, 'formulas' => [],
]]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload)->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->post(route('business.courier-quote-changes.submit', $change))->assertRedirect();
$before = $change->fresh()->getAttributes();
$eventsBefore = $change->events()->count();
$start = microtime(true);
$response = $this->get(route('business.courier-quote-changes.export', $change))->assertOk();
$response->assertHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$bytes = $response->streamedContent();
$file = tempnam(sys_get_temp_dir(), 'quote-export-');
try {
file_put_contents($file, $bytes);
$book = \PhpOffice\PhpSpreadsheet\IOFactory::load($file);
$sheet = $book->getSheetByName('横向变价对比');
$details = $book->getSheetByName('逐项明细');
$this->assertSame(58, $sheet->getHighestRow());
$this->assertSame('AE', $sheet->getHighestColumn());
$this->assertSame('江苏', $sheet->getCell('A9')->getValue());
$this->assertSame('江苏', $sheet->getCell('A10')->getValue());
$this->assertSame('0-1kg', $sheet->getCell('B7')->getValue());
$this->assertSame('9-10kg', $sheet->getCell('AC7')->getValue());
$this->assertEquals(5.1234, $sheet->getCell('B9')->getValue());
$this->assertEquals(4.5678, $sheet->getCell('C9')->getValue());
$this->assertEquals(-0.5556, $sheet->getCell('D9')->getValue());
$this->assertEquals(0, $sheet->getCell('AD58')->getValue());
$this->assertEquals(-5.1234, $sheet->getCell('AE58')->getValue());
$this->assertSame('0.0000', $sheet->getStyle('AD58')->getNumberFormat()->getFormatCode());
$this->assertSame('B9', $sheet->getFreezePane());
$this->assertSame(507, $details->getHighestRow());
$this->assertSame('=1+1', $details->getCell('G8')->getValue());
$this->assertSame('s', $details->getCell('G8')->getDataType());
$this->assertSame('K51', $details->getCell('H507')->getValue());
$book->disconnectWorksheets();
fwrite(STDERR, sprintf("\nQUOTE_EXPORT_500: %.3fs, %d bytes, process peak %.1f MiB\n", microtime(true) - $start, strlen($bytes), memory_get_peak_usage(true) / 1048576));
} finally {
unlink($file);
}
$this->assertSame($before, $change->fresh()->getAttributes());
$this->assertSame($eventsBefore, $change->events()->count());
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_export_and_withdraw_respect_company_and_quote_page_boundaries(): void
{
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload())->assertRedirect();
$change = CourierQuoteChange::firstOrFail();
$this->post(route('business.courier-quote-changes.submit', $change))->assertRedirect();
$other = \App\Models\Common\Company::create(['enabled' => true]);
$change->update(['company_id' => $other->id]);
$this->withExceptionHandling()->get(route('business.courier-quote-changes.export', $change))->assertRedirect();
$this->post(route('business.courier-quote-changes.withdraw', $change))->assertRedirect();
$other->makeCurrent();
$change->update(['company_id' => $this->company->id]);
$this->company->makeCurrent();
$subject = 'user_' . $this->user->id;
$this->user->roles()->sync([Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', json_encode(['configured' => [$subject],
'subjects' => [$subject => ['courier-payables/freight-reconciliation']]], JSON_UNESCAPED_UNICODE));
$this->get(route('business.courier-quote-changes.export', $change))->assertForbidden();
$this->post(route('business.courier-quote-changes.withdraw', $change))->assertForbidden();
}
public function test_multiple_periods_survive_draft_edit_approval_and_restore_on_every_boundary(): void
{
\Illuminate\Support\Facades\Storage::fake('uploads');
\Illuminate\Support\Facades\Http::preventStrayRequests();
config(['courier_onboarding.allow_same_reviewer' => true]);
$payload = [...$this->validPayload(), 'execution_mode' => 'temporary', 'periods' => [
['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
['effective_date' => '2026-10-20', 'end_date' => '2026-10-24'],
]];
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload)->assertSessionHasNoErrors();
$change = CourierQuoteChange::firstOrFail();
$this->assertFalse($change->hasQuotePayload());
$this->assertSame('2026-10-20', $change->effective_date->toDateString());
$this->assertSame('2026-11-14', $change->end_date->toDateString());
$this->patch(route('business.courier-quote-changes.update', $change), $payload)->assertSessionHasNoErrors();
$this->post(route('business.courier-quote-changes.submit', $change))->assertSessionHasNoErrors();
$this->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasNoErrors();
$change->refresh();
$this->assertFalse($change->hasQuotePayload());
$version = $change->version;
$baseChange = $change->replicate()->fill(['number' => 'BG-BASE', 'effective_date' => '2026-09-20', 'end_date' => null, 'quote_payload' => null]);
$baseChange->save();
$base = $version->replicate()->fill(['version_code' => 'QB-001', 'source_change_id' => $baseChange->id,
'effective_date' => '2026-09-20', 'end_date' => null, 'signature_status' => '已签署并归档']);
$base->save();
$version->update(['signature_status' => '已签署并归档']);
$versions = collect([$base, $version->fresh('change')]);
foreach (['2026-10-19' => $base->id, '2026-10-20' => $version->id, '2026-10-24' => $version->id,
'2026-10-25' => $base->id, '2026-11-10' => $base->id, '2026-11-11' => $version->id,
'2026-11-14' => $version->id, '2026-11-15' => $base->id] as $day => $id) {
$this->assertSame($id, \App\Support\CourierRequoteService::resolveAt($versions, new \DateTimeImmutable($day))->id, $day);
}
$confirmation = app(\App\Support\CourierQuoteConfirmation::class)->data($version->fresh());
$this->assertStringContainsString('2026-10-20 至 2026-10-24;2026-11-11 至 2026-11-14', $confirmation['temporary_note']);
$this->assertStringContainsString('自动恢复原报价 QB-001', $confirmation['temporary_note']);
$book = app(\App\Support\CourierQuoteChangeExport::class)->build($change);
$this->assertStringContainsString('2026-11-11 至 2026-11-14', $book->getSheet(0)->getCell('A3')->getValue());
$this->assertStringContainsString('区间外沿用原长期报价', $book->getSheet(0)->getCell('A6')->getValue());
$book->disconnectWorksheets();
$this->travelTo(new \Illuminate\Support\Carbon('2026-10-30 12:00:00'));
$quotes = collect(app(QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id');
$this->assertSame('间隔期(原报价执行)', $quotes[$version->version_code]['status']);
$this->assertSame('生效中', $quotes[$base->version_code]['status']);
$this->assertStringContainsString('2026-11-11', $quotes[$version->version_code]['status_note']);
$this->travelTo(new \Illuminate\Support\Carbon('2026-11-15 12:00:00'));
$quotes = collect(app(QuoteManagementSnapshot::class)->load()['quotes'])->keyBy('id');
$this->assertSame('已到期', $quotes[$version->version_code]['status']);
$this->assertSame('生效中', $quotes[$base->version_code]['status']);
$this->travelBack();
}
public function test_invalid_temporary_periods_do_not_save_partial_changes(): void
{
foreach ([
[['effective_date' => '2026-10-24', 'end_date' => '2026-10-20']],
[['effective_date' => '2026-10-20', 'end_date' => '2026-10-24'], ['effective_date' => '2026-10-24', 'end_date' => '2026-10-30']],
[['effective_date' => '2026-02-30', 'end_date' => '2026-03-01']],
[['effective_date' => '2026-10-20', 'end_date' => '']], [],
] as $periods) {
$this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'),
[...$this->validPayload(), 'execution_mode' => 'temporary', 'periods' => $periods])->assertSessionHasErrors();
$this->assertDatabaseCount('business_courier_quote_changes', 0);
}
}
public function test_interval_priority_uses_start_of_the_current_interval(): void
{
$change = new CourierQuoteChange(['quote_payload' => ['execution_periods' => [
['effective_date' => '2026-10-20', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
]]]);
$temporary = new CourierQuoteVersion(['effective_date' => '2026-10-20', 'end_date' => '2026-11-14', 'signature_status' => '已签署并归档']);
$temporary->setRelation('change', $change);
$other = new CourierQuoteVersion(['effective_date' => '2026-11-01', 'signature_status' => '已签署并归档']);
$other->setRelation('change', null);
$this->assertSame($temporary, \App\Support\CourierRequoteService::resolveAt(collect([$other, $temporary]), new \DateTimeImmutable('2026-11-12')));
$this->assertSame($other, \App\Support\CourierRequoteService::resolveAt(collect([$other, $temporary]), new \DateTimeImmutable('2026-11-15')));
}
public function test_approved_unsent_period_correction_is_audited_and_preserves_prices_and_old_pdf(): void
{
\Illuminate\Support\Facades\Storage::fake('uploads');
\Illuminate\Support\Facades\Http::preventStrayRequests();
config(['courier_onboarding.allow_same_reviewer' => true]);
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->post(route('business.courier-quote-changes.submit', $change));
$this->post(route('business.courier-quote-changes.approve', $change));
$change->refresh();
$version = $change->version;
$original = $version->signature;
$values = $version->values_json;
$this->patch(route('business.courier-quote-changes.periods', $change), [
'effective_date' => '2026-10-20', 'execution_mode' => 'temporary', 'periods' => [
['effective_date' => '2026-10-20', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-11', 'end_date' => '2026-11-14'],
],
])->assertSessionHasNoErrors();
$version->refresh();
$this->assertSame($values, $version->values_json);
$this->assertSame($change->approved_at->toDateTimeString(), $change->fresh()->approved_at->toDateTimeString());
$this->assertNotSame($original->source_pdf_path, $version->signature->source_pdf_path);
$this->assertNotSame($original->source_pdf_sha256, $version->signature->source_pdf_sha256);
\Illuminate\Support\Facades\Storage::disk('uploads')->assertExists($original->source_pdf_path);
$event = $change->events()->where('event_type', 'periods_corrected')->firstOrFail();
$audit = json_decode($event->details_json, true);
$this->assertSame($original->source_pdf_sha256, $audit['before']['documents'][0]['source_pdf_sha256']);
$this->assertCount(2, $audit['after']['periods']);
$this->get(route('business.courier-quote-changes.show', $change))->assertOk()
->assertSeeText('2026-10-20 至 2026-10-24')->assertSeeText('2026-11-11 至 2026-11-14')->assertSeeText('更正执行区间');
// A remote task, including a failed attempt, makes the terms immutable.
$version->signature->update(['status' => 'failed', 'esign_sign_flow_id' => 'existing-flow']);
$before = $change->fresh()->getAttributes();
$this->withExceptionHandling()->patch(route('business.courier-quote-changes.periods', $change), [
'effective_date' => '2027-01-01', 'execution_mode' => 'permanent',
])->assertSessionHasErrors('periods');
$this->assertSame($before, $change->fresh()->getAttributes());
$this->assertSame(1, $change->events()->where('event_type', 'periods_corrected')->count());
}
public function test_draft_can_switch_back_from_temporary_to_permanent(): void
{
$payload = [...$this->validPayload(), 'execution_mode' => 'temporary', 'periods' => [
['effective_date' => '2026-10-20', 'end_date' => '2026-10-24'],
]];
$this->loginAs()->post(route('business.courier-quote-changes.store'), $payload)->assertSessionHasNoErrors();
$change = CourierQuoteChange::firstOrFail();
$this->patch(route('business.courier-quote-changes.update', $change), [...$this->validPayload(), 'execution_mode' => 'permanent'])->assertSessionHasNoErrors();
$this->assertNull($change->fresh()->end_date);
$this->assertFalse($change->fresh()->hasQuotePayload());
}
public function test_correction_rolls_back_on_document_failure_and_requires_designated_reviewer(): void
{
\Illuminate\Support\Facades\Storage::fake('uploads');
config(['courier_onboarding.allow_same_reviewer' => true]);
$this->loginAs()->post(route('business.courier-quote-changes.store'), $this->validPayload());
$change = CourierQuoteChange::firstOrFail();
$this->post(route('business.courier-quote-changes.submit', $change));
$this->post(route('business.courier-quote-changes.approve', $change));
$change->refresh();
$before = $change->getAttributes();
$versionBefore = $change->version->getAttributes();
$signatureBefore = $change->version->signature->getAttributes();
$renderer = \Mockery::mock();
$renderer->shouldReceive('setPaper')->andReturnSelf();
$renderer->shouldReceive('loadHTML')->andReturnSelf();
$renderer->shouldReceive('output')->andThrow(new \RuntimeException('test renderer failure'));
app()->instance('dompdf.wrapper', $renderer);
try {
app(\App\Support\CourierQuotePeriodCorrection::class)->correct($change, [
'effective_date' => '2026-10-20', 'end_date' => '2026-10-24',
], $this->user->id);
$this->fail('Expected document generation failure.');
} catch (\RuntimeException $exception) {
$this->assertSame('test renderer failure', $exception->getMessage());
}
$this->assertSame($before, $change->fresh()->getAttributes());
$this->assertSame($versionBefore, $change->version->fresh()->getAttributes());
$this->assertSame($signatureBefore, $change->version->signature->fresh()->getAttributes());
$this->assertSame(0, $change->events()->where('event_type', 'periods_corrected')->count());
\Illuminate\Support\Facades\DB::table('settings')->insert(['company_id' => $this->company->id,
'key' => \App\Support\CourierQuoteWorkflowPermissions::KEY, 'value' => '{"initiators":[],"reviewers":[]}']);
$this->withExceptionHandling()->patch(route('business.courier-quote-changes.periods', $change), [
'effective_date' => '2027-01-01', 'execution_mode' => 'permanent',
])->assertForbidden();
$this->assertSame($before, $change->fresh()->getAttributes());
}
public function test_activated_courier_requote_accepts_multiple_periods_and_restores_original_in_the_gap(): void
{
\Illuminate\Support\Facades\Storage::fake('uploads');
\Illuminate\Support\Facades\Http::preventStrayRequests();
config(['courier_onboarding.allow_same_reviewer' => true]);
$this->loginAs()->post(route('business.courier-quote-changes.store'), [...$this->validPayload(), 'effective_date' => '2026-09-20']);
$initial = CourierQuoteChange::firstOrFail();
$this->post(route('business.courier-quote-changes.submit', $initial));
$this->post(route('business.courier-quote-changes.approve', $initial));
$base = $initial->fresh()->version;
$base->update(['signature_status' => '已签署并归档']);
$quote = ['quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => '6000',
'quote_effective_date' => '2026-09-20', 'quote_rows' => [['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']]]];
$onboarding = \App\Models\Business\CourierOnboarding::create([
...app(\App\Support\CourierOnboardingService::class)->quoteAttributes($quote),
'company_id' => $this->company->id, 'number' => 'RZ-PERIODS', 'token' => (string) \Illuminate\Support\Str::uuid(),
'status' => 'activated', 'courier_name' => $base->company_name, 'company_full_name' => '测试快递公司',
]);
$this->get(route('business.courier-onboardings.requote', $onboarding))->assertOk()
->assertSeeText('临时变价 · 指定日期区间')->assertSeeText('添加执行区间');
$periods = [['effective_date' => '2026-10-20', 'end_date' => '2026-10-24'],
['effective_date' => '2026-11-11', 'end_date' => '2026-11-14']];
$this->post(route('business.courier-onboardings.requote.store', $onboarding), [
...$quote, 'execution_mode' => 'temporary', 'periods' => $periods, 'summary' => '两个旺季区间临时调整',
'quote_rows' => [['region' => '江苏', 'base' => '4.5', 'rates' => ['0.8']]],
])->assertSessionHasNoErrors();
$change = CourierQuoteChange::query()->latest('id')->firstOrFail();
$this->assertTrue($change->hasQuotePayload());
$this->assertSame($periods, $change->executionPeriods());
$this->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasNoErrors();
$version = $change->fresh()->version;
$version->update(['signature_status' => '已签署并归档']);
$requotes = app(\App\Support\CourierRequoteService::class);
foreach (['2026-10-20' => $version->id, '2026-10-24' => $version->id, '2026-10-25' => $base->id,
'2026-11-11' => $version->id, '2026-11-14' => $version->id, '2026-11-15' => $base->id] as $day => $id) {
$this->assertSame($id, $requotes->effectiveVersion($base->company_name, new \DateTimeImmutable($day))->id, $day);
}
}
private function validPayload(): array
{
return [
'source_quote_id' => 'QB-001',
'change_type' => '面单调整',
'effective_date' => now()->addDay()->toDateString(),
'summary' => '广东省面单每单增加1元。',
'items' => [[
'source_cell' => 'B2',
'source_row' => 1,
'source_column' => 1,
'region' => '广东省',
'weight_band' => '面单',
'charge_item' => '面单',
'original_amount' => '5.0000',
'new_amount' => '6.0000',
'unit' => '元/单',
'note' => '快递通知调价',
]],
];
}
private function createReviewer()
{
$reviewer = user_model_class()::create([
'name' => 'Quote Reviewer',
'email' => 'quote-reviewer-' . uniqid('', true) . '@example.com',
'password' => 'password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$reviewer->companies()->attach($this->company->id);
$reviewer->roles()->attach(Role::where('name', 'admin')->firstOrFail()->id);
$reviewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
return $reviewer;
}
}