From 8b7f0b9f7ffa90afe4ebc4a284dec37a2a63a5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 29 Jan 2026 00:31:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C/?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommonCodeSyncController, CategorySyncController 생성 - 환경설정은 메뉴 동기화와 공유 (TenantSetting) - Export/Import API 추가 (/common-code-sync, /category-sync) - Push(로컬→원격), Pull(원격→로컬) 양방향 동기화 - 동일 코드 존재 시 체크박스 비활성화 (충돌 방지) - 글로벌 + 테넌트 코드 모두 동기화 가능 - 공통코드/카테고리 관리 페이지에 동기화 버튼 추가 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/CategorySyncController.php | 490 ++++++++++++++++++ .../Controllers/CommonCodeSyncController.php | 388 ++++++++++++++ resources/views/categories/index.blade.php | 25 +- resources/views/categories/sync.blade.php | 369 +++++++++++++ resources/views/common-codes/index.blade.php | 25 +- resources/views/common-codes/sync.blade.php | 370 +++++++++++++ routes/web.php | 30 +- 7 files changed, 1680 insertions(+), 17 deletions(-) create mode 100644 app/Http/Controllers/CategorySyncController.php create mode 100644 app/Http/Controllers/CommonCodeSyncController.php create mode 100644 resources/views/categories/sync.blade.php create mode 100644 resources/views/common-codes/sync.blade.php 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) - - @endif + 동기화 + + @if($tenant) + + @endif + diff --git a/resources/views/categories/sync.blade.php b/resources/views/categories/sync.blade.php new file mode 100644 index 00000000..a9d9ea69 --- /dev/null +++ b/resources/views/categories/sync.blade.php @@ -0,0 +1,369 @@ +@extends('layouts.app') + +@section('title', '카테고리 동기화') + +@section('content') +
+ +
+
+

카테고리 동기화

+

로컬과 원격 환경 간 카테고리를 동기화합니다.

+
+ + + + + + 환경 설정 (메뉴 동기화) + +
+ + + + + @if($remoteError) +
+ + + + 원격 서버 연결 실패: {{ $remoteError }} +
+ @endif + + @if(empty($environments[$selectedEnv]['url'])) +
+ + + + 메뉴 동기화 환경 설정에서 {{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }} 서버 URL을 설정해주세요. +
+ @endif + + + @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+
+
+ + + + + +
+

로컬에만 있음

+

{{ count($diff['local_only']) }}

+
+
+
+
+
+ + + + + +
+

양쪽 모두

+

{{ count($diff['both']) }}

+
+
+
+
+
+ + + + + +
+

원격에만 있음

+

{{ count($diff['remote_only']) }}

+
+
+
+
+ @endif + + +
+ +
+
+
+ + + + + + +

로컬 (현재)

+ ({{ count($localCategories) }}개) +
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+ 0개 선택 + +
+ @endif +
+
+ + + + + + + + + + + + + @forelse($localCategories as $cat) + @php + $typePart = $cat['is_global'] ? 'global' : 'tenant:' . $cat['tenant_id']; + $key = $typePart . ':' . $cat['code_group'] . ':' . $cat['code']; + $inBoth = in_array($key, $diff['both']); + $localOnly = in_array($key, $diff['local_only']); + @endphp + + + + + + + + + @empty + + + + @endforelse + +
타입그룹코드이름상위
+ + + @if($cat['is_global']) + 글로벌 + @else + 테넌트 + @endif + {{ $cat['code_group'] }}{{ $cat['code'] }}{{ $cat['name'] }}{{ $cat['parent_code'] ?? '-' }}
카테고리가 없습니다.
+
+
+ + +
+
+
+ + + + + + +

{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}

+ ({{ count($remoteCategories) }}개) +
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+ 0개 선택 + +
+ @endif +
+
+ @if(empty($environments[$selectedEnv]['url'])) +
+

환경을 설정해주세요

+
+ @elseif($remoteError) +
+

연결 실패

+
+ @elseif(empty($remoteCategories)) +
+

카테고리가 없습니다

+
+ @else + + + + + + + + + + + + + @foreach($remoteCategories as $cat) + @php + $typePart = $cat['is_global'] ? 'global' : 'tenant:' . ($cat['tenant_id'] ?? ''); + $key = $typePart . ':' . $cat['code_group'] . ':' . $cat['code']; + $inBoth = in_array($key, $diff['both']); + $remoteOnly = in_array($key, $diff['remote_only']); + @endphp + + + + + + + + + @endforeach + +
타입그룹코드이름상위
+ + + @if($cat['is_global']) + 글로벌 + @else + 테넌트 + @endif + {{ $cat['code_group'] }}{{ $cat['code'] }}{{ $cat['name'] }}{{ $cat['parent_code'] ?? '-' }}
+ @endif +
+
+
+
+@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/common-codes/index.blade.php b/resources/views/common-codes/index.blade.php index 43ae126b..7daa3483 100644 --- a/resources/views/common-codes/index.blade.php +++ b/resources/views/common-codes/index.blade.php @@ -22,16 +22,25 @@ @endif

- @if($tenant) - - @endif + 동기화 + + @if($tenant) + + @endif + diff --git a/resources/views/common-codes/sync.blade.php b/resources/views/common-codes/sync.blade.php new file mode 100644 index 00000000..ef9335e8 --- /dev/null +++ b/resources/views/common-codes/sync.blade.php @@ -0,0 +1,370 @@ +@extends('layouts.app') + +@section('title', '공통코드 동기화') + +@section('content') +
+ +
+
+

공통코드 동기화

+

로컬과 원격 환경 간 공통코드를 동기화합니다.

+
+ + + + + + 환경 설정 (메뉴 동기화) + +
+ + + + + @if($remoteError) +
+ + + + 원격 서버 연결 실패: {{ $remoteError }} +
+ @endif + + @if(empty($environments[$selectedEnv]['url'])) +
+ + + + 메뉴 동기화 환경 설정에서 {{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }} 서버 URL을 설정해주세요. +
+ @endif + + + @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+
+
+ + + + + +
+

로컬에만 있음

+

{{ count($diff['local_only']) }}

+
+
+
+
+
+ + + + + +
+

양쪽 모두

+

{{ count($diff['both']) }}

+
+
+
+
+
+ + + + + +
+

원격에만 있음

+

{{ count($diff['remote_only']) }}

+
+
+
+
+ @endif + + +
+ +
+
+
+ + + + + + +

로컬 (현재)

+ ({{ count($localCodes) }}개) +
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+ 0개 선택 + +
+ @endif +
+
+ + + + + + + + + + + + @php + $localCodeMap = []; + foreach ($localCodes as $code) { + $key = ($code['tenant_id'] ?? 'global') . ':' . $code['code_group'] . ':' . $code['code']; + $localCodeMap[$key] = $code; + } + @endphp + @forelse($localCodes as $code) + @php + $key = ($code['tenant_id'] ?? 'global') . ':' . $code['code_group'] . ':' . $code['code']; + $inBoth = in_array($key, $diff['both']); + $localOnly = in_array($key, $diff['local_only']); + @endphp + + + + + + + + @empty + + + + @endforelse + +
타입그룹코드이름
+ + + @if($code['is_global'] ?? false) + 글로벌 + @else + 테넌트 + @endif + {{ $code['code_group'] }}{{ $code['code'] }}{{ $code['name'] }}
코드가 없습니다.
+
+
+ + +
+
+
+ + + + + + +

{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}

+ ({{ count($remoteCodes) }}개) +
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError) +
+ 0개 선택 + +
+ @endif +
+
+ @if(empty($environments[$selectedEnv]['url'])) +
+

환경을 설정해주세요

+
+ @elseif($remoteError) +
+

연결 실패

+
+ @elseif(empty($remoteCodes)) +
+

코드가 없습니다

+
+ @else + + + + + + + + + + + + @foreach($remoteCodes as $code) + @php + $key = ($code['tenant_id'] ?? 'global') . ':' . $code['code_group'] . ':' . $code['code']; + $inBoth = in_array($key, $diff['both']); + $remoteOnly = in_array($key, $diff['remote_only']); + @endphp + + + + + + + + @endforeach + +
타입그룹코드이름
+ + + @if($code['is_global'] ?? false) + 글로벌 + @else + 테넌트 + @endif + {{ $code['code_group'] }}{{ $code['code'] }}{{ $code['name'] }}
+ @endif +
+
+
+
+@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 418eb3d9..ccee5b18 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,8 @@ use App\Http\Controllers\Lab\StrategyController; use App\Http\Controllers\MenuController; use App\Http\Controllers\MenuSyncController; +use App\Http\Controllers\CommonCodeSyncController; +use App\Http\Controllers\CategorySyncController; use App\Http\Controllers\PermissionController; use App\Http\Controllers\PostController; use App\Http\Controllers\ProfileController; @@ -65,6 +67,16 @@ Route::post('/import', [MenuSyncController::class, 'import']); }); +Route::prefix('common-code-sync')->group(function () { + Route::get('/export', [CommonCodeSyncController::class, 'export']); + Route::post('/import', [CommonCodeSyncController::class, 'import']); +}); + +Route::prefix('category-sync')->group(function () { + Route::get('/export', [CategorySyncController::class, 'export']); + Route::post('/import', [CategorySyncController::class, 'import']); +}); + /* |-------------------------------------------------------------------------- | Authenticated Routes (인증 필요) @@ -310,6 +322,13 @@ Route::post('/{id}/toggle', [CommonCodeController::class, 'toggle'])->name('toggle'); Route::post('/{id}/copy', [CommonCodeController::class, 'copy'])->name('copy'); Route::delete('/{id}', [CommonCodeController::class, 'destroy'])->name('destroy'); + + // 공통코드 동기화 + Route::prefix('sync')->name('sync.')->group(function () { + Route::get('/', [CommonCodeSyncController::class, 'index'])->name('index'); + Route::post('/push', [CommonCodeSyncController::class, 'push'])->name('push'); + Route::post('/pull', [CommonCodeSyncController::class, 'pull'])->name('pull'); + }); }); // 문서양식 관리 @@ -341,7 +360,16 @@ Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr'); // 카테고리 관리 - Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); + Route::prefix('categories')->name('categories.')->group(function () { + Route::get('/', [CategoryController::class, 'index'])->name('index'); + + // 카테고리 동기화 + Route::prefix('sync')->name('sync.')->group(function () { + Route::get('/', [CategorySyncController::class, 'index'])->name('index'); + Route::post('/push', [CategorySyncController::class, 'push'])->name('push'); + Route::post('/pull', [CategorySyncController::class, 'pull'])->name('pull'); + }); + }); /* |--------------------------------------------------------------------------