659 lines
38 KiB
PHP
659 lines
38 KiB
PHP
<?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;
|
||
}
|
||
}
|