diff --git a/app/Http/Controllers/Api/V1/CategoryController.php b/app/Http/Controllers/Api/V1/CategoryController.php new file mode 100644 index 0000000..1b47506 --- /dev/null +++ b/app/Http/Controllers/Api/V1/CategoryController.php @@ -0,0 +1,58 @@ + $this->service->index($request->all()), '카테고리 목록 조회'); + } + + public function show(int $id) + { + return ApiResponse::handle(fn () => $this->service->show($id), '카테고리 단건 조회'); + } + + public function store(Request $request) + { + return ApiResponse::handle(fn () => $this->service->store($request->all()), '카테고리 생성'); + } + + public function update(int $id, Request $request) + { + return ApiResponse::handle(fn () => $this->service->update($id, $request->all()), '카테고리 수정'); + } + + public function destroy(int $id) + { + return ApiResponse::handle(fn () => $this->service->destroy($id), '카테고리 삭제'); + } + + public function toggle(int $id) + { + return ApiResponse::handle(fn () => $this->service->toggle($id), '카테고리 활성/비활성 토글'); + } + + public function move(int $id, Request $request) + { + return ApiResponse::handle(fn () => $this->service->move($id, $request->all()), '카테고리 이동'); + } + + public function reorder(Request $request) + { + return ApiResponse::handle(fn () => $this->service->reorder($request->all()), '카테고리 정렬순서 일괄 변경'); + } + + public function tree(Request $request) + { + return ApiResponse::handle(fn () => $this->service->tree($request->all()), '카테고리 트리 조회'); + } +} diff --git a/app/Models/Commons/Category.php b/app/Models/Commons/Category.php index 504bbdb..64a9bbb 100644 --- a/app/Models/Commons/Category.php +++ b/app/Models/Commons/Category.php @@ -23,6 +23,10 @@ class Category extends Model 'sort_order' => 'integer', ]; + protected $hidden = [ + 'deleted_by','deleted_at' + ]; + // 계층 public function parent() { return $this->belongsTo(self::class, 'parent_id'); } public function children() { return $this->hasMany(self::class, 'parent_id'); } diff --git a/app/Services/CategoryService.php b/app/Services/CategoryService.php new file mode 100644 index 0000000..41c7c9f --- /dev/null +++ b/app/Services/CategoryService.php @@ -0,0 +1,200 @@ +tenantId(); + + $page = (int)($params['page'] ?? 1); + $size = (int)($params['size'] ?? 20); + $q = trim((string)($params['q'] ?? '')); + $pid = $params['parent_id'] ?? null; + $onlyActive = $params['only_active'] ?? null; + + $query = Category::query()->where('tenant_id', $tenantId); + + if ($q !== '') { + $query->where(function ($qq) use ($q) { + $qq->where('name', 'like', "%{$q}%") + ->orWhere('code', 'like', "%{$q}%"); + }); + } + if ($pid !== null) { + $query->where('parent_id', (int)$pid); + } + if ($onlyActive !== null) { + $query->where('is_active', (int)!!$onlyActive); + } + + $query->orderBy('parent_id')->orderBy('sort_order')->orderBy('id'); + + return $query->paginate($size, ['*'], 'page', $page); + } + + /** 단건 */ + public function show(int $id) + { + $tenantId = $this->tenantId(); + $cat = Category::where('tenant_id', $tenantId)->find($id); + if (!$cat) throw new NotFoundHttpException(__('error.not_found')); + return $cat; + } + + /** 생성 */ + public function store(array $params) + { + $tenantId = $this->tenantId(); + $uid = $this->apiUserId(); + + $v = Validator::make($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|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]); + if ($v->fails()) throw new BadRequestHttpException($v->errors()->first()); + + $data = $v->validated(); + $data['tenant_id'] = $tenantId; + $data['created_by'] = $uid; + $data['is_active'] = (int)($data['is_active'] ?? 1); + $data['sort_order'] = $data['sort_order'] ?? 0; + + return Category::create($data); + } + + /** 수정 */ + public function update(int $id, array $params) + { + $tenantId = $this->tenantId(); + $uid = $this->apiUserId(); + + $cat = Category::where('tenant_id', $tenantId)->find($id); + if (!$cat) throw new NotFoundHttpException(__('error.not_found')); + + $v = Validator::make($params, [ + 'parent_id' => 'nullable|integer|min:1', + 'code' => 'nullable|string|max:50', + 'name' => 'sometimes|required|string|max:100', + 'description'=> 'nullable|string|max:255', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]); + if ($v->fails()) throw new BadRequestHttpException($v->errors()->first()); + + $payload = $v->validated(); + $payload['updated_by'] = $uid; + + $cat->update($payload); + return $cat->refresh(); + } + + /** 삭제(soft) */ + public function destroy(int $id) + { + $tenantId = $this->tenantId(); + $uid = $this->apiUserId(); + + $cat = Category::where('tenant_id', $tenantId)->find($id); + if (!$cat) throw new NotFoundHttpException(__('error.not_found')); + + // (옵션) 하위 존재 검사 + $hasChild = Category::where('tenant_id', $tenantId)->where('parent_id', $id)->exists(); + if ($hasChild) throw new BadRequestHttpException(__('error.child_exists')); + + $cat->deleted_by = $uid; + $cat->save(); + $cat->delete(); + + return 'success'; + } + + /** 활성/비활성 토글 */ + public function toggle(int $id) + { + $tenantId = $this->tenantId(); + $cat = Category::where('tenant_id', $tenantId)->find($id); + if (!$cat) throw new NotFoundHttpException(__('error.not_found')); + + $cat->is_active = $cat->is_active ? 0 : 1; + $cat->save(); + return $cat->refresh(); + } + + /** 부모/순서 이동 */ + public function move(int $id, array $params) + { + $tenantId = $this->tenantId(); + $v = Validator::make($params, [ + 'parent_id' => 'nullable|integer|min:1', + 'sort_order' => 'nullable|integer|min:0', + ]); + if ($v->fails()) throw new BadRequestHttpException($v->errors()->first()); + + $cat = Category::where('tenant_id', $tenantId)->find($id); + if (!$cat) throw new NotFoundHttpException(__('error.not_found')); + + $payload = $v->validated(); + $cat->update($payload); + return $cat->refresh(); + } + + /** 정렬순서 일괄 변경: [{id, sort_order}] */ + public function reorder(array $params) + { + $tenantId = $this->tenantId(); + $items = $params['items'] ?? null; + if (!is_array($items) || empty($items)) { + throw new BadRequestHttpException(__('validation.required', ['attribute' => 'items'])); + } + foreach ($items as $row) { + if (!isset($row['id'])) continue; + Category::where('tenant_id', $tenantId) + ->where('id', (int)$row['id']) + ->update(['sort_order' => (int)($row['sort_order'] ?? 0)]); + } + return 'success'; + } + + /** 트리 조회 (parent_id=null 기준 전체) */ + public function tree(array $params) + { + $tenantId = $this->tenantId(); + $onlyActive = (bool)($params['only_active'] ?? false); + + $q = Category::where('tenant_id', $tenantId) + ->when($onlyActive, fn($qq) => $qq->where('is_active', 1)) + ->orderBy('parent_id')->orderBy('sort_order')->orderBy('id') + ->get(['id','parent_id','code','name','is_active','sort_order']); + + $byParent = []; + foreach ($q as $c) { $byParent[$c->parent_id ?? 0][] = $c; } + + $build = function($pid) use (&$build, &$byParent) { + $nodes = $byParent[$pid] ?? []; + return array_map(function($n) use ($build) { + return [ + 'id' => $n->id, + 'code' => $n->code, + 'name' => $n->name, + 'is_active' => (int)$n->is_active, + 'sort_order' => (int)$n->sort_order, + 'children' => $build($n->id), + ]; + }, $nodes); + }; + + return $build(0); + } +} diff --git a/app/Swagger/v1/CategoryApi.php b/app/Swagger/v1/CategoryApi.php new file mode 100644 index 0000000..55bc3a3 --- /dev/null +++ b/app/Swagger/v1/CategoryApi.php @@ -0,0 +1,252 @@ +prefix('menus')->group(function () { - Route::get ('/', [MenuController::class, 'index'])->name('v1.menus.index'); - Route::get ('/{id}', [MenuController::class, 'show'])->name('v1.menus.show'); - Route::post ('/', [MenuController::class, 'store'])->name('v1.menus.store'); - Route::patch ('/{id}', [MenuController::class, 'update'])->name('v1.menus.update'); - Route::delete('/{id}', [MenuController::class, 'destroy'])->name('v1.menus.destroy'); - Route::post ('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder'); - Route::post ('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle'); + Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index'); + Route::get('/{id}', [MenuController::class, 'show'])->name('v1.menus.show'); + Route::post('/', [MenuController::class, 'store'])->name('v1.menus.store'); + Route::patch('/{id}', [MenuController::class, 'update'])->name('v1.menus.update'); + Route::delete('/{id}', [MenuController::class, 'destroy'])->name('v1.menus.destroy'); + Route::post('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder'); + Route::post('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle'); }); // Role API Route::prefix('roles')->group(function () { - Route::get ('/', [RoleController::class, 'index'])->name('v1.roles.index'); // view - Route::post ('/', [RoleController::class, 'store'])->name('v1.roles.store'); // create - Route::get ('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); // view - Route::patch ('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); // update - Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy');// delete + Route::get('/', [RoleController::class, 'index'])->name('v1.roles.index'); // view + Route::post('/', [RoleController::class, 'store'])->name('v1.roles.store'); // create + Route::get('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); // view + Route::patch('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); // update + Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy');// delete }); // Role Permission API Route::prefix('roles/{id}/permissions')->group(function () { - Route::get ('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); // list - Route::post ('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); // grant - Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); // revoke - Route::put ('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); // sync + Route::get('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); // list + Route::post('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); // grant + Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); // revoke + Route::put('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); // sync }); // User Role API Route::prefix('users/{id}/roles')->group(function () { - Route::get ('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list - Route::post ('/', [UserRoleController::class, 'grant'])->name('v1.users.roles.grant'); // grant - Route::delete('/', [UserRoleController::class, 'revoke'])->name('v1.users.roles.revoke'); // revoke - Route::put ('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync + Route::get('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list + Route::post('/', [UserRoleController::class, 'grant'])->name('v1.users.roles.grant'); // grant + Route::delete('/', [UserRoleController::class, 'revoke'])->name('v1.users.roles.revoke'); // revoke + Route::put('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync }); // Department API Route::prefix('departments')->group(function () { - Route::get ('', [DepartmentController::class, 'index'])->name('departments.index'); // 목록 - Route::post ('', [DepartmentController::class, 'store'])->name('departments.store'); // 생성 - Route::get ('/{id}', [DepartmentController::class, 'show'])->name('departments.show'); // 단건 - Route::patch ('/{id}', [DepartmentController::class, 'update'])->name('departments.update'); // 수정 - Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('departments.destroy'); // 삭제(soft) + Route::get('', [DepartmentController::class, 'index'])->name('departments.index'); // 목록 + Route::post('', [DepartmentController::class, 'store'])->name('departments.store'); // 생성 + Route::get('/{id}', [DepartmentController::class, 'show'])->name('departments.show'); // 단건 + Route::patch('/{id}', [DepartmentController::class, 'update'])->name('departments.update'); // 수정 + Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('departments.destroy'); // 삭제(soft) // 부서-사용자 - Route::get ('/{id}/users', [DepartmentController::class, 'listUsers'])->name('departments.users.index'); // 부서 사용자 목록 - Route::post ('/{id}/users', [DepartmentController::class, 'attachUser'])->name('departments.users.attach'); // 사용자 배정(주/부서) + Route::get('/{id}/users', [DepartmentController::class, 'listUsers'])->name('departments.users.index'); // 부서 사용자 목록 + Route::post('/{id}/users', [DepartmentController::class, 'attachUser'])->name('departments.users.attach'); // 사용자 배정(주/부서) Route::delete('/{id}/users/{user}', [DepartmentController::class, 'detachUser'])->name('departments.users.detach'); // 사용자 제거 - Route::patch ('/{id}/users/{user}/primary', [DepartmentController::class, 'setPrimary'])->name('departments.users.primary'); // 주부서 설정/해제 + Route::patch('/{id}/users/{user}/primary', [DepartmentController::class, 'setPrimary'])->name('departments.users.primary'); // 주부서 설정/해제 // 부서-권한 - Route::get ('/{id}/permissions', [DepartmentController::class, 'listPermissions'])->name('departments.permissions.index'); // 권한 목록 - Route::post ('/{id}/permissions', [DepartmentController::class, 'upsertPermissions'])->name('departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능) + Route::get('/{id}/permissions', [DepartmentController::class, 'listPermissions'])->name('departments.permissions.index'); // 권한 목록 + Route::post('/{id}/permissions', [DepartmentController::class, 'upsertPermissions'])->name('departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능) Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) }); @@ -191,33 +192,50 @@ // 테넌트 필드 설정 Route::prefix('fields')->group(function () { - Route::get ('', [TenantFieldSettingController::class, 'index'])->name('v1.fields.index'); // 필드 설정 목록(전역+테넌트 병합 효과값) - Route::put ('/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.fields.bulk'); // 필드 설정 대량 저장(트랜잭션 처리) - Route::patch ('/{key}', [TenantFieldSettingController::class, 'updateOne'])->name('v1.fields.update'); // 필드 설정 단건 수정/업데이트 + Route::get('', [TenantFieldSettingController::class, 'index'])->name('v1.fields.index'); // 필드 설정 목록(전역+테넌트 병합 효과값) + Route::put('/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.fields.bulk'); // 필드 설정 대량 저장(트랜잭션 처리) + Route::patch('/{key}', [TenantFieldSettingController::class, 'updateOne'])->name('v1.fields.update'); // 필드 설정 단건 수정/업데이트 }); // 옵션 그룹/값 Route::prefix('opt-groups')->group(function () { - Route::get ('', [TenantOptionGroupController::class, 'index'])->name('v1.opt-groups.index'); // 옵션 그룹 목록 - Route::post ('', [TenantOptionGroupController::class, 'store'])->name('v1.opt-groups.store'); // 옵션 그룹 생성 - Route::get ('/{id}', [TenantOptionGroupController::class, 'show'])->name('v1.opt-groups.show'); // 옵션 그룹 단건 조회 - Route::patch ('/{id}', [TenantOptionGroupController::class, 'update'])->name('v1.opt-groups.update'); // 옵션 그룹 수정 - Route::delete('/{id}', [TenantOptionGroupController::class, 'destroy'])->name('v1.opt-groups.destroy'); // 옵션 그룹 삭제 - Route::get ('/{gid}/values', [TenantOptionValueController::class, 'index'])->name('v1.opt-groups.values.index'); // 옵션 값 목록 - Route::post ('/{gid}/values', [TenantOptionValueController::class, 'store'])->name('v1.opt-groups.values.store'); // 옵션 값 생성 - Route::get ('/{gid}/values/{id}', [TenantOptionValueController::class, 'show'])->name('v1.opt-groups.values.show'); // 옵션 값 단건 조회 - Route::patch ('/{gid}/values/{id}', [TenantOptionValueController::class, 'update'])->name('v1.opt-groups.values.update'); // 옵션 값 수정 - Route::delete('/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy'])->name('v1.opt-groups.values.destroy'); // 옵션 값 삭제 - Route::patch ('/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder'])->name('v1.opt-groups.values.reorder'); // 옵션 값 정렬순서 재배치 + Route::get('', [TenantOptionGroupController::class, 'index'])->name('v1.opt-groups.index'); // 옵션 그룹 목록 + Route::post('', [TenantOptionGroupController::class, 'store'])->name('v1.opt-groups.store'); // 옵션 그룹 생성 + Route::get('/{id}', [TenantOptionGroupController::class, 'show'])->name('v1.opt-groups.show'); // 옵션 그룹 단건 조회 + Route::patch('/{id}', [TenantOptionGroupController::class, 'update'])->name('v1.opt-groups.update'); // 옵션 그룹 수정 + Route::delete('/{id}', [TenantOptionGroupController::class, 'destroy'])->name('v1.opt-groups.destroy'); // 옵션 그룹 삭제 + Route::get('/{gid}/values', [TenantOptionValueController::class, 'index'])->name('v1.opt-groups.values.index'); // 옵션 값 목록 + Route::post('/{gid}/values', [TenantOptionValueController::class, 'store'])->name('v1.opt-groups.values.store'); // 옵션 값 생성 + Route::get('/{gid}/values/{id}', [TenantOptionValueController::class, 'show'])->name('v1.opt-groups.values.show'); // 옵션 값 단건 조회 + Route::patch('/{gid}/values/{id}', [TenantOptionValueController::class, 'update'])->name('v1.opt-groups.values.update'); // 옵션 값 수정 + Route::delete('/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy'])->name('v1.opt-groups.values.destroy'); // 옵션 값 삭제 + Route::patch('/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder'])->name('v1.opt-groups.values.reorder'); // 옵션 값 정렬순서 재배치 }); // 회원 프로필(테넌트 기준) Route::prefix('profiles')->group(function () { - Route::get ('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준) - Route::get ('/{userId}', [TenantUserProfileController::class, 'show'])->name('v1.profiles.show'); // 특정 사용자 프로필 조회 - Route::patch ('/{userId}', [TenantUserProfileController::class, 'update'])->name('v1.profiles.update'); // 특정 사용자 프로필 수정(관리자) - Route::get ('/me', [TenantUserProfileController::class, 'me'])->name('v1.profiles.me'); // 내 프로필 조회 - Route::patch ('/me', [TenantUserProfileController::class, 'updateMe'])->name('v1.profiles.me.update'); // 내 프로필 수정 + Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준) + Route::get('/{userId}', [TenantUserProfileController::class, 'show'])->name('v1.profiles.show'); // 특정 사용자 프로필 조회 + Route::patch('/{userId}', [TenantUserProfileController::class, 'update'])->name('v1.profiles.update'); // 특정 사용자 프로필 수정(관리자) + Route::get('/me', [TenantUserProfileController::class, 'me'])->name('v1.profiles.me'); // 내 프로필 조회 + Route::patch('/me', [TenantUserProfileController::class, 'updateMe'])->name('v1.profiles.me.update'); // 내 프로필 수정 + }); + + // Category API + Route::prefix('categories')->group(function () { + + // 확장 기능 + Route::get('/tree', [CategoryController::class, 'tree'])->name('v1.categories.tree'); // 트리 + Route::post('/reorder', [CategoryController::class, 'reorder'])->name('v1.categories.reorder'); // 정렬 일괄 + Route::post('/{id}/toggle', [CategoryController::class, 'toggle'])->name('v1.categories.toggle'); // 활성 토글 + Route::patch('/{id}/move', [CategoryController::class, 'move'])->name('v1.categories.move'); // 부모/순서 이동 + + // 기본 + Route::get('', [CategoryController::class, 'index'])->name('v1.categories.index'); // 목록(페이징) + Route::post('', [CategoryController::class, 'store'])->name('v1.categories.store'); // 생성 + Route::get('/{id}', [CategoryController::class, 'show'])->name('v1.categories.show'); // 단건 + Route::patch('/{id}', [CategoryController::class, 'update'])->name('v1.categories.update'); // 수정 + Route::delete('/{id}', [CategoryController::class, 'destroy'])->name('v1.categories.destroy'); // 삭제(soft) }); });