diff --git a/app/Http/Controllers/Business/CourierOnboardings.php b/app/Http/Controllers/Business/CourierOnboardings.php index 28165320..e0b73417 100644 --- a/app/Http/Controllers/Business/CourierOnboardings.php +++ b/app/Http/Controllers/Business/CourierOnboardings.php @@ -93,6 +93,7 @@ class CourierOnboardings extends Controller /** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */ public function requote(CourierOnboarding $onboarding): View|RedirectResponse { + app(\App\Support\CourierQuoteWorkflowPermissions::class)->assertInitiator((int) user_id()); $this->ensureReady(); $quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']); if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) { diff --git a/app/Http/Controllers/Business/CourierQuoteChanges.php b/app/Http/Controllers/Business/CourierQuoteChanges.php index 4c6adf34..0181575f 100644 --- a/app/Http/Controllers/Business/CourierQuoteChanges.php +++ b/app/Http/Controllers/Business/CourierQuoteChanges.php @@ -6,6 +6,7 @@ use App\Models\Business\CourierQuoteChange; use App\Models\Business\CourierStockQuoteEntry; use App\Services\Esign\EsignClient; use App\Support\CourierQuoteChangeService; +use App\Support\CourierQuoteWorkflowPermissions; use App\Support\CourierOnboardingService; use App\Support\CourierQuoteSignatureService; use App\Support\CourierRequoteService; @@ -38,9 +39,14 @@ class CourierQuoteChanges extends Controller ->paginate(20) ->withQueryString(); + $quoteReviewer = $this->canReview(); + $admin = $this->canAdminister(); + $reviewableIds = $changes->filter(fn ($change) => $change->isStockQuote() ? $admin : $quoteReviewer)->pluck('id')->all(); + return view('business.pages.courier-payables.quote-change-history', [ 'changes' => $changes, - 'canReview' => $this->canReview(), + 'canReview' => $quoteReviewer || $admin, + 'reviewableChangeIds' => $reviewableIds, 'filters' => $filters, 'carrierOptions' => CourierQuoteChange::query()->whereNotNull('carrier_name')->distinct()->orderBy('carrier_name')->pluck('carrier_name')->all(), 'changeTypeOptions' => CourierQuoteChange::query()->whereNotNull('change_type')->distinct()->orderBy('change_type')->pluck('change_type')->all(), @@ -68,6 +74,7 @@ class CourierQuoteChanges extends Controller public function create(Request $request): View|RedirectResponse { + app(CourierQuoteWorkflowPermissions::class)->assertInitiator((int) user_id()); $quotes = $this->snapshot->load()['quotes'] ?? []; abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。'); @@ -99,6 +106,8 @@ class CourierQuoteChanges extends Controller $requoteDiff = $onboarding !== null ? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload) : null; + $reviewAllowed = $this->canReview($quoteChange); + $quoteWorkflow = app(CourierQuoteWorkflowPermissions::class)->settings(); return view('business.pages.courier-payables.quote-change-show', [ 'change' => $quoteChange, @@ -109,8 +118,11 @@ class CourierQuoteChanges extends Controller ? $this->requotes->reviewWarnings($quoteChange, $requoteDiff) : [], 'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null, - 'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')), - 'canManageSignature' => $this->canReview(), + 'canReview' => $reviewAllowed && ($quoteChange->created_by !== user_id() + || (! $quoteWorkflow['configured'] || $quoteChange->isStockQuote()) && config('courier_onboarding.allow_same_reviewer')), + 'canManageSignature' => $this->canAdminister() || $reviewAllowed, + 'canVoidQuote' => $this->canAdminister(), + 'quoteWorkflow' => $quoteWorkflow, 'esignConfigured' => EsignClient::configured(), 'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'), ]); @@ -118,6 +130,9 @@ class CourierQuoteChanges extends Controller public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse { + if (! $quoteChange->isStockQuote()) { + app(CourierQuoteWorkflowPermissions::class)->assertInitiator((int) user_id()); + } abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403); if ($quoteChange->isStockQuote()) { return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id')); @@ -151,7 +166,7 @@ class CourierQuoteChanges extends Controller public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { - abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_unless($this->canReview($quoteChange), 403, '当前账号没有报价审核权限。'); if ($quoteChange->isStockQuote()) { $request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']); } @@ -194,7 +209,7 @@ class CourierQuoteChanges extends Controller public function batchApprove(Request $request): RedirectResponse { - abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_unless($this->canReview() || $this->canAdminister(), 403, '当前账号没有报价审核权限。'); $input = $request->validate([ 'ids' => ['required', 'array', 'min:1', 'max:100'], 'ids.*' => ['required', 'integer', 'min:1', 'distinct'], @@ -231,7 +246,7 @@ class CourierQuoteChanges extends Controller /** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */ public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse { - abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_unless($this->canAdminister() || $this->canReview($quoteChange), 403, '当前账号没有报价审核权限。'); abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。'); $onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name); abort_if($onboarding === null || $quoteChange->version === null, 404); @@ -244,7 +259,7 @@ class CourierQuoteChanges extends Controller /** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */ public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { - abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_unless($this->canAdminister(), 403, '当前账号没有报价审核权限。'); abort_if($quoteChange->version === null, 404); $data = $request->validate( ['void_reason' => ['required', 'string', 'min:2', 'max:500']], @@ -257,7 +272,7 @@ class CourierQuoteChanges extends Controller public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse { - abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。'); + abort_unless($this->canReview($quoteChange), 403, '当前账号没有报价审核权限。'); $validated = $request->validate([ 'review_note' => ['required', 'string', 'min:2', 'max:1000'], ]); @@ -390,7 +405,13 @@ class CourierQuoteChanges extends Controller } } - private function canReview(): bool + private function canReview(?CourierQuoteChange $change = null): bool + { + return $change?->isStockQuote() ? $this->canAdminister() + : app(CourierQuoteWorkflowPermissions::class)->can('reviewers', (int) user_id()); + } + + private function canAdminister(): bool { return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true); } diff --git a/app/Http/Controllers/Business/CourierQuoteSignatures.php b/app/Http/Controllers/Business/CourierQuoteSignatures.php index fc0f6dff..4696b912 100644 --- a/app/Http/Controllers/Business/CourierQuoteSignatures.php +++ b/app/Http/Controllers/Business/CourierQuoteSignatures.php @@ -6,6 +6,7 @@ use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; use App\Services\Esign\EsignClient; use App\Support\CourierQuoteSignatureService; +use App\Support\CourierQuoteWorkflowPermissions; use App\Utilities\SystemAccess; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -22,7 +23,7 @@ class CourierQuoteSignatures extends Controller public function prepare(Request $request, CourierQuoteVersion $quoteVersion): RedirectResponse { - $this->ensureCanManage(); + $this->ensureCanManage($quoteVersion); abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成签署文件。'); $data = $request->validate([ 'signature_date' => ['required', 'date'], @@ -36,7 +37,7 @@ class CourierQuoteSignatures extends Controller public function send(Request $request, CourierQuoteSignature $quoteSignature): RedirectResponse { - $this->ensureCanManage(); + $this->ensureCanManage($quoteSignature->version); abort_unless($quoteSignature->version?->status === 'approved', 422, '只有已审核报价版本可以发起签署。'); abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。'); $data = $request->validate([ @@ -64,7 +65,7 @@ class CourierQuoteSignatures extends Controller public function synchronize(CourierQuoteSignature $quoteSignature): RedirectResponse { - $this->ensureCanManage(); + $this->ensureCanManage($quoteSignature->version); $signature = $this->service->synchronize($quoteSignature, user_id()); $message = $signature->status === CourierQuoteSignature::STATUS_ARCHIVED ? '签署完成,签署 PDF 和完成证书已私有归档。' @@ -311,10 +312,12 @@ class CourierQuoteSignatures extends Controller } } - private function ensureCanManage(): void + private function ensureCanManage(?CourierQuoteVersion $version = null): void { abort_unless( - in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true), + in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true) + || ($version?->change && ! $version->change->isStockQuote() + && app(CourierQuoteWorkflowPermissions::class)->can('reviewers', (int) user_id())), 403, '当前账号没有报价签署管理权限。', ); diff --git a/app/Http/Controllers/SystemSettings/WorkflowReviewers.php b/app/Http/Controllers/SystemSettings/WorkflowReviewers.php index d04bac8a..51daa349 100644 --- a/app/Http/Controllers/SystemSettings/WorkflowReviewers.php +++ b/app/Http/Controllers/SystemSettings/WorkflowReviewers.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\SystemSettings; use App\Support\CourierOnboardingReviewers; +use App\Support\CourierQuoteWorkflowPermissions; use App\Utilities\SystemAccess; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -21,6 +22,8 @@ class WorkflowReviewers extends Controller { return view('system_settings.workflow-reviewers.index', [ 'reviewers' => $this->reviewers->settings(), 'history' => $this->reviewers->history(), + 'quotePermissions' => app(CourierQuoteWorkflowPermissions::class)->settings(), + 'quoteHistory' => app(CourierQuoteWorkflowPermissions::class)->history(), ]); } @@ -28,7 +31,11 @@ class WorkflowReviewers extends Controller { $data = $request->validate(['q' => ['required', 'string', 'min:1', 'max:50']]); try { - return response()->json(['people' => $this->reviewers->search(trim($data['q']))])->header('Cache-Control', 'no-store'); + $people = $request->query('workflow') === 'quote' + ? app(CourierQuoteWorkflowPermissions::class)->search(trim($data['q'])) + : $this->reviewers->search(trim($data['q'])); + + return response()->json(['people' => $people])->header('Cache-Control', 'no-store'); } catch (\Throwable $e) { return response()->json(['message' => '钉钉花名册暂时无法查询,请稍后重试。'], 503)->header('Cache-Control', 'no-store'); } @@ -45,4 +52,17 @@ class WorkflowReviewers extends Controller $this->reviewers->save($data, user()); return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '审核人员已保存,立即对待审核申请生效。'); } + + public function updateQuote(Request $request) + { + $data = $request->validate([ + 'initiators' => ['required', 'array', 'min:1', 'max:10'], + 'initiators.*' => ['required', 'string', 'max:100', 'distinct'], + 'reviewers' => ['required', 'array', 'min:1', 'max:10'], + 'reviewers.*' => ['required', 'string', 'max:100', 'distinct'], + ]); + app(CourierQuoteWorkflowPermissions::class)->save($data, user()->name, (int) user_id()); + + return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '报价变价发起和审核人员已保存,立即对未完成变价生效。'); + } } diff --git a/app/Support/CourierQuoteChangeService.php b/app/Support/CourierQuoteChangeService.php index 30fcbfb6..447add93 100644 --- a/app/Support/CourierQuoteChangeService.php +++ b/app/Support/CourierQuoteChangeService.php @@ -18,6 +18,9 @@ final class CourierQuoteChangeService public function create(array $data, int $userId): CourierQuoteChange { + if (($data['change_type'] ?? '') !== '存量报价') { + app(CourierQuoteWorkflowPermissions::class)->assertInitiator($userId); + } if (str_starts_with($data['source_quote_id'], 'STOCK-') && ($data['change_type'] ?? '') !== '存量报价') { throw ValidationException::withMessages(['source_quote_id' => '存量报价请先完成首版审核,再从正式报价版本发起变价。']); } @@ -121,6 +124,7 @@ final class CourierQuoteChangeService public function returnForChanges(CourierQuoteChange $change, string $note, int $userId): CourierQuoteChange { + app(CourierQuoteWorkflowPermissions::class)->assertReviewer($change, $userId); if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { throw ValidationException::withMessages(['status' => '只有待审核的变更单可以退回。']); } @@ -135,6 +139,7 @@ final class CourierQuoteChangeService public function approve(CourierQuoteChange $change, int $userId, bool $historicalDocumentsConfirmed = false): CourierQuoteChange { + app(CourierQuoteWorkflowPermissions::class)->assertReviewer($change, $userId); if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) { throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过。']); } @@ -306,6 +311,9 @@ final class CourierQuoteChangeService private function ensureEditableBy(CourierQuoteChange $change, int $userId): void { + if (! $change->isStockQuote()) { + app(CourierQuoteWorkflowPermissions::class)->assertInitiator($userId); + } if (! $change->isEditable()) { throw ValidationException::withMessages(['status' => '当前状态不允许继续修改。']); } diff --git a/app/Support/CourierQuoteWorkflowPermissions.php b/app/Support/CourierQuoteWorkflowPermissions.php new file mode 100644 index 00000000..288f5a5c --- /dev/null +++ b/app/Support/CourierQuoteWorkflowPermissions.php @@ -0,0 +1,143 @@ +where('company_id', company_id())->where('key', self::KEY)->value('value'); + + $settings = json_decode($value ?? '', true); + $settings = is_array($settings) ? $settings : []; + + return ['configured' => $value !== null] + $settings + ['initiators' => [], 'reviewers' => []]; + } + + public function history(): array + { + return DB::table('settings')->where('company_id', company_id())->where('key', 'like', self::AUDIT_PREFIX . '%') + ->orderByDesc('key')->limit(10)->pluck('value')->map(fn ($value) => json_decode($value, true))->all(); + } + + public function save(array $data, string $actor, ?int $actorId): void + { + $next = ['initiators' => [], 'reviewers' => []]; + foreach (array_keys($next) as $stage) { + $ids = $data[$stage] ?? []; + if (! is_array($ids) || count($ids) < 1 || count($ids) > 10 || count(array_unique($ids)) !== count($ids)) { + throw ValidationException::withMessages([$stage => '每类请选择 1 至 10 名不同人员。']); + } + foreach ($ids as $id) { + $account = $this->account((string) $id); + $person = $account ? $this->eligible((string) $id) : null; + if (! $person) { + throw ValidationException::withMessages([$stage => '所选人员须为在职财务人员,已绑定启用账号并有报价管理页面权限。']); + } + $next[$stage][] = ['dingtalk_user_id' => (string) $id, 'display_name' => $person['display_name']]; + } + } + if (count(array_unique(array_merge($data['initiators'], $data['reviewers']))) < 2) { + throw ValidationException::withMessages(['reviewers' => '发起人不能自审,请至少配置两名不同人员。']); + } + DB::transaction(function () use ($next, $actor, $actorId): void { + abort_unless(DB::table('companies')->where('id', company_id())->lockForUpdate()->first(), 404); + $before = $this->settings(); + $next['updated_at'] = now()->toIso8601String(); + $next['updated_by'] = $actor; + DB::table('settings')->updateOrInsert(['company_id' => company_id(), 'key' => self::KEY], [ + 'value' => json_encode($next, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ]); + DB::table('settings')->insert(['company_id' => company_id(), 'key' => self::AUDIT_PREFIX . Str::ulid(), + 'value' => json_encode(['at' => $next['updated_at'], 'actor_id' => $actorId, 'actor' => $actor, + 'before' => $before, 'after' => $next], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]); + }); + } + + public function search(string $term): array + { + return array_map(function (array $row): array { + $id = (string) $row['flowerNameId']; + $account = $this->account($id); + $eligible = $account ? $this->eligible($id) : null; + + return ['id' => $id, 'name' => trim((string) ($row['flowerName'] ?: $row['name'])), + 'account' => $account?->name, 'selectable' => $eligible !== null, + 'reason' => $eligible ? '可选' : '须在职、绑定启用账号并有报价管理页面权限']; + }, $this->roster->search($term)); + } + + public function can(string $stage, int $userId): bool + { + $settings = $this->settings(); + if (! $settings['configured']) { + $user = User::find($userId); + + return $user && ($stage === 'initiators' || in_array(SystemAccess::roleName($user), [SystemAccess::ADMIN, SystemAccess::SUPER_ADMIN], true)); + } + $identity = DingTalkIdentity::query()->where('user_id', $userId)->first(); + if (! $identity || ! in_array($identity->dingtalk_user_id, array_column($settings[$stage] ?? [], 'dingtalk_user_id'), true) + || $this->account($identity->dingtalk_user_id)?->id !== $userId) { + return false; + } + + return $this->eligible($identity->dingtalk_user_id) !== null; + } + + public function assertInitiator(int $userId): void + { + abort_unless($this->can('initiators', $userId), 403, '你不是报价变价的指定发起人员。'); + } + + public function assertReviewer(CourierQuoteChange $change, int $userId): void + { + abort_unless((int) $change->company_id === (int) company_id(), 403); + $allowed = $change->isStockQuote() + ? in_array(SystemAccess::roleName(User::find($userId)), [SystemAccess::ADMIN, SystemAccess::SUPER_ADMIN], true) + : $this->can('reviewers', $userId); + abort_unless($allowed, 403, '你不是该报价变价的指定审核人员。'); + if (! $change->isStockQuote() && $this->settings()['configured'] && $change->created_by === $userId) { + throw ValidationException::withMessages(['approval' => '发起人不能审核自己提交的报价变更。']); + } + } + + private function account(string $id): ?User + { + $identities = DingTalkIdentity::query()->where('dingtalk_user_id', $id)->with('user')->limit(2)->get(); + $user = $identities->count() === 1 ? $identities->first()->user : null; + if (! $user || ! $user->enabled || ! $user->companies()->where('companies.id', company_id())->exists()) { + return null; + } + $keys = SidebarPermissions::allowedKeys($user); + + return $keys === null || array_intersect(['courier-payables', 'courier-payables/quote-management'], $keys) ? $user : null; + } + + private function eligible(string $id): ?array + { + try { + $person = $this->roster->findEligible($id); + + return $person && empty($person['restricted']) ? $person : null; + } catch (\Throwable) { + throw ValidationException::withMessages(['approval' => '钉钉花名册暂时无法核验,请稍后重试。']); + } + } +} diff --git a/app/Support/CourierRequoteService.php b/app/Support/CourierRequoteService.php index 5bc9dbbb..afc72e52 100644 --- a/app/Support/CourierRequoteService.php +++ b/app/Support/CourierRequoteService.php @@ -134,6 +134,7 @@ final class CourierRequoteService */ public function submit(CourierOnboarding $onboarding, array $data, int $userId): CourierQuoteChange { + app(CourierQuoteWorkflowPermissions::class)->assertInitiator($userId); if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) { throw ValidationException::withMessages(['status' => '快递开通后才能发起变价。']); } diff --git a/resources/views/business/pages/courier-payables/quote-change-editor.blade.php b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php index 197a73f1..9f538508 100644 --- a/resources/views/business/pages/courier-payables/quote-change-editor.blade.php +++ b/resources/views/business/pages/courier-payables/quote-change-editor.blade.php @@ -129,7 +129,7 @@
{{ $change ? '变价明细' : '第三步:编辑新价格' }}
仅新价格可编辑;系统会再次核对原价格,防止过期数据覆盖。
-
保存草稿不会让报价生效。提交审核后草稿将锁定,由另一位管理员审核并生成新版本。
+
保存草稿不会让报价生效。提交审核后草稿将锁定,由指定审核人员审核并生成新版本,发起人不能自审。
diff --git a/resources/views/business/pages/courier-payables/quote-change-history.blade.php b/resources/views/business/pages/courier-payables/quote-change-history.blade.php index 5db96cd2..077a3957 100644 --- a/resources/views/business/pages/courier-payables/quote-change-history.blade.php +++ b/resources/views/business/pages/courier-payables/quote-change-history.blade.php @@ -69,7 +69,7 @@
@if($canReview)@endif @foreach($changes as $change) @php($badge = match($change->status){'pending_review'=>'pending','approved'=>'approved','returned'=>'returned',default=>''}) - @if($canReview)@endif + @if($canReview)@endif @endforeach
变更单号原报价快递公司变更类型明细生效日期生效方式追溯核算状态新版本更新时间操作
@if($change->status === 'pending_review' && ($change->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')))@else—@endif{{ $change->number }}{{ $change->source_quote_id }}{{ $change->carrier_name }}{{ $change->change_type }}{{ $change->items->count() }} 项{{ $change->effective_date->toDateString() }}{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}{{ $change->recalculationStatusLabel() }}{{ $change->statusLabel() }}{{ $change->version?->version_code ?? '—' }}{{ $change->updated_at?->format('Y-m-d H:i') }}查看@if($change->isEditable() && $change->created_by===user_id()) · 编辑@endif
@if(in_array($change->id, $reviewableChangeIds, true) && $change->status === 'pending_review' && ($change->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')))@else—@endif{{ $change->number }}{{ $change->source_quote_id }}{{ $change->carrier_name }}{{ $change->change_type }}{{ $change->items->count() }} 项{{ $change->effective_date->toDateString() }}{{ $change->isRetrospective() ? '追溯变价' : '正常生效' }}{{ $change->recalculationStatusLabel() }}{{ $change->statusLabel() }}{{ $change->version?->version_code ?? '—' }}{{ $change->updated_at?->format('Y-m-d H:i') }}查看@if($change->isEditable() && $change->created_by===user_id()) · 编辑@endif
@if($canReview) diff --git a/resources/views/business/pages/courier-payables/quote-change-show.blade.php b/resources/views/business/pages/courier-payables/quote-change-show.blade.php index 5230df4e..ee4ac9a5 100644 --- a/resources/views/business/pages/courier-payables/quote-change-show.blade.php +++ b/resources/views/business/pages/courier-payables/quote-change-show.blade.php @@ -71,6 +71,9 @@ @endif
+ @if(!$change->isStockQuote() && ($quoteWorkflow['configured'] ?? false)) +
变价发起:{{ implode('、', array_column($quoteWorkflow['initiators'], 'display_name')) }};指定审核:{{ implode('、', array_column($quoteWorkflow['reviewers'], 'display_name')) }}。任意一名审核人通过即可,发起人不能自审。
+ @endif @if(is_string(session('success')))
{{ session('success') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @if($reviewWarnings)@endif @@ -191,7 +194,7 @@ @endif @endif - @if($canManageSignature && !$change->version->isVoided()) + @if($canVoidQuote && !$change->version->isVoided())
@csrf
{{ $quoteSignature && in_array($quoteSignature->status, ['archived','completed'], true) ? '快递已签署:作废只在我方停用此版本,回签单保留,之后请发起一版正确的变价让快递重新签。' : '未签完:作废会撤回快递 e签宝 里的待签任务,此版本不再生效。' }}
diff --git a/resources/views/system_settings/partials/quote-workflow-permissions.blade.php b/resources/views/system_settings/partials/quote-workflow-permissions.blade.php new file mode 100644 index 00000000..af6fa76f --- /dev/null +++ b/resources/views/system_settings/partials/quote-workflow-permissions.blade.php @@ -0,0 +1,47 @@ +
+

报价变价发起和审核人员

+

指定人员发起变价,由任意一名指定审核人通过即可。发起人不能审核自己的变价;该设置不改变快递入驻和存量报价补录流程。

+ @if(!$quotePermissions['configured'])

尚未单独配置报价变价人员,当前沿用原有权限;保存后按下方名单执行。

@endif + + @csrf @method('PATCH') + +
+ + +
+

人员须在职、已绑定启用账号并有报价管理页面权限,无需管理员身份。每类最多 10 人。

+

+
+
+ @foreach(['initiators' => '变价发起人员', 'reviewers' => '变价审核人员'] as $stage => $label) +
+

{{ $label }}

+
    + @foreach(old($stage, array_column($quotePermissions[$stage], 'dingtalk_user_id')) as $dingId) + @php($person = collect($quotePermissions[$stage])->firstWhere('dingtalk_user_id', $dingId)) +
  • + + {{ $person['display_name'] ?? '所选人员(保存时重新核验)' }} + +
  • + @endforeach +
+

{{ $stage === 'reviewers' ? '任意一人通过即可,其余人无需重复审核。' : '可创建、保存和提交本人发起的报价变价。' }}

+
+ @endforeach +
+
+ + 保存后立即对未完成变价生效,已完成的审核记录保持不变。 +
+ +

变价权限变更记录(最近 10 次)

+ @forelse($quoteHistory as $entry) +
+

{{ $entry['actor'] }} · {{ \Illuminate\Support\Carbon::parse($entry['at'])->format('Y-m-d H:i') }}

+

发起:{{ implode('、', array_column($entry['after']['initiators'], 'display_name')) }};审核:{{ implode('、', array_column($entry['after']['reviewers'], 'display_name')) }}

+
+ @empty +

尚无变更记录。

+ @endforelse +
diff --git a/resources/views/system_settings/partials/workflow-reviewers-script.blade.php b/resources/views/system_settings/partials/workflow-reviewers-script.blade.php index e8cac330..de3aa2d3 100644 --- a/resources/views/system_settings/partials/workflow-reviewers-script.blade.php +++ b/resources/views/system_settings/partials/workflow-reviewers-script.blade.php @@ -4,7 +4,7 @@ (() => { if (window.caiwuWorkflowReviewers) return; window.caiwuWorkflowReviewers = true; - const people = new Map(); + const peopleByForm = new WeakMap(); let pending; async function search(form) { const input = form.querySelector('[data-reviewer-search]'); @@ -22,7 +22,8 @@ const response = await fetch(url, {signal:controller.signal, credentials:'same-origin', headers:{Accept:'application/json'}}); if (!response.ok) throw new Error('unavailable'); const data = await response.json(); - people.clear(); + const people = new Map(); + peopleByForm.set(form, people); for (const person of data.people) { people.set(person.id, person); const row = document.createElement('div'); @@ -32,7 +33,8 @@ row.append(name); const actions = document.createElement('div'); actions.className = 'flex gap-3'; - for (const [stage, label] of [['first','加入初审'],['final','加入复审']]) { + const stages = form.dataset.reviewerStages ? JSON.parse(form.dataset.reviewerStages) : [['first','加入初审'],['final','加入复审']]; + for (const [stage, label] of stages) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.dataset.reviewerAdd = stage; button.dataset.reviewerId = person.id; @@ -53,7 +55,7 @@ if (!form) return; if (button.hasAttribute('data-reviewer-search-button')) { search(form); return; } if (button.hasAttribute('data-reviewer-remove')) { button.closest('li').remove(); return; } - const person = people.get(button.dataset.reviewerId); + const person = peopleByForm.get(form)?.get(button.dataset.reviewerId); if (!person?.selectable) return; const stage = button.dataset.reviewerAdd; const list = form.querySelector(`[data-reviewer-selected="${stage}"]`); diff --git a/resources/views/system_settings/workflow-reviewers/index.blade.php b/resources/views/system_settings/workflow-reviewers/index.blade.php index dd2e0e1b..95721fc0 100644 --- a/resources/views/system_settings/workflow-reviewers/index.blade.php +++ b/resources/views/system_settings/workflow-reviewers/index.blade.php @@ -43,6 +43,7 @@
+ @include('system_settings.partials.quote-workflow-permissions')

配置变更记录(最近 10 次)

@forelse ($history as $entry) diff --git a/routes/admin.php b/routes/admin.php index 4a6e4d76..33a69ecf 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1182,6 +1182,7 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi Route::get('workflow-reviewers', 'SystemSettings\WorkflowReviewers@index')->name('workflow-reviewers.index'); Route::get('workflow-reviewers/search', 'SystemSettings\WorkflowReviewers@search')->name('workflow-reviewers.search'); Route::patch('workflow-reviewers', 'SystemSettings\WorkflowReviewers@update')->name('workflow-reviewers.update'); + Route::patch('workflow-reviewers/quote', 'SystemSettings\WorkflowReviewers@updateQuote')->name('workflow-reviewers.quote.update'); Route::get('sidebar', 'SystemSettings\Sidebar@edit')->name('sidebar.edit'); Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update'); diff --git a/tests/Feature/Business/CourierQuoteWorkflowPermissionsTest.php b/tests/Feature/Business/CourierQuoteWorkflowPermissionsTest.php new file mode 100644 index 00000000..fd0eee28 --- /dev/null +++ b/tests/Feature/Business/CourierQuoteWorkflowPermissionsTest.php @@ -0,0 +1,200 @@ +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()); + } +}