CaiWuXiTong/tests/Feature/Business/CourierQuoteWorkflowPermissionsTest.php

282 lines
19 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierQuoteChange;
use App\Models\Common\Company;
use App\Services\Auth\FinanceRoster;
use App\Support\CourierQuoteWorkflowPermissions;
use App\Support\CourierOnboardingService;
use App\Support\QuoteManagementSnapshot;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class CourierQuoteWorkflowPermissionsTest extends FeatureTestCase
{
private string $fixturePath;
private array $people;
private bool $eligible = true;
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}