..., 'code'=>...] 형태로 반환 */ protected function v(array $params, array $rules) { $v = Validator::make($params, $rules); if ($v->fails()) { return ['error' => $v->errors()->first(), 'code' => 422]; } return $v->validated(); } /** 목록 */ public function index(array $params) { $p = $this->v($params, [ 'q' => 'nullable|string|max:100', 'is_active' => 'nullable|in:0,1', 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:200', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } $q = Department::query(); if (isset($p['is_active'])) { $q->where('is_active', (int) $p['is_active']); } if (! empty($p['q'])) { $q->where(function ($w) use ($p) { $w->where('name', 'like', '%'.$p['q'].'%') ->orWhere('code', 'like', '%'.$p['q'].'%'); }); } $q->orderBy('sort_order')->orderBy('name'); $perPage = $p['per_page'] ?? 20; $page = $p['page'] ?? null; return $q->paginate($perPage, ['*'], 'page', $page); } /** 부서 트리 조회 */ public function tree(array $params = []): array { $p = $this->v($params, [ 'with_users' => 'nullable|in:0,1,true,false', ]); if (isset($p['error'])) { return $p; } $withUsers = filter_var($p['with_users'] ?? false, FILTER_VALIDATE_BOOLEAN); // 최상위 부서 조회 (parent_id가 null인 부서) $query = Department::query() ->whereNull('parent_id') ->orderBy('sort_order') ->orderBy('name'); // 재귀적으로 자식 부서 로드 $query->with(['children' => function ($q) use ($withUsers) { $q->orderBy('sort_order')->orderBy('name'); $this->loadChildrenRecursive($q, $withUsers); }]); // employees 관계 사용 (tenant_user_profiles 기반) if ($withUsers) { $query->with(['employees.user:id,name,email']); } $result = $query->get(); // users 형태로 변환 (프론트엔드 호환성) if ($withUsers) { $result = $this->transformEmployeesToUsers($result); } return $result->toArray(); } /** 재귀적으로 자식 부서 로드 */ private function loadChildrenRecursive($query, bool $withUsers): void { $query->with(['children' => function ($q) use ($withUsers) { $q->orderBy('sort_order')->orderBy('name'); $this->loadChildrenRecursive($q, $withUsers); }]); // employees 관계 사용 (tenant_user_profiles 기반) if ($withUsers) { $query->with(['employees.user:id,name,email']); } } /** employees를 users 형태로 변환 (재귀) */ private function transformEmployeesToUsers($departments) { return $departments->map(function ($dept) { // employees → users 변환 if ($dept->relationLoaded('employees')) { $users = $dept->employees->map(function ($profile) { return $profile->user; })->filter(); // null 제거 $dept->setRelation('users', $users); $dept->unsetRelation('employees'); } // 자식 부서도 재귀 처리 if ($dept->relationLoaded('children') && $dept->children->count() > 0) { $dept->setRelation('children', $this->transformEmployeesToUsers($dept->children)); } return $dept; }); } /** 생성 */ public function store(array $params) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $p = $this->v($params, [ 'parent_id' => 'nullable|integer|min:1', 'code' => 'nullable|string|max:50', 'name' => 'required|string|max:100', 'description' => 'nullable|string|max:255', 'is_active' => 'nullable|in:0,1', 'sort_order' => 'nullable|integer', 'created_by' => 'nullable|integer|min:1', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } // parent_id 유효성 검사 if (! empty($p['parent_id'])) { $parent = Department::query()->find($p['parent_id']); if (! $parent) { return ['error' => '상위 부서를 찾을 수 없습니다.', 'code' => 404]; } } if (! empty($p['code'])) { $exists = Department::query()->where('code', $p['code'])->exists(); if ($exists) { return ['error' => '이미 존재하는 부서 코드입니다.', 'code' => 409]; } } $dept = Department::create([ 'tenant_id' => $tenantId, 'parent_id' => $p['parent_id'] ?? null, 'code' => $p['code'] ?? null, 'name' => $p['name'], 'description' => $p['description'] ?? null, 'is_active' => isset($p['is_active']) ? (int) $p['is_active'] : 1, 'sort_order' => $p['sort_order'] ?? 0, 'created_by' => $userId ?? null, 'updated_by' => $userId ?? null, ]); return $dept->fresh(); } /** 단건 */ public function show(int $id, array $params = []) { if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $res = Department::query()->find($id); if (! $res) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } return $res; } /** 수정 */ public function update(int $id, array $params) { if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $p = $this->v($params, [ 'parent_id' => 'nullable|integer|min:0', // 0이면 최상위로 이동 'code' => 'nullable|string|max:50', 'name' => 'nullable|string|max:100', 'description' => 'nullable|string|max:255', 'is_active' => 'nullable|in:0,1', 'sort_order' => 'nullable|integer', 'updated_by' => 'nullable|integer|min:1', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } $dept = Department::query()->find($id); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } // parent_id 유효성 검사 if (array_key_exists('parent_id', $p)) { $parentId = $p['parent_id']; if ($parentId === 0) { $parentId = null; // 0이면 최상위로 이동 } elseif ($parentId) { if ($parentId === $id) { return ['error' => '자기 자신을 상위 부서로 설정할 수 없습니다.', 'code' => 422]; } $parent = Department::query()->find($parentId); if (! $parent) { return ['error' => '상위 부서를 찾을 수 없습니다.', 'code' => 404]; } } } if (array_key_exists('code', $p) && ! is_null($p['code'])) { $exists = Department::query() ->where('code', $p['code']) ->where('id', '!=', $id) ->exists(); if ($exists) { return ['error' => '이미 존재하는 부서 코드입니다.', 'code' => 409]; } } $fillData = [ 'code' => array_key_exists('code', $p) ? $p['code'] : $dept->code, 'name' => $p['name'] ?? $dept->name, 'description' => $p['description'] ?? $dept->description, 'is_active' => isset($p['is_active']) ? (int) $p['is_active'] : $dept->is_active, 'sort_order' => $p['sort_order'] ?? $dept->sort_order, 'updated_by' => $p['updated_by'] ?? $dept->updated_by, ]; // parent_id 업데이트 if (array_key_exists('parent_id', $p)) { $fillData['parent_id'] = $p['parent_id'] === 0 ? null : $p['parent_id']; } $dept->fill($fillData)->save(); return $dept->fresh(); } /** 삭제(soft) */ public function destroy(int $id, array $params) { if (! $id) { return ['error' => 'id가 필요합니다.', 'code' => 400]; } $p = $this->v($params, [ 'deleted_by' => 'nullable|integer|min:1', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } $dept = Department::query()->find($id); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } if (! empty($p['deleted_by'])) { $dept->deleted_by = $p['deleted_by']; $dept->save(); } $dept->delete(); return ['id' => $id, 'deleted_at' => now()->toDateTimeString()]; } /** 부서 사용자 목록 */ public function listUsers(int $deptId, array $params) { $p = $this->v($params, [ 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:200', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } $dept = Department::query()->find($deptId); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } $builder = $dept->departmentUsers()->with('user') ->orderByDesc('is_primary')->orderBy('id'); $perPage = $p['per_page'] ?? 20; $page = $p['page'] ?? null; return $builder->paginate($perPage, ['*'], 'page', $page); } /** 사용자 배정 (단건) */ public function attachUser(int $deptId, array $params) { $p = $this->v($params, [ 'user_id' => 'required|integer|min:1', 'is_primary' => 'nullable|in:0,1', 'joined_at' => 'nullable|date', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } $dept = Department::query()->find($deptId); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } $result = DB::transaction(function () use ($dept, $p) { $tenantId = $this->tenantId(); $du = DepartmentUser::withTrashed() ->where('department_id', $dept->id) ->where('user_id', $p['user_id']) ->first(); if ($du && is_null($du->deleted_at)) { return ['error' => '이미 배정된 사용자입니다.', 'code' => 409]; } if (! empty($p['is_primary']) && (int) $p['is_primary'] === 1) { DepartmentUser::whereNull('deleted_at') ->where('user_id', $p['user_id']) ->update(['is_primary' => 0]); } $payload = [ 'department_id' => $dept->id, 'tenant_id' => $tenantId, 'user_id' => $p['user_id'], 'is_primary' => isset($p['is_primary']) ? (int) $p['is_primary'] : 0, 'joined_at' => ! empty($p['joined_at']) ? Carbon::parse($p['joined_at']) : now(), ]; if ($du) { $du->fill($payload); $du->restore(); $du->save(); } else { DepartmentUser::create($payload); } return ['department_id' => $dept->id, 'user_id' => $p['user_id']]; }); if ($result instanceof JsonResponse) { return $result; } return $result; } /** 사용자 제거(soft) */ public function detachUser(int $deptId, int $userId, array $params) { $du = DepartmentUser::whereNull('deleted_at') ->where('department_id', $deptId) ->where('user_id', $userId) ->first(); if (! $du) { return ['error' => '배정된 사용자를 찾을 수 없습니다.', 'code' => 404]; } $du->delete(); return [ 'user_id' => $userId, 'deleted_at' => now()->toDateTimeString(), ]; } /** 주부서 설정/해제 */ public function setPrimary(int $deptId, int $userId, array $params) { $p = $this->v($params, [ 'is_primary' => 'required|in:0,1', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } $result = DB::transaction(function () use ($deptId, $userId, $p) { $du = DepartmentUser::whereNull('deleted_at') ->where('department_id', $deptId) ->where('user_id', $userId) ->first(); if (! $du) { return ['error' => '배정된 사용자를 찾을 수 없습니다.', 'code' => 404]; } if ((int) $p['is_primary'] === 1) { DepartmentUser::whereNull('deleted_at') ->where('user_id', $userId) ->update(['is_primary' => 0]); } $du->is_primary = (int) $p['is_primary']; $du->save(); return ['user_id' => $userId, 'department_id' => $deptId, 'is_primary' => $du->is_primary]; }); if ($result instanceof JsonResponse) { return $result; } return $result; } /** 부서 권한 목록 */ public function listPermissions(int $deptId, array $params) { // 1) 파라미터 검증 $p = $this->v($params, [ 'menu_id' => 'nullable|integer|min:1', 'is_allowed' => 'nullable|in:0,1', 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:200', ]); if ($p instanceof JsonResponse) { return $p; } if (isset($p['error'])) { return $p; } // 2) 부서 확인 $dept = Department::query()->find($deptId); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } $tenantId = (int) $dept->tenant_id; $modelType = Department::class; // 3) ALLOW/DENY 서브쿼리 준비 (컬럼 형태 통일) $allowSub = DB::table('model_has_permissions') ->select([ 'permission_id', DB::raw('1 as is_allowed'), DB::raw('NULL as reason'), DB::raw('NULL as effective_from'), DB::raw('NULL as effective_to'), DB::raw('NULL as override_id'), DB::raw('NULL as override_updated_at'), ]) ->where([ 'model_type' => $modelType, 'model_id' => $deptId, 'tenant_id' => $tenantId, ]); $denySub = DB::table('permission_overrides') ->select([ 'permission_id', DB::raw('0 as is_allowed'), 'reason', 'effective_from', 'effective_to', 'id as override_id', 'updated_at as override_updated_at', ]) ->where([ 'tenant_id' => $tenantId, 'model_type' => $modelType, 'model_id' => $deptId, ]) ->where('effect', -1); // 4) 합치고 permissions 조인 $q = DB::query() ->fromSub(function ($sub) use ($allowSub, $denySub) { $sub->from($allowSub->unionAll($denySub), 'u'); }, 'u') ->join('permissions', 'permissions.id', '=', 'u.permission_id') ->select([ 'u.permission_id', 'u.is_allowed', 'permissions.name as permission_code', 'permissions.guard_name', 'u.reason', 'u.effective_from', 'u.effective_to', 'u.override_id', 'u.override_updated_at', ]); // 5) 필터 if (isset($p['is_allowed'])) { $q->where('u.is_allowed', (int) $p['is_allowed']); } if (isset($p['menu_id'])) { // 권한코드가 menu.{id}.xxx 형태라는 전제 $q->where('permissions.name', 'like', 'menu.'.(int) $p['menu_id'].'.%'); } // 6) 정렬(ALLOW 우선, 그 다음 permission_id 오름차순) $q->orderByDesc('u.is_allowed')->orderBy('u.permission_id'); // 7) 페이지네이션 $perPage = $p['per_page'] ?? 20; $page = $p['page'] ?? null; return $q->paginate($perPage, ['*'], 'page', $page); } /** 권한 부여/차단 upsert */ public function upsertPermissions(int $deptId, array $payload): array { // 단일이면 items로 감싸기 $items = isset($payload['permission_id']) ? [$payload] : ($payload['items'] ?? $payload); $v = Validator::make(['items' => $items], [ 'items' => 'required|array|max:1000', 'items.*.permission_id' => 'required|integer|min:1', 'items.*.is_allowed' => 'nullable|in:0,1', // 생략 시 1(허용) ]); if ($v->fails()) { return ['error' => $v->errors()->first(), 'code' => 422]; } $dept = Department::query()->find($deptId); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } $tenantId = (int) $dept->tenant_id; $modelType = Department::class; $ok = 0; $failed = []; DB::transaction(function () use ($items, $tenantId, $deptId, $modelType, &$ok, &$failed) { foreach ($items as $i => $r) { try { $permissionId = (int) $r['permission_id']; $isAllowed = array_key_exists('is_allowed', $r) ? (int) $r['is_allowed'] : 1; if ($isAllowed === 1) { // ALLOW: Spatie 표준에 넣고, 동일 권한의 DENY 제거 $exists = DB::table('model_has_permissions')->where([ 'permission_id' => $permissionId, 'model_type' => $modelType, 'model_id' => $deptId, 'tenant_id' => $tenantId, ])->exists(); if (! $exists) { DB::table('model_has_permissions')->insert([ 'permission_id' => $permissionId, 'model_type' => $modelType, 'model_id' => $deptId, 'tenant_id' => $tenantId, ]); } DB::table('permission_overrides')->where([ 'tenant_id' => $tenantId, 'model_type' => $modelType, 'model_id' => $deptId, 'permission_id' => $permissionId, 'effect' => -1, ])->delete(); } else { // DENY: overrides(effect=-1) upsert, 그리고 ALLOW 제거 $exists = DB::table('permission_overrides')->where([ 'tenant_id' => $tenantId, 'model_type' => $modelType, 'model_id' => $deptId, 'permission_id' => $permissionId, ])->exists(); if ($exists) { DB::table('permission_overrides')->where([ 'tenant_id' => $tenantId, 'model_type' => $modelType, 'model_id' => $deptId, 'permission_id' => $permissionId, ])->update(['effect' => -1, 'updated_at' => now()]); } else { DB::table('permission_overrides')->insert([ 'tenant_id' => $tenantId, 'model_type' => $modelType, 'model_id' => $deptId, 'permission_id' => $permissionId, 'effect' => -1, 'created_at' => now(), 'updated_at' => now(), ]); } DB::table('model_has_permissions')->where([ 'permission_id' => $permissionId, 'model_type' => $modelType, 'model_id' => $deptId, 'tenant_id' => $tenantId, ])->delete(); } $ok++; } catch (\Throwable $e) { $failed[] = [ 'index' => $i, 'permission_id' => $r['permission_id'] ?? null, 'message' => $e->getMessage(), ]; } } }); return [ 'processed' => count($items), 'succeeded' => $ok, 'failed' => $failed, ]; } /** 권한 제거 (menu_id 없으면 전체 제거) */ public function revokePermissions(int $deptId, array $payload): array { $items = isset($payload['permission_id']) ? [$payload] : ($payload['items'] ?? $payload); $v = Validator::make(['items' => $items], [ 'items' => 'required|array|max:1000', 'items.*.permission_id' => 'required|integer|min:1', ]); if ($v->fails()) { return ['error' => $v->errors()->first(), 'code' => 422]; } $dept = Department::query()->find($deptId); if (! $dept) { return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404]; } $tenantId = (int) $dept->tenant_id; $modelType = Department::class; $ok = 0; $failed = []; DB::transaction(function () use ($items, $tenantId, $deptId, $modelType, &$ok, &$failed) { foreach ($items as $i => $r) { try { $permissionId = (int) $r['permission_id']; // ALLOW 제거 DB::table('model_has_permissions')->where([ 'permission_id' => $permissionId, 'model_type' => $modelType, 'model_id' => $deptId, 'tenant_id' => $tenantId, ])->delete(); // DENY/임시허용 오버라이드 제거 DB::table('permission_overrides')->where([ 'tenant_id' => $tenantId, 'model_type' => $modelType, 'model_id' => $deptId, 'permission_id' => $permissionId, ])->delete(); $ok++; } catch (\Throwable $e) { $failed[] = [ 'index' => $i, 'permission_id' => $r['permission_id'] ?? null, 'message' => $e->getMessage(), ]; } } }); return [ 'processed' => count($items), 'succeeded' => $ok, 'failed' => $failed, ]; } }