CaiWuXiTong/tests/Feature/Business/CourierQuoteChangeWorkflowTest.php

307 lines
14 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-price-row', false)
->assertSee('name="items[0][new_amount]"', false)
->assertSee('data-effective-date', false)
->assertDontSee('type="date" min=', false)
->assertSeeText('生效日期可以早于今天')
->assertSeeText('保存草稿');
}
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();
}
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;
}
}