diff --git a/app/Http/Controllers/Business/CourierOnboardings.php b/app/Http/Controllers/Business/CourierOnboardings.php index abc412de..7d17a434 100644 --- a/app/Http/Controllers/Business/CourierOnboardings.php +++ b/app/Http/Controllers/Business/CourierOnboardings.php @@ -6,6 +6,7 @@ use App\Models\Business\CourierOnboarding; use App\Models\Business\CourierOnboardingEvent; use App\Models\Business\CourierOnboardingFile; use App\Support\CourierOnboardingService; +use App\Support\CourierOnboardingReviewers; use App\Support\QrCode; use App\Utilities\SystemAccess; use Illuminate\Http\RedirectResponse; @@ -68,6 +69,9 @@ class CourierOnboardings extends Controller report($exception); } $onboarding->load(['files', 'events.actor', 'quoteVersion.signature']); + $reviewers = app(CourierOnboardingReviewers::class); + $reviewAccess = in_array($onboarding->status, ['submitted', 'first_approved', 'returned'], true) + ? $reviewers->access($onboarding, (int) user_id()) : ['allowed' => false, 'message' => '当前申请无需审核。']; return view('business.pages.courier-payables.courier-onboarding-show', [ 'onboarding' => $onboarding, @@ -76,9 +80,10 @@ class CourierOnboardings extends Controller 'signedAgreement' => $this->service->signedAgreement($onboarding), 'quoteSignature' => $onboarding->quoteSignature(), 'esignReady' => $this->service->esignReady(), - 'canFinalReview' => $this->isAdmin() && ( - (int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer') - ), + 'canFirstReview' => $reviewAccess['allowed'], + 'canFinalReview' => $reviewAccess['allowed'], + 'reviewAccess' => $reviewAccess, + 'reviewerSettings' => $reviewers->settings(), 'isAdmin' => $this->isAdmin(), 'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(), ]); @@ -269,13 +274,12 @@ class CourierOnboardings extends Controller public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse { - $this->ensureAdmin(); $data = $request->validate([ 'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'], 'reconciler' => ['nullable', 'string', 'max:100'], 'note' => ['nullable', 'string', 'max:1000'], ], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']); - $this->service->finalApprove($onboarding, $data, (int) user_id()); + $onboarding = $this->service->finalApprove($onboarding, $data, (int) user_id()); if (! $onboarding->quote_version_id) { return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。'); diff --git a/app/Http/Controllers/SystemSettings/WorkflowReviewers.php b/app/Http/Controllers/SystemSettings/WorkflowReviewers.php new file mode 100644 index 00000000..d04bac8a --- /dev/null +++ b/app/Http/Controllers/SystemSettings/WorkflowReviewers.php @@ -0,0 +1,48 @@ +middleware(function ($request, $next) { + abort_unless(SystemAccess::canOpenSettings(), 403); + return $next($request); + }); + } + + public function index() + { + return view('system_settings.workflow-reviewers.index', [ + 'reviewers' => $this->reviewers->settings(), 'history' => $this->reviewers->history(), + ]); + } + + public function search(Request $request) + { + $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'); + } catch (\Throwable $e) { + return response()->json(['message' => '钉钉花名册暂时无法查询,请稍后重试。'], 503)->header('Cache-Control', 'no-store'); + } + } + + public function update(Request $request) + { + $data = $request->validate([ + 'first' => ['required', 'array', 'min:1', 'max:10'], + 'first.*' => ['required', 'string', 'max:100', 'distinct'], + 'final' => ['required', 'array', 'min:1', 'max:10'], + 'final.*' => ['required', 'string', 'max:100', 'distinct'], + ], ['first.required' => '请至少选择一名初审人员。', 'final.required' => '请至少选择一名复审人员。']); + $this->reviewers->save($data, user()); + return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '审核人员已保存,立即对待审核申请生效。'); + } +} diff --git a/app/Services/Auth/FinanceRoster.php b/app/Services/Auth/FinanceRoster.php index a6ee0c39..c106b27b 100644 --- a/app/Services/Auth/FinanceRoster.php +++ b/app/Services/Auth/FinanceRoster.php @@ -94,6 +94,20 @@ class FinanceRoster ]; } + /** Minimal, bounded directory search for workflow selection; no HR dataset is cached. */ + public function search(string $term): array + { + if (trim($term) === '') { + return []; + } + $like = '%' . str_replace(['!', '%', '_', '['], ['!!', '!%', '!_', '!['], trim($term)) . '%'; + return $this->queries->forSource('operations_replica')->select( + "SELECT TOP (12) flowerNameId, name, flowerName FROM rpt.hr_flowerName_book + WHERE leaveDate IS NULL AND flowerNameId IS NOT NULL + AND (name LIKE ? ESCAPE '!' OR flowerName LIKE ? ESCAPE '!') ORDER BY Id DESC", [$like, $like] + ); + } + private function isResigned(mixed $value): bool { $status = trim((string) $value); diff --git a/app/Support/CourierOnboardingReviewers.php b/app/Support/CourierOnboardingReviewers.php new file mode 100644 index 00000000..69ba74ca --- /dev/null +++ b/app/Support/CourierOnboardingReviewers.php @@ -0,0 +1,164 @@ +where('company_id', company_id())->where('key', self::KEY)->value('value'); + + return (json_decode($value ?? '', true) ?: []) + ['first' => [], 'final' => []]; + } + + 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, User $actor): void + { + $resolved = []; + $next = ['first' => [], 'final' => []]; + foreach (array_keys($next) as $stage) { + foreach ($data[$stage] as $dingId) { + if (! isset($resolved[$dingId])) { + $account = $this->account($dingId); + if (! $account) { + throw ValidationException::withMessages([$stage => '所选人员须有本公司启用的钉钉绑定账号,并拥有快递入驻页面权限。']); + } + $roster = $this->eligible($dingId); + if (! $roster) { + throw ValidationException::withMessages([$stage => '所选人员已离职、停用或不在当前财务准入范围,请重新选择。']); + } + $resolved[$dingId] = ['dingtalk_user_id' => $dingId, 'display_name' => $roster['display_name']]; + } + $next[$stage][] = $resolved[$dingId]; + } + } + // At least two distinct identities must be able to complete the workflow. + if (count($resolved) < 2) { + throw ValidationException::withMessages(['final' => '初审和复审不能仅配置同一人,请至少选择两名不同人员。']); + } + + DB::transaction(function () use ($next, $actor): void { + DB::table('companies')->where('id', company_id())->lockForUpdate()->first(); + $previous = $this->settings(); + $next['updated_at'] = now()->toIso8601String(); + $next['updated_by'] = $actor->name; + DB::table('settings')->updateOrInsert(['company_id' => company_id(), 'key' => self::KEY], [ + 'value' => json_encode($next, JSON_UNESCAPED_UNICODE), + ]); + DB::table('settings')->insert([ + 'company_id' => company_id(), 'key' => self::AUDIT_PREFIX . Str::ulid(), + 'value' => json_encode(['at' => $next['updated_at'], 'actor_id' => $actor->id, + 'actor' => $actor->name, 'before' => $previous, 'after' => $next], JSON_UNESCAPED_UNICODE), + ]); + }); + } + + public function search(string $term): array + { + return array_map(function (array $row): array { + $dingId = (string) $row['flowerNameId']; + $account = $this->account($dingId); + $eligible = $account ? $this->eligible($dingId) : null; + + return [ + 'id' => $dingId, + 'name' => trim((string) ($row['flowerName'] ?: $row['name'])), + 'account' => $account?->name, + 'selectable' => $eligible !== null, + 'reason' => ! $account ? '需先登录绑定钉钉、启用账号并开通快递入驻页面权限' + : ($eligible === null ? '不符合当前在职或财务准入条件' : '可选'), + ]; + }, $this->roster->search($term)); + } + + public function stage(CourierOnboarding $onboarding): string + { + return $onboarding->status === CourierOnboarding::STATUS_FIRST_APPROVED ? 'final' : 'first'; + } + + public function assertAllowed(CourierOnboarding $onboarding, int $userId, string $stage): void + { + abort_unless((int) $onboarding->company_id === (int) company_id(), 403); + $settings = $this->settings(); + if ($settings['first'] === [] || $settings['final'] === []) { + throw ValidationException::withMessages(['approval' => '尚未配置初审和复审人员,请管理员到系统设置 → 流程审核权限设置。']); + } + $identity = DingTalkIdentity::query()->where('user_id', $userId)->first(); + abort_unless($identity && in_array($identity->dingtalk_user_id, array_column($settings[$stage], 'dingtalk_user_id'), true) + && $this->account($identity->dingtalk_user_id)?->id === $userId, 403, '你不是当前环节的指定审核人员。'); + if ($stage === 'final' && (int) $onboarding->first_reviewed_by === $userId && ! $this->allowSameReviewer()) { + throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请另一名指定复审人员处理。']); + } + if (! $this->eligible($identity->dingtalk_user_id)) { + abort(403, '当前花名册身份已失效,不能审核。'); + } + } + + public function access(CourierOnboarding $onboarding, int $userId): array + { + $stage = $this->stage($onboarding); + try { + $this->assertAllowed($onboarding, $userId, $stage); + return ['allowed' => true, 'message' => '']; + } catch (ValidationException $e) { + return ['allowed' => false, 'message' => collect($e->errors())->flatten()->first()]; + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { + return ['allowed' => false, 'message' => $e->getMessage()]; + } + } + + private function account(string $dingId): ?User + { + $identities = DingTalkIdentity::query()->where('dingtalk_user_id', $dingId)->with('user')->limit(2)->get(); + if ($identities->count() !== 1) { + return null; + } + $user = $identities->first()->user; + if (! $user || ! $user->enabled || ! $user->companies()->where('companies.id', company_id())->exists()) { + return null; + } + $allowed = SidebarPermissions::allowedKeys($user); + if ($allowed !== null && ! array_intersect(['courier-payables', 'courier-payables/courier-onboarding'], $allowed)) { + return null; + } + return $user; + } + + private function eligible(string $dingId): ?array + { + try { + return $this->roster->findEligible($dingId); + } catch (\Throwable $e) { + // Do not expose connection details or use a stale HR grant. + throw ValidationException::withMessages(['approval' => '钉钉花名册暂时无法核验,请稍后重试。']); + } + } + + private function allowSameReviewer(): bool + { + return app()->environment('local', 'testing') && config('courier_onboarding.allow_same_reviewer'); + } +} diff --git a/app/Support/CourierOnboardingService.php b/app/Support/CourierOnboardingService.php index 6fb1b1ae..67e2db9c 100644 --- a/app/Support/CourierOnboardingService.php +++ b/app/Support/CourierOnboardingService.php @@ -161,40 +161,46 @@ class CourierOnboardingService public function firstApprove(CourierOnboarding $onboarding, int $userId, ?string $note): CourierOnboarding { - $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。'); + return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $note): CourierOnboarding { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED], '只有待初审的申请可以初审通过。'); - return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [ - 'first_reviewed_by' => $userId, - 'first_reviewed_at' => now(), - ]); + return $this->transition($onboarding, CourierOnboarding::STATUS_FIRST_APPROVED, 'first_approved', $note, $userId, [ + 'first_reviewed_by' => $userId, + 'first_reviewed_at' => now(), + ]); + }); } public function returnForChanges(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding { - $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。'); + return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED], '只有待审核的申请可以退回补件。'); - $expiresAt = $onboarding->expires_at; - if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) { - $expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay(); - } + $expiresAt = $onboarding->expires_at; + if ($expiresAt === null || $expiresAt->lt(now()->addDays(7))) { + $expiresAt = now()->addDays(self::DEFAULT_VALID_DAYS)->endOfDay(); + } - return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [ - 'return_reason' => $reason, - 'expires_at' => $expiresAt, - 'first_reviewed_by' => null, - 'first_reviewed_at' => null, - ]); + return $this->transition($onboarding, CourierOnboarding::STATUS_RETURNED, 'returned', $reason, $userId, [ + 'return_reason' => $reason, + 'expires_at' => $expiresAt, + 'first_reviewed_by' => null, + 'first_reviewed_at' => null, + ]); + }); } public function reject(CourierOnboarding $onboarding, int $userId, string $reason): CourierOnboarding { - $this->assertStatus($onboarding, [ - CourierOnboarding::STATUS_SUBMITTED, - CourierOnboarding::STATUS_FIRST_APPROVED, - CourierOnboarding::STATUS_RETURNED, - ], '当前状态不能标记为不予合作。'); + return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($userId, $reason): CourierOnboarding { + $this->assertStatus($onboarding, [ + CourierOnboarding::STATUS_SUBMITTED, + CourierOnboarding::STATUS_FIRST_APPROVED, + CourierOnboarding::STATUS_RETURNED, + ], '当前状态不能标记为不予合作。'); - return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId); + return $this->transition($onboarding, CourierOnboarding::STATUS_REJECTED, 'rejected', $reason, $userId); + }); } public function cancel(CourierOnboarding $onboarding, int $userId): CourierOnboarding @@ -244,16 +250,12 @@ class CourierOnboardingService */ public function finalApprove(CourierOnboarding $onboarding, array $data, int $userId): CourierOnboarding { - $this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。'); - if ((int) $onboarding->first_reviewed_by === $userId && ! config('courier_onboarding.allow_same_reviewer')) { - throw ValidationException::withMessages(['approval' => '初审人不能同时做财务复审,请换一位管理员复审。']); - } + return $this->review($onboarding, $userId, function (CourierOnboarding $onboarding) use ($data, $userId): CourierOnboarding { + $this->assertStatus($onboarding, [CourierOnboarding::STATUS_FIRST_APPROVED], '只有初审通过的申请可以复审。'); - // 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。 - $courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name); - $this->assertCourierNameAvailable($onboarding, $courierName); - - return DB::transaction(function () use ($onboarding, $data, $courierName, $userId): CourierOnboarding { + // 系统快递名称在生成二维码时由我方定好;复审只确认,不再按快递填写的内容推断。 + $courierName = trim((string) ($data['courier_name'] ?? '')) ?: trim((string) $onboarding->courier_name); + $this->assertCourierNameAvailable($onboarding, $courierName); $onboarding->forceFill([ 'courier_name' => $courierName, 'reconciler' => filled($data['reconciler'] ?? null) ? trim((string) $data['reconciler']) : $onboarding->reconciler, @@ -275,6 +277,20 @@ class CourierOnboardingService }); } + private function review(CourierOnboarding $onboarding, int $userId, \Closure $action): CourierOnboarding + { + return DB::transaction(function () use ($onboarding, $userId, $action): CourierOnboarding { + // Serialize all review outcomes on the current row, including stale/repeated requests. + $current = CourierOnboarding::query()->disableCache()->lockForUpdate()->findOrFail($onboarding->id); + $this->assertStatus($current, [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, + CourierOnboarding::STATUS_RETURNED], '申请已由其他人员处理,请刷新页面查看最新状态。'); + $reviewers = app(CourierOnboardingReviewers::class); + $reviewers->assertAllowed($current, $userId, $reviewers->stage($current)); + + return $action($current); + }); + } + public function activate(CourierOnboarding $onboarding, int $userId, ?string $offlineNote): CourierOnboarding { $this->assertStatus($onboarding, [CourierOnboarding::STATUS_APPROVED], '只有复审通过的申请可以开通。'); diff --git a/config/courier_onboarding.php b/config/courier_onboarding.php index 9130d608..8eb5fb45 100644 --- a/config/courier_onboarding.php +++ b/config/courier_onboarding.php @@ -5,6 +5,6 @@ return [ // 生产填公网地址(如 https://caiwu.jiansuntuzhi.cn:8442),保证内网生成的二维码手机也能打开。 'public_url' => env('COURIER_ONBOARDING_PUBLIC_URL'), - // 是否允许初审人自己做财务复审。默认不允许(两人把关);测试/预览环境可打开方便一个人走完流程。 + // 仅 local/testing 环境允许初审人自己复审;生产始终由不同人员完成。 'allow_same_reviewer' => (bool) env('COURIER_ONBOARDING_ALLOW_SAME_REVIEWER', false), ]; diff --git a/docs/快递入驻审核权限.md b/docs/快递入驻审核权限.md new file mode 100644 index 00000000..22340c95 --- /dev/null +++ b/docs/快递入驻审核权限.md @@ -0,0 +1,13 @@ +# 快递入驻审核人员 + +管理员在「系统设置 → 流程审核权限」按姓名或花名查询钉钉花名册,分别加入初审、复审名单并保存。每环节至少 1 人、最多 10 人。 + +- 各环节任意一名指定人员通过即可,不是所有人会签;复审通过后沿用生成首版报价、自动推送 e签宝的流程。 +- 同一申请的初审人与复审人必须不同。两份名单可以有交集,但不能只配置同一人;正式环境不能通过测试开关绕过。 +- 所选人员须已绑定钉钉、本公司账号已启用且具有快递入驻页面权限;保存和实际审核时均核验当前花名册在职及现有财务准入条件。选人不授予管理员角色,也不扩大钉钉登录组织范围。 +- 退回补件、不予合作由当前环节的指定人员处理;退回后重新提交,从初审重新开始。报价重推和开通仍由管理员处理。 +- 未配置两个环节时暂停审核,页面会提示先设置,不自动放行所有管理员。上线不会根据截图姓名自动分配生产权限。 +- 配置即时影响尚未完成的审核,不改已完成审核的人员、时间、意见;页面展示当前名单,处理记录展示实际操作人。 +- 花名册不可用时暂不能保存或审核,恢复后重试;不使用过期花名册授权。 + +配置与变更记录使用现有公司级 `settings`,不新增数据库迁移。不保存完整花名册,只保存指定人员的唯一身份和显示名。审核操作按申请行加锁后重新核对当前状态,重复或过期请求不会再次生成报价。 diff --git a/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php b/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php index 8121d872..121ae8e4 100644 --- a/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php +++ b/resources/views/business/pages/courier-payables/courier-onboarding-show.blade.php @@ -235,6 +235,15 @@