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; } }