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'); } }