where('tenant_id', $this->getTenantId()) ->where('setting_group', 'menu_sync') ->where('setting_key', 'environments') ->first(); return $setting?->setting_value ?? [ 'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''], 'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''], ]; } /** * 카테고리 동기화 페이지 */ public function index(Request $request): View|Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('categories.sync.index')); } $environments = $this->getEnvironments(); $selectedEnv = $request->get('env', 'dev'); $selectedType = $request->get('type', 'global'); // global or tenant // 로컬 테넌트 정보 $localTenant = Tenant::find($this->getTenantId()); // 로컬 카테고리 조회 (타입 필터 적용) $localCategories = $this->getCategoryList($selectedType); // 원격 카테고리 조회 $remoteCategories = []; $remoteError = null; $this->remoteTenantName = null; if (! empty($environments[$selectedEnv]['url'])) { try { $remoteCategories = $this->fetchRemoteCategories($environments[$selectedEnv], $selectedType); } catch (\Exception $e) { $remoteError = $e->getMessage(); } } // 차이점 계산 $diff = $this->calculateDiff($localCategories, $remoteCategories); return view('categories.sync', [ 'environments' => $environments, 'selectedEnv' => $selectedEnv, 'selectedType' => $selectedType, 'localCategories' => $localCategories, 'remoteCategories' => $remoteCategories, 'remoteError' => $remoteError, 'diff' => $diff, 'localTenantName' => $localTenant?->company_name ?? '알 수 없음', 'remoteTenantName' => $this->remoteTenantName, ]); } /** * 카테고리 Export API (다른 환경에서 호출) */ public function export(Request $request): JsonResponse { // API Key 검증 $apiKey = $request->header('X-Menu-Sync-Key'); $validKey = config('app.menu_sync_api_key'); if (empty($validKey) || $apiKey !== $validKey) { return response()->json(['error' => 'Unauthorized'], 401); } $type = $request->get('type', 'all'); // global, tenant, or all $categories = $this->getCategoryList($type); $tenant = Tenant::find($this->getTenantId()); return response()->json([ 'success' => true, 'environment' => config('app.env'), 'tenant_name' => $tenant?->company_name ?? '알 수 없음', 'exported_at' => now()->toIso8601String(), 'categories' => $categories, ]); } /** * 카테고리 Import API (다른 환경에서 호출) */ public function import(Request $request): JsonResponse { // API Key 검증 $apiKey = $request->header('X-Menu-Sync-Key'); $validKey = config('app.menu_sync_api_key'); if (empty($validKey) || $apiKey !== $validKey) { return response()->json(['error' => 'Unauthorized'], 401); } $validated = $request->validate([ 'categories' => 'required|array', 'categories.*.is_global' => 'required|boolean', 'categories.*.tenant_id' => 'nullable|integer', 'categories.*.code_group' => 'required|string|max:50', 'categories.*.code' => 'required|string|max:50', 'categories.*.name' => 'required|string|max:100', 'categories.*.parent_code' => 'nullable|string|max:50', 'categories.*.sort_order' => 'nullable|integer', 'categories.*.description' => 'nullable|string', 'categories.*.is_active' => 'nullable|boolean', ]); $imported = 0; $skipped = 0; foreach ($validated['categories'] as $catData) { if ($catData['is_global']) { // 글로벌 카테고리 $exists = GlobalCategory::where('code_group', $catData['code_group']) ->where('code', $catData['code']) ->exists(); if ($exists) { $skipped++; continue; } // 부모 찾기 $parentId = null; if (! empty($catData['parent_code'])) { $parent = GlobalCategory::where('code_group', $catData['code_group']) ->where('code', $catData['parent_code']) ->first(); $parentId = $parent?->id; } GlobalCategory::create([ 'parent_id' => $parentId, 'code_group' => $catData['code_group'], 'code' => $catData['code'], 'name' => $catData['name'], 'sort_order' => $catData['sort_order'] ?? 0, 'description' => $catData['description'] ?? null, 'is_active' => $catData['is_active'] ?? true, ]); } else { // 테넌트 카테고리 $tenantId = $catData['tenant_id'] ?? $this->getTenantId(); $exists = Category::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('code_group', $catData['code_group']) ->where('code', $catData['code']) ->exists(); if ($exists) { $skipped++; continue; } // 부모 찾기 $parentId = null; if (! empty($catData['parent_code'])) { $parent = Category::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('code_group', $catData['code_group']) ->where('code', $catData['parent_code']) ->first(); $parentId = $parent?->id; } Category::create([ 'tenant_id' => $tenantId, 'parent_id' => $parentId, 'code_group' => $catData['code_group'], 'code' => $catData['code'], 'name' => $catData['name'], 'sort_order' => $catData['sort_order'] ?? 0, 'description' => $catData['description'] ?? null, 'is_active' => $catData['is_active'] ?? true, ]); } $imported++; } return response()->json([ 'success' => true, 'message' => "{$imported}개 카테고리가 동기화되었습니다.".($skipped > 0 ? " ({$skipped}개 스킵)" : ''), 'imported' => $imported, 'skipped' => $skipped, ]); } /** * Push (로컬 → 원격) */ public function push(Request $request): JsonResponse { $validated = $request->validate([ 'env' => 'required|string|in:dev,prod', 'type' => 'required|string|in:global,tenant', 'category_keys' => 'required|array|min:1', 'category_keys.*' => 'string', ]); $environments = $this->getEnvironments(); $env = $environments[$validated['env']] ?? null; if (! $env || empty($env['url'])) { return response()->json(['error' => '환경 설정이 없습니다.'], 400); } // 선택된 카테고리 조회 (타입 필터 적용) $localCategories = $this->getCategoryList($validated['type']); $selectedCategories = array_filter($localCategories, function ($cat) use ($validated) { $key = $this->makeCategoryKey($cat); return in_array($key, $validated['category_keys']); }); if (empty($selectedCategories)) { return response()->json(['error' => '선택된 카테고리가 없습니다.'], 400); } // 원격 서버로 전송 try { $response = Http::withHeaders([ 'X-Menu-Sync-Key' => $env['api_key'], 'Accept' => 'application/json', ])->post(rtrim($env['url'], '/').'/category-sync/import', [ 'categories' => array_values($selectedCategories), ]); if ($response->successful()) { return response()->json([ 'success' => true, 'message' => $response->json('message', '동기화 완료'), 'imported' => $response->json('imported', 0), 'skipped' => $response->json('skipped', 0), ]); } return response()->json([ 'error' => $response->json('error', '원격 서버 오류'), ], $response->status()); } catch (\Exception $e) { return response()->json(['error' => '연결 실패: '.$e->getMessage()], 500); } } /** * Pull (원격 → 로컬) */ public function pull(Request $request): JsonResponse { $validated = $request->validate([ 'env' => 'required|string|in:dev,prod', 'type' => 'required|string|in:global,tenant', 'category_keys' => 'required|array|min:1', 'category_keys.*' => 'string', ]); $environments = $this->getEnvironments(); $env = $environments[$validated['env']] ?? null; if (! $env || empty($env['url'])) { return response()->json(['error' => '환경 설정이 없습니다.'], 400); } // 원격 카테고리 조회 (타입 필터 적용) try { $remoteCategories = $this->fetchRemoteCategories($env, $validated['type']); } catch (\Exception $e) { return response()->json(['error' => $e->getMessage()], 500); } // 선택된 카테고리만 필터링 $selectedCategories = array_filter($remoteCategories, function ($cat) use ($validated) { $key = $this->makeCategoryKey($cat); return in_array($key, $validated['category_keys']); }); if (empty($selectedCategories)) { return response()->json(['error' => '선택된 카테고리를 찾을 수 없습니다.'], 400); } // 로컬에 Import $imported = 0; $skipped = 0; foreach ($selectedCategories as $catData) { if ($catData['is_global']) { $exists = GlobalCategory::where('code_group', $catData['code_group']) ->where('code', $catData['code']) ->exists(); if ($exists) { $skipped++; continue; } $parentId = null; if (! empty($catData['parent_code'])) { $parent = GlobalCategory::where('code_group', $catData['code_group']) ->where('code', $catData['parent_code']) ->first(); $parentId = $parent?->id; } GlobalCategory::create([ 'parent_id' => $parentId, 'code_group' => $catData['code_group'], 'code' => $catData['code'], 'name' => $catData['name'], 'sort_order' => $catData['sort_order'] ?? 0, 'description' => $catData['description'] ?? null, 'is_active' => $catData['is_active'] ?? true, ]); } else { $tenantId = $this->getTenantId(); $exists = Category::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('code_group', $catData['code_group']) ->where('code', $catData['code']) ->exists(); if ($exists) { $skipped++; continue; } $parentId = null; if (! empty($catData['parent_code'])) { $parent = Category::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('code_group', $catData['code_group']) ->where('code', $catData['parent_code']) ->first(); $parentId = $parent?->id; } Category::create([ 'tenant_id' => $tenantId, 'parent_id' => $parentId, 'code_group' => $catData['code_group'], 'code' => $catData['code'], 'name' => $catData['name'], 'sort_order' => $catData['sort_order'] ?? 0, 'description' => $catData['description'] ?? null, 'is_active' => $catData['is_active'] ?? true, ]); } $imported++; } return response()->json([ 'success' => true, 'message' => "{$imported}개 카테고리가 동기화되었습니다.".($skipped > 0 ? " ({$skipped}개 스킵)" : ''), 'imported' => $imported, 'skipped' => $skipped, ]); } /** * 카테고리 목록 조회 * * @param string $type 'global', 'tenant', or 'all' */ private function getCategoryList(string $type = 'all'): array { $tenantId = $this->getTenantId(); $categories = []; // 글로벌 카테고리 if ($type === 'global' || $type === 'all') { $globalCategories = GlobalCategory::whereNull('deleted_at') ->orderBy('code_group') ->orderBy('sort_order') ->get(); foreach ($globalCategories as $cat) { $parentCode = null; if ($cat->parent_id) { $parent = GlobalCategory::find($cat->parent_id); $parentCode = $parent?->code; } $categories[] = [ 'is_global' => true, 'tenant_id' => null, 'code_group' => $cat->code_group, 'code' => $cat->code, 'name' => $cat->name, 'parent_code' => $parentCode, 'sort_order' => $cat->sort_order, 'description' => $cat->description, 'is_active' => $cat->is_active, ]; } } // 테넌트 카테고리 if ($type === 'tenant' || $type === 'all') { $tenantCategories = Category::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->orderBy('code_group') ->orderBy('sort_order') ->get(); foreach ($tenantCategories as $cat) { $parentCode = null; if ($cat->parent_id) { $parent = Category::withoutGlobalScopes()->find($cat->parent_id); $parentCode = $parent?->code; } $categories[] = [ 'is_global' => false, 'tenant_id' => $cat->tenant_id, 'code_group' => $cat->code_group, 'code' => $cat->code, 'name' => $cat->name, 'parent_code' => $parentCode, 'sort_order' => $cat->sort_order, 'description' => $cat->description, 'is_active' => $cat->is_active, ]; } } return $categories; } /** * 원격 카테고리 조회 */ private function fetchRemoteCategories(array $env, string $type = 'all'): array { $response = Http::withHeaders([ 'X-Menu-Sync-Key' => $env['api_key'], 'Accept' => 'application/json', ])->timeout(10)->get(rtrim($env['url'], '/').'/category-sync/export', [ 'type' => $type, ]); if (! $response->successful()) { throw new \Exception('API 오류: HTTP '.$response->status()); } $data = $response->json(); if (! isset($data['categories'])) { throw new \Exception('잘못된 응답 형식'); } $this->remoteTenantName = $data['tenant_name'] ?? null; return $data['categories']; } /** * 카테고리 키 생성 (유니크 식별자) */ private function makeCategoryKey(array $cat): string { $typePart = $cat['is_global'] ? 'global' : "tenant:{$cat['tenant_id']}"; return "{$typePart}:{$cat['code_group']}:{$cat['code']}"; } /** * 차이점 계산 */ private function calculateDiff(array $localCategories, array $remoteCategories): array { $localKeys = array_map(fn ($c) => $this->makeCategoryKey($c), $localCategories); $remoteKeys = array_map(fn ($c) => $this->makeCategoryKey($c), $remoteCategories); return [ 'local_only' => array_values(array_diff($localKeys, $remoteKeys)), 'remote_only' => array_values(array_diff($remoteKeys, $localKeys)), 'both' => array_values(array_intersect($localKeys, $remoteKeys)), ]; } }