diff --git a/app/Http/Controllers/CategorySyncController.php b/app/Http/Controllers/CategorySyncController.php new file mode 100644 index 00000000..fc05cd42 --- /dev/null +++ b/app/Http/Controllers/CategorySyncController.php @@ -0,0 +1,490 @@ +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'); + + // 로컬 카테고리 조회 + $localCategories = $this->getCategoryList(); + + // 원격 카테고리 조회 + $remoteCategories = []; + $remoteError = null; + + if (! empty($environments[$selectedEnv]['url'])) { + try { + $remoteCategories = $this->fetchRemoteCategories($environments[$selectedEnv]); + } catch (\Exception $e) { + $remoteError = $e->getMessage(); + } + } + + // 차이점 계산 + $diff = $this->calculateDiff($localCategories, $remoteCategories); + + return view('categories.sync', [ + 'environments' => $environments, + 'selectedEnv' => $selectedEnv, + 'localCategories' => $localCategories, + 'remoteCategories' => $remoteCategories, + 'remoteError' => $remoteError, + 'diff' => $diff, + ]); + } + + /** + * 카테고리 Export API (다른 환경에서 호출) + */ + public function export(Request $request): JsonResponse + { + // API Key 검증 + $apiKey = $request->header('X-Menu-Sync-Key'); + $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY')); + + if (empty($validKey) || $apiKey !== $validKey) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $categories = $this->getCategoryList(); + + return response()->json([ + 'success' => true, + 'environment' => config('app.env'), + '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', env('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', + '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(); + $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', + '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); + } 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, + ]); + } + + /** + * 카테고리 목록 조회 (글로벌 + 테넌트) + */ + private function getCategoryList(): array + { + $tenantId = $this->getTenantId(); + $categories = []; + + // 글로벌 카테고리 + $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, + ]; + } + + // 테넌트 카테고리 + $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): array + { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(10)->get(rtrim($env['url'], '/') . '/category-sync/export'); + + if (! $response->successful()) { + throw new \Exception('API 오류: HTTP ' . $response->status()); + } + + $data = $response->json(); + if (! isset($data['categories'])) { + throw new \Exception('잘못된 응답 형식'); + } + + 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)), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/CommonCodeSyncController.php b/app/Http/Controllers/CommonCodeSyncController.php new file mode 100644 index 00000000..ba1b8f92 --- /dev/null +++ b/app/Http/Controllers/CommonCodeSyncController.php @@ -0,0 +1,388 @@ +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('common-codes.sync.index')); + } + + $environments = $this->getEnvironments(); + $selectedEnv = $request->get('env', 'dev'); + + // 로컬 코드 조회 + $localCodes = $this->getCodeList(); + + // 원격 코드 조회 + $remoteCodes = []; + $remoteError = null; + + if (! empty($environments[$selectedEnv]['url'])) { + try { + $remoteCodes = $this->fetchRemoteCodes($environments[$selectedEnv]); + } catch (\Exception $e) { + $remoteError = $e->getMessage(); + } + } + + // 차이점 계산 + $diff = $this->calculateDiff($localCodes, $remoteCodes); + + return view('common-codes.sync', [ + 'environments' => $environments, + 'selectedEnv' => $selectedEnv, + 'localCodes' => $localCodes, + 'remoteCodes' => $remoteCodes, + 'remoteError' => $remoteError, + 'diff' => $diff, + ]); + } + + /** + * 공통코드 Export API (다른 환경에서 호출) + */ + public function export(Request $request): JsonResponse + { + // API Key 검증 + $apiKey = $request->header('X-Menu-Sync-Key'); + $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY')); + + if (empty($validKey) || $apiKey !== $validKey) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $codes = $this->getCodeList(); + + return response()->json([ + 'success' => true, + 'environment' => config('app.env'), + 'exported_at' => now()->toIso8601String(), + 'codes' => $codes, + ]); + } + + /** + * 공통코드 Import API (다른 환경에서 호출) + */ + public function import(Request $request): JsonResponse + { + // API Key 검증 + $apiKey = $request->header('X-Menu-Sync-Key'); + $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY')); + + if (empty($validKey) || $apiKey !== $validKey) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $validated = $request->validate([ + 'codes' => 'required|array', + 'codes.*.tenant_id' => 'nullable|integer', + 'codes.*.code_group' => 'required|string|max:50', + 'codes.*.code' => 'required|string|max:50', + 'codes.*.name' => 'required|string|max:100', + 'codes.*.sort_order' => 'nullable|integer', + 'codes.*.attributes' => 'nullable|array', + 'codes.*.is_active' => 'nullable|boolean', + ]); + + $imported = 0; + $skipped = 0; + + foreach ($validated['codes'] as $codeData) { + // 동일 코드 존재 확인 + $exists = CommonCode::query() + ->where('tenant_id', $codeData['tenant_id'] ?? null) + ->where('code_group', $codeData['code_group']) + ->where('code', $codeData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + CommonCode::create([ + 'tenant_id' => $codeData['tenant_id'] ?? null, + 'code_group' => $codeData['code_group'], + 'code' => $codeData['code'], + 'name' => $codeData['name'], + 'sort_order' => $codeData['sort_order'] ?? 0, + 'attributes' => $codeData['attributes'] ?? null, + 'is_active' => $codeData['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', + 'code_keys' => 'required|array|min:1', + 'code_keys.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 선택된 코드 조회 + $localCodes = $this->getCodeList(); + $selectedCodes = array_filter($localCodes, function ($code) use ($validated) { + $key = $this->makeCodeKey($code); + return in_array($key, $validated['code_keys']); + }); + + if (empty($selectedCodes)) { + return response()->json(['error' => '선택된 코드가 없습니다.'], 400); + } + + // 원격 서버로 전송 + try { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->post(rtrim($env['url'], '/') . '/common-code-sync/import', [ + 'codes' => array_values($selectedCodes), + ]); + + 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', + 'code_keys' => 'required|array|min:1', + 'code_keys.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 원격 코드 조회 + try { + $remoteCodes = $this->fetchRemoteCodes($env); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + + // 선택된 코드만 필터링 + $selectedCodes = array_filter($remoteCodes, function ($code) use ($validated) { + $key = $this->makeCodeKey($code); + return in_array($key, $validated['code_keys']); + }); + + if (empty($selectedCodes)) { + return response()->json(['error' => '선택된 코드를 찾을 수 없습니다.'], 400); + } + + // 로컬에 Import + $imported = 0; + $skipped = 0; + + foreach ($selectedCodes as $codeData) { + // 동일 코드 존재 확인 + $exists = CommonCode::query() + ->where('tenant_id', $codeData['tenant_id'] ?? null) + ->where('code_group', $codeData['code_group']) + ->where('code', $codeData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + CommonCode::create([ + 'tenant_id' => $codeData['tenant_id'] ?? null, + 'code_group' => $codeData['code_group'], + 'code' => $codeData['code'], + 'name' => $codeData['name'], + 'sort_order' => $codeData['sort_order'] ?? 0, + 'attributes' => $codeData['attributes'] ?? null, + 'is_active' => $codeData['is_active'] ?? true, + ]); + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''), + 'imported' => $imported, + 'skipped' => $skipped, + ]); + } + + /** + * 코드 목록 조회 (글로벌 + 테넌트) + */ + private function getCodeList(): array + { + $tenantId = $this->getTenantId(); + + // 글로벌 코드 (tenant_id IS NULL) + $globalCodes = CommonCode::query() + ->whereNull('tenant_id') + ->orderBy('code_group') + ->orderBy('sort_order') + ->get(); + + // 테넌트 코드 + $tenantCodes = CommonCode::query() + ->where('tenant_id', $tenantId) + ->orderBy('code_group') + ->orderBy('sort_order') + ->get(); + + $codes = []; + + foreach ($globalCodes as $code) { + $codes[] = [ + 'tenant_id' => null, + 'code_group' => $code->code_group, + 'code' => $code->code, + 'name' => $code->name, + 'sort_order' => $code->sort_order, + 'attributes' => $code->attributes, + 'is_active' => $code->is_active, + 'is_global' => true, + ]; + } + + foreach ($tenantCodes as $code) { + $codes[] = [ + 'tenant_id' => $code->tenant_id, + 'code_group' => $code->code_group, + 'code' => $code->code, + 'name' => $code->name, + 'sort_order' => $code->sort_order, + 'attributes' => $code->attributes, + 'is_active' => $code->is_active, + 'is_global' => false, + ]; + } + + return $codes; + } + + /** + * 원격 코드 조회 + */ + private function fetchRemoteCodes(array $env): array + { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(10)->get(rtrim($env['url'], '/') . '/common-code-sync/export'); + + if (! $response->successful()) { + throw new \Exception('API 오류: HTTP ' . $response->status()); + } + + $data = $response->json(); + if (! isset($data['codes'])) { + throw new \Exception('잘못된 응답 형식'); + } + + return $data['codes']; + } + + /** + * 코드 키 생성 (유니크 식별자) + */ + private function makeCodeKey(array $code): string + { + $tenantPart = $code['tenant_id'] ?? 'global'; + return "{$tenantPart}:{$code['code_group']}:{$code['code']}"; + } + + /** + * 차이점 계산 + */ + private function calculateDiff(array $localCodes, array $remoteCodes): array + { + $localKeys = array_map(fn($c) => $this->makeCodeKey($c), $localCodes); + $remoteKeys = array_map(fn($c) => $this->makeCodeKey($c), $remoteCodes); + + return [ + 'local_only' => array_values(array_diff($localKeys, $remoteKeys)), + 'remote_only' => array_values(array_diff($remoteKeys, $localKeys)), + 'both' => array_values(array_intersect($localKeys, $remoteKeys)), + ]; + } +} \ No newline at end of file diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php index 4b4fd318..cdc37599 100644 --- a/resources/views/categories/index.blade.php +++ b/resources/views/categories/index.blade.php @@ -22,16 +22,25 @@ @endif
- @if($tenant) -