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('' . $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('' . $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; } }