isBoardMenuUrl($menu->url); } /** * 게시판 메뉴 URL 수동 생성/수정 방지 검증 * * @throws \InvalidArgumentException 게시판 URL 패턴 사용 시 */ public function validateNotBoardUrl(?string $url): void { if ($this->isBoardMenuUrl($url)) { throw new \InvalidArgumentException( '게시판 연동 URL 패턴(/customer-center/*, /boards/*)은 직접 사용할 수 없습니다. 게시판 관리에서 생성해주세요.' ); } } // ========================================================================= // 메뉴 목록 조회 // ========================================================================= /** * 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬 */ public function getMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator { $tenantId = session('selected_tenant_id'); $query = Menu::query()->withTrashed(); // 테넌트 필터링 if ($tenantId) { // 특정 테넌트 선택 시: 해당 테넌트의 메뉴만 $query->where('tenant_id', $tenantId); } else { // 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만 $query->whereNull('tenant_id'); } // Soft Delete 필터 if (isset($filters['trashed'])) { if ($filters['trashed'] === 'only') { $query->onlyTrashed(); } elseif ($filters['trashed'] === 'with') { $query->withTrashed(); } } // 검색 필터 if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('url', 'like', "%{$search}%"); }); } // 활성 상태 필터 if (isset($filters['is_active'])) { $query->where('is_active', $filters['is_active']); } // 부모 메뉴 필터 (트리 구조) if (isset($filters['parent_id'])) { if ($filters['parent_id'] === 'null') { $query->whereNull('parent_id'); } else { $query->where('parent_id', $filters['parent_id']); } } // 모든 메뉴 가져오기 (트리 구조 정렬을 위해) $allMenus = $query->with(['parent', 'tenant'])->orderBy('sort_order')->orderBy('id')->get(); // 트리 구조로 정렬 후 플랫한 배열로 변환 $flattenedMenus = $this->flattenMenuTree($allMenus); // 수동 페이지네이션 $currentPage = request()->input('page', 1); $offset = ($currentPage - 1) * $perPage; $items = $flattenedMenus->slice($offset, $perPage)->values(); return new \Illuminate\Pagination\LengthAwarePaginator( $items, $flattenedMenus->count(), $perPage, $currentPage, ['path' => request()->url(), 'query' => request()->query()] ); } /** * 트리 구조를 플랫한 배열로 변환 (depth 정보 포함) */ private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): Collection { $result = collect(); $filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order'); foreach ($filteredMenus as $menu) { $menu->depth = $depth; // 자식 메뉴 존재 여부 확인 $menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0; $result->push($menu); // 자식 메뉴 재귀적으로 추가 $children = $this->flattenMenuTree($menus, $menu->id, $depth + 1); $result = $result->merge($children); } return $result; } /** * 메뉴 상세 조회 */ public function getMenuById(int $id): ?Menu { return Menu::with(['parent', 'children'])->find($id); } /** * 메뉴 트리 구조로 조회 (전체) */ public function getMenuTree(?int $tenantId = null): Collection { $tenantId = $tenantId ?? session('selected_tenant_id'); $query = Menu::query() ->where('is_active', true) ->orderBy('sort_order') ->orderBy('id'); if ($tenantId) { // 특정 테넌트 선택 시: 해당 테넌트의 메뉴만 $query->where('tenant_id', $tenantId); } else { // 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만 $query->whereNull('tenant_id'); } $allMenus = $query->get(); // 부모 메뉴만 필터링하고 자식 메뉴를 재귀적으로 연결 return $allMenus->where('parent_id', null)->map(function ($menu) use ($allMenus) { $menu->children = $this->buildChildren($menu, $allMenus); return $menu; }); } /** * 재귀적으로 자식 메뉴 구성 */ private function buildChildren(Menu $parent, Collection $allMenus): Collection { $children = $allMenus->where('parent_id', $parent->id); return $children->map(function ($child) use ($allMenus) { $child->children = $this->buildChildren($child, $allMenus); return $child; }); } /** * 부모 메뉴 목록 조회 (드롭다운용) - 트리 구조 순서로 정렬, depth 정보 포함 */ public function getParentMenus(?int $tenantId = null): Collection { $tenantId = $tenantId ?? session('selected_tenant_id'); $query = Menu::query() ->where('is_active', true) ->orderBy('sort_order') ->orderBy('id'); if ($tenantId) { // 특정 테넌트 선택 시: 해당 테넌트의 메뉴만 $query->where('tenant_id', $tenantId); } else { // 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만 $query->whereNull('tenant_id'); } $allMenus = $query->get(); // 트리 구조로 정렬 (depth 정보 포함) return $this->flattenMenuTree($allMenus); } /** * 메뉴 생성 * * @throws \InvalidArgumentException 게시판 URL 패턴 사용 시 */ public function createMenu(array $data): Menu { // 게시판 URL 패턴 수동 생성 방지 $this->validateNotBoardUrl($data['url'] ?? null); $tenantId = session('selected_tenant_id'); // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; // hidden 처리 $data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1'; // is_external 처리 $data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1'; // 생성자 정보 $data['created_by'] = auth()->id(); // 테넌트 정보 if ($tenantId) { $data['tenant_id'] = $tenantId; } // parent_id null 처리 if (empty($data['parent_id'])) { $data['parent_id'] = null; } return Menu::create($data); } /** * 메뉴 수정 * * @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시 */ public function updateMenu(int $id, array $data): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } // 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용) if ($this->isBoardMenu($menu)) { throw new \InvalidArgumentException( '게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.' ); } // 새 URL이 게시판 URL 패턴이면 거부 $this->validateNotBoardUrl($data['url'] ?? null); // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; // hidden 처리 $data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1'; // is_external 처리 $data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1'; // 수정자 정보 $data['updated_by'] = auth()->id(); // parent_id null 처리 if (empty($data['parent_id'])) { $data['parent_id'] = null; } // 자기 자신을 부모로 설정하는 것 방지 if (isset($data['parent_id']) && $data['parent_id'] == $id) { return false; } return $menu->update($data); } /** * 메뉴 삭제 (Soft Delete) * * @throws \InvalidArgumentException 게시판 메뉴 삭제 시도 시 */ public function deleteMenu(int $id): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } // 게시판 연동 메뉴는 삭제 불가 if ($this->isBoardMenu($menu)) { throw new \InvalidArgumentException( '게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' ); } // 자식 메뉴가 있는 경우 삭제 불가 if ($menu->children()->count() > 0) { return false; } $menu->deleted_by = auth()->id(); $menu->save(); return $menu->delete(); } /** * 메뉴 복원 */ public function restoreMenu(int $id): bool { $menu = Menu::onlyTrashed()->findOrFail($id); return $menu->restore(); } /** * 메뉴 영구 삭제 (슈퍼관리자 전용) * - 연관 권한(permissions)도 함께 삭제 * - role_has_permissions, model_has_permissions는 FK CASCADE로 자동 삭제 * - 삭제 정보를 archived_records에 저장 * * @return array{success: bool, message: string, deleted_permissions: array} * * @throws \InvalidArgumentException 게시판 메뉴 영구 삭제 시도 시 */ public function forceDeleteMenu(int $id): array { $menu = Menu::withTrashed()->findOrFail($id); // 게시판 연동 메뉴는 영구 삭제 불가 if ($this->isBoardMenu($menu)) { throw new \InvalidArgumentException( '게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' ); } // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { return [ 'success' => false, 'message' => '자식 메뉴가 있어 삭제할 수 없습니다.', 'deleted_permissions' => [], ]; } return DB::transaction(function () use ($menu) { // 연관 권한 조회 (삭제 전 기록용) $permissions = Permission::where('name', 'like', "menu:{$menu->id}.%")->get(); $permissionData = $permissions->map(fn ($p) => [ 'id' => $p->id, 'name' => $p->name, 'guard_name' => $p->guard_name, 'tenant_id' => $p->tenant_id, ])->toArray(); // 역할-권한 연결 정보 조회 $rolePermissions = DB::table('role_has_permissions') ->join('roles', 'role_has_permissions.role_id', '=', 'roles.id') ->whereIn('role_has_permissions.permission_id', $permissions->pluck('id')) ->select('roles.id as role_id', 'roles.name as role_name', 'role_has_permissions.permission_id') ->get() ->toArray(); // 아카이브 레코드 생성 $batchId = (string) Str::uuid(); $archivedRecord = ArchivedRecord::create([ 'batch_id' => $batchId, 'batch_description' => "메뉴 영구 삭제: {$menu->name} (ID: {$menu->id})", 'record_type' => 'menu', 'tenant_id' => $menu->tenant_id, 'original_id' => $menu->id, 'main_data' => $menu->toArray(), 'schema_version' => '1.0', 'deleted_by' => auth()->id(), 'deleted_at' => now(), 'notes' => "메뉴 영구 삭제 - 연관 권한 {$permissions->count()}개 함께 삭제", ]); // 연관 권한 정보 저장 if ($permissions->isNotEmpty()) { ArchivedRecordRelation::create([ 'archived_record_id' => $archivedRecord->id, 'table_name' => 'permissions', 'data' => $permissionData, 'record_count' => $permissions->count(), ]); } // 역할-권한 연결 정보 저장 if (! empty($rolePermissions)) { ArchivedRecordRelation::create([ 'archived_record_id' => $archivedRecord->id, 'table_name' => 'role_has_permissions', 'data' => $rolePermissions, 'record_count' => count($rolePermissions), ]); } // 연관 권한 삭제 (FK CASCADE로 role_has_permissions, model_has_permissions 자동 삭제) Permission::where('name', 'like', "menu:{$menu->id}.%")->delete(); // 메뉴 영구 삭제 $menu->forceDelete(); return [ 'success' => true, 'message' => "메뉴 '{$menu->name}'와 연관 권한 {$permissions->count()}개가 삭제되었습니다.", 'deleted_permissions' => $permissionData, 'batch_id' => $batchId, ]; }); } /** * 메뉴 활성 상태 토글 */ public function toggleActive(int $id): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } $menu->is_active = ! $menu->is_active; $menu->updated_by = auth()->id(); return $menu->save(); } /** * 메뉴 숨김 상태 토글 */ public function toggleHidden(int $id): bool { $menu = $this->getMenuById($id); if (! $menu) { return false; } $menu->hidden = ! $menu->hidden; $menu->updated_by = auth()->id(); return $menu->save(); } /** * 메뉴 순서 변경 (드래그앤드롭) * 같은 parent_id 내에서만 순서 변경 */ public function reorderMenus(array $items): bool { return \DB::transaction(function () use ($items) { foreach ($items as $item) { Menu::where('id', $item['id']) ->update([ 'sort_order' => $item['sort_order'], 'updated_by' => auth()->id(), ]); } return true; }); } /** * 메뉴 이동 (계층 구조 변경) * - 다른 부모 아래로 이동 가능 * - 하위 메뉴는 자동으로 따라감 * - 순환 참조 방지 */ public function moveMenu(int $menuId, ?int $newParentId, int $sortOrder): array { $menu = Menu::find($menuId); if (! $menu) { return ['success' => false, 'message' => '메뉴를 찾을 수 없습니다.']; } // 순환 참조 방지: 자신의 하위 메뉴로 이동 불가 if ($newParentId !== null && $this->isDescendant($menuId, $newParentId)) { return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.']; } // 자기 자신을 부모로 설정 방지 if ($newParentId === $menuId) { return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.']; } return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) { $oldParentId = $menu->parent_id; // 부모 변경 $menu->parent_id = $newParentId; $menu->sort_order = $sortOrder; $menu->updated_by = auth()->id(); $menu->save(); // 같은 부모의 다른 메뉴들 순서 재정렬 $this->reorderSiblings($newParentId, $menu->id, $sortOrder); // 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기) if ($oldParentId !== $newParentId) { $this->compactSiblings($oldParentId); } return ['success' => true, 'message' => '메뉴가 이동되었습니다.']; }); } /** * 특정 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지) */ private function isDescendant(int $ancestorId, int $menuId): bool { $menu = Menu::find($menuId); while ($menu) { if ($menu->id === $ancestorId) { return true; } $menu = $menu->parent; } return false; } /** * 같은 부모의 형제 메뉴들 순서 재정렬 */ private function reorderSiblings(?int $parentId, int $excludeId, int $insertAt): void { $siblings = Menu::where('parent_id', $parentId) ->where('id', '!=', $excludeId) ->orderBy('sort_order') ->get(); $order = 1; foreach ($siblings as $sibling) { if ($order === $insertAt) { $order++; // 삽입 위치 건너뛰기 } if ($sibling->sort_order !== $order) { $sibling->update(['sort_order' => $order]); } $order++; } } /** * 형제 메뉴들 순서 압축 (빈 자리 채우기) */ private function compactSiblings(?int $parentId): void { $siblings = Menu::where('parent_id', $parentId) ->orderBy('sort_order') ->get(); $order = 1; foreach ($siblings as $sibling) { if ($sibling->sort_order !== $order) { $sibling->update(['sort_order' => $order]); } $order++; } } /** * 글로벌 부모 메뉴 목록 조회 (드롭다운용) - 트리 구조 순서로 정렬, depth 정보 포함 */ public function getGlobalParentMenus(): Collection { $allMenus = GlobalMenu::query() ->where('is_active', true) ->orderBy('sort_order') ->orderBy('id') ->get(); // 트리 구조로 정렬 (depth 정보 포함) return $this->flattenMenuTree($allMenus); } /** * 글로벌 메뉴 상세 조회 */ public function getGlobalMenuById(int $id): ?GlobalMenu { return GlobalMenu::with(['parent', 'children'])->find($id); } /** * 글로벌 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬 */ public function getGlobalMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator { $query = GlobalMenu::query()->withTrashed(); // Soft Delete 필터 if (isset($filters['trashed'])) { if ($filters['trashed'] === 'only') { $query->onlyTrashed(); } elseif ($filters['trashed'] === 'with') { $query->withTrashed(); } } // 검색 필터 if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") ->orWhere('url', 'like', "%{$search}%"); }); } // 활성 상태 필터 if (isset($filters['is_active'])) { $query->where('is_active', $filters['is_active']); } // 모든 메뉴 가져오기 (트리 구조 정렬을 위해) $allMenus = $query->with(['parent'])->orderBy('sort_order')->orderBy('id')->get(); // 트리 구조로 정렬 후 플랫한 배열로 변환 $flattenedMenus = $this->flattenMenuTree($allMenus); // 수동 페이지네이션 $currentPage = request()->input('page', 1); $offset = ($currentPage - 1) * $perPage; $items = $flattenedMenus->slice($offset, $perPage)->values(); return new \Illuminate\Pagination\LengthAwarePaginator( $items, $flattenedMenus->count(), $perPage, $currentPage, ['path' => request()->url(), 'query' => request()->query()] ); } /** * 글로벌 메뉴 생성 * * @throws \InvalidArgumentException 게시판 URL 패턴 사용 시 */ public function createGlobalMenu(array $data): GlobalMenu { // 게시판 URL 패턴 수동 생성 방지 $this->validateNotBoardUrl($data['url'] ?? null); // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; // hidden 처리 $data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1'; // is_external 처리 $data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1'; // parent_id null 처리 if (empty($data['parent_id'])) { $data['parent_id'] = null; } return GlobalMenu::create($data); } /** * 글로벌 메뉴 수정 * * @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시 */ public function updateGlobalMenu(int $id, array $data): bool { $menu = $this->getGlobalMenuById($id); if (! $menu) { return false; } // 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용) if ($this->isBoardMenu($menu)) { throw new \InvalidArgumentException( '게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.' ); } // 새 URL이 게시판 URL 패턴이면 거부 $this->validateNotBoardUrl($data['url'] ?? null); // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; // hidden 처리 $data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1'; // is_external 처리 $data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1'; // parent_id null 처리 if (empty($data['parent_id'])) { $data['parent_id'] = null; } // 자기 자신을 부모로 설정하는 것 방지 if (isset($data['parent_id']) && $data['parent_id'] == $id) { return false; } return $menu->update($data); } /** * 글로벌 메뉴 삭제 (Soft Delete) * * @throws \InvalidArgumentException 게시판 연동 메뉴인 경우 */ public function deleteGlobalMenu(int $id): bool { $menu = $this->getGlobalMenuById($id); if (! $menu) { return false; } // 게시판 연동 메뉴는 삭제 불가 if ($this->isBoardMenu($menu)) { throw new \InvalidArgumentException( '게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' ); } // 자식 메뉴가 있는 경우 삭제 불가 if ($menu->children()->count() > 0) { return false; } return $menu->delete(); } /** * 글로벌 메뉴 복원 */ public function restoreGlobalMenu(int $id): bool { $menu = GlobalMenu::onlyTrashed()->findOrFail($id); return $menu->restore(); } /** * 글로벌 메뉴 영구 삭제 * - 연관 권한(permissions)도 함께 삭제 * - 이 글로벌 메뉴를 참조하는 테넌트 메뉴들의 global_menu_id도 null 처리 * - 삭제 정보를 archived_records에 저장 * * @return array{success: bool, message: string, deleted_permissions: array} * * @throws \InvalidArgumentException 게시판 연동 메뉴인 경우 */ public function forceDeleteGlobalMenu(int $id): array { $menu = GlobalMenu::withTrashed()->findOrFail($id); // 게시판 연동 메뉴는 영구 삭제 불가 if ($this->isBoardMenu($menu)) { throw new \InvalidArgumentException( '게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' ); } // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { return [ 'success' => false, 'message' => '자식 메뉴가 있어 삭제할 수 없습니다.', 'deleted_permissions' => [], ]; } return DB::transaction(function () use ($menu) { // 연관 권한 조회 (삭제 전 기록용) $permissions = Permission::where('name', 'like', "global_menu:{$menu->id}.%")->get(); $permissionData = $permissions->map(fn ($p) => [ 'id' => $p->id, 'name' => $p->name, 'guard_name' => $p->guard_name, 'tenant_id' => $p->tenant_id, ])->toArray(); // 역할-권한 연결 정보 조회 $rolePermissions = DB::table('role_has_permissions') ->join('roles', 'role_has_permissions.role_id', '=', 'roles.id') ->whereIn('role_has_permissions.permission_id', $permissions->pluck('id')) ->select('roles.id as role_id', 'roles.name as role_name', 'role_has_permissions.permission_id') ->get() ->toArray(); // 참조하는 테넌트 메뉴 조회 $referencingMenus = Menu::withTrashed() ->where('global_menu_id', $menu->id) ->get(['id', 'tenant_id', 'name']) ->toArray(); // 아카이브 레코드 생성 $batchId = (string) Str::uuid(); $archivedRecord = ArchivedRecord::create([ 'batch_id' => $batchId, 'batch_description' => "글로벌 메뉴 영구 삭제: {$menu->name} (ID: {$menu->id})", 'record_type' => 'global_menu', 'tenant_id' => null, 'original_id' => $menu->id, 'main_data' => $menu->toArray(), 'schema_version' => '1.0', 'deleted_by' => auth()->id(), 'deleted_at' => now(), 'notes' => "글로벌 메뉴 영구 삭제 - 연관 권한 {$permissions->count()}개, 참조 테넌트 메뉴 ".count($referencingMenus).'개 해제', ]); // 연관 권한 정보 저장 if ($permissions->isNotEmpty()) { ArchivedRecordRelation::create([ 'archived_record_id' => $archivedRecord->id, 'table_name' => 'permissions', 'data' => $permissionData, 'record_count' => $permissions->count(), ]); } // 역할-권한 연결 정보 저장 if (! empty($rolePermissions)) { ArchivedRecordRelation::create([ 'archived_record_id' => $archivedRecord->id, 'table_name' => 'role_has_permissions', 'data' => $rolePermissions, 'record_count' => count($rolePermissions), ]); } // 참조 테넌트 메뉴 정보 저장 if (! empty($referencingMenus)) { ArchivedRecordRelation::create([ 'archived_record_id' => $archivedRecord->id, 'table_name' => 'menus (referencing)', 'data' => $referencingMenus, 'record_count' => count($referencingMenus), ]); } // 연관 권한 삭제 Permission::where('name', 'like', "global_menu:{$menu->id}.%")->delete(); // 이 글로벌 메뉴를 참조하는 테넌트 메뉴들의 참조 해제 Menu::withTrashed() ->where('global_menu_id', $menu->id) ->update(['global_menu_id' => null, 'is_customized' => true]); // 글로벌 메뉴 영구 삭제 $menu->forceDelete(); return [ 'success' => true, 'message' => "글로벌 메뉴 '{$menu->name}'와 연관 권한 {$permissions->count()}개가 삭제되었습니다.", 'deleted_permissions' => $permissionData, 'referencing_menus_unlinked' => count($referencingMenus), 'batch_id' => $batchId, ]; }); } /** * 글로벌 메뉴 활성 상태 토글 */ public function toggleGlobalActive(int $id): bool { $menu = $this->getGlobalMenuById($id); if (! $menu) { return false; } $menu->is_active = ! $menu->is_active; return $menu->save(); } /** * 글로벌 메뉴 숨김 상태 토글 */ public function toggleGlobalHidden(int $id): bool { $menu = $this->getGlobalMenuById($id); if (! $menu) { return false; } $menu->hidden = ! $menu->hidden; return $menu->save(); } /** * 글로벌 메뉴 순서 변경 */ public function reorderGlobalMenus(array $items): bool { return \DB::transaction(function () use ($items) { foreach ($items as $item) { GlobalMenu::where('id', $item['id']) ->update(['sort_order' => $item['sort_order']]); } return true; }); } /** * 글로벌 메뉴 이동 (계층 구조 변경 - 인덴트/아웃덴트) * - 다른 부모 아래로 이동 가능 * - 하위 메뉴는 자동으로 따라감 * - 순환 참조 방지 */ public function moveGlobalMenu(int $menuId, ?int $newParentId, int $sortOrder): array { $menu = GlobalMenu::find($menuId); if (! $menu) { return ['success' => false, 'message' => '글로벌 메뉴를 찾을 수 없습니다.']; } // 순환 참조 방지: 자신의 하위 메뉴로 이동 불가 if ($newParentId !== null && $this->isGlobalDescendant($menuId, $newParentId)) { return ['success' => false, 'message' => '자신의 하위 메뉴로 이동할 수 없습니다.']; } // 자기 자신을 부모로 설정 방지 if ($newParentId === $menuId) { return ['success' => false, 'message' => '자기 자신을 부모로 설정할 수 없습니다.']; } return \DB::transaction(function () use ($menu, $newParentId, $sortOrder) { $oldParentId = $menu->parent_id; // 부모 변경 $menu->parent_id = $newParentId; $menu->sort_order = $sortOrder; $menu->save(); // 같은 부모의 다른 메뉴들 순서 재정렬 $this->reorderGlobalSiblings($newParentId, $menu->id, $sortOrder); // 이전 부모의 메뉴들도 순서 재정렬 (빈 자리 채우기) if ($oldParentId !== $newParentId) { $this->compactGlobalSiblings($oldParentId); } return ['success' => true, 'message' => '글로벌 메뉴가 이동되었습니다.']; }); } /** * 특정 글로벌 메뉴가 다른 메뉴의 하위인지 확인 (순환 참조 방지) */ private function isGlobalDescendant(int $ancestorId, int $menuId): bool { $menu = GlobalMenu::find($menuId); while ($menu) { if ($menu->id === $ancestorId) { return true; } $menu = $menu->parent; } return false; } /** * 같은 부모의 글로벌 형제 메뉴들 순서 재정렬 */ private function reorderGlobalSiblings(?int $parentId, int $excludeId, int $insertAt): void { $siblings = GlobalMenu::where('parent_id', $parentId) ->where('id', '!=', $excludeId) ->orderBy('sort_order') ->get(); $order = 1; foreach ($siblings as $sibling) { if ($order === $insertAt) { $order++; // 삽입 위치 건너뛰기 } if ($sibling->sort_order !== $order) { $sibling->update(['sort_order' => $order]); } $order++; } } /** * 글로벌 형제 메뉴들 순서 압축 (빈 자리 채우기) */ private function compactGlobalSiblings(?int $parentId): void { $siblings = GlobalMenu::where('parent_id', $parentId) ->orderBy('sort_order') ->get(); $order = 1; foreach ($siblings as $sibling) { if ($sibling->sort_order !== $order) { $sibling->update(['sort_order' => $order]); } $order++; } } /** * 글로벌 메뉴 목록 조회 (가져오기 상태 포함) * - 모든 글로벌 메뉴 반환 * - 이미 가져온 메뉴는 is_imported=true로 표시 */ public function getAllGlobalMenusWithStatus(int $tenantId): Collection { // 글로벌 메뉴 전체 조회 (global_menus 테이블에서) $globalMenus = GlobalMenu::query() ->where('is_active', true) ->orderBy('parent_id') ->orderBy('sort_order') ->get(); // 현재 테넌트에 이미 복사된 메뉴의 global_menu_id 목록 $existingGlobalIds = Menu::where('tenant_id', $tenantId) ->whereNotNull('global_menu_id') ->pluck('global_menu_id') ->toArray(); // 각 메뉴에 is_imported 속성 추가 $globalMenus->each(function ($menu) use ($existingGlobalIds) { $menu->is_imported = in_array($menu->id, $existingGlobalIds); }); // 트리 구조로 정렬 (depth 정보 포함) return $this->flattenMenuTree($globalMenus); } /** * 선택한 글로벌 메뉴를 현재 테넌트로 복사 */ public function copyFromGlobal(int $tenantId, array $menuIds): array { if (empty($menuIds)) { return ['success' => false, 'message' => '복사할 메뉴를 선택해주세요.', 'copied' => 0]; } // 선택된 글로벌 메뉴 조회 (global_menus 테이블에서) $globalMenus = GlobalMenu::query() ->whereIn('id', $menuIds) ->orderBy('parent_id') // 부모 먼저 복사하기 위해 ->orderBy('sort_order') ->get(); if ($globalMenus->isEmpty()) { return ['success' => false, 'message' => '유효한 글로벌 메뉴가 없습니다.', 'copied' => 0]; } $copied = 0; return DB::transaction(function () use ($globalMenus, $tenantId, &$copied) { // global_menu_id → 새로 생성된 tenant menu id 매핑 $idMapping = []; foreach ($globalMenus as $globalMenu) { // 이미 복사된 메뉴인지 확인 $exists = Menu::where('tenant_id', $tenantId) ->where('global_menu_id', $globalMenu->id) ->exists(); if ($exists) { continue; } // 부모 메뉴 매핑 (글로벌 → 테넌트) $newParentId = null; if ($globalMenu->parent_id) { // 이번 복사에서 생성된 부모가 있는지 확인 if (isset($idMapping[$globalMenu->parent_id])) { $newParentId = $idMapping[$globalMenu->parent_id]; } else { // 기존에 복사된 부모 메뉴가 있는지 확인 $parentTenantMenu = Menu::where('tenant_id', $tenantId) ->where('global_menu_id', $globalMenu->parent_id) ->first(); $newParentId = $parentTenantMenu?->id; } } // 새 테넌트 메뉴 생성 $newMenu = Menu::create([ 'tenant_id' => $tenantId, 'parent_id' => $newParentId, 'global_menu_id' => $globalMenu->id, 'name' => $globalMenu->name, 'url' => $globalMenu->url, 'icon' => $globalMenu->icon, 'sort_order' => $globalMenu->sort_order, 'is_active' => $globalMenu->is_active, 'hidden' => $globalMenu->hidden, 'is_external' => $globalMenu->is_external, 'external_url' => $globalMenu->external_url, 'is_customized' => false, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); // ID 매핑 저장 (자식 메뉴 복사 시 참조용) $idMapping[$globalMenu->id] = $newMenu->id; $copied++; } return [ 'success' => true, 'message' => "{$copied}개 메뉴가 복사되었습니다.", 'copied' => $copied, ]; }); } // ========================================================================= // 게시판 메뉴 자동 생성 // ========================================================================= /** * URL로 부모 메뉴 찾기 (우선순위: /customer-center → /boards or /system-boards → null) * * @param bool $isSystem 시스템 게시판 여부 * @param int|null $tenantId 테넌트 ID (시스템 게시판이면 null) */ public function findParentMenuForBoard(bool $isSystem, ?int $tenantId = null): ?int { if ($isSystem) { // 시스템 게시판: global_menus에서 찾기 // 우선순위: /customer-center → /system-boards → /boards → null $priorityUrls = ['/customer-center', '/system-boards', '/boards']; foreach ($priorityUrls as $url) { $menu = GlobalMenu::where('url', $url) ->where('is_active', true) ->first(); if ($menu) { return $menu->id; } } return null; // 최상위로 추가 } else { // 테넌트 게시판: menus에서 찾기 // 우선순위: /boards → /customer-center → null $priorityUrls = ['/boards', '/customer-center']; foreach ($priorityUrls as $url) { $query = Menu::where('url', $url) ->where('is_active', true); if ($tenantId) { $query->where('tenant_id', $tenantId); } else { $query->whereNull('tenant_id'); } $menu = $query->first(); if ($menu) { return $menu->id; } } return null; // 최상위로 추가 } } /** * 게시판에 대한 메뉴 자동 생성 * * @param array $boardData 게시판 정보 (board_code, name, is_system, tenant_id) * @return GlobalMenu|Menu|null 생성된 메뉴 또는 null */ public function createMenuForBoard(array $boardData): GlobalMenu|Menu|null { $isSystem = $boardData['is_system'] ?? false; $tenantId = $boardData['tenant_id'] ?? null; $boardCode = $boardData['board_code']; $boardName = $boardData['name']; // 부모 메뉴 찾기 $parentId = $this->findParentMenuForBoard($isSystem, $tenantId); if ($isSystem) { // 시스템 게시판 → global_menus + 모든 테넌트 menus에 추가 $url = '/customer-center/'.$boardCode; // 1. GlobalMenu 조회 또는 생성 $globalMenu = GlobalMenu::where('url', $url)->first(); if (! $globalMenu) { $maxOrder = GlobalMenu::where('parent_id', $parentId)->max('sort_order') ?? 0; $globalMenu = GlobalMenu::create([ 'parent_id' => $parentId, 'name' => $boardName, 'url' => $url, 'icon' => 'document-text', 'sort_order' => $maxOrder + 1, 'is_active' => true, 'hidden' => false, 'is_external' => false, ]); } // 2. 모든 테넌트의 Menu에 추가 (global_menu_id 연결) $tenants = Tenant::active()->get(); foreach ($tenants as $tenant) { $menuParentId = $this->findParentMenuForBoard(false, $tenant->id); $menuExists = Menu::where('url', $url)->where('tenant_id', $tenant->id)->exists(); if (! $menuExists) { $maxMenuOrder = Menu::where('parent_id', $menuParentId) ->where('tenant_id', $tenant->id) ->max('sort_order') ?? 0; Menu::create([ 'tenant_id' => $tenant->id, 'parent_id' => $menuParentId, 'global_menu_id' => $globalMenu->id, 'name' => $boardName, 'url' => $url, 'icon' => 'document-text', 'sort_order' => $maxMenuOrder + 1, 'is_active' => true, 'hidden' => false, 'is_external' => false, 'is_customized' => false, 'created_by' => auth()->id(), ]); } } return $globalMenu; } else { // 테넌트 게시판 → menus에 추가 $url = '/boards/'.$boardCode; // 중복 체크 $query = Menu::where('url', $url); if ($tenantId) { $query->where('tenant_id', $tenantId); } else { $query->whereNull('tenant_id'); } if ($query->exists()) { return null; } // 정렬 순서 계산 $maxOrderQuery = Menu::where('parent_id', $parentId); if ($tenantId) { $maxOrderQuery->where('tenant_id', $tenantId); } else { $maxOrderQuery->whereNull('tenant_id'); } $maxOrder = $maxOrderQuery->max('sort_order') ?? 0; return Menu::create([ 'tenant_id' => $tenantId, 'parent_id' => $parentId, 'name' => $boardName, 'url' => $url, 'icon' => 'document-text', // 기본 아이콘 'sort_order' => $maxOrder + 1, 'is_active' => true, 'hidden' => false, 'is_external' => false, 'created_by' => auth()->id(), ]); } } /** * 게시판 삭제 시 연결된 메뉴도 삭제 * * @param string $boardCode 게시판 코드 * @param bool $isSystem 시스템 게시판 여부 * @param int|null $tenantId 테넌트 ID */ public function deleteMenuForBoard(string $boardCode, bool $isSystem, ?int $tenantId = null, bool $forceDelete = false): bool { if ($isSystem) { $url = '/customer-center/'.$boardCode; // 1. GlobalMenu 삭제 $globalMenu = $forceDelete ? GlobalMenu::withTrashed()->where('url', $url)->first() : GlobalMenu::where('url', $url)->first(); if ($globalMenu) { $forceDelete ? $globalMenu->forceDelete() : $globalMenu->delete(); } // 2. 모든 테넌트의 Menu 삭제 $menusQuery = $forceDelete ? Menu::withTrashed()->where('url', $url) : Menu::where('url', $url); $menus = $menusQuery->get(); foreach ($menus as $menu) { if (! $forceDelete) { $menu->deleted_by = auth()->id(); $menu->save(); } $forceDelete ? $menu->forceDelete() : $menu->delete(); } return true; } else { $url = '/boards/'.$boardCode; $query = $forceDelete ? Menu::withTrashed()->where('url', $url) : Menu::where('url', $url); if ($tenantId) { $query->where('tenant_id', $tenantId); } else { $query->whereNull('tenant_id'); } $menu = $query->first(); if ($menu) { if (! $forceDelete) { $menu->deleted_by = auth()->id(); $menu->save(); } return $forceDelete ? $menu->forceDelete() : $menu->delete(); } } return false; } /** * 게시판 복원 시 연결된 메뉴도 복원 * - soft-deleted 메뉴가 있으면 복원 * - 활성 메뉴가 이미 있으면 아무것도 안함 * - 둘 다 없으면 새로 생성 * * @param string $boardCode 게시판 코드 * @param string $boardName 게시판 이름 (메뉴 생성 시 필요) * @param bool $isSystem 시스템 게시판 여부 * @param int|null $tenantId 테넌트 ID */ public function restoreMenuForBoard(string $boardCode, string $boardName, bool $isSystem, ?int $tenantId = null): bool { if ($isSystem) { $url = '/customer-center/'.$boardCode; // === GlobalMenu 복원 또는 생성 === $globalMenu = GlobalMenu::withTrashed()->where('url', $url)->first(); if ($globalMenu && $globalMenu->trashed()) { $globalMenu->restore(); $globalMenu->deleted_by = null; $globalMenu->save(); } elseif (! $globalMenu) { // 없으면 생성 $parentId = $this->findParentMenuForBoard(true, null); $maxOrder = GlobalMenu::where('parent_id', $parentId)->max('sort_order') ?? 0; $globalMenu = GlobalMenu::create([ 'parent_id' => $parentId, 'name' => $boardName, 'url' => $url, 'icon' => 'document-text', 'sort_order' => $maxOrder + 1, 'is_active' => true, 'hidden' => false, 'is_external' => false, ]); } // === 모든 활성 테넌트의 Menu 복원/생성 (global_menu_id 연결) === $tenants = Tenant::active()->get(); foreach ($tenants as $tenant) { $menuTrashed = Menu::onlyTrashed()->where('url', $url)->where('tenant_id', $tenant->id)->first(); if ($menuTrashed) { $menuTrashed->restore(); $menuTrashed->deleted_by = null; $menuTrashed->global_menu_id = $globalMenu->id; $menuTrashed->save(); } elseif (! Menu::where('url', $url)->where('tenant_id', $tenant->id)->exists()) { // 없으면 생성 $menuParentId = $this->findParentMenuForBoard(false, $tenant->id); $maxMenuOrder = Menu::where('parent_id', $menuParentId)->where('tenant_id', $tenant->id)->max('sort_order') ?? 0; Menu::create([ 'tenant_id' => $tenant->id, 'parent_id' => $menuParentId, 'global_menu_id' => $globalMenu->id, 'name' => $boardName, 'url' => $url, 'icon' => 'document-text', 'sort_order' => $maxMenuOrder + 1, 'is_active' => true, 'hidden' => false, 'is_external' => false, 'is_customized' => false, 'created_by' => auth()->id(), ]); } } return true; } else { $url = '/boards/'.$boardCode; // 1. soft-deleted 메뉴 확인 $trashedQuery = Menu::onlyTrashed()->where('url', $url); if ($tenantId) { $trashedQuery->where('tenant_id', $tenantId); } else { $trashedQuery->whereNull('tenant_id'); } $trashedMenu = $trashedQuery->first(); if ($trashedMenu) { $restored = $trashedMenu->restore(); if ($restored) { $trashedMenu->deleted_by = null; $trashedMenu->save(); } return $restored; } // 2. 활성 메뉴가 이미 있는지 확인 $activeQuery = Menu::where('url', $url); if ($tenantId) { $activeQuery->where('tenant_id', $tenantId); } else { $activeQuery->whereNull('tenant_id'); } $activeMenu = $activeQuery->first(); if ($activeMenu) { return true; // 이미 존재, 아무것도 안함 } // 3. 둘 다 없으면 새로 생성 $created = $this->createMenuForBoard([ 'board_code' => $boardCode, 'name' => $boardName, 'is_system' => false, 'tenant_id' => $tenantId, ]); return $created !== null; } } }