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_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); } 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()); } }