369 lines
18 KiB
PHP
369 lines
18 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]') 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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|