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