diff --git a/app/Http/Controllers/Api/Admin/GlobalMenuController.php b/app/Http/Controllers/Api/Admin/GlobalMenuController.php new file mode 100644 index 0000000..bd15b53 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/GlobalMenuController.php @@ -0,0 +1,130 @@ +globalMenuService->index($request->all()); + }, '글로벌 메뉴 목록 조회'); + } + + /** + * 글로벌 메뉴 트리 조회 + */ + public function tree() + { + return ApiResponse::handle(function () { + return $this->globalMenuService->tree(); + }, '글로벌 메뉴 트리 조회'); + } + + /** + * 글로벌 메뉴 단건 조회 + */ + public function show($id) + { + return ApiResponse::handle(function () use ($id) { + $menu = $this->globalMenuService->show((int) $id); + + if (! $menu) { + return ['error' => __('error.menu_not_found'), 'code' => 404]; + } + + return $menu; + }, '글로벌 메뉴 상세 조회'); + } + + /** + * 글로벌 메뉴 생성 + */ + public function store(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return $this->globalMenuService->store($request->all()); + }, '글로벌 메뉴 생성'); + } + + /** + * 글로벌 메뉴 수정 + */ + public function update(Request $request, $id) + { + return ApiResponse::handle(function () use ($request, $id) { + $menu = $this->globalMenuService->update((int) $id, $request->all()); + + if (! $menu) { + return ['error' => __('error.menu_not_found'), 'code' => 404]; + } + + return $menu; + }, '글로벌 메뉴 수정'); + } + + /** + * 글로벌 메뉴 삭제 + */ + public function destroy($id) + { + return ApiResponse::handle(function () use ($id) { + $result = $this->globalMenuService->destroy((int) $id); + + if (! $result) { + return ['error' => __('error.menu_not_found'), 'code' => 404]; + } + + return __('message.deleted'); + }, '글로벌 메뉴 삭제'); + } + + /** + * 글로벌 메뉴 순서 변경 + */ + public function reorder(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $items = $request->input('items', []); + + $this->globalMenuService->reorder($items); + + return __('message.reordered'); + }, '글로벌 메뉴 순서 변경'); + } + + /** + * 특정 글로벌 메뉴를 모든 테넌트에 동기화 + */ + public function syncToTenants($id) + { + return ApiResponse::handle(function () use ($id) { + return $this->globalMenuService->syncToAllTenants((int) $id); + }, '모든 테넌트에 메뉴 동기화'); + } + + /** + * 글로벌 메뉴 통계 조회 + */ + public function stats() + { + return ApiResponse::handle(function () { + return $this->globalMenuService->stats(); + }, '글로벌 메뉴 통계 조회'); + } +} diff --git a/app/Http/Controllers/Api/V1/MenuController.php b/app/Http/Controllers/Api/V1/MenuController.php index 6695318..72b603f 100644 --- a/app/Http/Controllers/Api/V1/MenuController.php +++ b/app/Http/Controllers/Api/V1/MenuController.php @@ -5,10 +5,15 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Services\MenuService; +use App\Services\MenuSyncService; use Illuminate\Http\Request; class MenuController extends Controller { + public function __construct( + protected MenuSyncService $menuSyncService + ) {} + public function index(Request $request) { return ApiResponse::handle(function () use ($request) { @@ -63,4 +68,79 @@ public function toggle(Request $request, $id) return MenuService::toggle($params); }, '메뉴 상태 토글'); } + + /** + * 삭제된 메뉴 복원 + */ + public function restore($id) + { + return ApiResponse::handle(function () use ($id) { + return MenuService::restore(['id' => (int) $id]); + }, '메뉴 복원'); + } + + /** + * 삭제된 메뉴 목록 조회 + */ + public function trashed() + { + return ApiResponse::handle(function () { + return MenuService::trashedList(); + }, '삭제된 메뉴 목록 조회'); + } + + /** + * 복제 가능한 글로벌 메뉴 목록 + */ + public function availableGlobal() + { + return ApiResponse::handle(function () { + return $this->menuSyncService->getAvailableGlobalMenus(); + }, '복제 가능한 글로벌 메뉴 목록'); + } + + /** + * 동기화 상태 목록 조회 + */ + public function syncStatus(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $statusFilter = $request->query('status'); + + return $this->menuSyncService->getSyncStatus($statusFilter); + }, '동기화 상태 조회'); + } + + /** + * 선택 동기화 (신규 생성 또는 기존 업데이트) + */ + public function sync(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $menuIds = $request->input('menu_ids', []); + $force = $request->boolean('force', false); + + return $this->menuSyncService->syncMenus($menuIds, $force); + }, '메뉴 동기화'); + } + + /** + * 신규 글로벌 메뉴 일괄 가져오기 + */ + public function syncNew() + { + return ApiResponse::handle(function () { + return $this->menuSyncService->importNewMenus(); + }, '신규 메뉴 가져오기'); + } + + /** + * 변경된 기존 메뉴 일괄 업데이트 (커스텀 제외) + */ + public function syncUpdates() + { + return ApiResponse::handle(function () { + return $this->menuSyncService->syncUpdates(); + }, '메뉴 업데이트 동기화'); + } } diff --git a/app/Services/GlobalMenuService.php b/app/Services/GlobalMenuService.php new file mode 100644 index 0000000..84bdb73 --- /dev/null +++ b/app/Services/GlobalMenuService.php @@ -0,0 +1,270 @@ +where('is_active', (bool) $params['is_active']); + } + + // 필터: 숨김 상태 + if (isset($params['hidden'])) { + $query->where('hidden', (bool) $params['hidden']); + } + + // 필터: 상위 메뉴 + if (array_key_exists('parent_id', $params)) { + $query->where('parent_id', $params['parent_id']); + } + + return $query + ->orderByRaw('COALESCE(parent_id, 0)') + ->orderBy('sort_order') + ->get(); + } + + /** + * 글로벌 메뉴 트리 구조로 조회 + */ + public function tree(): Collection + { + $menus = Menu::global() + ->orderBy('sort_order') + ->get(); + + return $this->buildTree($menus); + } + + /** + * 글로벌 메뉴 단건 조회 + */ + public function show(int $id): ?Menu + { + return Menu::global() + ->with('children') + ->find($id); + } + + /** + * 글로벌 메뉴 생성 + */ + public function store(array $data): Menu + { + return Menu::create([ + 'tenant_id' => null, // 글로벌 메뉴 + 'parent_id' => $data['parent_id'] ?? null, + 'name' => $data['name'], + 'url' => $data['url'] ?? null, + 'icon' => $data['icon'] ?? null, + 'sort_order' => $data['sort_order'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + 'hidden' => $data['hidden'] ?? false, + 'is_external' => $data['is_external'] ?? false, + 'external_url' => $data['external_url'] ?? null, + 'created_by' => $this->apiUserId(), + 'updated_by' => $this->apiUserId(), + ]); + } + + /** + * 글로벌 메뉴 수정 + */ + public function update(int $id, array $data): ?Menu + { + $menu = Menu::global()->find($id); + + if (! $menu) { + return null; + } + + $updateData = array_filter([ + 'parent_id' => $data['parent_id'] ?? null, + 'name' => $data['name'] ?? null, + 'url' => $data['url'] ?? null, + 'icon' => $data['icon'] ?? null, + 'sort_order' => $data['sort_order'] ?? null, + 'is_active' => isset($data['is_active']) ? (bool) $data['is_active'] : null, + 'hidden' => isset($data['hidden']) ? (bool) $data['hidden'] : null, + 'is_external' => isset($data['is_external']) ? (bool) $data['is_external'] : null, + 'external_url' => $data['external_url'] ?? null, + ], fn ($v) => ! is_null($v)); + + $updateData['updated_by'] = $this->apiUserId(); + + $menu->update($updateData); + + return $menu->fresh(); + } + + /** + * 글로벌 메뉴 삭제 + * - 연결된 테넌트 메뉴의 global_menu_id를 NULL로 변경 + */ + public function destroy(int $id): bool + { + $menu = Menu::global()->find($id); + + if (! $menu) { + return false; + } + + return DB::transaction(function () use ($menu) { + // 연결된 테넌트 메뉴의 global_menu_id를 NULL로 변경 + Menu::withoutGlobalScopes() + ->where('global_menu_id', $menu->id) + ->update(['global_menu_id' => null]); + + // 하위 메뉴도 삭제 + Menu::global() + ->where('parent_id', $menu->id) + ->delete(); + + $menu->deleted_by = $this->apiUserId(); + $menu->save(); + $menu->delete(); + + return true; + }); + } + + /** + * 글로벌 메뉴 순서 변경 + */ + public function reorder(array $items): bool + { + return DB::transaction(function () use ($items) { + foreach ($items as $item) { + if (! isset($item['id'], $item['sort_order'])) { + continue; + } + + Menu::global() + ->where('id', $item['id']) + ->update([ + 'sort_order' => (int) $item['sort_order'], + 'updated_by' => $this->apiUserId(), + ]); + } + + return true; + }); + } + + /** + * 특정 글로벌 메뉴를 모든 테넌트에 동기화 + * - 이미 복제된 테넌트는 건너뜀 + */ + public function syncToAllTenants(int $globalMenuId): array + { + $globalMenu = Menu::global()->find($globalMenuId); + + if (! $globalMenu) { + return ['synced' => 0, 'skipped' => 0, 'details' => []]; + } + + // 모든 테넌트 ID 조회 + $tenantIds = Menu::withoutGlobalScopes() + ->whereNotNull('tenant_id') + ->distinct() + ->pluck('tenant_id'); + + $synced = 0; + $skipped = 0; + $details = []; + + foreach ($tenantIds as $tenantId) { + // 이미 복제된 경우 건너뜀 + $exists = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('global_menu_id', $globalMenuId) + ->exists(); + + if ($exists) { + $skipped++; + $details[] = [ + 'tenant_id' => $tenantId, + 'action' => 'skipped', + 'reason' => 'already_exists', + ]; + + continue; + } + + // 복제 + $newMenu = MenuBootstrapService::cloneSingleMenu($globalMenuId, $tenantId); + + if ($newMenu) { + $synced++; + $details[] = [ + 'tenant_id' => $tenantId, + 'action' => 'created', + 'tenant_menu_id' => $newMenu->id, + ]; + } + } + + return [ + 'synced' => $synced, + 'skipped' => $skipped, + 'details' => $details, + ]; + } + + /** + * 글로벌 메뉴 통계 조회 + */ + public function stats(): array + { + $total = Menu::global()->count(); + $active = Menu::global()->where('is_active', true)->count(); + $inactive = Menu::global()->where('is_active', false)->count(); + $hidden = Menu::global()->where('hidden', true)->count(); + + // 테넌트별 사용 현황 + $tenantUsage = Menu::withoutGlobalScopes() + ->whereNotNull('global_menu_id') + ->groupBy('global_menu_id') + ->selectRaw('global_menu_id, COUNT(DISTINCT tenant_id) as tenant_count') + ->pluck('tenant_count', 'global_menu_id') + ->toArray(); + + return [ + 'total' => $total, + 'active' => $active, + 'inactive' => $inactive, + 'hidden' => $hidden, + 'tenant_usage' => $tenantUsage, + ]; + } + + /** + * 메뉴 컬렉션을 트리 구조로 변환 + */ + private function buildTree(Collection $menus, ?int $parentId = null): Collection + { + return $menus + ->filter(fn ($menu) => $menu->parent_id === $parentId) + ->map(function ($menu) use ($menus) { + $menu->children = $this->buildTree($menus, $menu->id); + + return $menu; + }) + ->values(); + } +} \ No newline at end of file diff --git a/app/Services/MenuBootstrapService.php b/app/Services/MenuBootstrapService.php index 2138a7f..c6fb55f 100644 --- a/app/Services/MenuBootstrapService.php +++ b/app/Services/MenuBootstrapService.php @@ -13,36 +13,48 @@ class MenuBootstrapService /** * 글로벌 메뉴 템플릿을 테넌트에 복제 * + * - 활성화된 글로벌 메뉴만 복제 (is_active=1) + * - global_menu_id로 원본 추적 가능 + * - is_customized=0 (원본 상태) + * * @param int $tenantId 테넌트 ID * @return array 생성된 메뉴 ID 목록 */ public static function cloneGlobalMenusForTenant(int $tenantId): array { return DB::transaction(function () use ($tenantId) { - // 1. 글로벌 템플릿 메뉴 조회 (parent_id 순서대로 정렬) + // 1. 활성화된 글로벌 템플릿 메뉴 조회 (parent_id 순서대로 정렬) $templateMenus = Menu::withoutGlobalScopes() ->whereNull('tenant_id') + ->where('is_active', true) // 활성화된 메뉴만 복사 ->orderByRaw('COALESCE(parent_id, 0)') ->orderBy('sort_order') ->get(); - // 2. parent_id 매핑 테이블 생성 + // 2. parent_id 매핑 테이블 생성 (글로벌 ID → 테넌트 ID) $idMapping = []; $menuIds = []; // 3. 계층 순서대로 복제 (parent → child) foreach ($templateMenus as $template) { + // parent가 복제되지 않은 경우 (비활성 부모) 건너뛰기 + if ($template->parent_id && ! isset($idMapping[$template->parent_id])) { + continue; + } + $newMenu = Menu::create([ 'tenant_id' => $tenantId, 'parent_id' => $template->parent_id ? ($idMapping[$template->parent_id] ?? null) : null, + 'global_menu_id' => $template->id, // 원본 글로벌 메뉴 ID 저장 'name' => $template->name, 'url' => $template->url, 'icon' => $template->icon, 'sort_order' => $template->sort_order, 'is_active' => $template->is_active, 'hidden' => $template->hidden, + 'is_customized' => false, // 원본 상태 'is_external' => $template->is_external, 'external_url' => $template->external_url, ]); @@ -56,6 +68,89 @@ public static function cloneGlobalMenusForTenant(int $tenantId): array }); } + /** + * 특정 글로벌 메뉴를 테넌트에 복제 (단일) + * + * @param int $globalMenuId 글로벌 메뉴 ID + * @param int $tenantId 테넌트 ID + * @param int|null $newParentId 복제 후 부모 메뉴 ID (테넌트 메뉴) + * @return Menu|null 생성된 테넌트 메뉴 (이미 복제된 경우 null) + */ + public static function cloneSingleMenu(int $globalMenuId, int $tenantId, ?int $newParentId = null): ?Menu + { + // 이미 복제된 메뉴인지 확인 + $exists = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('global_menu_id', $globalMenuId) + ->exists(); + + if ($exists) { + return null; + } + + // 글로벌 메뉴 조회 + $globalMenu = Menu::withoutGlobalScopes() + ->whereNull('tenant_id') + ->find($globalMenuId); + + if (! $globalMenu) { + return null; + } + + // 테넌트 메뉴로 복제 + return 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_customized' => false, + 'is_external' => $globalMenu->is_external, + 'external_url' => $globalMenu->external_url, + ]); + } + + /** + * 글로벌 메뉴와 그 하위 메뉴를 모두 테넌트에 복제 (재귀) + * + * @param int $globalMenuId 글로벌 메뉴 ID + * @param int $tenantId 테넌트 ID + * @param int|null $newParentId 복제 후 부모 메뉴 ID + * @return array 생성된 메뉴 ID 목록 + */ + public static function cloneMenuWithChildren(int $globalMenuId, int $tenantId, ?int $newParentId = null): array + { + return DB::transaction(function () use ($globalMenuId, $tenantId, $newParentId) { + $menuIds = []; + + // 1. 루트 메뉴 복제 + $rootMenu = self::cloneSingleMenu($globalMenuId, $tenantId, $newParentId); + if (! $rootMenu) { + return $menuIds; + } + $menuIds[] = $rootMenu->id; + + // 2. 하위 메뉴 재귀 복제 + $children = Menu::withoutGlobalScopes() + ->whereNull('tenant_id') + ->where('parent_id', $globalMenuId) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + + foreach ($children as $child) { + $childIds = self::cloneMenuWithChildren($child->id, $tenantId, $rootMenu->id); + $menuIds = array_merge($menuIds, $childIds); + } + + return $menuIds; + }); + } + /** * 테넌트를 위한 기본 메뉴 구조 생성 (구버전 - 하위 호환성 유지) * diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index 66ee0f8..170ada6 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -112,6 +112,7 @@ public static function store(array $params) /** * 메뉴 수정 + * - global_menu_id가 있는 테넌트 메뉴 수정 시 is_customized = true 자동 설정 */ public static function update(array $params) { @@ -154,6 +155,11 @@ public static function update(array $params) return ['error' => '수정할 데이터가 없습니다.', 'code' => 400]; } + // 글로벌 메뉴에서 복제된 테넌트 메뉴 수정 시 커스터마이징 플래그 설정 + if ($menu->global_menu_id && ! $menu->is_customized) { + $update['is_customized'] = true; + } + $update['updated_by'] = $userId; $menu->fill($update)->save(); @@ -246,4 +252,60 @@ public static function toggle(array $params) return $menu->fresh(); } + + /** + * 삭제된 메뉴 복원 + */ + public static function restore(array $params) + { + $id = (int) ($params['id'] ?? 0); + $tenantId = self::tenantId(); + $userId = self::actorId(); + + if (! $id) { + return ['error' => 'id가 필요합니다.', 'code' => 400]; + } + + // 삭제된 메뉴 포함하여 조회 + $menu = Menu::withTrashed() + ->withoutGlobalScopes() + ->where(function ($q) use ($tenantId) { + $q->whereNull('tenant_id') + ->orWhere('tenant_id', $tenantId); + }) + ->where('id', $id) + ->first(); + + if (! $menu) { + return ['error' => 'Menu not found', 'code' => 404]; + } + + if (! $menu->trashed()) { + return ['error' => '삭제되지 않은 메뉴입니다.', 'code' => 400]; + } + + $menu->restore(); + $menu->deleted_by = null; + $menu->updated_by = $userId; + $menu->save(); + + return $menu->fresh(); + } + + /** + * 삭제된 메뉴 목록 조회 + */ + public static function trashedList(array $params = []) + { + $tenantId = self::tenantId(); + + return Menu::onlyTrashed() + ->withoutGlobalScopes() + ->where(function ($q) use ($tenantId) { + $q->whereNull('tenant_id') + ->orWhere('tenant_id', $tenantId); + }) + ->orderBy('deleted_at', 'desc') + ->get(); + } } diff --git a/app/Services/MenuSyncService.php b/app/Services/MenuSyncService.php new file mode 100644 index 0000000..ad034fa --- /dev/null +++ b/app/Services/MenuSyncService.php @@ -0,0 +1,424 @@ +tenantId(); + + // 글로벌 메뉴 조회 + $globalMenus = Menu::global() + ->where('is_active', true) + ->get() + ->keyBy('id'); + + // 테넌트 메뉴 조회 (global_menu_id가 있는 것만) + $tenantMenus = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereNotNull('global_menu_id') + ->get() + ->keyBy('global_menu_id'); + + $items = []; + $summary = [ + self::STATUS_NEW => 0, + self::STATUS_UP_TO_DATE => 0, + self::STATUS_UPDATABLE => 0, + self::STATUS_CUSTOMIZED => 0, + self::STATUS_DELETED => 0, + ]; + + // 1. 글로벌 메뉴 기준으로 상태 확인 + foreach ($globalMenus as $globalMenu) { + $tenantMenu = $tenantMenus->get($globalMenu->id); + + if (! $tenantMenu) { + // 테넌트에 없음 → 신규 + $status = self::STATUS_NEW; + $item = $this->buildStatusItem($globalMenu, null, $status, []); + } else { + // 테넌트에 있음 → 상태 판단 + $status = $this->determineStatus($tenantMenu, $globalMenu); + $changes = $status === self::STATUS_UPDATABLE + ? $this->getChangedFields($tenantMenu, $globalMenu) + : []; + $item = $this->buildStatusItem($globalMenu, $tenantMenu, $status, $changes); + } + + $summary[$status]++; + + // 필터 적용 + if ($statusFilter === null || $status === $statusFilter) { + $items[] = $item; + } + } + + // 2. 테넌트 메뉴 중 글로벌이 삭제된 것 확인 + foreach ($tenantMenus as $globalMenuId => $tenantMenu) { + if (! $globalMenus->has($globalMenuId)) { + // 글로벌 메뉴가 삭제됨 + $status = self::STATUS_DELETED; + $item = $this->buildStatusItem(null, $tenantMenu, $status, []); + + $summary[$status]++; + + if ($statusFilter === null || $status === $statusFilter) { + $items[] = $item; + } + } + } + + return [ + 'summary' => $summary, + 'items' => $items, + ]; + } + + /** + * 선택 동기화 (신규 생성 또는 기존 업데이트) + */ + public function syncMenus(array $globalMenuIds, bool $force = false): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $synced = 0; + $created = 0; + $skipped = 0; + $details = []; + + return DB::transaction(function () use ($globalMenuIds, $force, $tenantId, $userId, &$synced, &$created, &$skipped, &$details) { + foreach ($globalMenuIds as $globalMenuId) { + $globalMenu = Menu::global()->find($globalMenuId); + + if (! $globalMenu) { + $skipped++; + $details[] = [ + 'global_menu_id' => $globalMenuId, + 'action' => 'skipped', + 'reason' => 'global_not_found', + ]; + + continue; + } + + // 테넌트 메뉴 확인 + $tenantMenu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('global_menu_id', $globalMenuId) + ->first(); + + if (! $tenantMenu) { + // 신규 생성 + $newMenu = $this->createFromGlobal($globalMenu, $tenantId, $userId); + $created++; + $details[] = [ + 'global_menu_id' => $globalMenuId, + 'action' => 'created', + 'tenant_menu_id' => $newMenu->id, + ]; + } else { + // 기존 업데이트 + if ($tenantMenu->is_customized && ! $force) { + $skipped++; + $details[] = [ + 'global_menu_id' => $globalMenuId, + 'action' => 'skipped', + 'tenant_menu_id' => $tenantMenu->id, + 'reason' => 'customized', + ]; + + continue; + } + + $this->updateFromGlobal($tenantMenu, $globalMenu, $userId); + $synced++; + $details[] = [ + 'global_menu_id' => $globalMenuId, + 'action' => 'updated', + 'tenant_menu_id' => $tenantMenu->id, + ]; + } + } + + return [ + 'synced' => $synced, + 'created' => $created, + 'skipped' => $skipped, + 'details' => $details, + ]; + }); + } + + /** + * 신규 글로벌 메뉴 일괄 가져오기 + */ + public function importNewMenus(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 신규 상태인 글로벌 메뉴만 가져오기 + $status = $this->getSyncStatus(self::STATUS_NEW); + + $imported = 0; + $menus = []; + + return DB::transaction(function () use ($status, $tenantId, $userId, &$imported, &$menus) { + foreach ($status['items'] as $item) { + $globalMenu = Menu::global()->find($item['global_menu_id']); + + if (! $globalMenu) { + continue; + } + + // 부모 메뉴 처리 (글로벌 → 테넌트 매핑) + $newParentId = null; + if ($globalMenu->parent_id) { + $parentTenantMenu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('global_menu_id', $globalMenu->parent_id) + ->first(); + + $newParentId = $parentTenantMenu?->id; + } + + $newMenu = $this->createFromGlobal($globalMenu, $tenantId, $userId, $newParentId); + $imported++; + $menus[] = [ + 'global_menu_id' => $globalMenu->id, + 'name' => $globalMenu->name, + 'tenant_menu_id' => $newMenu->id, + ]; + } + + return [ + 'imported' => $imported, + 'menus' => $menus, + ]; + }); + } + + /** + * 변경된 기존 메뉴 일괄 업데이트 (커스텀 제외) + */ + public function syncUpdates(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // updatable 상태인 메뉴만 가져오기 + $status = $this->getSyncStatus(self::STATUS_UPDATABLE); + + $updated = 0; + $skippedCustomized = 0; + $details = []; + $skipped = []; + + return DB::transaction(function () use ($status, $tenantId, $userId, &$updated, &$skippedCustomized, &$details, &$skipped) { + foreach ($status['items'] as $item) { + $tenantMenu = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('id', $item['tenant_menu_id']) + ->first(); + + if (! $tenantMenu) { + continue; + } + + // 커스텀 메뉴 건너뛰기 + if ($tenantMenu->is_customized) { + $skippedCustomized++; + $skipped[] = [ + 'global_menu_id' => $item['global_menu_id'], + 'tenant_menu_id' => $tenantMenu->id, + 'reason' => 'customized', + ]; + + continue; + } + + $globalMenu = Menu::global()->find($item['global_menu_id']); + + if (! $globalMenu) { + continue; + } + + $this->updateFromGlobal($tenantMenu, $globalMenu, $userId); + $updated++; + $details[] = [ + 'global_menu_id' => $item['global_menu_id'], + 'tenant_menu_id' => $tenantMenu->id, + 'changes' => $item['changes'], + ]; + } + + return [ + 'updated' => $updated, + 'skipped_customized' => $skippedCustomized, + 'details' => $details, + 'skipped' => $skipped, + ]; + }); + } + + /** + * 복제 가능한 글로벌 메뉴 목록 (테넌트가 아직 복제하지 않은 것) + */ + public function getAvailableGlobalMenus(): Collection + { + $tenantId = $this->tenantId(); + + // 테넌트가 이미 복제한 글로벌 메뉴 ID 목록 + $clonedIds = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereNotNull('global_menu_id') + ->pluck('global_menu_id') + ->toArray(); + + // 아직 복제하지 않은 글로벌 메뉴 + return Menu::global() + ->where('is_active', true) + ->whereNotIn('id', $clonedIds) + ->orderByRaw('COALESCE(parent_id, 0)') + ->orderBy('sort_order') + ->get(); + } + + /** + * 동기화 상태 판단 + */ + private function determineStatus(Menu $tenantMenu, Menu $globalMenu): string + { + // 커스터마이징된 메뉴 + if ($tenantMenu->is_customized) { + return self::STATUS_CUSTOMIZED; + } + + // 변경 여부 확인 + $hasChanges = $this->hasChanges($tenantMenu, $globalMenu); + + return $hasChanges ? self::STATUS_UPDATABLE : self::STATUS_UP_TO_DATE; + } + + /** + * 변경 여부 확인 + */ + private function hasChanges(Menu $tenantMenu, Menu $globalMenu): bool + { + foreach (Menu::getSyncFields() as $field) { + if ($tenantMenu->$field !== $globalMenu->$field) { + return true; + } + } + + return false; + } + + /** + * 변경된 필드 목록 조회 + */ + private function getChangedFields(Menu $tenantMenu, Menu $globalMenu): array + { + $changes = []; + + foreach (Menu::getSyncFields() as $field) { + if ($tenantMenu->$field !== $globalMenu->$field) { + $changes[] = $field; + } + } + + return $changes; + } + + /** + * 상태 아이템 구성 + */ + private function buildStatusItem(?Menu $globalMenu, ?Menu $tenantMenu, string $status, array $changes): array + { + return [ + 'global_menu_id' => $globalMenu?->id, + 'global_name' => $globalMenu?->name, + 'global_url' => $globalMenu?->url, + 'global_icon' => $globalMenu?->icon, + 'status' => $status, + 'tenant_menu_id' => $tenantMenu?->id, + 'tenant_name' => $tenantMenu?->name, + 'is_customized' => $tenantMenu?->is_customized ?? false, + 'changes' => $changes, + ]; + } + + /** + * 글로벌 메뉴로부터 테넌트 메뉴 생성 + */ + private function createFromGlobal(Menu $globalMenu, int $tenantId, int $userId, ?int $parentId = null): Menu + { + return Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + '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_customized' => false, + 'is_external' => $globalMenu->is_external, + 'external_url' => $globalMenu->external_url, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + /** + * 글로벌 메뉴로부터 테넌트 메뉴 업데이트 + */ + private function updateFromGlobal(Menu $tenantMenu, Menu $globalMenu, int $userId): Menu + { + $tenantMenu->update([ + 'name' => $globalMenu->name, + 'url' => $globalMenu->url, + 'icon' => $globalMenu->icon, + 'sort_order' => $globalMenu->sort_order, + 'is_active' => $globalMenu->is_active, + 'hidden' => $globalMenu->hidden, + 'is_customized' => false, // 동기화 후 커스텀 플래그 해제 + 'is_external' => $globalMenu->is_external, + 'external_url' => $globalMenu->external_url, + 'updated_by' => $userId, + ]); + + return $tenantMenu->fresh(); + } +} \ No newline at end of file diff --git a/database/migrations/2025_12_02_100000_add_global_menu_link_columns_to_menus_table.php b/database/migrations/2025_12_02_100000_add_global_menu_link_columns_to_menus_table.php new file mode 100644 index 0000000..f328bc3 --- /dev/null +++ b/database/migrations/2025_12_02_100000_add_global_menu_link_columns_to_menus_table.php @@ -0,0 +1,50 @@ +unsignedBigInteger('global_menu_id') + ->nullable() + ->after('parent_id') + ->comment('원본 글로벌 메뉴 ID (복제된 메뉴인 경우)'); + + // is_customized 플래그 추가 (hidden 다음에 위치) + $table->boolean('is_customized') + ->default(false) + ->after('hidden') + ->comment('테넌트가 커스터마이징 했는지 여부'); + + // 인덱스 추가 + $table->index('global_menu_id', 'menus_global_menu_id_idx'); + $table->index(['tenant_id', 'global_menu_id'], 'menus_tenant_global_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('menus', function (Blueprint $table) { + // 인덱스 삭제 + $table->dropIndex('menus_tenant_global_idx'); + $table->dropIndex('menus_global_menu_id_idx'); + + // 컬럼 삭제 + $table->dropColumn(['global_menu_id', 'is_customized']); + }); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index e763b56..c0847a6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CommonController; +use App\Http\Controllers\Api\Admin\GlobalMenuController; use App\Http\Controllers\Api\V1\DepartmentController; use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; use App\Http\Controllers\Api\V1\Design\BomCalculationController; @@ -92,6 +93,19 @@ // 비밀번호 초기화 Route::post('users/{id}/reset-password', [AdminController::class, 'reset'])->name('v1.admin.users.password.reset'); // 테넌트 사용자 비밀번호 초기화 + + // 글로벌 메뉴 관리 (시스템 관리자용) + Route::prefix('global-menus')->group(function () { + Route::get('/', [GlobalMenuController::class, 'index'])->name('v1.admin.global-menus.index'); // 글로벌 메뉴 목록 + Route::post('/', [GlobalMenuController::class, 'store'])->name('v1.admin.global-menus.store'); // 글로벌 메뉴 생성 + Route::get('/tree', [GlobalMenuController::class, 'tree'])->name('v1.admin.global-menus.tree'); // 글로벌 메뉴 트리 + Route::get('/stats', [GlobalMenuController::class, 'stats'])->name('v1.admin.global-menus.stats'); // 글로벌 메뉴 통계 + Route::post('/reorder', [GlobalMenuController::class, 'reorder'])->name('v1.admin.global-menus.reorder'); // 글로벌 메뉴 순서 변경 + Route::get('/{id}', [GlobalMenuController::class, 'show'])->name('v1.admin.global-menus.show'); // 글로벌 메뉴 단건 조회 + Route::put('/{id}', [GlobalMenuController::class, 'update'])->name('v1.admin.global-menus.update'); // 글로벌 메뉴 수정 + Route::delete('/{id}', [GlobalMenuController::class, 'destroy'])->name('v1.admin.global-menus.destroy'); // 글로벌 메뉴 삭제 + Route::post('/{id}/sync-to-tenants', [GlobalMenuController::class, 'syncToTenants'])->name('v1.admin.global-menus.sync-to-tenants'); // 테넌트에 동기화 + }); }); // Member API @@ -131,12 +145,23 @@ // Menu API Route::middleware(['perm.map', 'permission'])->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::post('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder'); + + // 동기화 관련 라우트 (/{id} 전에 위치해야 함) + Route::get('/trashed', [MenuController::class, 'trashed'])->name('v1.menus.trashed'); + Route::get('/available-global', [MenuController::class, 'availableGlobal'])->name('v1.menus.available-global'); + Route::get('/sync-status', [MenuController::class, 'syncStatus'])->name('v1.menus.sync-status'); + Route::post('/sync', [MenuController::class, 'sync'])->name('v1.menus.sync'); + Route::post('/sync-new', [MenuController::class, 'syncNew'])->name('v1.menus.sync-new'); + Route::post('/sync-updates', [MenuController::class, 'syncUpdates'])->name('v1.menus.sync-updates'); + + // 단일 메뉴 관련 라우트 + Route::get('/{id}', [MenuController::class, 'show'])->name('v1.menus.show'); 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::post('/{id}/restore', [MenuController::class, 'restore'])->name('v1.menus.restore'); }); // Role API