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)
-