diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index c8e8ce82..231a7cdc 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3394,13 +3394,23 @@ export async function copyMenu( } : undefined; + // 추가 복사 옵션 (카테고리, 코드, 채번규칙 등) + const additionalCopyOptions = req.body.additionalCopyOptions + ? { + copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true, + copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true, + copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true, + } + : undefined; + // 메뉴 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid, 10), targetCompanyCode, userId, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); logger.info("✅ 메뉴 복사 API 성공"); diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b12d7a4a..b5266377 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,12 +10,27 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; + copiedCodeCategories: number; + copiedCodes: number; + copiedNumberingRules: number; + copiedCategoryMappings: number; + copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; warnings: string[]; } +/** + * 추가 복사 옵션 + */ +export interface AdditionalCopyOptions { + copyCodeCategory?: boolean; + copyNumberingRules?: boolean; + copyCategoryMapping?: boolean; + copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 +} + /** * 메뉴 정보 */ @@ -431,12 +446,13 @@ export class MenuCopyService { * properties 내부 참조 업데이트 */ /** - * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId, numberingRuleId 재귀 업데이트 */ private updateReferencesInProperties( properties: any, screenIdMap: Map, - flowIdMap: Map + flowIdMap: Map, + numberingRuleIdMap?: Map ): any { if (!properties) return properties; @@ -444,7 +460,7 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); return updated; } @@ -456,7 +472,8 @@ export class MenuCopyService { obj: any, screenIdMap: Map, flowIdMap: Map, - path: string = "" + path: string = "", + numberingRuleIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; @@ -467,7 +484,8 @@ export class MenuCopyService { item, screenIdMap, flowIdMap, - `${path}[${index}]` + `${path}[${index}]`, + numberingRuleIdMap ); }); return; @@ -518,13 +536,25 @@ export class MenuCopyService { } } + // numberingRuleId 매핑 (문자열) + if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) { + const newRuleId = numberingRuleIdMap.get(value); + if (newRuleId) { + obj[key] = newRuleId; + logger.info( + ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` + ); + } + } + // 재귀 호출 if (typeof value === "object" && value !== null) { this.recursiveUpdateReferences( value, screenIdMap, flowIdMap, - currentPath + currentPath, + numberingRuleIdMap ); } } @@ -534,6 +564,8 @@ export class MenuCopyService { * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) * * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + * - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제 + * - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지) */ private async deleteExistingCopy( sourceMenuObjid: number, @@ -542,9 +574,9 @@ export class MenuCopyService { ): Promise { logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); - // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + // 1. 원본 메뉴 정보 확인 const sourceMenuResult = await client.query( - `SELECT menu_name_kor, menu_name_eng + `SELECT menu_name_kor, menu_name_eng, parent_obj_id FROM menu_info WHERE objid = $1`, [sourceMenuObjid] @@ -556,14 +588,15 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; + const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) - const existingMenuResult = await client.query<{ objid: number }>( - `SELECT objid + // 최상위/하위 구분 없이 모든 복사본 검색 + const existingMenuResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 - AND company_code = $2 - AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + AND company_code = $2`, [sourceMenuObjid, targetCompanyCode] ); @@ -573,11 +606,14 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; + const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; + logger.info( - `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` ); - // 3. 기존 메뉴 트리 수집 + // 3. 기존 메뉴 트리 수집 (해당 메뉴 + 하위 메뉴 모두) const existingMenus = await this.collectMenuTree(existingMenuObjid, client); const existingMenuIds = existingMenus.map((m) => m.objid); @@ -595,16 +631,7 @@ export class MenuCopyService { // 5. 삭제 순서 (외래키 제약 고려) - // 5-1. 화면 레이아웃 삭제 - if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, - [screenIds] - ); - logger.info(` ✅ 화면 레이아웃 삭제 완료`); - } - - // 5-2. 화면-메뉴 할당 삭제 + // 5-1. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제) await client.query( `DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, @@ -612,23 +639,47 @@ export class MenuCopyService { ); logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); - // 5-3. 화면 정의 삭제 + // 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외) if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_definitions + // 다른 메뉴에서도 사용 중인 화면 ID 조회 + const sharedScreensResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_menu_assignments WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - logger.info(` ✅ 화면 정의 삭제 완료`); + const sharedScreenIds = new Set(sharedScreensResult.rows.map(r => r.screen_id)); + + // 공유되지 않은 화면만 삭제 + const screensToDelete = screenIds.filter(id => !sharedScreenIds.has(id)); + + if (screensToDelete.length > 0) { + // 레이아웃 삭제 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screensToDelete] + ); + + // 화면 정의 삭제 + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screensToDelete, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); + } + + if (sharedScreenIds.size > 0) { + logger.info(` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)`); + } } - // 5-4. 메뉴 권한 삭제 + // 5-3. 메뉴 권한 삭제 await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ existingMenuIds, ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ @@ -650,7 +701,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + additionalCopyOptions?: AdditionalCopyOptions ): Promise { logger.info(` 🚀 ============================================ @@ -702,6 +754,36 @@ export class MenuCopyService { client ); + // === 2.5단계: 채번 규칙 복사 (화면 복사 전에 실행하여 참조 업데이트 가능) === + let copiedCodeCategories = 0; + let copiedCodes = 0; + let copiedNumberingRules = 0; + let copiedCategoryMappings = 0; + let copiedTableTypeColumns = 0; + let numberingRuleIdMap = new Map(); + + const menuObjids = menus.map((m) => m.objid); + + // 메뉴 ID 맵을 먼저 생성 (채번 규칙 복사에 필요) + const tempMenuIdMap = new Map(); + let tempObjId = await this.getNextMenuObjid(client); + for (const menu of menus) { + tempMenuIdMap.set(menu.objid, tempObjId++); + } + + if (additionalCopyOptions?.copyNumberingRules) { + logger.info("\n📦 [2.5단계] 채번 규칙 복사 (화면 복사 전)"); + const ruleResult = await this.copyNumberingRulesWithMap( + menuObjids, + tempMenuIdMap, + targetCompanyCode, + userId, + client + ); + copiedNumberingRules = ruleResult.copiedCount; + numberingRuleIdMap = ruleResult.ruleIdMap; + } + // === 3단계: 화면 복사 === logger.info("\n📄 [3단계] 화면 복사"); const screenIdMap = await this.copyScreens( @@ -710,7 +792,8 @@ export class MenuCopyService { flowIdMap, userId, client, - screenNameConfig + screenNameConfig, + numberingRuleIdMap ); // === 4단계: 메뉴 복사 === @@ -718,6 +801,7 @@ export class MenuCopyService { const menuIdMap = await this.copyMenus( menus, sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 + sourceCompanyCode, targetCompanyCode, screenIdMap, userId, @@ -734,6 +818,46 @@ export class MenuCopyService { client ); + // === 6단계: 추가 복사 옵션 처리 (코드 카테고리, 카테고리 매핑) === + if (additionalCopyOptions) { + // 6-1. 코드 카테고리 + 코드 복사 + if (additionalCopyOptions.copyCodeCategory) { + logger.info("\n📦 [6-1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // 6-2. 카테고리 매핑 + 값 복사 + if (additionalCopyOptions.copyCategoryMapping) { + logger.info("\n📦 [6-2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // 6-3. 테이블 타입관리 입력타입 설정 복사 + if (additionalCopyOptions.copyTableTypeColumns) { + logger.info("\n📦 [6-3단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), // 원본 화면 IDs + sourceCompanyCode, + targetCompanyCode, + client + ); + } + } + // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -743,6 +867,11 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, + copiedCodeCategories, + copiedCodes, + copiedNumberingRules, + copiedCategoryMappings, + copiedTableTypeColumns, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -755,8 +884,11 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. + - 코드 카테고리: ${copiedCodeCategories}개 + - 코드: ${copiedCodes}개 + - 채번규칙: ${copiedNumberingRules}개 + - 카테고리 매핑: ${copiedCategoryMappings}개 + - 테이블 타입 설정: ${copiedTableTypeColumns}개 ============================================ `); @@ -949,7 +1081,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + numberingRuleIdMap?: Map ): Promise> { const screenIdMap = new Map(); @@ -984,7 +1117,7 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; // 2) 기존 복사본 찾기: source_screen_id로 검색 - const existingCopyResult = await client.query<{ + let existingCopyResult = await client.query<{ screen_id: number; screen_name: string; updated_date: Date; @@ -996,6 +1129,36 @@ export class MenuCopyService { [originalScreenId, targetCompanyCode] ); + // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && screenDef.screen_name) { + existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE screen_name = $1 + AND table_name = $2 + AND company_code = $3 + AND source_screen_id IS NULL + AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_name, screenDef.table_name, targetCompanyCode] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) + await client.query( + `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, + [originalScreenId, existingCopyResult.rows[0].screen_id] + ); + logger.info( + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id} ← ${originalScreenId}` + ); + } + } + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { @@ -1185,7 +1348,8 @@ export class MenuCopyService { const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, - flowIdMap + flowIdMap, + numberingRuleIdMap ); await client.query( @@ -1332,12 +1496,76 @@ export class MenuCopyService { return screenCode; } + /** + * 대상 회사에서 부모 메뉴 찾기 + * - 원본 메뉴의 parent_obj_id를 source_menu_objid로 가진 메뉴를 대상 회사에서 검색 + * - 2레벨 이하 메뉴 복사 시 기존에 복사된 부모 메뉴와 연결하기 위함 + */ + private async findParentMenuInTargetCompany( + originalParentObjId: number, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + // 1. 대상 회사에서 source_menu_objid가 원본 부모 ID인 메뉴 찾기 + const result = await client.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [originalParentObjId, targetCompanyCode] + ); + + if (result.rows.length > 0) { + return result.rows[0].objid; + } + + // 2. source_menu_objid로 못 찾으면, 동일 원본 회사에서 복사된 메뉴 중 같은 이름으로 찾기 (fallback) + // 원본 부모 메뉴 정보 조회 + const parentMenuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [originalParentObjId] + ); + + if (parentMenuResult.rows.length === 0) { + return null; + } + + const parentMenu = parentMenuResult.rows[0]; + + // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 + const sameNameResult = await client.query<{ objid: number }>( + `SELECT m.objid FROM menu_info m + WHERE m.menu_name_kor = $1 + AND m.company_code = $2 + AND m.source_menu_objid IS NOT NULL + AND EXISTS ( + SELECT 1 FROM menu_info orig + WHERE orig.objid = m.source_menu_objid + AND orig.company_code = $3 + ) + LIMIT 1`, + [parentMenu.menu_name_kor, targetCompanyCode, sourceCompanyCode] + ); + + if (sameNameResult.rows.length > 0) { + logger.info( + ` 📎 이름으로 부모 메뉴 찾음: "${parentMenu.menu_name_kor}" → objid: ${sameNameResult.rows[0].objid}` + ); + return sameNameResult.rows[0].objid; + } + + return null; + } + /** * 메뉴 복사 */ private async copyMenus( menus: Menu[], rootMenuObjid: number, + sourceCompanyCode: string, targetCompanyCode: string, screenIdMap: Map, userId: string, @@ -1357,27 +1585,106 @@ export class MenuCopyService { for (const menu of sortedMenus) { try { - // 새 objid 생성 - const newObjId = await this.getNextMenuObjid(client); + // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) + // 1차: source_menu_objid로 검색 + let existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [menu.objid, targetCompanyCode] + ); - // parent_obj_id 재매핑 - // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { + existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE menu_name_kor = $1 + AND company_code = $2 + AND menu_type = $3 + AND source_menu_objid IS NULL + LIMIT 1`, + [menu.menu_name_kor, targetCompanyCode, menu.menu_type] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_menu_objid 업데이트 (마이그레이션) + await client.query( + `UPDATE menu_info SET source_menu_objid = $1 WHERE objid = $2`, + [menu.objid, existingCopyResult.rows[0].objid] + ); + logger.info( + ` 📝 기존 메뉴에 source_menu_objid 추가: ${existingCopyResult.rows[0].objid} ← ${menu.objid}` + ); + } + } + + // parent_obj_id 계산 (신규/재연결 모두 필요) let newParentObjId: number | null; if (!menu.parent_obj_id || menu.parent_obj_id === 0) { newParentObjId = 0; // 최상위 메뉴는 항상 0 } else { - newParentObjId = - menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + // 1. 현재 복사 세션에서 부모가 이미 복사되었는지 확인 + newParentObjId = menuIdMap.get(menu.parent_obj_id) || null; + + // 2. 현재 세션에서 못 찾으면, 대상 회사에서 기존에 복사된 부모 찾기 + if (!newParentObjId) { + const existingParent = await this.findParentMenuInTargetCompany( + menu.parent_obj_id, + sourceCompanyCode, + targetCompanyCode, + client + ); + + if (existingParent) { + newParentObjId = existingParent; + logger.info( + ` 🔗 기존 부모 메뉴 연결: 원본 ${menu.parent_obj_id} → 대상 ${existingParent}` + ); + } else { + // 3. 부모를 못 찾으면 최상위로 설정 (경고 로그) + newParentObjId = 0; + logger.warn( + ` ⚠️ 부모 메뉴를 찾을 수 없음: ${menu.parent_obj_id} - 최상위로 생성됨` + ); + } + } } - // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) - // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 - const isRootMenu = String(menu.objid) === String(rootMenuObjid); - const sourceMenuObjid = isRootMenu ? menu.objid : null; + if (existingCopyResult.rows.length > 0) { + // === 이미 복사된 메뉴가 있는 경우: 재연결만 === + const existingMenu = existingCopyResult.rows[0]; + const existingObjId = existingMenu.objid; + const existingParentId = existingMenu.parent_obj_id; - if (sourceMenuObjid) { + // 부모가 다르면 업데이트 (고아 메뉴 재연결) + if (existingParentId !== newParentObjId) { + await client.query( + `UPDATE menu_info SET parent_obj_id = $1, writer = $2 WHERE objid = $3`, + [newParentObjId, userId, existingObjId] + ); + logger.info( + ` ♻️ 메뉴 재연결: ${menu.objid} → ${existingObjId} (${menu.menu_name_kor}), parent: ${existingParentId} → ${newParentObjId}` + ); + } else { + logger.info( + ` ⏭️ 메뉴 이미 존재 (스킵): ${menu.objid} → ${existingObjId} (${menu.menu_name_kor})` + ); + } + + menuIdMap.set(menu.objid, existingObjId); + continue; + } + + // === 신규 메뉴 복사 === + const newObjId = await this.getNextMenuObjid(client); + + // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) + const sourceMenuObjid = menu.objid; + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + + if (isRootMenu) { logger.info( - ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (복사 시작 메뉴)` ); } @@ -1486,4 +1793,430 @@ export class MenuCopyService { logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); } + /** + * 코드 카테고리 + 코드 복사 + */ + private async copyCodeCategoriesAndCodes( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCategories: number; copiedCodes: number }> { + let copiedCategories = 0; + let copiedCodes = 0; + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 1. 코드 카테고리 조회 + const categoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const category of categoriesResult.rows) { + // 대상 회사에 같은 category_code가 이미 있는지 확인 + const existingCategory = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = $1 AND company_code = $2`, + [category.category_code, targetCompanyCode] + ); + + if (existingCategory.rows.length > 0) { + logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`); + continue; + } + + // 카테고리 복사 + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, + [ + category.category_code, + category.category_name, + category.category_name_eng, + category.description, + category.sort_order, + category.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ] + ); + copiedCategories++; + logger.info(` ✅ 코드 카테고리 복사: ${category.category_code}`); + + // 2. 해당 카테고리의 코드 조회 및 복사 + const codesResult = await client.query( + `SELECT * FROM code_info + WHERE code_category = $1 AND menu_objid = $2`, + [category.category_code, menuObjid] + ); + + for (const code of codesResult.rows) { + // 대상 회사에 같은 code_value가 이미 있는지 확인 + const existingCode = await client.query( + `SELECT code_value FROM code_info + WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, + [category.category_code, code.code_value, targetCompanyCode] + ); + + if (existingCode.rows.length > 0) { + logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`); + continue; + } + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)`, + [ + category.category_code, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ] + ); + copiedCodes++; + } + logger.info(` ↳ 코드 ${codesResult.rows.length}개 복사 완료`); + } + } + + logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); + return { copiedCategories, copiedCodes }; + } + + /** + * 채번 규칙 복사 (ID 매핑 반환 버전) + * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 + */ + private async copyNumberingRulesWithMap( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCount: number; ruleIdMap: Map }> { + let copiedCount = 0; + const ruleIdMap = new Map(); + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 채번 규칙 조회 + const rulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const rule of rulesResult.rows) { + // 대상 회사에 같은 rule_id가 이미 있는지 확인 + const existingRule = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = $1 AND company_code = $2`, + [rule.rule_id, targetCompanyCode] + ); + + if (existingRule.rows.length > 0) { + logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + // 기존 rule_id도 매핑에 추가 (동일한 ID 유지) + ruleIdMap.set(rule.rule_id, rule.rule_id); + continue; + } + + // 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거) + const originalSuffix = rule.rule_id.includes('_') + ? rule.rule_id.replace(/^[^_]*_/, '') + : rule.rule_id; + const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + + // 매핑 저장 (원본 rule_id → 새 rule_id) + ruleIdMap.set(rule.rule_id, newRuleId); + + // 채번 규칙 복사 + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12, $13)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 0, // 시퀀스는 0부터 시작 + rule.table_name, + rule.column_name, + targetCompanyCode, + userId, + newMenuObjid, + rule.scope_type, + null, // 마지막 생성일은 null로 초기화 + ] + ); + copiedCount++; + logger.info(` ✅ 채번규칙 복사: ${rule.rule_id} → ${newRuleId}`); + + // 채번 규칙 파트 복사 + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, + [rule.rule_id] + ); + + for (const part of partsResult.rows) { + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config, + part.manual_config, + targetCompanyCode, + ] + ); + } + logger.info(` ↳ 채번규칙 파트 ${partsResult.rows.length}개 복사`); + } + } + + logger.info(`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`); + return { copiedCount, ruleIdMap }; + } + + /** + * 카테고리 매핑 + 값 복사 + */ + private async copyCategoryMappingsAndValues( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + let copiedCount = 0; + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 1. 카테고리 컬럼 매핑 조회 + const mappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const mapping of mappingsResult.rows) { + // 대상 회사에 같은 매핑이 이미 있는지 확인 + const existingMapping = await client.query( + `SELECT mapping_id FROM category_column_mapping + WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3`, + [mapping.table_name, mapping.logical_column_name, targetCompanyCode] + ); + + let newMappingId: number; + + if (existingMapping.rows.length > 0) { + logger.info(` ♻️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.logical_column_name}`); + newMappingId = existingMapping.rows[0].mapping_id; + } else { + // 매핑 복사 + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7) + RETURNING mapping_id`, + [ + mapping.table_name, + mapping.logical_column_name, + mapping.physical_column_name, + newMenuObjid, + targetCompanyCode, + mapping.description, + userId, + ] + ); + newMappingId = insertResult.rows[0].mapping_id; + copiedCount++; + logger.info(` ✅ 카테고리 매핑 복사: ${mapping.table_name}.${mapping.logical_column_name}`); + } + + // 2. 카테고리 값 조회 및 복사 (menu_objid 기준) + const valuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND menu_objid = $3 + ORDER BY parent_value_id NULLS FIRST, value_order`, + [mapping.table_name, mapping.logical_column_name, menuObjid] + ); + + // 값 ID 매핑 (부모-자식 관계 유지를 위해) + const valueIdMap = new Map(); + + for (const value of valuesResult.rows) { + // 대상 회사에 같은 값이 이미 있는지 확인 + const existingValue = await client.query( + `SELECT value_id FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, + [value.table_name, value.column_name, value.value_code, targetCompanyCode] + ); + + if (existingValue.rows.length > 0) { + valueIdMap.set(value.value_id, existingValue.rows[0].value_id); + continue; + } + + // 부모 ID 재매핑 + const newParentId = value.parent_value_id + ? valueIdMap.get(value.parent_value_id) || null + : null; + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, company_code, created_at, created_by, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15) + RETURNING value_id`, + [ + value.table_name, + value.column_name, + value.value_code, + value.value_label, + value.value_order, + newParentId, + value.depth, + value.description, + value.color, + value.icon, + value.is_active, + value.is_default, + targetCompanyCode, + userId, + newMenuObjid, + ] + ); + + valueIdMap.set(value.value_id, insertResult.rows[0].value_id); + } + + if (valuesResult.rows.length > 0) { + logger.info(` ↳ 카테고리 값 ${valuesResult.rows.length}개 처리`); + } + } + } + + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + /** + * 테이블 타입관리 입력타입 설정 복사 + * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 + */ + private async copyTableTypeColumns( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.length === 0) { + return 0; + } + + logger.info(`📋 테이블 타입 설정 복사 시작`); + logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`); + + // 1. 복사된 화면에서 사용하는 테이블 목록 조회 + const tablesResult = await client.query<{ table_name: string }>( + `SELECT DISTINCT table_name FROM screen_definitions + WHERE screen_id = ANY($1) AND table_name IS NOT NULL AND table_name != ''`, + [screenIds] + ); + + if (tablesResult.rows.length === 0) { + logger.info(" ⚠️ 복사된 화면에 테이블이 없음"); + return 0; + } + + const tableNames = tablesResult.rows.map((r) => r.table_name); + logger.info(` 사용 테이블: ${tableNames.join(", ")}`); + + let copiedCount = 0; + + for (const tableName of tableNames) { + // 2. 원본 회사의 테이블 타입 설정 조회 + const sourceSettings = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = $1 AND company_code = $2`, + [tableName, sourceCompanyCode] + ); + + if (sourceSettings.rows.length === 0) { + logger.info(` ⚠️ ${tableName}: 원본 회사 설정 없음 (기본 설정 사용)`); + continue; + } + + for (const setting of sourceSettings.rows) { + // 3. 대상 회사에 같은 설정이 이미 있는지 확인 + const existing = await client.query( + `SELECT id FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, + [setting.table_name, setting.column_name, targetCompanyCode] + ); + + if (existing.rows.length > 0) { + // 이미 존재하면 스킵 (대상 회사에서 커스터마이징한 설정 유지) + logger.info( + ` ↳ ${setting.table_name}.${setting.column_name}: 이미 존재 (스킵)` + ); + continue; + } + + // 새로 삽입 + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`, + [ + setting.table_name, + setting.column_name, + setting.input_type, + setting.detail_settings, + setting.is_nullable, + setting.display_order, + targetCompanyCode, + ] + ); + logger.info( + ` ↳ ${setting.table_name}.${setting.column_name}: 신규 추가` + ); + copiedCount++; + } + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}개`); + return copiedCount; + } + } diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx index 58b2c896..88d29de6 100644 --- a/frontend/components/admin/MenuCopyDialog.tsx +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -56,6 +56,12 @@ export function MenuCopyDialog({ const [removeText, setRemoveText] = useState(""); const [addPrefix, setAddPrefix] = useState(""); + // 카테고리/코드 복사 옵션 + const [copyCodeCategory, setCopyCodeCategory] = useState(false); + const [copyNumberingRules, setCopyNumberingRules] = useState(false); + const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); + const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); + // 회사 목록 로드 useEffect(() => { if (open) { @@ -66,6 +72,10 @@ export function MenuCopyDialog({ setUseBulkRename(false); setRemoveText(""); setAddPrefix(""); + setCopyCodeCategory(false); + setCopyNumberingRules(false); + setCopyCategoryMapping(false); + setCopyTableTypeColumns(false); } }, [open]); @@ -112,10 +122,19 @@ export function MenuCopyDialog({ } : undefined; + // 추가 복사 옵션 + const additionalCopyOptions = { + copyCodeCategory, + copyNumberingRules, + copyCategoryMapping, + copyTableTypeColumns, + }; + const response = await menuApi.copyMenu( menuObjid, targetCompanyCode, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); if (response.success && response.data) { @@ -264,19 +283,82 @@ export function MenuCopyDialog({ )} + {/* 추가 복사 옵션 */} + {!result && ( +
+

추가 복사 옵션 (선택사항):

+
+
+ setCopyCodeCategory(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyNumberingRules(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyCategoryMapping(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyTableTypeColumns(checked as boolean)} + disabled={copying} + /> + +
+
+
+ )} + {/* 복사 항목 안내 */} {!result && (
-

복사되는 항목:

+

기본 복사 항목:

  • 메뉴 구조 (하위 메뉴 포함)
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • 플로우 제어 (스텝, 연결)
  • -
  • 코드 카테고리 + 코드
  • -
  • 카테고리 설정 + 채번 규칙
-

- ⚠️ 실제 데이터는 복사되지 않습니다. +

+ * 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.

)} @@ -294,10 +376,40 @@ export function MenuCopyDialog({ 화면:{" "} {result.copiedScreens}개 -
+
플로우:{" "} {result.copiedFlows}개
+ {(result.copiedCodeCategories ?? 0) > 0 && ( +
+ 코드 카테고리:{" "} + {result.copiedCodeCategories}개 +
+ )} + {(result.copiedCodes ?? 0) > 0 && ( +
+ 코드:{" "} + {result.copiedCodes}개 +
+ )} + {(result.copiedNumberingRules ?? 0) > 0 && ( +
+ 채번규칙:{" "} + {result.copiedNumberingRules}개 +
+ )} + {(result.copiedCategoryMappings ?? 0) > 0 && ( +
+ 카테고리 매핑:{" "} + {result.copiedCategoryMappings}개 +
+ )} + {(result.copiedTableTypeColumns ?? 0) > 0 && ( +
+ 테이블 타입 설정:{" "} + {result.copiedTableTypeColumns}개 +
+ )}
)} diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 8d917e3d..5119b7e4 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -170,6 +170,12 @@ export const menuApi = { screenNameConfig?: { removeText?: string; addPrefix?: string; + }, + additionalCopyOptions?: { + copyCodeCategory?: boolean; + copyNumberingRules?: boolean; + copyCategoryMapping?: boolean; + copyTableTypeColumns?: boolean; } ): Promise> => { try { @@ -177,7 +183,8 @@ export const menuApi = { `/admin/menus/${menuObjid}/copy`, { targetCompanyCode, - screenNameConfig + screenNameConfig, + additionalCopyOptions } ); return response.data; @@ -199,6 +206,11 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; + copiedCodeCategories?: number; + copiedCodes?: number; + copiedNumberingRules?: number; + copiedCategoryMappings?: number; + copiedTableTypeColumns?: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record;