mock(FinanceRoster::class, function ($mock): void { $mock->shouldReceive('findEligible')->andReturnUsing(fn ($id) => $this->eligible ? ['display_name' => $id, 'restricted' => false] : null); $mock->shouldReceive('search')->andReturn([['flowerNameId' => 'review-one', 'name' => '审核一', 'flowerName' => '审核一']]); }); $this->people = []; foreach (['init-one', 'init-two', 'review-one', 'review-two'] as $id) { $person = User::create(['name' => $id, 'email' => $id . '@example.test', 'password' => 'password', 'locale' => 'zh-CN', 'enabled' => true, 'landing_page' => 'dashboard', 'created_from' => 'core::test']); $person->companies()->attach($this->company->id); $person->roles()->attach(Role::where('name', 'user')->firstOrFail()->id); $person->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); DingTalkIdentity::create(['user_id' => $person->id, 'union_id' => 'union-' . $id, 'dingtalk_user_id' => $id, 'bound_at' => now()]); $this->people[$id] = $person; } $this->fixturePath = storage_path('framework/testing/quote-permissions-' . uniqid() . '.json'); file_put_contents($this->fixturePath, json_encode(['sourcePath' => 'private/quote.xlsx', 'sheets' => [[ 'index' => 0, 'name' => '测试快递报价', 'address' => 'A1:B2', 'values' => [['省份', '面单'], ['江苏', 5]], 'formulas' => [[], []], ]]], JSON_UNESCAPED_UNICODE)); app()->instance(QuoteManagementSnapshot::class, new QuoteManagementSnapshot($this->fixturePath)); $this->loginAs(); app(CourierQuoteWorkflowPermissions::class)->save($this->permissionGroups(), $this->user->name, $this->user->id); } protected function tearDown(): void { @unlink($this->fixturePath); parent::tearDown(); } private function permissionGroups(): array { return ['initiators' => ['init-one', 'init-two'], 'reviewers' => ['review-one', 'review-two']]; } private function payload(): array { return ['source_quote_id' => 'QB-001', 'change_type' => '费率调整', 'effective_date' => now()->addDay()->toDateString(), 'summary' => '调整江苏面单价格', 'items' => [['source_cell' => 'B2', 'source_row' => 1, 'source_column' => 1, 'region' => '江苏', 'weight_band' => '面单', 'charge_item' => '面单', 'original_amount' => '5', 'new_amount' => '6', 'unit' => '元']]]; } private function pending(string $initiator = 'init-one'): CourierQuoteChange { $this->loginAs($this->people[$initiator], $this->company) ->post(route('business.courier-quote-changes.store'), $this->payload())->assertRedirect(); $change = CourierQuoteChange::latest('id')->firstOrFail(); $this->post(route('business.courier-quote-changes.submit', $change))->assertRedirect(); return $change->fresh(); } public function test_either_named_regular_reviewer_can_approve_named_initiators_without_admin_grants(): void { foreach (['review-one' => 'init-one', 'review-two' => 'init-two'] as $reviewer => $initiator) { $change = $this->pending($initiator); $this->loginAs($this->people[$reviewer], $this->company) ->get(route('business.courier-quote-changes.show', $change))->assertOk()->assertSeeText('指定审核')->assertSeeText('审核通过'); $this->post(route('business.courier-quote-changes.approve', $change))->assertRedirect(); $this->assertSame('approved', $change->fresh()->status); $this->assertSame($this->people[$reviewer]->id, $change->fresh()->version->approved_by); $this->post(route('business.courier-quote-signatures.prepare', $change->fresh()->version), ['signature_date' => now()->toDateString()])->assertRedirect(); $this->get(route('business.courier-quote-changes.show', $change))->assertOk()->assertSeeText('推送到 E 签宝')->assertDontSeeText('作废此版本'); $this->withExceptionHandling()->post(route('business.courier-quote-changes.void', $change), ['void_reason' => '不应获得作废权限'])->assertForbidden(); $this->withExceptionHandling()->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasErrors('status'); $this->assertSame('user', $this->people[$reviewer]->roles()->first()->name); } $this->assertDatabaseCount('business_courier_quote_versions', 2); } public function test_admin_and_initiator_cannot_bypass_named_reviewers_or_batch_approval(): void { $change = $this->pending(); $this->withExceptionHandling()->post(route('business.courier-quote-changes.approve', $change))->assertForbidden(); $this->loginAs()->post(route('business.courier-quote-changes.approve', $change))->assertForbidden(); $this->get(route('business.courier-quote-changes.index'))->assertOk()->assertDontSee('name="ids[]"', false); $this->post(route('business.courier-quote-changes.batch-approve'), ['ids' => [$change->id], 'stock_rules_confirmed' => 1])->assertRedirect(); $this->assertSame('pending_review', $change->fresh()->status); $this->assertDatabaseCount('business_courier_quote_versions', 0); } public function test_unlisted_admin_cannot_create_change_and_other_initiator_cannot_edit_owners_draft(): void { $this->withExceptionHandling()->loginAs()->post(route('business.courier-quote-changes.store'), $this->payload())->assertForbidden(); $this->loginAs($this->people['init-one'], $this->company)->post(route('business.courier-quote-changes.store'), $this->payload())->assertRedirect(); $change = CourierQuoteChange::firstOrFail(); $this->loginAs($this->people['init-two'], $this->company)->patch(route('business.courier-quote-changes.update', $change), $this->payload())->assertForbidden(); $this->assertSame('draft', $change->fresh()->status); } public function test_reviewer_can_return_and_revocation_of_identity_or_configuration_is_immediate(): void { $change = $this->pending(); $this->loginAs($this->people['review-one'], $this->company) ->post(route('business.courier-quote-changes.return', $change), ['review_note' => '请核对面单价格'])->assertRedirect(); $this->assertSame('returned', $change->fresh()->status); $this->loginAs($this->people['init-one'], $this->company)->post(route('business.courier-quote-changes.submit', $change))->assertRedirect(); app(CourierQuoteWorkflowPermissions::class)->save(['initiators' => ['init-one', 'init-two'], 'reviewers' => ['review-two']], 'test admin', $this->user->id); $this->withExceptionHandling()->loginAs($this->people['review-one'], $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertForbidden(); $this->people['review-two']->update(['enabled' => false]); $this->assertFalse(app(CourierQuoteWorkflowPermissions::class)->can('reviewers', $this->people['review-two']->id)); $this->loginAs($this->people['review-two'], $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertRedirect(); $this->assertDatabaseCount('business_courier_quote_versions', 0); } public function test_self_review_stays_blocked_even_when_both_roles_are_assigned(): void { app(CourierQuoteWorkflowPermissions::class)->save(['initiators' => ['init-one'], 'reviewers' => ['init-one', 'review-one']], 'test admin', $this->user->id); $change = $this->pending(); config(['courier_onboarding.allow_same_reviewer' => true]); $this->withExceptionHandling()->post(route('business.courier-quote-changes.approve', $change))->assertSessionHasErrors('approval'); $this->assertDatabaseCount('business_courier_quote_versions', 0); } public function test_only_explicit_named_reviewer_can_review_own_change_and_grant_survives_settings_save(): void { $permissions = app(CourierQuoteWorkflowPermissions::class); $groups = ['initiators' => ['init-one', 'init-two'], 'reviewers' => ['init-one'], 'self_reviewers' => ['init-one']]; $permissions->save($groups, 'explicit authorization', $this->user->id); $change = $this->pending(); $this->get(route('business.courier-quote-changes.show', $change))->assertOk() ->assertSeeText('可审核本人发起的变价')->assertSeeText('审核通过')->assertSeeText('撤回修改')->assertSeeText('导出变价明细'); $this->get(route('business.courier-quote-changes.index'))->assertOk()->assertSee('name="ids[]"', false); $this->withExceptionHandling()->loginAs($this->people['review-one'], $this->company) ->post(route('business.courier-quote-changes.approve', $change))->assertForbidden(); $this->loginAs()->post(route('business.courier-quote-changes.approve', $change))->assertForbidden(); unset($groups['self_reviewers']); $this->patch(route('system-settings.workflow-reviewers.quote.update'), $groups)->assertRedirect(); $this->assertTrue($permissions->allowsSelfReview($this->people['init-one']->id)); $this->loginAs($this->people['init-one'], $this->company)->post(route('business.courier-quote-changes.approve', $change))->assertRedirect(); $this->assertSame('approved', $change->fresh()->status); $permissions->save(['initiators' => ['init-one', 'init-two'], 'reviewers' => ['review-one']], 'revoke', $this->user->id); $this->assertFalse($permissions->allowsSelfReview($this->people['init-one']->id)); $this->assertSame([], $permissions->settings()['self_reviewers']); } public function test_withdraw_requires_current_initiator_and_owner_then_allows_edit_and_resubmit(): void { $change = $this->pending(); $this->withExceptionHandling()->loginAs($this->people['init-two'], $this->company) ->post(route('business.courier-quote-changes.withdraw', $change))->assertForbidden(); $this->loginAs($this->people['review-one'], $this->company) ->get(route('business.courier-quote-changes.show', $change))->assertOk()->assertDontSee('action="' . route('business.courier-quote-changes.withdraw', $change) . '"', false); $this->post(route('business.courier-quote-changes.withdraw', $change))->assertForbidden(); $permissions = app(CourierQuoteWorkflowPermissions::class); $permissions->save(['initiators' => ['init-two'], 'reviewers' => ['review-one']], 'revoke initiator', $this->user->id); $this->loginAs($this->people['init-one'], $this->company) ->post(route('business.courier-quote-changes.withdraw', $change))->assertForbidden(); $permissions->save($this->permissionGroups(), 'restore', $this->user->id); $this->post(route('business.courier-quote-changes.withdraw', $change))->assertRedirect(route('business.courier-quote-changes.edit', $change)); $draft = $change->fresh(['items', 'events']); $this->assertSame('draft', $draft->status); $this->assertNull($draft->submitted_at); $this->assertNull($draft->submitted_by); $this->assertSame('6.0000', $draft->items->first()->new_amount); $this->assertSame('withdrawn', $draft->events->last()->event_type); $this->assertSame('撤回修改', $draft->events->last()->eventLabel()); $this->post(route('business.courier-quote-changes.withdraw', $change))->assertSessionHasErrors('status'); try { app(\App\Support\CourierQuoteChangeService::class)->approve($change, $this->people['review-one']->id); $this->fail('Stale pending model must not approve withdrawn draft'); } catch (\Illuminate\Validation\ValidationException $exception) { $this->assertArrayHasKey('status', $exception->errors()); } $payload = $this->payload(); $payload['items'][0]['new_amount'] = '6.1234'; $this->patch(route('business.courier-quote-changes.update', $draft), $payload)->assertRedirect(); $this->post(route('business.courier-quote-changes.submit', $draft))->assertRedirect(); $pending = $draft->fresh(); $this->loginAs($this->people['review-one'], $this->company) ->post(route('business.courier-quote-changes.approve', $pending))->assertRedirect(); $this->loginAs($this->people['init-one'], $this->company) ->post(route('business.courier-quote-changes.withdraw', $pending))->assertSessionHasErrors('status'); $this->get(route('business.courier-quote-changes.show', $change))->assertOk()->assertDontSee('action="' . route('business.courier-quote-changes.withdraw', $change) . '"', false); $this->assertSame('approved', $change->fresh()->status); $this->assertSame('6.1234', $change->fresh()->items->first()->new_amount); } public function test_onboarded_courier_requote_entry_and_submission_use_the_same_initiator_list(): void { $quote = ['quote_mode' => 'waybill_rate', 'volumetric_rule' => '计泡', 'volumetric_divisor' => 6000, 'quote_effective_date' => now()->addDay()->toDateString(), 'quote_rows' => [['region' => '江苏', 'base' => '3.2', 'rates' => ['0.8']]]]; $onboarding = CourierOnboarding::create([ 'company_id' => $this->company->id, 'number' => 'RZ-PERMISSIONS', 'token' => str_repeat('q', 40), 'status' => CourierOnboarding::STATUS_ACTIVATED, 'courier_name' => '权限测试快递', ...app(CourierOnboardingService::class)->quoteAttributes($quote), ]); $quote['quote_rows'][0]['base'] = '3.5'; $quote['summary'] = '测试变价发起人员权限'; foreach (['review-one', 'review-two'] as $id) { $this->withExceptionHandling()->loginAs($this->people[$id], $this->company) ->get(route('business.courier-onboardings.requote', $onboarding))->assertForbidden(); $this->post(route('business.courier-onboardings.requote.store', $onboarding), $quote)->assertForbidden(); } $this->assertDatabaseCount('business_courier_quote_changes', 0); $this->loginAs($this->people['init-two'], $this->company) ->get(route('business.courier-onboardings.requote', $onboarding))->assertOk(); $this->post(route('business.courier-onboardings.requote.store', $onboarding), $quote)->assertSessionHasNoErrors()->assertRedirect(); $change = CourierQuoteChange::firstOrFail(); $this->assertSame('pending_review', $change->status); $this->assertSame($this->people['init-two']->id, $change->created_by); $this->get(route('business.courier-quote-changes.export', $change))->assertOk()->assertHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $book = app(\App\Support\CourierQuoteChangeExport::class)->build($change); $this->assertSame('江苏', $book->getSheetByName('横向变价对比')->getCell('A9')->getValue()); $this->assertEquals(3.2, $book->getSheetByName('横向变价对比')->getCell('B9')->getValue()); $this->assertEquals(3.5, $book->getSheetByName('横向变价对比')->getCell('C9')->getValue()); $this->assertNotNull($book->getSheetByName('原报价全表')); $this->assertNotNull($book->getSheetByName('新报价全表')); $book->disconnectWorksheets(); $this->post(route('business.courier-quote-changes.withdraw', $change))->assertRedirect(); $this->get(route('business.courier-quote-changes.edit', $change))->assertRedirect(route('business.courier-onboardings.requote', $onboarding)); $this->get(route('business.courier-onboardings.requote', $onboarding))->assertOk()->assertSee('3.5'); $this->withExceptionHandling()->loginAs($this->people['init-one'], $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote)->assertForbidden(); $this->loginAs($this->people['init-two'], $this->company) ->post(route('business.courier-onboardings.requote.store', $onboarding), $quote)->assertSessionHasNoErrors()->assertRedirect(); $this->assertSame('pending_review', $change->fresh()->status); $this->assertDatabaseCount('business_courier_quote_changes', 1); } public function test_settings_can_be_saved_and_searched_only_by_administrators_and_are_audited(): void { $this->loginAs()->get(route('system-settings.workflow-reviewers.index'))->assertOk()->assertSeeText('报价变价发起和审核人员'); $this->patch(route('system-settings.workflow-reviewers.quote.update'), $this->permissionGroups())->assertRedirect(); $this->assertCount(2, app(CourierQuoteWorkflowPermissions::class)->history()); $this->getJson(route('system-settings.workflow-reviewers.search', ['q' => '审核', 'workflow' => 'quote']))->assertOk()->assertJsonPath('people.0.selectable', true); $this->withExceptionHandling()->loginAs($this->people['review-one'], $this->company) ->patch(route('system-settings.workflow-reviewers.quote.update'), $this->permissionGroups())->assertForbidden(); $this->getJson(route('system-settings.workflow-reviewers.search', ['q' => '审核', 'workflow' => 'quote']))->assertForbidden(); } public function test_disabled_departed_unbound_and_cross_company_candidates_fail_without_writing(): void { $this->eligible = false; $this->withExceptionHandling()->loginAs()->patch(route('system-settings.workflow-reviewers.quote.update'), $this->permissionGroups())->assertSessionHasErrors('initiators'); $this->eligible = true; $this->patch(route('system-settings.workflow-reviewers.quote.update'), ['initiators' => ['init-one'], 'reviewers' => ['unbound']])->assertSessionHasErrors('reviewers'); $this->patch(route('system-settings.workflow-reviewers.quote.update'), ['initiators' => ['init-one'], 'reviewers' => ['init-one']])->assertSessionHasErrors('reviewers'); $other = Company::create(['enabled' => true]); $other->makeCurrent(); $permissions = app(CourierQuoteWorkflowPermissions::class); $this->assertFalse($permissions->settings()['configured']); try { $permissions->save($this->permissionGroups(), 'other admin', null); $this->fail('Cross-company people must not be assigned'); } catch (\Illuminate\Validation\ValidationException $e) { $this->assertArrayHasKey('initiators', $e->errors()); } $this->assertCount(0, $permissions->history()); $this->company->makeCurrent(); $this->assertCount(1, $permissions->history()); } }