CaiWuXiTong/tests/Feature/Business/CourierStockQuoteEntryTest.php

390 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\Models\Business\CourierStockQuoteEntry;
use App\Support\CourierQuoteChangeService;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Tests\Feature\FeatureTestCase;
class CourierStockQuoteEntryTest extends FeatureTestCase
{
private array $temporaryFiles = [];
protected function setUp(): void
{
parent::setUp();
Storage::fake('uploads');
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$mirror = \Mockery::mock(CourierInformationMirrorRepository::class);
$mirror->shouldReceive('overviewSource')->andReturn(['express' => [
(object) ['expcode' => 'EXP-001', 'expname' => '同名快递网点'],
(object) ['expcode' => 'EXP-002', 'expname' => '同名快递网点'],
]]);
app()->instance(CourierInformationMirrorRepository::class, $mirror);
app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot('/nonexistent-stock-quote-fixture.json'));
}
protected function tearDown(): void
{
foreach ($this->temporaryFiles as $path) {
if (is_file($path)) {
unlink($path);
}
}
parent::tearDown();
}
public function test_entry_uses_existing_courier_and_retains_original_file_formula_and_real_date(): void
{
$this->loginAs()->get(route('business.courier-stock-quotes.single'))->assertOk()
->assertSee('value="同名快递网点"', false)->assertSeeText('实际开始执行日期')->assertDontSee('name="courier_expcode"', false);
$data = $this->payload();
$originalHash = hash_file('sha256', $data['source_quote']->getRealPath());
$this->loginAs()->post(route('business.courier-stock-quotes.store'), $data)->assertRedirect();
$entry = CourierStockQuoteEntry::firstOrFail();
$this->assertSame('EXP-001', $entry->courier_expcode);
$this->assertSame($originalHash, $entry->files[0]['sha256']);
$this->assertSame('原始报价.xlsx', $entry->files[0]['name']);
$this->assertSame('=B2/2', $entry->sourceQuote()['formulas'][1][2]);
$this->assertSame('2025-12-01', $entry->change->effective_date->toDateString());
$this->assertFalse($entry->change->isRetrospective());
Storage::disk('uploads')->assertExists($entry->files[0]['path']);
$this->assertDatabaseCount('business_courier_quote_versions', 0);
$this->assertDatabaseCount('business_courier_onboardings', 0);
$this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk()
->assertSeeText('存量报价原文与核对')->assertSeeText('=B2/2')->assertSeeText('首重1kg');
}
public function test_review_requires_confirmation_and_never_claims_unsigned_quote_is_signed(): void
{
$entry = $this->createEntry();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change))->assertRedirect();
$reviewer = $this->reviewer();
$this->withExceptionHandling()->loginAs($reviewer, $this->company)
->post(route('business.courier-quote-changes.approve', $entry->change))->assertSessionHasErrors('stock_rules_confirmed');
$this->assertDatabaseCount('business_courier_quote_versions', 0);
$this->loginAs($reviewer, $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1'])->assertRedirect();
$version = CourierQuoteVersion::firstOrFail();
$this->assertSame('存量报价,审核生效(无需签章)', $version->signature_status);
$this->assertSame('EXP-001', $version->courier_expcode);
$this->assertSame(CourierQuoteChange::RECALCULATION_NOT_REQUIRED, $entry->change->fresh()->recalculation_status);
$this->assertSame('2025-12-01', $version->effective_date->toDateString());
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
$this->assertSame('生效中', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']);
$this->assertSame($version->id, \App\Support\CourierRequoteService::resolveAt(collect([$version->fresh()]), now())->id);
// 已上线旧记录无需改库或重新审核,也按存量审核规则生效。
$version->update(['signature_status' => '待确认/待补签']);
$snapshot = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
$this->assertSame('生效中', $snapshot['status']);
$this->assertSame('存量报价,审核生效(无需签章)', $snapshot['signature_status']);
}
public function test_historical_documents_require_reviewer_verification_and_remain_distinct_from_esign(): void
{
$entry = $this->createEntry(['signed_files' => [UploadedFile::fake()->create('历史合同.pdf', 5, 'application/pdf')]]);
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
$this->loginAs($this->reviewer(), $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), [
'stock_rules_confirmed' => '1', 'stock_signature_verified' => '1',
])->assertRedirect();
$version = CourierQuoteVersion::firstOrFail();
$this->assertSame('历史签署资料已归档', $version->signature_status);
$this->assertDatabaseCount('business_courier_quote_signatures', 0);
$snapshot = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
$this->assertSame('生效中', $snapshot['status']);
$this->assertArrayHasKey('signed', $snapshot['signed_files']);
$this->loginAs()->get(route('business.courier-quote-changes.show', $entry->change))->assertOk()
->assertSeeText('尚无本系统电子签署记录')->assertDontSeeText('生成签署 PDF');
}
public function test_claiming_verified_history_without_documents_is_rejected(): void
{
$entry = $this->createEntry();
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
$this->withExceptionHandling()->loginAs($this->reviewer(), $this->company)
->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1', 'stock_signature_verified' => '1'])
->assertSessionHasErrors('stock_signature_verified');
$this->assertDatabaseCount('business_courier_quote_versions', 0);
}
public function test_approved_stock_quote_exposes_all_originals_without_requiring_signature_verification(): void
{
$entry = $this->createEntry(['signed_files' => [
UploadedFile::fake()->createWithContent('signed-original.pdf', "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF"),
UploadedFile::fake()->image('签署原件二.png'),
]]);
$this->loginAs()->post(route('business.courier-quote-changes.submit', $entry->change));
$this->loginAs($this->reviewer(), $this->company)->post(route('business.courier-quote-changes.approve', $entry->change), ['stock_rules_confirmed' => '1'])->assertRedirect();
$quote = app(QuoteManagementSnapshot::class)->load()['quotes'][0];
$this->assertSame('生效中', $quote['status']);
$this->assertCount(2, $quote['stock_originals']);
$this->assertSame([], $quote['signed_files']);
$this->loginAs()->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']))
->assertOk()->assertSee('data-stock-original-links', false)->assertSee('预览签署原件');
foreach ($quote['stock_originals'] as $original) {
$this->assertTrue($original['previewable']);
$response = $this->loginAs()->get($original['url'])->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff');
$this->assertStringStartsWith('inline;', $response->headers->get('Content-Disposition'));
}
$version = CourierQuoteVersion::firstOrFail();
$version->update(['status' => CourierQuoteVersion::STATUS_VOIDED]);
$this->assertNull(\App\Support\CourierRequoteService::resolveAt(collect([$version->fresh()]), now()));
}
public function test_same_code_and_type_cannot_be_imported_twice_but_same_names_with_different_codes_can(): void
{
$this->createEntry();
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), $this->payload())
->assertSessionHasErrors('courier_name');
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'EXP-002'])->assertRedirect();
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'courier_expcode' => 'UNREGISTERED'])
->assertSessionHasErrors('courier_expcode');
}
public function test_multi_sheet_upload_requires_exact_sheet_and_invalid_workbooks_leave_no_record(): void
{
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true)])
->assertSessionHasErrors('sheet_name');
$this->assertDatabaseCount('business_courier_stock_quote_entries', 0);
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'source_quote' => $this->workbook(true), 'sheet_name' => '报价'])
->assertRedirect();
}
public function test_manual_name_can_be_corrected_in_draft_and_follows_review_and_later_price_change(): void
{
$entry = $this->createEntry(['courier_expcode' => null, 'courier_name' => '手填名称待修正', 'signed_files' => [UploadedFile::fake()->create('签署原件.pdf', 1, 'application/pdf')]]);
$files = $entry->files;
$this->assertNull($entry->courier_expcode);
$this->loginAs()->patch(route('business.courier-stock-quotes.update', $entry), [
'courier_name' => '发货数据完整快递名称', 'effective_date' => '2025-12-01', 'billing_rules' => '首重1kg,续重按原报价', 'summary' => '更正为发货名称',
])->assertRedirect();
$entry->refresh();
$this->assertSame('发货数据完整快递名称', $entry->courier_name);
$this->assertSame('发货数据完整快递名称', $entry->change->carrier_name);
$this->assertSame('报价', $entry->sheet_name);
$this->assertSame($files, $entry->files);
$savedDetails = json_decode($entry->change->events()->where('event_type', 'saved')->latest('id')->firstOrFail()->details_json, true);
$this->assertSame('发货数据完整快递名称', $savedDetails['carrier_name']);
$service = app(CourierQuoteChangeService::class);
$reviewer = (int) $this->reviewer()->id;
$service->submit($entry->change, (int) $this->user->id);
$service->approve($entry->change->fresh(), $reviewer, true);
$original = CourierQuoteVersion::firstOrFail();
$this->assertNull($original->courier_expcode);
$this->assertSame('发货数据完整快递名称', $original->company_name);
$this->assertSame('生效中', app(QuoteManagementSnapshot::class)->load()['quotes'][0]['status']);
$change = $service->create([
'source_quote_id' => $original->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-01-01', 'summary' => '后续变价',
'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 7]],
], (int) $this->user->id);
$service->submit($change, (int) $this->user->id);
$changed = $service->approve($change->fresh(), $reviewer);
$this->assertSame('发货数据完整快递名称', $changed->version->company_name);
$this->assertNull($changed->version->courier_expcode);
$this->assertEquals(5, json_decode($original->fresh()->values_json, true)[1][1]);
$this->assertEquals(7, json_decode($changed->version->values_json, true)[1][1]);
$this->assertSame($files, $entry->fresh()->files);
}
public function test_shipping_names_can_be_suggested_without_a_courier_directory_record(): void
{
$shipping = \Mockery::mock(\Modules\EcommerceFinance\Repositories\ShippingOrderRepository::class);
$shipping->shouldReceive('recentCourierNames')->once()->with('缺资料', 20)->andReturn(['缺资料发货快递']);
app()->instance(\Modules\EcommerceFinance\Repositories\ShippingOrderRepository::class, $shipping);
$this->loginAs()->getJson(route('business.courier-stock-quotes.courier-names', ['q' => '缺资料']))->assertOk()->assertJsonPath('items.0', '缺资料发货快递');
$this->assertDatabaseCount('business_courier_onboardings', 0);
}
public function test_name_only_versions_of_different_quote_types_do_not_supersede_each_other(): void
{
$service = app(CourierQuoteChangeService::class);
$reviewer = (int) $this->reviewer()->id;
foreach (['标准快递报价', '大件快递报价'] as $type) {
$entry = $this->createEntry(['courier_expcode' => null, 'courier_name' => '同家无编号快递', 'quote_type' => $type,
'signed_files' => [UploadedFile::fake()->create('签署原件.pdf', 1, 'application/pdf')]]);
$service->submit($entry->change, (int) $this->user->id);
$service->approve($entry->change->fresh(), $reviewer, true);
}
$this->assertSame(['生效中', '生效中'], array_column(app(QuoteManagementSnapshot::class)->load()['quotes'], 'status'));
}
public function test_draft_replacement_retains_previous_original_and_updates_preview_and_audit(): void
{
$entry = $this->createEntry();
$originalPath = $entry->files[0]['path'];
$this->loginAs()->patch(route('business.courier-stock-quotes.update', $entry), [
'effective_date' => '2025-11-01', 'billing_rules' => '修正后首重1kg', 'summary' => '更正原报价附件',
'source_quote' => $this->workbook(false, 8),
])->assertRedirect();
$entry->refresh();
$this->assertCount(2, $entry->files);
$this->assertSame('previous_source_quote', $entry->files[0]['kind']);
Storage::disk('uploads')->assertExists($originalPath);
$this->assertEquals(8, $entry->sourceQuote()['values'][1][1]);
$this->assertEquals(8, json_decode($entry->change->proposed_values_json, true)[1][1]);
$details = json_decode($entry->change->events()->where('event_type', 'saved')->firstOrFail()->details_json, true);
$this->assertSame('修正后首重1kg', $details['billing_rules']);
$this->assertCount(2, $details['files']);
}
public function test_followup_price_change_reuses_version_and_keeps_exact_courier_identity(): void
{
$entry = $this->createEntry();
app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id);
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $this->reviewer()->id);
$version = CourierQuoteVersion::firstOrFail();
$this->loginAs()->get(route('business.courier-quote-changes.start', ['source_quote_id' => $version->version_code]))
->assertOk()->assertSeeText('编辑新价格');
$change = app(CourierQuoteChangeService::class)->create([
'source_quote_id' => $version->version_code, 'change_type' => '费率调整', 'effective_date' => '2026-10-16',
'summary' => '存量快递后续变价', 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'original_amount' => 5, 'new_amount' => 6]],
], (int) $this->user->id);
app(CourierQuoteChangeService::class)->submit($change, (int) $this->user->id);
$approved = app(CourierQuoteChangeService::class)->approve($change->fresh(), (int) $this->reviewer()->id);
$this->assertSame('EXP-001', $approved->version->courier_expcode);
$this->assertSame('首重1kg,续重按原报价', $approved->version->billing_rules);
$this->assertEquals(6, json_decode($approved->version->values_json, true)[1][1]);
$this->assertEquals(5, json_decode($version->fresh()->values_json, true)[1][1]);
}
public function test_other_company_cannot_download_or_edit_stock_quote(): void
{
$entry = $this->createEntry();
\DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
$this->withExceptionHandling()->loginAs()
->get(route('business.courier-stock-quotes.download', [$entry, 0]))->assertNotFound();
$this->loginAs()->get(route('business.courier-stock-quotes.edit', $entry))->assertNotFound();
$this->assertNull(CourierStockQuoteEntry::find($entry->id));
}
public function test_same_name_couriers_keep_separate_effective_versions(): void
{
$reviewer = $this->reviewer();
foreach (['EXP-001', 'EXP-002'] as $code) {
$entry = $this->createEntry(['courier_expcode' => $code, 'signed_files' => [UploadedFile::fake()->create('回签.pdf', 1, 'application/pdf')]]);
app(CourierQuoteChangeService::class)->submit($entry->change, (int) $this->user->id);
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), (int) $reviewer->id, true);
}
$quotes = app(QuoteManagementSnapshot::class)->load()['quotes'];
$this->assertCount(2, $quotes);
$this->assertSame(['生效中', '生效中'], array_column($quotes, 'status'));
$this->assertSame(['EXP-001', 'EXP-002'], array_column($quotes, 'courier_expcode'));
}
private function createEntry(array $overrides = []): CourierStockQuoteEntry
{
$this->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), ...$overrides])->assertRedirect();
return CourierStockQuoteEntry::latest('id')->firstOrFail();
}
public function test_same_courier_can_import_disjoint_periods_and_resolve_history_without_temporary_price_semantics(): void
{
$first = $this->createEntry(['effective_date' => '2025-12-01', 'end_date' => '2026-09-30']);
$second = $this->createEntry(['effective_date' => '2026-10-01', 'end_date' => null]);
$reviewer = $this->reviewer();
foreach ([$first, $second] as $entry) {
app(CourierQuoteChangeService::class)->submit($entry->change, $this->user->id);
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), $reviewer->id);
}
$versions = CourierQuoteVersion::with('change')->get();
$this->assertCount(2, $versions);
$this->assertFalse($versions[0]->isTemporary());
$this->assertSame('2025-12-01 至 2026-09-30', $versions[0]->periodLabel());
$this->assertSame($versions[0]->id, \App\Support\CourierRequoteService::resolveAt($versions, new \DateTimeImmutable('2026-09-30'))->id);
$this->assertSame($versions[1]->id, \App\Support\CourierRequoteService::resolveAt($versions, new \DateTimeImmutable('2026-10-01'))->id);
$quotes = app(QuoteManagementSnapshot::class)->load()['quotes'];
$this->assertSame($quotes[0]['group_key'], $quotes[1]['group_key']);
$this->assertSame('已到期', $quotes[0]['status']);
$this->assertSame('生效中', $quotes[1]['status']);
}
public function test_overlapping_periods_and_shared_boundary_day_are_rejected_but_earlier_history_is_allowed(): void
{
$this->createEntry(['effective_date' => '2026-10-01']);
foreach ([['2026-09-01', '2026-10-01'], ['2026-10-02', null], ['2026-11-01', '2026-10-01']] as [$start, $end]) {
$this->withExceptionHandling()->loginAs()->post(route('business.courier-stock-quotes.store'), [...$this->payload(), 'effective_date' => $start, 'end_date' => $end])->assertSessionHasErrors();
}
$this->assertDatabaseCount('business_courier_stock_quote_entries', 1);
$this->createEntry(['effective_date' => '2025-12-01', 'end_date' => '2026-09-30']);
$this->assertDatabaseCount('business_courier_stock_quote_entries', 2);
}
public function test_explicit_period_end_preserves_originals_and_audits_change_before_next_period_import(): void
{
$entry = $this->createEntry(['effective_date' => '2025-12-01']);
$reviewer = $this->reviewer();
app(CourierQuoteChangeService::class)->submit($entry->change, $this->user->id);
app(CourierQuoteChangeService::class)->approve($entry->change->fresh(), $reviewer->id);
$original = $entry->values_json;
$files = $entry->files;
$this->loginAs($reviewer, $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-09-30', 'reason' => '依据新旧报价交接日期'])->assertRedirect();
$this->assertSame('2026-09-30', $entry->fresh()->end_date->toDateString());
$this->assertSame('2026-09-30', $entry->change->fresh()->version->end_date->toDateString());
$this->assertSame($original, $entry->fresh()->values_json);
$this->assertSame($files, $entry->fresh()->files);
$this->assertDatabaseHas('business_courier_quote_change_events', ['quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated', 'created_by' => $reviewer->id]);
$this->createEntry(['effective_date' => '2026-10-01']);
$this->withExceptionHandling()->loginAs($reviewer, $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-10-01', 'reason' => '重叠边界验证'])->assertSessionHasErrors('courier_name');
$this->get(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']))->assertOk()->assertSeeText('执行期间重叠');
$this->assertSame('2026-09-30', $entry->fresh()->end_date->toDateString());
}
public function test_period_end_requires_admin_and_current_company(): void
{
$entry = $this->createEntry();
$reader = $this->reviewer();
$reader->roles()->detach();
$this->withExceptionHandling()->loginAs($reader, $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-09-30', 'reason' => '权限检查'])->assertForbidden();
\DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update(['company_id' => $this->company->id + 999]);
$this->withExceptionHandling()->loginAs($this->reviewer(), $this->company)->post(route('business.courier-stock-quotes.period-end', $entry), ['end_date' => '2026-09-30', 'reason' => '公司隔离检查'])->assertNotFound();
}
private function payload(): array
{
return ['courier_expcode' => 'EXP-001', 'quote_type' => '标准快递报价', 'source_quote' => $this->workbook(),
'effective_date' => '2025-12-01', 'billing_rules' => '首重1kg,续重按原报价', 'summary' => '补录历史合作报价'];
}
private function workbook(bool $multipleSheets = false, int $price = 5): UploadedFile
{
$book = new Spreadsheet();
$sheet = $book->getActiveSheet()->setTitle('报价');
$sheet->fromArray([['省份', '首重', '续重'], ['江苏省', $price, null]]);
$sheet->setCellValue('C2', '=B2/2');
if ($multipleSheets) {
$book->createSheet()->setTitle('其他网点')->setCellValue('A1', '其他报价');
}
$path = tempnam(sys_get_temp_dir(), 'stock-quote-');
$this->temporaryFiles[] = $path;
IOFactory::createWriter($book, 'Xlsx')->save($path);
$book->disconnectWorksheets();
return new UploadedFile($path, '原始报价.xlsx', null, null, true);
}
private function reviewer()
{
$user = user_model_class()::create([
'name' => '报价审核员', 'email' => 'stock-reviewer-' . uniqid() . '@example.com',
'password' => 'local-test-password', 'locale' => 'zh-CN', 'enabled' => 1,
'landing_page' => 'dashboard', 'created_from' => 'core::test',
]);
$user->companies()->attach($this->company->id);
$user->attachRole(Role::where('name', 'admin')->firstOrFail());
$user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
return $user;
}
}