diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b3ecbffb..bfc1f3b1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; import { validateBusinessNumber } from "../utils/businessNumberValidator"; +import { MenuCopyService } from "../services/menuCopyService"; /** * 관리자 메뉴 목록 조회 @@ -3253,3 +3254,84 @@ export async function getTableSchema( }); } } + +/** + * 메뉴 복사 + * POST /api/admin/menus/:menuObjid/copy + */ +export async function copyMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const { targetCompanyCode } = req.body; + const userId = req.user!.userId; + const userCompanyCode = req.user!.companyCode; + const userType = req.user!.userType; + const isSuperAdmin = req.user!.isSuperAdmin; + + logger.info(` +=== 메뉴 복사 API 호출 === + menuObjid: ${menuObjid} + targetCompanyCode: ${targetCompanyCode} + userId: ${userId} + userCompanyCode: ${userCompanyCode} + userType: ${userType} + isSuperAdmin: ${isSuperAdmin} + `); + + // 권한 체크: 최고 관리자만 가능 + if (!isSuperAdmin && userType !== "SUPER_ADMIN") { + logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`); + res.status(403).json({ + success: false, + message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다", + error: { + code: "FORBIDDEN", + details: "Only super admin can copy menus", + }, + }); + return; + } + + // 필수 파라미터 검증 + if (!menuObjid || !targetCompanyCode) { + res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다", + error: { + code: "MISSING_PARAMETERS", + details: "menuObjid and targetCompanyCode are required", + }, + }); + return; + } + + // 메뉴 복사 실행 + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + parseInt(menuObjid, 10), + targetCompanyCode, + userId + ); + + logger.info("✅ 메뉴 복사 API 성공"); + + res.json({ + success: true, + message: "메뉴 복사 완료", + data: result, + }); + } catch (error: any) { + logger.error("❌ 메뉴 복사 API 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 복사 중 오류가 발생했습니다", + error: { + code: "MENU_COPY_ERROR", + details: error.message || "Unknown error", + }, + }); + } +} diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 378a38d9..188e5580 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -8,6 +8,7 @@ import { deleteMenu, // 메뉴 삭제 deleteMenusBatch, // 메뉴 일괄 삭제 toggleMenuStatus, // 메뉴 상태 토글 + copyMenu, // 메뉴 복사 getUserList, getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 @@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus); router.get("/user-menus", getUserMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 +router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!) router.put("/menus/:menuId", updateMenu); // 메뉴 수정 router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글 router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts new file mode 100644 index 00000000..4a9fc8bf --- /dev/null +++ b/backend-node/src/services/menuCopyService.ts @@ -0,0 +1,1439 @@ +import { PoolClient } from "pg"; +import { query, pool } from "../database/db"; +import logger from "../utils/logger"; + +/** + * 메뉴 복사 결과 + */ +export interface MenuCopyResult { + success: boolean; + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + menuIdMap: Record; + screenIdMap: Record; + flowIdMap: Record; + warnings: string[]; +} + +/** + * 메뉴 정보 + */ +interface Menu { + objid: number; + menu_type: number | null; + parent_obj_id: number | null; + menu_name_kor: string | null; + menu_name_eng: string | null; + seq: number | null; + menu_url: string | null; + menu_desc: string | null; + writer: string | null; + regdate: Date | null; + status: string | null; + system_name: string | null; + company_code: string | null; + lang_key: string | null; + lang_key_desc: string | null; + screen_code: string | null; + menu_code: string | null; +} + +/** + * 화면 정의 + */ +interface ScreenDefinition { + screen_id: number; + screen_name: string; + screen_code: string; + table_name: string; + company_code: string; + description: string | null; + is_active: string; + layout_metadata: any; + db_source_type: string | null; + db_connection_id: number | null; +} + +/** + * 화면 레이아웃 + */ +interface ScreenLayout { + layout_id: number; + screen_id: number; + component_type: string; + component_id: string; + parent_id: string | null; + position_x: number; + position_y: number; + width: number; + height: number; + properties: any; + display_order: number; + layout_type: string | null; + layout_config: any; + zones_config: any; + zone_id: string | null; +} + +/** + * 플로우 정의 + */ +interface FlowDefinition { + id: number; + name: string; + description: string | null; + table_name: string; + is_active: boolean; + company_code: string; + db_source_type: string | null; + db_connection_id: number | null; +} + +/** + * 플로우 스텝 + */ +interface FlowStep { + id: number; + flow_definition_id: number; + step_name: string; + step_order: number; + condition_json: any; + color: string | null; + position_x: number | null; + position_y: number | null; + table_name: string | null; + move_type: string | null; + status_column: string | null; + status_value: string | null; + target_table: string | null; + field_mappings: any; + required_fields: any; + integration_type: string | null; + integration_config: any; + display_config: any; +} + +/** + * 플로우 스텝 연결 + */ +interface FlowStepConnection { + id: number; + flow_definition_id: number; + from_step_id: number; + to_step_id: number; + label: string | null; +} + +/** + * 코드 카테고리 + */ +interface CodeCategory { + category_code: string; + category_name: string; + category_name_eng: string | null; + description: string | null; + sort_order: number | null; + is_active: string; + company_code: string; + menu_objid: number; +} + +/** + * 코드 정보 + */ +interface CodeInfo { + code_category: string; + code_value: string; + code_name: string; + code_name_eng: string | null; + description: string | null; + sort_order: number | null; + is_active: string; + company_code: string; + menu_objid: number; +} + +/** + * 메뉴 복사 서비스 + */ +export class MenuCopyService { + /** + * 메뉴 트리 수집 (재귀) + */ + private async collectMenuTree( + rootMenuObjid: number, + client: PoolClient + ): Promise { + logger.info(`📂 메뉴 트리 수집 시작: rootMenuObjid=${rootMenuObjid}`); + + const result: Menu[] = []; + const visited = new Set(); + const stack: number[] = [rootMenuObjid]; + + while (stack.length > 0) { + const currentObjid = stack.pop()!; + + if (visited.has(currentObjid)) continue; + visited.add(currentObjid); + + // 현재 메뉴 조회 + const menuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [currentObjid] + ); + + if (menuResult.rows.length === 0) { + logger.warn(`⚠️ 메뉴를 찾을 수 없음: objid=${currentObjid}`); + continue; + } + + const menu = menuResult.rows[0]; + result.push(menu); + + // 자식 메뉴 조회 + const childrenResult = await client.query( + `SELECT * FROM menu_info WHERE parent_obj_id = $1 ORDER BY seq`, + [currentObjid] + ); + + for (const child of childrenResult.rows) { + if (!visited.has(child.objid)) { + stack.push(child.objid); + } + } + } + + logger.info(`✅ 메뉴 트리 수집 완료: ${result.length}개`); + return result; + } + + /** + * 화면 레이아웃에서 참조 화면 추출 + */ + private extractReferencedScreens(layouts: ScreenLayout[]): number[] { + const referenced: number[] = []; + + for (const layout of layouts) { + const props = layout.properties; + + if (!props) continue; + + // 1) 모달 버튼 (숫자 또는 문자열) + if (props?.componentConfig?.action?.targetScreenId) { + const targetId = props.componentConfig.action.targetScreenId; + const numId = + typeof targetId === "number" ? targetId : parseInt(targetId); + if (!isNaN(numId)) { + referenced.push(numId); + } + } + + // 2) 조건부 컨테이너 (숫자 또는 문자열) + if (props?.sections && Array.isArray(props.sections)) { + for (const section of props.sections) { + if (section.screenId) { + const screenId = section.screenId; + const numId = + typeof screenId === "number" ? screenId : parseInt(screenId); + if (!isNaN(numId)) { + referenced.push(numId); + } + } + } + } + } + + return referenced; + } + + /** + * 화면 수집 (중복 제거, 재귀적 참조 추적) + */ + private async collectScreens( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise> { + logger.info( + `📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}` + ); + + const screenIds = new Set(); + const visited = new Set(); + + // 1) 메뉴에 직접 할당된 화면 + for (const menuObjid of menuObjids) { + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); + } + } + + logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); + + // 2) 화면 내부에서 참조되는 화면 (재귀) + const queue = Array.from(screenIds); + + while (queue.length > 0) { + const screenId = queue.shift()!; + + if (visited.has(screenId)) continue; + visited.add(screenId); + + // 화면 레이아웃 조회 + const layoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1`, + [screenId] + ); + + // 참조 화면 추출 + const referencedScreens = this.extractReferencedScreens( + layoutsResult.rows + ); + + if (referencedScreens.length > 0) { + logger.info( + ` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}` + ); + } + + for (const refId of referencedScreens) { + if (!screenIds.has(refId)) { + screenIds.add(refId); + queue.push(refId); + } + } + } + + logger.info(`✅ 화면 수집 완료: ${screenIds.size}개 (참조 포함)`); + return screenIds; + } + + /** + * 플로우 수집 + */ + private async collectFlows( + screenIds: Set, + client: PoolClient + ): Promise> { + logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); + + const flowIds = new Set(); + + for (const screenId of screenIds) { + const layoutsResult = await client.query( + `SELECT properties FROM screen_layouts WHERE screen_id = $1`, + [screenId] + ); + + for (const layout of layoutsResult.rows) { + const props = layout.properties; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId) { + flowIds.add(flowId); + } + } + } + + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + return flowIds; + } + + /** + * 코드 수집 + */ + private async collectCodes( + menuObjids: number[], + sourceCompanyCode: string, + client: PoolClient + ): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> { + logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`); + + const categories: CodeCategory[] = []; + const codes: CodeInfo[] = []; + + for (const menuObjid of menuObjids) { + // 코드 카테고리 + const catsResult = await client.query( + `SELECT * FROM code_category + WHERE menu_objid = $1 AND company_code = $2`, + [menuObjid, sourceCompanyCode] + ); + categories.push(...catsResult.rows); + + // 각 카테고리의 코드 정보 + for (const cat of catsResult.rows) { + const codesResult = await client.query( + `SELECT * FROM code_info + WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`, + [cat.category_code, menuObjid, sourceCompanyCode] + ); + codes.push(...codesResult.rows); + } + } + + logger.info( + `✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개` + ); + return { categories, codes }; + } + + /** + * 다음 메뉴 objid 생성 + */ + private async getNextMenuObjid(client: PoolClient): Promise { + const result = await client.query<{ max_objid: string }>( + `SELECT COALESCE(MAX(objid), 0)::text as max_objid FROM menu_info` + ); + return parseInt(result.rows[0].max_objid, 10) + 1; + } + + /** + * 고유 화면 코드 생성 + */ + private async generateUniqueScreenCode( + targetCompanyCode: string, + client: PoolClient + ): Promise { + // {company_code}_{순번} 형식 + const prefix = targetCompanyCode === "*" ? "*" : targetCompanyCode; + + const result = await client.query<{ max_num: string }>( + `SELECT COALESCE( + MAX( + CASE + WHEN screen_code ~ '^${prefix}_[0-9]+$' + THEN CAST(SUBSTRING(screen_code FROM '${prefix}_([0-9]+)') AS INTEGER) + ELSE 0 + END + ), 0 + )::text as max_num + FROM screen_definitions + WHERE company_code = $1`, + [targetCompanyCode] + ); + + const maxNum = parseInt(result.rows[0].max_num, 10); + const newNum = maxNum + 1; + return `${prefix}_${String(newNum).padStart(3, "0")}`; + } + + /** + * properties 내부 참조 업데이트 + */ + /** + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + */ + private updateReferencesInProperties( + properties: any, + screenIdMap: Map, + flowIdMap: Map + ): any { + if (!properties) return properties; + + // 깊은 복사 + const updated = JSON.parse(JSON.stringify(properties)); + + // 재귀적으로 객체/배열 탐색 + this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + + return updated; + } + + /** + * 재귀적으로 모든 ID 참조 업데이트 + */ + private recursiveUpdateReferences( + obj: any, + screenIdMap: Map, + flowIdMap: Map, + path: string = "" + ): void { + if (!obj || typeof obj !== "object") return; + + // 배열인 경우 + if (Array.isArray(obj)) { + obj.forEach((item, index) => { + this.recursiveUpdateReferences( + item, + screenIdMap, + flowIdMap, + `${path}[${index}]` + ); + }); + return; + } + + // 객체인 경우 - 키별로 처리 + for (const key of Object.keys(obj)) { + const value = obj[key]; + const currentPath = path ? `${path}.${key}` : key; + + // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + if ( + key === "screen_id" || + key === "screenId" || + key === "targetScreenId" + ) { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue)) { + const newId = screenIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 + logger.info( + ` 🔗 화면 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } + } + } + + // flowId 매핑 (숫자 또는 숫자 문자열) + if (key === "flowId") { + const numValue = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numValue)) { + const newId = flowIdMap.get(numValue); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 + logger.debug( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } + } + } + + // 재귀 호출 + if (typeof value === "object" && value !== null) { + this.recursiveUpdateReferences( + value, + screenIdMap, + flowIdMap, + currentPath + ); + } + } + } + + /** + * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) + * + * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + */ + private async deleteExistingCopy( + sourceMenuObjid: number, + targetCompanyCode: string, + client: PoolClient + ): Promise { + logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); + + // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + const sourceMenuResult = await client.query( + `SELECT menu_name_kor, menu_name_eng + FROM menu_info + WHERE objid = $1`, + [sourceMenuObjid] + ); + + if (sourceMenuResult.rows.length === 0) { + logger.warn("⚠️ 원본 메뉴를 찾을 수 없습니다"); + return; + } + + const sourceMenu = sourceMenuResult.rows[0]; + + // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) + const existingMenuResult = await client.query<{ objid: number }>( + `SELECT objid + FROM menu_info + WHERE source_menu_objid = $1 + AND company_code = $2 + AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + [sourceMenuObjid, targetCompanyCode] + ); + + if (existingMenuResult.rows.length === 0) { + logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다"); + return; + } + + const existingMenuObjid = existingMenuResult.rows[0].objid; + logger.info( + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + ); + + // 3. 기존 메뉴 트리 수집 + const existingMenus = await this.collectMenuTree(existingMenuObjid, client); + const existingMenuIds = existingMenus.map((m) => m.objid); + + logger.info(`📊 삭제 대상: 메뉴 ${existingMenus.length}개`); + + // 4. 관련 화면 ID 수집 + const existingScreenIds = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + + const screenIds = existingScreenIds.rows.map((r) => r.screen_id); + + // 5. 삭제 순서 (외래키 제약 고려) + + // 5-1. 화면 레이아웃 삭제 + if (screenIds.length > 0) { + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + logger.info(` ✅ 화면 레이아웃 삭제 완료`); + } + + // 5-2. 화면-메뉴 할당 삭제 + await client.query( + `DELETE FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [existingMenuIds, targetCompanyCode] + ); + logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); + + // 5-3. 화면 정의 삭제 + if (screenIds.length > 0) { + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screenIds, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료`); + } + + // 5-4. 메뉴 권한 삭제 + await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ + existingMenuIds, + ]); + logger.info(` ✅ 메뉴 권한 삭제 완료`); + + // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + for (let i = existingMenus.length - 1; i >= 0; i--) { + await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ + existingMenus[i].objid, + ]); + } + logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); + + logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨"); + } + + /** + * 메뉴 복사 (메인 함수) + */ + async copyMenu( + sourceMenuObjid: number, + targetCompanyCode: string, + userId: string + ): Promise { + logger.info(` +🚀 ============================================ + 메뉴 복사 시작 + 원본 메뉴: ${sourceMenuObjid} + 대상 회사: ${targetCompanyCode} + 사용자: ${userId} +============================================ + `); + + const warnings: string[] = []; + const client = await pool.connect(); + + try { + // 트랜잭션 시작 + await client.query("BEGIN"); + logger.info("📦 트랜잭션 시작"); + + // === 0단계: 기존 복사본 삭제 (덮어쓰기) === + await this.deleteExistingCopy(sourceMenuObjid, targetCompanyCode, client); + + // === 1단계: 수집 (Collection Phase) === + logger.info("\n📂 [1단계] 데이터 수집"); + + const menus = await this.collectMenuTree(sourceMenuObjid, client); + const sourceCompanyCode = menus[0].company_code!; + + const screenIds = await this.collectScreens( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + const flowIds = await this.collectFlows(screenIds, client); + + const codes = await this.collectCodes( + menus.map((m) => m.objid), + sourceCompanyCode, + client + ); + + logger.info(` +📊 수집 완료: + - 메뉴: ${menus.length}개 + - 화면: ${screenIds.size}개 + - 플로우: ${flowIds.size}개 + - 코드 카테고리: ${codes.categories.length}개 + - 코드: ${codes.codes.length}개 + `); + + // === 2단계: 플로우 복사 === + logger.info("\n🔄 [2단계] 플로우 복사"); + const flowIdMap = await this.copyFlows( + flowIds, + targetCompanyCode, + userId, + client + ); + + // === 3단계: 화면 복사 === + logger.info("\n📄 [3단계] 화면 복사"); + const screenIdMap = await this.copyScreens( + screenIds, + targetCompanyCode, + flowIdMap, + userId, + client + ); + + // === 4단계: 메뉴 복사 === + logger.info("\n📂 [4단계] 메뉴 복사"); + const menuIdMap = await this.copyMenus( + menus, + targetCompanyCode, + screenIdMap, + userId, + client + ); + + // === 5단계: 화면-메뉴 할당 === + logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + await this.createScreenMenuAssignments( + menus, + menuIdMap, + screenIdMap, + targetCompanyCode, + client + ); + + // === 6단계: 코드 복사 === + logger.info("\n📋 [6단계] 코드 복사"); + await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); + + // 커밋 + await client.query("COMMIT"); + logger.info("✅ 트랜잭션 커밋 완료"); + + const result: MenuCopyResult = { + success: true, + copiedMenus: menuIdMap.size, + copiedScreens: screenIdMap.size, + copiedFlows: flowIdMap.size, + copiedCategories: codes.categories.length, + copiedCodes: codes.codes.length, + menuIdMap: Object.fromEntries(menuIdMap), + screenIdMap: Object.fromEntries(screenIdMap), + flowIdMap: Object.fromEntries(flowIdMap), + warnings, + }; + + logger.info(` +🎉 ============================================ + 메뉴 복사 완료! + - 메뉴: ${result.copiedMenus}개 + - 화면: ${result.copiedScreens}개 + - 플로우: ${result.copiedFlows}개 + - 코드 카테고리: ${result.copiedCategories}개 + - 코드: ${result.copiedCodes}개 +============================================ + `); + + return result; + } catch (error: any) { + // 롤백 + await client.query("ROLLBACK"); + logger.error("❌ 메뉴 복사 실패, 롤백됨:", error); + throw error; + } finally { + client.release(); + } + } + + /** + * 플로우 복사 + */ + private async copyFlows( + flowIds: Set, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise> { + const flowIdMap = new Map(); + + if (flowIds.size === 0) { + logger.info("📭 복사할 플로우 없음"); + return flowIdMap; + } + + logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + + for (const originalFlowId of flowIds) { + try { + // 1) flow_definition 조회 + const flowDefResult = await client.query( + `SELECT * FROM flow_definition WHERE id = $1`, + [originalFlowId] + ); + + if (flowDefResult.rows.length === 0) { + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } + + const flowDef = flowDefResult.rows[0]; + + // 2) flow_definition 복사 + const newFlowResult = await client.query<{ id: number }>( + `INSERT INTO flow_definition ( + name, description, table_name, is_active, + company_code, created_by, db_source_type, db_connection_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + flowDef.name, + flowDef.description, + flowDef.table_name, + flowDef.is_active, + targetCompanyCode, // 새 회사 코드 + userId, + flowDef.db_source_type, + flowDef.db_connection_id, + ] + ); + + const newFlowId = newFlowResult.rows[0].id; + flowIdMap.set(originalFlowId, newFlowId); + + logger.info( + ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ); + + // 3) flow_step 복사 + const stepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, + [originalFlowId] + ); + + const stepIdMap = new Map(); + + for (const step of stepsResult.rows) { + const newStepResult = await client.query<{ id: number }>( + `INSERT INTO flow_step ( + flow_definition_id, step_name, step_order, condition_json, + color, position_x, position_y, table_name, move_type, + status_column, status_value, target_table, field_mappings, + required_fields, integration_type, integration_config, display_config + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING id`, + [ + newFlowId, // 새 플로우 ID + step.step_name, + step.step_order, + step.condition_json, + step.color, + step.position_x, + step.position_y, + step.table_name, + step.move_type, + step.status_column, + step.status_value, + step.target_table, + step.field_mappings, + step.required_fields, + step.integration_type, + step.integration_config, + step.display_config, + ] + ); + + const newStepId = newStepResult.rows[0].id; + stepIdMap.set(step.id, newStepId); + } + + logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + + // 4) flow_step_connection 복사 (스텝 ID 재매핑) + const connectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, + [originalFlowId] + ); + + for (const conn of connectionsResult.rows) { + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); + + if (!newFromStepId || !newToStepId) { + logger.warn( + `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` + ); + continue; + } + + await client.query( + `INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) VALUES ($1, $2, $3, $4)`, + [newFlowId, newFromStepId, newToStepId, conn.label] + ); + } + + logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); + } catch (error: any) { + logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); + throw error; + } + } + + logger.info(`✅ 플로우 복사 완료: ${flowIdMap.size}개`); + return flowIdMap; + } + + /** + * 화면 복사 + */ + private async copyScreens( + screenIds: Set, + targetCompanyCode: string, + flowIdMap: Map, + userId: string, + client: PoolClient + ): Promise> { + const screenIdMap = new Map(); + + if (screenIds.size === 0) { + logger.info("📭 복사할 화면 없음"); + return screenIdMap; + } + + logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + + // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + const screenDefsToProcess: Array<{ + originalScreenId: number; + newScreenId: number; + screenDef: ScreenDefinition; + }> = []; + + for (const originalScreenId of screenIds) { + try { + // 1) screen_definitions 조회 + const screenDefResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = $1`, + [originalScreenId] + ); + + if (screenDefResult.rows.length === 0) { + logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); + continue; + } + + const screenDef = screenDefResult.rows[0]; + + // 2) 새 screen_code 생성 + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING screen_id`, + [ + screenDef.screen_name, + newScreenCode, // 새 화면 코드 + screenDef.table_name, + targetCompanyCode, // 새 회사 코드 + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, // deleted_date: NULL (새 화면은 삭제되지 않음) + null, // deleted_by: NULL + null, // delete_reason: NULL + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + // 저장해서 2단계에서 처리 + screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + } catch (error: any) { + logger.error( + `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + error + ); + throw error; + } + } + + // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + logger.info( + `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + ); + + for (const { + originalScreenId, + newScreenId, + screenDef, + } of screenDefsToProcess) { + try { + // screen_layouts 복사 + const layoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); + + // 1단계: component_id 매핑 생성 (원본 → 새 ID) + const componentIdMap = new Map(); + for (const layout of layoutsResult.rows) { + const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + componentIdMap.set(layout.component_id, newComponentId); + } + + // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; + + // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; + + // properties 내부 참조 업데이트 + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap + ); + + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + [ + newScreenId, // 새 화면 ID + layout.component_type, + newComponentId, // 새 컴포넌트 ID + newParentId, // 매핑된 parent_id + layout.position_x, + layout.position_y, + layout.width, + layout.height, + updatedProperties, // 업데이트된 속성 + layout.display_order, + layout.layout_type, + layout.layout_config, + layout.zones_config, + newZoneId, // 매핑된 zone_id + ] + ); + } + + logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + } catch (error: any) { + logger.error( + `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + error + ); + throw error; + } + } + + logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + return screenIdMap; + } + + /** + * 메뉴 위상 정렬 (부모 먼저) + */ + private topologicalSortMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + const visited = new Set(); + const menuMap = new Map(); + + for (const menu of menus) { + menuMap.set(menu.objid, menu); + } + + const visit = (menu: Menu) => { + if (visited.has(menu.objid)) return; + + // 부모 먼저 방문 + if (menu.parent_obj_id) { + const parent = menuMap.get(menu.parent_obj_id); + if (parent) { + visit(parent); + } + } + + visited.add(menu.objid); + result.push(menu); + }; + + for (const menu of menus) { + visit(menu); + } + + return result; + } + + /** + * screen_code 재매핑 + */ + private getNewScreenCode( + screenIdMap: Map, + screenCode: string | null, + client: PoolClient + ): string | null { + if (!screenCode) return null; + + // screen_code로 screen_id 조회 (원본 회사) + // 간단하게 처리: 새 화면 코드는 이미 생성됨 + return screenCode; + } + + /** + * 메뉴 복사 + */ + private async copyMenus( + menus: Menu[], + targetCompanyCode: string, + screenIdMap: Map, + userId: string, + client: PoolClient + ): Promise> { + const menuIdMap = new Map(); + + if (menus.length === 0) { + logger.info("📭 복사할 메뉴 없음"); + return menuIdMap; + } + + logger.info(`📂 메뉴 복사 중: ${menus.length}개`); + + // 위상 정렬 (부모 먼저 삽입) + const sortedMenus = this.topologicalSortMenus(menus); + + for (const menu of sortedMenus) { + try { + // 새 objid 생성 + const newObjId = await this.getNextMenuObjid(client); + + // parent_obj_id 재매핑 + // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + 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; + } + + // source_menu_objid 저장: 최상위 메뉴는 원본 ID, 하위 메뉴는 최상위의 원본 ID + const sourceMenuObjid = + !menu.parent_obj_id || menu.parent_obj_id === 0 + ? menu.objid // 최상위 메뉴: 자신의 ID가 원본 + : null; // 하위 메뉴: NULL (최상위만 추적) + + if (sourceMenuObjid) { + logger.info( + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (최상위 메뉴)` + ); + } + + // screen_code는 그대로 유지 (화면-메뉴 할당에서 처리) + await client.query( + `INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_url, menu_desc, writer, status, system_name, + company_code, lang_key, lang_key_desc, screen_code, menu_code, + source_menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [ + newObjId, + menu.menu_type, + newParentObjId, // 재매핑 + menu.menu_name_kor, + menu.menu_name_eng, + menu.seq, + menu.menu_url, + menu.menu_desc, + userId, + menu.status, + menu.system_name, + targetCompanyCode, // 새 회사 코드 + menu.lang_key, + menu.lang_key_desc, + menu.screen_code, // 그대로 유지 + menu.menu_code, + sourceMenuObjid, // 원본 메뉴 ID (최상위만) + ] + ); + + menuIdMap.set(menu.objid, newObjId); + + logger.info( + ` ✅ 메뉴 복사: ${menu.objid} → ${newObjId} (${menu.menu_name_kor})` + ); + } catch (error: any) { + logger.error(`❌ 메뉴 복사 실패: objid=${menu.objid}`, error); + throw error; + } + } + + logger.info(`✅ 메뉴 복사 완료: ${menuIdMap.size}개`); + return menuIdMap; + } + + /** + * 화면-메뉴 할당 + */ + private async createScreenMenuAssignments( + menus: Menu[], + menuIdMap: Map, + screenIdMap: Map, + targetCompanyCode: string, + client: PoolClient + ): Promise { + logger.info(`🔗 화면-메뉴 할당 중...`); + + let assignmentCount = 0; + + for (const menu of menus) { + const newMenuObjid = menuIdMap.get(menu.objid); + if (!newMenuObjid) continue; + + // 원본 메뉴에 할당된 화면 조회 + const assignmentsResult = await client.query<{ + screen_id: number; + display_order: number; + is_active: string; + }>( + `SELECT screen_id, display_order, is_active + FROM screen_menu_assignments + WHERE menu_objid = $1 AND company_code = $2`, + [menu.objid, menu.company_code] + ); + + for (const assignment of assignmentsResult.rows) { + const newScreenId = screenIdMap.get(assignment.screen_id); + if (!newScreenId) { + logger.warn( + `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` + ); + continue; + } + + // 새 할당 생성 + await client.query( + `INSERT INTO screen_menu_assignments ( + screen_id, menu_objid, company_code, display_order, is_active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + newScreenId, // 재매핑 + newMenuObjid, // 재매핑 + targetCompanyCode, + assignment.display_order, + assignment.is_active, + "system", + ] + ); + + assignmentCount++; + } + } + + logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + } + + /** + * 코드 카테고리 중복 체크 + */ + private async checkCodeCategoryExists( + categoryCode: string, + companyCode: string, + menuObjid: number, + client: PoolClient + ): Promise { + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM code_category + WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3 + ) as exists`, + [categoryCode, companyCode, menuObjid] + ); + return result.rows[0].exists; + } + + /** + * 코드 정보 중복 체크 + */ + private async checkCodeInfoExists( + categoryCode: string, + codeValue: string, + companyCode: string, + menuObjid: number, + client: PoolClient + ): Promise { + const result = await client.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM code_info + WHERE code_category = $1 AND code_value = $2 + AND company_code = $3 AND menu_objid = $4 + ) as exists`, + [categoryCode, codeValue, companyCode, menuObjid] + ); + return result.rows[0].exists; + } + + /** + * 코드 복사 + */ + private async copyCodes( + codes: { categories: CodeCategory[]; codes: CodeInfo[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 코드 복사 중...`); + + let categoryCount = 0; + let codeCount = 0; + let skippedCategories = 0; + let skippedCodes = 0; + + // 1) 코드 카테고리 복사 (중복 체크) + for (const category of codes.categories) { + const newMenuObjid = menuIdMap.get(category.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const exists = await this.checkCodeCategoryExists( + category.category_code, + targetCompanyCode, + newMenuObjid, + client + ); + + if (exists) { + skippedCategories++; + logger.debug( + ` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})` + ); + continue; + } + + // 카테고리 복사 + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + category.category_code, + category.category_name, + category.category_name_eng, + category.description, + category.sort_order, + category.is_active, + targetCompanyCode, // 새 회사 코드 + newMenuObjid, // 재매핑 + userId, + ] + ); + + categoryCount++; + } + + // 2) 코드 정보 복사 (중복 체크) + for (const code of codes.codes) { + const newMenuObjid = menuIdMap.get(code.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크 + const exists = await this.checkCodeInfoExists( + code.code_category, + code.code_value, + targetCompanyCode, + newMenuObjid, + client + ); + + if (exists) { + skippedCodes++; + logger.debug( + ` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})` + ); + continue; + } + + // 코드 복사 + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + code.code_category, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + targetCompanyCode, // 새 회사 코드 + newMenuObjid, // 재매핑 + userId, + ] + ); + + codeCount++; + } + + logger.info( + `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` + ); + } +} diff --git a/db/migrations/README_1003.md b/db/migrations/README_1003.md new file mode 100644 index 00000000..629e2cb8 --- /dev/null +++ b/db/migrations/README_1003.md @@ -0,0 +1,184 @@ +# 마이그레이션 1003: source_menu_objid 추가 + +## 📋 개요 + +메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다. + +## 🎯 목적 + +### 이전 방식의 문제점 +- 메뉴 이름으로만 기존 복사본 판단 +- 같은 이름의 다른 메뉴도 삭제될 위험 +- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가 + +### 개선 후 +- 원본 메뉴 ID로 정확히 추적 +- 같은 원본에서 복사된 메뉴만 덮어쓰기 +- 수동 메뉴와 복사 메뉴 명확히 구분 + +## 🗄️ 스키마 변경 + +### 추가되는 컬럼 +```sql +ALTER TABLE menu_info +ADD COLUMN source_menu_objid BIGINT; +``` + +### 인덱스 +```sql +-- 단일 인덱스 +CREATE INDEX idx_menu_info_source_menu_objid +ON menu_info(source_menu_objid); + +-- 복합 인덱스 (회사별 검색 최적화) +CREATE INDEX idx_menu_info_source_company +ON menu_info(source_menu_objid, company_code); +``` + +## 📊 데이터 구조 + +### 복사된 메뉴의 source_menu_objid 값 + +| 메뉴 레벨 | source_menu_objid | 설명 | +|-----------|-------------------|------| +| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 | +| 하위 메뉴 | NULL | 최상위 메뉴만 추적 | +| 수동 생성 메뉴 | NULL | 복사가 아님 | + +### 예시 + +#### 원본 (COMPANY_7) +``` +- 사용자 (objid: 1762407678882) + └─ 영업관리 (objid: 1762421877772) + └─ 거래처관리 (objid: 1762421920304) +``` + +#### 복사본 (COMPANY_11) +``` +- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적 + └─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL) + └─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL) +``` + +## 🚀 실행 방법 + +### 로컬 PostgreSQL +```bash +psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql +``` + +### Docker 환경 +```bash +# 백엔드 컨테이너를 통해 실행 +docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql +``` + +### DBeaver / pgAdmin +1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기 +2. 전체 스크립트 실행 + +## ✅ 확인 방법 + +### 1. 컬럼 추가 확인 +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +**예상 결과**: +``` +column_name | data_type | is_nullable +-------------------|-----------|------------- +source_menu_objid | bigint | YES +``` + +### 2. 인덱스 생성 확인 +```sql +SELECT indexname, indexdef +FROM pg_indexes +WHERE tablename = 'menu_info' + AND indexname LIKE '%source%'; +``` + +**예상 결과**: +``` +indexname | indexdef +---------------------------------|---------------------------------- +idx_menu_info_source_menu_objid | CREATE INDEX ... +idx_menu_info_source_company | CREATE INDEX ... +``` + +### 3. 기존 데이터 확인 +```sql +-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터) +SELECT + COUNT(*) as total, + COUNT(source_menu_objid) as with_source +FROM menu_info; +``` + +**예상 결과**: +``` +total | with_source +------|------------- + 114 | 0 +``` + +## 🔄 롤백 (필요 시) + +```sql +-- 인덱스 삭제 +DROP INDEX IF EXISTS idx_menu_info_source_menu_objid; +DROP INDEX IF EXISTS idx_menu_info_source_company; + +-- 컬럼 삭제 +ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid; +``` + +## 📝 주의사항 + +1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다. +2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다. +3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다. + +## 🧪 테스트 시나리오 + +### 1. 첫 복사 (source_menu_objid 설정) +``` +원본: 사용자 (objid: 1762407678882, COMPANY_7) +복사: 사용자 (objid: 1763688215729, COMPANY_11) + source_menu_objid: 1762407678882 ✅ +``` + +### 2. 재복사 (정확한 덮어쓰기) +``` +복사 전 조회: + SELECT objid FROM menu_info + WHERE source_menu_objid = 1762407678882 + AND company_code = 'COMPANY_11' + → 1763688215729 발견 + +동작: + 1. objid=1763688215729의 메뉴 트리 전체 삭제 + 2. 새로 복사 (source_menu_objid: 1762407678882) +``` + +### 3. 다른 메뉴는 영향 없음 +``` +수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11) + source_menu_objid: NULL ✅ + +"사용자" 메뉴 재복사 시: + → 관리자 메뉴는 그대로 유지 ✅ +``` + +## 📚 관련 파일 + +- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` +- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts` + - `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기 + - `copyMenus()`: 복사 시 source_menu_objid 저장 + diff --git a/db/migrations/RUN_MIGRATION_1003.md b/db/migrations/RUN_MIGRATION_1003.md new file mode 100644 index 00000000..6b33bafd --- /dev/null +++ b/db/migrations/RUN_MIGRATION_1003.md @@ -0,0 +1,146 @@ +# 마이그레이션 1003 실행 가이드 + +## ❌ 현재 에러 +``` +column "source_menu_objid" does not exist +``` + +**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음 + +## ✅ 해결 방법 + +### 방법 1: psql 직접 실행 (권장) + +```bash +# 1. PostgreSQL 접속 정보 확인 +# - Host: localhost (또는 실제 DB 호스트) +# - Port: 5432 (기본값) +# - Database: ilshin +# - User: postgres + +# 2. 마이그레이션 실행 +cd /Users/kimjuseok/ERP-node +psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql + +# 또는 대화형으로 +psql -h localhost -U postgres -d ilshin +# 그 다음 파일 내용 붙여넣기 +``` + +### 방법 2: DBeaver / pgAdmin에서 실행 + +1. DBeaver 또는 pgAdmin 실행 +2. `ilshin` 데이터베이스 연결 +3. SQL 편집기 열기 +4. 아래 SQL 복사하여 실행: + +```sql +-- source_menu_objid 컬럼 추가 +ALTER TABLE menu_info +ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT; + +-- 인덱스 생성 (검색 성능 향상) +CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid +ON menu_info(source_menu_objid); + +-- 복합 인덱스: 회사별 원본 메뉴 검색 +CREATE INDEX IF NOT EXISTS idx_menu_info_source_company +ON menu_info(source_menu_objid, company_code); + +-- 컬럼 설명 추가 +COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)'; + +-- 확인 +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +### 방법 3: Docker를 통한 실행 + +Docker Compose 설정 확인 후: + +```bash +# Docker Compose에 DB 서비스가 있는 경우 +docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql + +# 또는 SQL을 직접 실행 +docker-compose exec db psql -U postgres -d ilshin -c " +ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT; +CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid); +CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code); +" +``` + +## ✅ 실행 후 확인 + +### 1. 컬럼이 추가되었는지 확인 +```sql +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'menu_info' + AND column_name = 'source_menu_objid'; +``` + +**예상 결과**: +``` +column_name | data_type | is_nullable +-------------------|-----------|------------- +source_menu_objid | bigint | YES +``` + +### 2. 인덱스 확인 +```sql +SELECT indexname +FROM pg_indexes +WHERE tablename = 'menu_info' + AND indexname LIKE '%source%'; +``` + +**예상 결과**: +``` +indexname +--------------------------------- +idx_menu_info_source_menu_objid +idx_menu_info_source_company +``` + +### 3. 메뉴 복사 재시도 +마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요. + +## 🔍 DB 접속 정보 찾기 + +### 환경 변수 확인 +```bash +# .env 파일 확인 +cat backend-node/.env | grep DB + +# Docker Compose 확인 +cat docker-compose*.yml | grep -A 10 postgres +``` + +### 일반적인 접속 정보 +- **Host**: localhost 또는 127.0.0.1 +- **Port**: 5432 (기본값) +- **Database**: ilshin +- **User**: postgres +- **Password**: (환경 설정 파일에서 확인) + +## ⚠️ 주의사항 + +1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장 +2. **권한 확인**: ALTER TABLE 권한이 필요합니다 +3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다 + +## 📞 문제 해결 + +### "permission denied" 에러 +→ postgres 사용자 또는 superuser 권한으로 실행 필요 + +### "relation does not exist" 에러 +→ 올바른 데이터베이스(ilshin)에 접속했는지 확인 + +### "already exists" 에러 +→ 이미 실행됨. 무시하고 진행 가능 + diff --git a/db/scripts/README_cleanup.md b/db/scripts/README_cleanup.md new file mode 100644 index 00000000..ecd7879f --- /dev/null +++ b/db/scripts/README_cleanup.md @@ -0,0 +1,126 @@ +# COMPANY_11 테스트 데이터 정리 가이드 + +## 📋 개요 + +메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다. + +## ⚠️ 중요 사항 + +- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`) +- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드 +- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능 +- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT + +## 🚀 실행 방법 + +### 방법 1: Docker 컨테이너에서 직접 실행 (권장) + +```bash +# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨) +cd /Users/kimjuseok/ERP-node +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql + +# 2. 실제 삭제 실행 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql +``` + +### 방법 2: DBeaver 또는 pgAdmin에서 실행 + +1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기 +2. 전체 스크립트 실행 (롤백되어 안전) +3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행 + +### 방법 3: psql 직접 접속 + +```bash +# 1. 컨테이너 접속 +docker exec -it erp-node-db-1 psql -U postgres -d ilshin + +# 2. SQL 복사 붙여넣기 +# (cleanup_company_11_execute.sql 내용 복사) +``` + +## 📊 삭제 대상 + +| 항목 | 테이블명 | 삭제 여부 | +|------|----------|-----------| +| 메뉴 | `menu_info` | ✅ 삭제 | +| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 | +| 화면 정의 | `screen_definitions` | ✅ 삭제 | +| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 | +| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 | +| 플로우 정의 | `flow_definition` | ✅ 삭제 | +| 플로우 스텝 | `flow_step` | ✅ 삭제 | +| 플로우 연결 | `flow_step_connection` | ✅ 삭제 | +| 코드 카테고리 | `code_category` | ✅ 삭제 | +| 코드 정보 | `code_info` | ✅ 삭제 | +| **권한 그룹** | `authority_master` | ❌ **보존** | +| **권한 멤버** | `authority_sub_user` | ❌ **보존** | +| **사용자** | `user_info` | ❌ **보존** | + +## 🔍 삭제 순서 (외래키 제약 고려) + +``` +1. screen_layouts (화면 레이아웃) +2. screen_menu_assignments (화면-메뉴 할당) +3. screen_definitions (화면 정의) +4. rel_menu_auth (메뉴 권한) +5. menu_info (메뉴) +6. flow_step (플로우 스텝) +7. flow_step_connection (플로우 연결) +8. flow_definition (플로우 정의) +9. code_info (코드 정보) +10. code_category (코드 카테고리) +``` + +## ✅ 실행 후 확인 + +스크립트 실행 후 다음과 같이 표시됩니다: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ 삭제 완료! + +남은 데이터: + - 메뉴: 0 개 + - 화면: 0 개 + - 권한 그룹: 1 개 (보존됨) + - 사용자: 1 개 (보존됨) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✨ 정리 완료! 메뉴 복사 테스트 준비됨 +``` + +## 🧪 테스트 시나리오 + +1. **데이터 정리** + ```bash + docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql + ``` + +2. **메뉴 복사 실행** + - 프론트엔드에서 원본 메뉴 선택 + - "복사" 버튼 클릭 + - 대상 회사: COMPANY_11 선택 + - 복사 실행 + +3. **복사 결과 확인** + - COMPANY_11 사용자(copy)로 로그인 + - 사용자 메뉴에 복사된 메뉴 표시 확인 + - 버튼 클릭 시 모달 화면 정상 열림 확인 + - 플로우 기능 정상 작동 확인 + +## 🔄 재테스트 + +재테스트가 필요하면 다시 정리 스크립트를 실행하세요: + +```bash +# 빠른 재테스트 +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql +``` + +## 📝 참고 + +- **백업**: 중요한 데이터가 있다면 먼저 백업하세요 +- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다 +- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다 + diff --git a/docs/메뉴_복사_기능_구현_계획서.md b/docs/메뉴_복사_기능_구현_계획서.md new file mode 100644 index 00000000..a53a5704 --- /dev/null +++ b/docs/메뉴_복사_기능_구현_계획서.md @@ -0,0 +1,1660 @@ +# 메뉴 복사 기능 구현 계획서 + +## 📋 목차 +1. [개요](#개요) +2. [요구사항](#요구사항) +3. [데이터베이스 구조 분석](#데이터베이스-구조-분석) +4. [복사 대상 항목](#복사-대상-항목) +5. [복사 알고리즘](#복사-알고리즘) +6. [구현 단계](#구현-단계) +7. [API 명세](#api-명세) +8. [UI/UX 설계](#uiux-설계) +9. [예외 처리](#예외-처리) +10. [테스트 계획](#테스트-계획) + +--- + +## 개요 + +### 목적 +메뉴관리 화면에서 **복사 버튼 하나**로 선택된 메뉴와 관련된 모든 리소스를 다른 회사로 복사하여, 복사 즉시 해당 회사에서 사용 가능하도록 합니다. + +### 핵심 기능 +- 메뉴 트리 구조 복사 (부모-자식 관계 유지) +- 화면 + 레이아웃 복사 (모달, 조건부 컨테이너 포함) +- 플로우 제어 복사 (스텝, 연결, 조건) +- 코드 카테고리 + 코드 정보 복사 +- 중복 화면 자동 제거 +- 참조 관계 자동 재매핑 +- company_code 자동 변경 + +--- + +## 요구사항 + +### 기능 요구사항 + +#### FR-1: 메뉴 복사 +- **설명**: 선택된 메뉴와 하위 메뉴를 모두 복사 +- **입력**: 원본 메뉴 objid, 대상 회사 company_code +- **출력**: 복사된 메뉴 목록 +- **제약**: 메뉴 계층 구조 유지 + +#### FR-2: 화면 복사 +- **설명**: 메뉴에 할당된 모든 화면 복사 +- **입력**: 메뉴 objid 목록 +- **출력**: 복사된 화면 목록 +- **제약**: 중복 화면은 하나만 복사 + +#### FR-3: 화면 내부 참조 추적 +- **설명**: 화면 레이아웃에서 참조되는 화면들을 재귀적으로 추적 +- **대상**: + - 모달 버튼의 targetScreenId + - 조건부 컨테이너의 sections[].screenId + - 모달 안의 모달 (중첩 구조) +- **제약**: 무한 루프 방지 (이미 방문한 화면 체크) + +#### FR-4: 플로우 복사 +- **설명**: 화면에서 참조되는 플로우를 모두 복사 +- **대상**: + - flow_definition (플로우 정의) + - flow_step (스텝) + - flow_step_connection (스텝 간 연결) +- **제약**: 스텝 ID 재매핑 + +#### FR-5: 코드 복사 +- **설명**: 메뉴에 연결된 코드 카테고리와 코드 복사 +- **대상**: + - code_category (menu_objid 기준) + - code_info (menu_objid 기준) +- **제약**: 중복 카테고리 병합 + +#### FR-6: 참조 ID 재매핑 +- **설명**: 복사된 리소스의 ID를 원본 ID에서 새 ID로 자동 변경 +- **대상**: + - screen_id (화면 ID) + - flow_id (플로우 ID) + - menu_objid (메뉴 ID) + - step_id (스텝 ID) +- **방법**: ID 매핑 테이블 사용 + +### 비기능 요구사항 + +#### NFR-1: 성능 +- 복사 시간: 메뉴 100개 기준 2분 이내 +- 트랜잭션: 전체 작업을 하나의 트랜잭션으로 처리 + +#### NFR-2: 신뢰성 +- 실패 시 롤백: 일부만 복사되는 것 방지 +- 중복 실행 방지: 같은 요청 중복 처리 방지 + +#### NFR-3: 사용성 +- 진행 상황 표시: 실시간 복사 진행률 표시 +- 결과 보고서: 복사된 항목 상세 리스트 + +--- + +## 데이터베이스 구조 분석 + +### 주요 테이블 및 관계 + +```sql +-- 1. 메뉴 (계층 구조) +menu_info + ├─ objid (PK) - 메뉴 고유 ID + ├─ parent_obj_id - 부모 메뉴 ID + ├─ company_code (FK) - 회사 코드 + └─ screen_code - 할당된 화면 코드 + +-- 2. 화면 정의 +screen_definitions + ├─ screen_id (PK) - 화면 고유 ID + ├─ screen_code (UNIQUE) - 화면 코드 + ├─ company_code (FK) - 회사 코드 + ├─ table_name - 연결된 테이블 + └─ layout_metadata (JSONB) - 레이아웃 메타데이터 + +-- 3. 화면 레이아웃 (컴포넌트) +screen_layouts + ├─ layout_id (PK) + ├─ screen_id (FK) - 화면 ID + ├─ component_type - 컴포넌트 타입 + ├─ properties (JSONB) - 컴포넌트 속성 + │ ├─ componentConfig.action.targetScreenId (모달 참조) + │ ├─ sections[].screenId (조건부 컨테이너) + │ └─ dataflowConfig.flowConfig.flowId (플로우 참조) + └─ parent_id - 부모 컴포넌트 ID + +-- 4. 화면-메뉴 할당 +screen_menu_assignments + ├─ assignment_id (PK) + ├─ screen_id (FK) - 화면 ID + ├─ menu_objid (FK) - 메뉴 ID + └─ company_code (FK) - 회사 코드 + +-- 5. 플로우 정의 +flow_definition + ├─ id (PK) - 플로우 ID + ├─ name - 플로우 이름 + ├─ table_name - 연결된 테이블 + └─ company_code (FK) - 회사 코드 + +-- 6. 플로우 스텝 +flow_step + ├─ id (PK) - 스텝 ID + ├─ flow_definition_id (FK) - 플로우 ID + ├─ step_name - 스텝 이름 + ├─ step_order - 순서 + ├─ condition_json (JSONB) - 조건 + └─ integration_config (JSONB) - 통합 설정 + +-- 7. 플로우 스텝 연결 +flow_step_connection + ├─ id (PK) + ├─ flow_definition_id (FK) - 플로우 ID + ├─ from_step_id (FK) - 출발 스텝 ID + ├─ to_step_id (FK) - 도착 스텝 ID + └─ label - 연결 라벨 + +-- 8. 코드 카테고리 +code_category + ├─ category_code (PK) + ├─ company_code (PK, FK) + ├─ menu_objid (PK, FK) - 메뉴 ID + ├─ category_name - 카테고리 이름 + └─ description - 설명 + +-- 9. 코드 정보 +code_info + ├─ code_category (PK, FK) + ├─ company_code (PK, FK) + ├─ menu_objid (PK, FK) + ├─ code_value (PK) - 코드 값 + ├─ code_name - 코드 이름 + └─ description - 설명 +``` + +### 외래키 제약조건 + +```sql +-- 중요: 삽입 순서 고려 필요 +1. company_mng (회사 정보) - 먼저 존재해야 함 +2. menu_info (메뉴) +3. screen_definitions (화면) +4. flow_definition (플로우) +5. screen_layouts (레이아웃) +6. screen_menu_assignments (화면-메뉴 할당) +7. flow_step (플로우 스텝) +8. flow_step_connection (스텝 연결) +9. code_category (코드 카테고리) +10. code_info (코드 정보) +``` + +--- + +## 복사 대상 항목 + +### 1단계: 메뉴 트리 수집 + +```typescript +// 재귀적으로 하위 메뉴 수집 +function collectMenuTree(rootMenuObjid: number): Menu[] { + const result: Menu[] = []; + const stack: number[] = [rootMenuObjid]; + + while (stack.length > 0) { + const currentObjid = stack.pop()!; + const menu = getMenuByObjid(currentObjid); + result.push(menu); + + // 자식 메뉴들을 스택에 추가 + const children = getChildMenus(currentObjid); + stack.push(...children.map(c => c.objid)); + } + + return result; +} +``` + +**수집 항목**: +- 원본 메뉴 objid +- 하위 메뉴 objid 목록 (재귀) +- 부모-자식 관계 매핑 + +### 2단계: 화면 수집 (중복 제거) + +```typescript +// 메뉴에 할당된 화면 + 참조 화면 수집 +function collectScreens(menuObjids: number[]): Set { + const screenIds = new Set(); + const visited = new Set(); // 무한 루프 방지 + + // 1) 메뉴에 직접 할당된 화면 + for (const menuObjid of menuObjids) { + const assignments = getScreenMenuAssignments(menuObjid); + assignments.forEach(a => screenIds.add(a.screen_id)); + } + + // 2) 화면 내부에서 참조되는 화면 (재귀) + const queue = Array.from(screenIds); + while (queue.length > 0) { + const screenId = queue.shift()!; + if (visited.has(screenId)) continue; + visited.add(screenId); + + const referencedScreens = extractReferencedScreens(screenId); + referencedScreens.forEach(refId => { + if (!screenIds.has(refId)) { + screenIds.add(refId); + queue.push(refId); + } + }); + } + + return screenIds; +} + +// 화면 레이아웃에서 참조 화면 추출 +function extractReferencedScreens(screenId: number): number[] { + const layouts = getScreenLayouts(screenId); + const referenced: number[] = []; + + for (const layout of layouts) { + const props = layout.properties; + + // 모달 버튼 + if (props?.componentConfig?.action?.targetScreenId) { + referenced.push(props.componentConfig.action.targetScreenId); + } + + // 조건부 컨테이너 + if (props?.sections) { + for (const section of props.sections) { + if (section.screenId) { + referenced.push(section.screenId); + } + } + } + } + + return referenced; +} +``` + +**수집 항목**: +- 직접 할당 화면 ID 목록 +- 모달 참조 화면 ID 목록 +- 조건부 컨테이너 내 화면 ID 목록 +- 중복 제거된 최종 화면 ID Set + +### 3단계: 플로우 수집 + +```typescript +// 화면에서 참조되는 플로우 수집 +function collectFlows(screenIds: Set): Set { + const flowIds = new Set(); + + for (const screenId of screenIds) { + const layouts = getScreenLayouts(screenId); + + for (const layout of layouts) { + const flowId = layout.properties?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowId) { + flowIds.add(flowId); + } + } + } + + return flowIds; +} +``` + +**수집 항목**: +- flow_definition.id 목록 +- 각 플로우의 flow_step 목록 +- 각 플로우의 flow_step_connection 목록 + +### 4단계: 코드 수집 + +```typescript +// 메뉴에 연결된 코드 수집 +function collectCodes(menuObjids: number[], companyCode: string): { + categories: CodeCategory[]; + codes: CodeInfo[]; +} { + const categories: CodeCategory[] = []; + const codes: CodeInfo[] = []; + + for (const menuObjid of menuObjids) { + // 코드 카테고리 + const cats = getCodeCategories(menuObjid, companyCode); + categories.push(...cats); + + // 각 카테고리의 코드 정보 + for (const cat of cats) { + const infos = getCodeInfos(cat.category_code, menuObjid, companyCode); + codes.push(...infos); + } + } + + return { categories, codes }; +} +``` + +**수집 항목**: +- code_category 목록 (menu_objid 기준) +- code_info 목록 (menu_objid + category_code 기준) + +--- + +## 복사 알고리즘 + +### 전체 프로세스 + +```typescript +async function copyMenu( + sourceMenuObjid: number, + targetCompanyCode: string, + userId: string +): Promise { + + // 트랜잭션 시작 + const client = await pool.connect(); + await client.query('BEGIN'); + + try { + // 1단계: 수집 (Collection Phase) + const menus = collectMenuTree(sourceMenuObjid); + const screenIds = collectScreens(menus.map(m => m.objid)); + const flowIds = collectFlows(screenIds); + const codes = collectCodes(menus.map(m => m.objid), menus[0].company_code); + + // 2단계: 플로우 복사 (Flow Copy Phase) + const flowIdMap = await copyFlows(flowIds, targetCompanyCode, userId, client); + + // 3단계: 화면 복사 (Screen Copy Phase) + const screenIdMap = await copyScreens( + screenIds, + targetCompanyCode, + flowIdMap, // 플로우 ID 재매핑 + userId, + client + ); + + // 4단계: 메뉴 복사 (Menu Copy Phase) + const menuIdMap = await copyMenus( + menus, + targetCompanyCode, + screenIdMap, // 화면 ID 재매핑 + userId, + client + ); + + // 5단계: 화면-메뉴 할당 (Assignment Phase) + await createScreenMenuAssignments( + menus, + menuIdMap, + screenIdMap, + targetCompanyCode, + client + ); + + // 6단계: 코드 복사 (Code Copy Phase) + await copyCodes( + codes, + menuIdMap, + targetCompanyCode, + userId, + client + ); + + // 커밋 + await client.query('COMMIT'); + + return { + success: true, + copiedMenus: Object.values(menuIdMap).length, + copiedScreens: Object.values(screenIdMap).length, + copiedFlows: Object.values(flowIdMap).length, + copiedCategories: codes.categories.length, + copiedCodes: codes.codes.length, + }; + + } catch (error) { + // 롤백 + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} +``` + +### 플로우 복사 알고리즘 + +```typescript +async function copyFlows( + flowIds: Set, + targetCompanyCode: string, + userId: string, + client: PoolClient +): Promise> { + + const flowIdMap = new Map(); // 원본 ID → 새 ID + + for (const originalFlowId of flowIds) { + // 1) flow_definition 복사 + const flowDef = await getFlowDefinition(originalFlowId, client); + const newFlowId = await insertFlowDefinition({ + ...flowDef, + company_code: targetCompanyCode, + created_by: userId, + }, client); + + flowIdMap.set(originalFlowId, newFlowId); + + // 2) flow_step 복사 + const steps = await getFlowSteps(originalFlowId, client); + const stepIdMap = new Map(); // 스텝 ID 매핑 + + for (const step of steps) { + const newStepId = await insertFlowStep({ + ...step, + flow_definition_id: newFlowId, // 새 플로우 ID + }, client); + + stepIdMap.set(step.id, newStepId); + } + + // 3) flow_step_connection 복사 (스텝 ID 재매핑) + const connections = await getFlowStepConnections(originalFlowId, client); + + for (const conn of connections) { + await insertFlowStepConnection({ + flow_definition_id: newFlowId, + from_step_id: stepIdMap.get(conn.from_step_id)!, // 재매핑 + to_step_id: stepIdMap.get(conn.to_step_id)!, // 재매핑 + label: conn.label, + }, client); + } + } + + return flowIdMap; +} +``` + +### 화면 복사 알고리즘 + +```typescript +async function copyScreens( + screenIds: Set, + targetCompanyCode: string, + flowIdMap: Map, // 플로우 ID 재매핑 + userId: string, + client: PoolClient +): Promise> { + + const screenIdMap = new Map(); // 원본 ID → 새 ID + + for (const originalScreenId of screenIds) { + // 1) screen_definitions 복사 + const screenDef = await getScreenDefinition(originalScreenId, client); + + // 새 screen_code 생성 (중복 방지) + const newScreenCode = await generateUniqueScreenCode(targetCompanyCode, client); + + const newScreenId = await insertScreenDefinition({ + ...screenDef, + screen_code: newScreenCode, + company_code: targetCompanyCode, + created_by: userId, + }, client); + + screenIdMap.set(originalScreenId, newScreenId); + + // 2) screen_layouts 복사 + const layouts = await getScreenLayouts(originalScreenId, client); + + for (const layout of layouts) { + // properties 내부 참조 업데이트 + const updatedProperties = updateReferencesInProperties( + layout.properties, + screenIdMap, // 화면 ID 재매핑 + flowIdMap // 플로우 ID 재매핑 + ); + + await insertScreenLayout({ + screen_id: newScreenId, // 새 화면 ID + component_type: layout.component_type, + component_id: layout.component_id, + parent_id: layout.parent_id, + position_x: layout.position_x, + position_y: layout.position_y, + width: layout.width, + height: layout.height, + properties: updatedProperties, // 업데이트된 속성 + display_order: layout.display_order, + layout_type: layout.layout_type, + layout_config: layout.layout_config, + zones_config: layout.zones_config, + zone_id: layout.zone_id, + }, client); + } + } + + return screenIdMap; +} + +// properties 내부 참조 업데이트 +function updateReferencesInProperties( + properties: any, + screenIdMap: Map, + flowIdMap: Map +): any { + + if (!properties) return properties; + + const updated = JSON.parse(JSON.stringify(properties)); // 깊은 복사 + + // 1) 모달 버튼의 targetScreenId + if (updated?.componentConfig?.action?.targetScreenId) { + const oldId = updated.componentConfig.action.targetScreenId; + const newId = screenIdMap.get(oldId); + if (newId) { + updated.componentConfig.action.targetScreenId = newId; + } + } + + // 2) 조건부 컨테이너의 sections[].screenId + if (updated?.sections) { + for (const section of updated.sections) { + if (section.screenId) { + const oldId = section.screenId; + const newId = screenIdMap.get(oldId); + if (newId) { + section.screenId = newId; + } + } + } + } + + // 3) 플로우 제어의 flowId + if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { + const oldId = updated.webTypeConfig.dataflowConfig.flowConfig.flowId; + const newId = flowIdMap.get(oldId); + if (newId) { + updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; + } + } + + return updated; +} +``` + +### 메뉴 복사 알고리즘 + +```typescript +async function copyMenus( + menus: Menu[], + targetCompanyCode: string, + screenIdMap: Map, + userId: string, + client: PoolClient +): Promise> { + + const menuIdMap = new Map(); // 원본 objid → 새 objid + + // 1) 메뉴를 깊이 순으로 정렬 (부모 먼저 삽입) + const sortedMenus = topologicalSortMenus(menus); + + for (const menu of sortedMenus) { + // screen_code 업데이트 (화면 ID 재매핑) + const newScreenCode = menu.screen_code + ? getNewScreenCode(screenIdMap, menu.screen_code) + : null; + + // parent_obj_id 업데이트 (메뉴 ID 재매핑) + const newParentObjId = menu.parent_obj_id + ? menuIdMap.get(menu.parent_obj_id) || null + : null; + + // 새 objid 생성 + const newObjId = await getNextMenuObjid(client); + + await insertMenu({ + objid: newObjId, + menu_type: menu.menu_type, + parent_obj_id: newParentObjId, // 재매핑 + menu_name_kor: menu.menu_name_kor, + menu_name_eng: menu.menu_name_eng, + seq: menu.seq, + menu_url: menu.menu_url, + menu_desc: menu.menu_desc, + writer: userId, + status: menu.status, + system_name: menu.system_name, + company_code: targetCompanyCode, // 새 회사 코드 + lang_key: menu.lang_key, + lang_key_desc: menu.lang_key_desc, + screen_code: newScreenCode, // 재매핑 + menu_code: menu.menu_code, + }, client); + + menuIdMap.set(menu.objid, newObjId); + } + + return menuIdMap; +} + +// 위상 정렬 (부모 먼저) +function topologicalSortMenus(menus: Menu[]): Menu[] { + const result: Menu[] = []; + const visited = new Set(); + + function visit(menu: Menu) { + if (visited.has(menu.objid)) return; + + // 부모 먼저 방문 + if (menu.parent_obj_id) { + const parent = menus.find(m => m.objid === menu.parent_obj_id); + if (parent) visit(parent); + } + + visited.add(menu.objid); + result.push(menu); + } + + for (const menu of menus) { + visit(menu); + } + + return result; +} +``` + +### 코드 복사 알고리즘 + +```typescript +async function copyCodes( + codes: { categories: CodeCategory[]; codes: CodeInfo[] }, + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient +): Promise { + + // 1) 코드 카테고리 복사 (중복 체크) + for (const category of codes.categories) { + const newMenuObjid = menuIdMap.get(category.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크: 같은 category_code + company_code + menu_objid + const exists = await checkCodeCategoryExists( + category.category_code, + targetCompanyCode, + newMenuObjid, + client + ); + + if (!exists) { + await insertCodeCategory({ + category_code: category.category_code, + category_name: category.category_name, + category_name_eng: category.category_name_eng, + description: category.description, + sort_order: category.sort_order, + is_active: category.is_active, + company_code: targetCompanyCode, // 새 회사 코드 + menu_objid: newMenuObjid, // 재매핑 + created_by: userId, + }, client); + } + } + + // 2) 코드 정보 복사 (중복 체크) + for (const code of codes.codes) { + const newMenuObjid = menuIdMap.get(code.menu_objid); + if (!newMenuObjid) continue; + + // 중복 체크: 같은 code_category + code_value + company_code + menu_objid + const exists = await checkCodeInfoExists( + code.code_category, + code.code_value, + targetCompanyCode, + newMenuObjid, + client + ); + + if (!exists) { + await insertCodeInfo({ + code_category: code.code_category, + code_value: code.code_value, + code_name: code.code_name, + code_name_eng: code.code_name_eng, + description: code.description, + sort_order: code.sort_order, + is_active: code.is_active, + company_code: targetCompanyCode, // 새 회사 코드 + menu_objid: newMenuObjid, // 재매핑 + created_by: userId, + }, client); + } + } +} +``` + +--- + +## 구현 단계 + +### Phase 1: 백엔드 서비스 구현 + +**파일**: `backend-node/src/services/menuCopyService.ts` + +#### 1.1 데이터 수집 함수 +- `collectMenuTree()` - 메뉴 트리 수집 +- `collectScreens()` - 화면 수집 (중복 제거) +- `collectFlows()` - 플로우 수집 +- `collectCodes()` - 코드 수집 +- `extractReferencedScreens()` - 화면 참조 추출 + +#### 1.2 복사 함수 +- `copyFlows()` - 플로우 복사 +- `copyScreens()` - 화면 복사 +- `copyMenus()` - 메뉴 복사 +- `copyCodes()` - 코드 복사 +- `createScreenMenuAssignments()` - 화면-메뉴 할당 + +#### 1.3 유틸리티 함수 +- `updateReferencesInProperties()` - JSONB 내부 참조 업데이트 +- `topologicalSortMenus()` - 메뉴 위상 정렬 +- `generateUniqueScreenCode()` - 고유 화면 코드 생성 +- `getNextMenuObjid()` - 다음 메뉴 objid + +### Phase 2: 백엔드 컨트롤러 구현 + +**파일**: `backend-node/src/controllers/menuController.ts` + +```typescript +// POST /api/admin/menus/:menuObjid/copy +export async function copyMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const { targetCompanyCode } = req.body; + const userId = req.user!.userId; + + // 권한 체크 + if (req.user!.companyCode !== "*") { + // 최고 관리자만 가능 + res.status(403).json({ + success: false, + message: "메뉴 복사는 최고 관리자만 가능합니다", + }); + return; + } + + // 복사 실행 + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + parseInt(menuObjid), + targetCompanyCode, + userId + ); + + res.json({ + success: true, + message: "메뉴 복사 완료", + data: result, + }); + + } catch (error: any) { + logger.error("메뉴 복사 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 복사 중 오류가 발생했습니다", + error: error.message, + }); + } +} +``` + +### Phase 3: 백엔드 라우터 등록 + +**파일**: `backend-node/src/routes/admin.ts` + +```typescript +// 메뉴 복사 API +router.post( + "/menus/:menuObjid/copy", + authenticate, + copyMenu +); +``` + +### Phase 4: 프론트엔드 API 클라이언트 + +**파일**: `frontend/lib/api/menu.ts` + +```typescript +/** + * 메뉴 복사 + */ +export async function copyMenu( + menuObjid: number, + targetCompanyCode: string +): Promise> { + try { + const response = await apiClient.post( + `/admin/menus/${menuObjid}/copy`, + { targetCompanyCode } + ); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} + +export interface MenuCopyResult { + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + warnings?: string[]; +} +``` + +### Phase 5: 프론트엔드 UI 구현 + +**파일**: `frontend/components/admin/MenuCopyDialog.tsx` + +```typescript +export function MenuCopyDialog({ + menuObjid, + menuName, + open, + onOpenChange, +}: MenuCopyDialogProps) { + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + const [companies, setCompanies] = useState([]); + const [copying, setCopying] = useState(false); + const [result, setResult] = useState(null); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + loadCompanies(); + } + }, [open]); + + const handleCopy = async () => { + if (!targetCompanyCode) { + toast.error("대상 회사를 선택해주세요"); + return; + } + + setCopying(true); + setResult(null); + + const response = await copyMenu(menuObjid, targetCompanyCode); + + if (response.success && response.data) { + setResult(response.data); + toast.success("메뉴 복사 완료!"); + } else { + toast.error(response.error || "메뉴 복사 실패"); + } + + setCopying(false); + }; + + return ( + + + + + 메뉴 복사 + + + "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다. + + + +
+ {/* 회사 선택 */} +
+ + +
+ + {/* 복사 항목 안내 */} +
+

복사되는 항목:

+
    +
  • 메뉴 구조 (하위 메뉴 포함)
  • +
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • +
  • 플로우 제어 (스텝, 연결)
  • +
  • 코드 카테고리 + 코드
  • +
+
+ + {/* 복사 결과 */} + {result && ( +
+

복사 완료!

+
    +
  • 메뉴: {result.copiedMenus}개
  • +
  • 화면: {result.copiedScreens}개
  • +
  • 플로우: {result.copiedFlows}개
  • +
  • 코드 카테고리: {result.copiedCategories}개
  • +
  • 코드: {result.copiedCodes}개
  • +
+
+ )} +
+ + + + {!result && ( + + )} + +
+
+ ); +} +``` + +### Phase 6: 메뉴 관리 화면 통합 + +**파일**: `frontend/components/admin/MenuManagement.tsx` + +```typescript +// 복사 버튼 추가 + + +// 다이얼로그 + +``` + +--- + +## API 명세 + +### POST /api/admin/menus/:menuObjid/copy + +**설명**: 메뉴와 관련된 모든 리소스를 다른 회사로 복사 + +**권한**: 최고 관리자 전용 (company_code = "*") + +**요청**: +```typescript +POST /api/admin/menus/100/copy +Content-Type: application/json + +{ + "targetCompanyCode": "COMPANY_B" +} +``` + +**응답 (성공)**: +```typescript +{ + "success": true, + "message": "메뉴 복사 완료", + "data": { + "copiedMenus": 5, + "copiedScreens": 12, + "copiedFlows": 3, + "copiedCategories": 8, + "copiedCodes": 45, + "menuIdMap": { + "100": 200, + "101": 201, + "102": 202 + }, + "screenIdMap": { + "10": 30, + "11": 31, + "12": 32 + }, + "flowIdMap": { + "5": 10, + "6": 11 + }, + "warnings": [ + "item_info 테이블에 데이터를 추가해야 합니다", + "메뉴 권한 설정이 필요합니다" + ] + } +} +``` + +**응답 (실패)**: +```typescript +{ + "success": false, + "message": "메뉴 복사 중 오류가 발생했습니다", + "error": "대상 회사가 존재하지 않습니다" +} +``` + +**에러 코드**: +- `403`: 권한 없음 (최고 관리자 아님) +- `404`: 메뉴를 찾을 수 없음 +- `400`: 잘못된 요청 (대상 회사 코드 누락) +- `500`: 서버 내부 오류 + +--- + +## UI/UX 설계 + +### 1. 메뉴 관리 화면 + +``` +┌─────────────────────────────────────────────┐ +│ 메뉴 관리 │ +├─────────────────────────────────────────────┤ +│ ┌─ 영업관리 (objid: 100) │ +│ │ ├─ [편집] [삭제] [복사] ← 복사 버튼 │ +│ │ ├─ 수주관리 (objid: 101) │ +│ │ │ └─ [편집] [삭제] [복사] │ +│ │ └─ 견적관리 (objid: 102) │ +│ │ └─ [편집] [삭제] [복사] │ +│ └─ ... │ +└─────────────────────────────────────────────┘ +``` + +### 2. 복사 다이얼로그 + +#### 초기 상태 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사 * │ +│ [회사 선택 ▼] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 복사되는 항목: │ │ +│ │ • 메뉴 구조 (하위 메뉴 포함) │ │ +│ │ • 화면 + 레이아웃 │ │ +│ │ • 플로우 제어 │ │ +│ │ • 코드 카테고리 + 코드 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [취소] [복사 시작] │ +└─────────────────────────────────────────┘ +``` + +#### 복사 중 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사: 회사B (COMPANY_B) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ⚙️ 복사 진행 중... │ │ +│ │ │ │ +│ │ ✅ 메뉴 수집 완료 │ │ +│ │ ✅ 화면 수집 완료 │ │ +│ │ ⏳ 플로우 복사 중... │ │ +│ │ ⬜ 화면 복사 대기 │ │ +│ │ ⬜ 메뉴 복사 대기 │ │ +│ │ ⬜ 코드 복사 대기 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [취소 불가] │ +└─────────────────────────────────────────┘ +``` + +#### 복사 완료 +``` +┌─────────────────────────────────────────┐ +│ 메뉴 복사 [X] │ +├─────────────────────────────────────────┤ +│ "영업관리" 메뉴와 관련된 모든 리소스를 │ +│ 다른 회사로 복사합니다. │ +│ │ +│ 대상 회사: 회사B (COMPANY_B) │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ✅ 복사 완료! │ │ +│ │ │ │ +│ │ • 메뉴: 5개 │ │ +│ │ • 화면: 12개 │ │ +│ │ • 플로우: 3개 │ │ +│ │ • 코드 카테고리: 8개 │ │ +│ │ • 코드: 45개 │ │ +│ │ │ │ +│ │ ⚠️ 주의사항: │ │ +│ │ - 실제 데이터는 복사되지 않음 │ │ +│ │ - 메뉴 권한 설정 필요 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ [닫기] │ +└─────────────────────────────────────────┘ +``` + +### 3. 사용자 흐름 + +``` +1. 메뉴 관리 화면 접속 + ↓ +2. 복사할 메뉴 옆 [복사] 버튼 클릭 + ↓ +3. 복사 다이얼로그 열림 + ↓ +4. 대상 회사 선택 + ↓ +5. [복사 시작] 버튼 클릭 + ↓ +6. 진행 상황 표시 (30초 ~ 2분) + ↓ +7. 완료 메시지 확인 + ↓ +8. [닫기] 버튼으로 다이얼로그 닫기 +``` + +--- + +## 예외 처리 + +### 1. 권한 검증 +```typescript +if (req.user!.companyCode !== "*") { + throw new Error("메뉴 복사는 최고 관리자만 가능합니다"); +} +``` + +### 2. 메뉴 존재 여부 +```typescript +const menu = await getMenuByObjid(menuObjid, client); +if (!menu) { + throw new Error("메뉴를 찾을 수 없습니다"); +} +``` + +### 3. 대상 회사 존재 여부 +```typescript +const company = await getCompanyByCode(targetCompanyCode, client); +if (!company) { + throw new Error("대상 회사가 존재하지 않습니다"); +} +``` + +### 4. 중복 메뉴 체크 +```typescript +// 같은 이름의 메뉴가 이미 있는지 확인 +const existingMenu = await getMenuByNameAndCompany( + menu.menu_name_kor, + targetCompanyCode, + client +); + +if (existingMenu) { + // 경고만 표시하고 진행 (사용자가 이름 변경 가능) + warnings.push(`같은 이름의 메뉴가 이미 존재합니다: ${menu.menu_name_kor}`); +} +``` + +### 5. 트랜잭션 롤백 +```typescript +try { + await client.query('BEGIN'); + // ... 복사 작업 + await client.query('COMMIT'); +} catch (error) { + await client.query('ROLLBACK'); + logger.error("메뉴 복사 실패, 롤백됨:", error); + throw error; +} +``` + +### 6. 무한 루프 방지 +```typescript +// 화면 참조 추적 시 +const visited = new Set(); + +function collectScreensRecursive(screenId: number) { + if (visited.has(screenId)) return; // 이미 방문함 + visited.add(screenId); + // ... 참조 화면 수집 +} +``` + +### 7. JSONB 파싱 오류 +```typescript +try { + const properties = JSON.parse(layout.properties); + // ... properties 처리 +} catch (error) { + logger.warn(`JSONB 파싱 실패: layout_id=${layout.layout_id}`, error); + // 원본 그대로 사용 +} +``` + +### 8. 부분 실패 처리 +```typescript +// 플로우 복사 실패 시 경고만 표시하고 계속 진행 +try { + await copyFlows(flowIds, targetCompanyCode, userId, client); +} catch (error) { + logger.error("플로우 복사 실패:", error); + warnings.push("일부 플로우가 복사되지 않았습니다"); + // 계속 진행 (메뉴와 화면은 복사) +} +``` + +--- + +## 테스트 계획 + +### 단위 테스트 (Unit Tests) + +#### 1. 수집 함수 테스트 +```typescript +describe("MenuCopyService - Collection", () => { + test("collectMenuTree: 하위 메뉴 재귀 수집", async () => { + const menus = await collectMenuTree(100); + expect(menus.length).toBeGreaterThan(1); + expect(menus[0].objid).toBe(100); + }); + + test("collectScreens: 중복 제거", async () => { + const screenIds = await collectScreens([100, 101]); + const uniqueIds = new Set(screenIds); + expect(screenIds.length).toBe(uniqueIds.size); + }); + + test("extractReferencedScreens: 모달 참조 추출", async () => { + const referenced = extractReferencedScreens(10); + expect(referenced).toContain(26); // 모달 화면 ID + }); +}); +``` + +#### 2. 복사 함수 테스트 +```typescript +describe("MenuCopyService - Copy", () => { + test("copyFlows: 플로우 + 스텝 + 연결 복사", async () => { + const flowIdMap = await copyFlows( + new Set([5]), + "TEST_COMPANY", + "test_user", + client + ); + + expect(flowIdMap.size).toBe(1); + const newFlowId = flowIdMap.get(5); + expect(newFlowId).toBeDefined(); + + const steps = await getFlowSteps(newFlowId!, client); + expect(steps.length).toBeGreaterThan(0); + }); + + test("copyScreens: properties 내부 참조 업데이트", async () => { + const screenIdMap = await copyScreens( + new Set([10]), + "TEST_COMPANY", + new Map(), // flowIdMap + "test_user", + client + ); + + const newScreenId = screenIdMap.get(10); + const layouts = await getScreenLayouts(newScreenId!, client); + + // targetScreenId가 재매핑되었는지 확인 + const modalLayout = layouts.find( + l => l.properties?.componentConfig?.action?.type === "modal" + ); + expect(modalLayout?.properties.componentConfig.action.targetScreenId).not.toBe(26); + }); +}); +``` + +#### 3. ID 재매핑 테스트 +```typescript +describe("MenuCopyService - Remapping", () => { + test("updateReferencesInProperties: 모달 참조 업데이트", () => { + const properties = { + componentConfig: { + action: { + type: "modal", + targetScreenId: 26 + } + } + }; + + const screenIdMap = new Map([[26, 50]]); + const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); + + expect(updated.componentConfig.action.targetScreenId).toBe(50); + }); + + test("updateReferencesInProperties: 조건부 컨테이너 참조 업데이트", () => { + const properties = { + sections: [ + { id: "1", condition: "A", screenId: 10 }, + { id: "2", condition: "B", screenId: 11 } + ] + }; + + const screenIdMap = new Map([[10, 30], [11, 31]]); + const updated = updateReferencesInProperties(properties, screenIdMap, new Map()); + + expect(updated.sections[0].screenId).toBe(30); + expect(updated.sections[1].screenId).toBe(31); + }); +}); +``` + +### 통합 테스트 (Integration Tests) + +#### 1. 전체 복사 플로우 +```typescript +describe("Menu Copy - Full Flow", () => { + let testMenuObjid: number; + let targetCompanyCode: string; + + beforeAll(async () => { + // 테스트 데이터 준비 + testMenuObjid = await createTestMenu(); + targetCompanyCode = "TEST_COMPANY_" + Date.now(); + await createTestCompany(targetCompanyCode); + }); + + afterAll(async () => { + // 테스트 데이터 정리 + await deleteTestData(targetCompanyCode); + }); + + test("메뉴 복사: 성공", async () => { + const menuCopyService = new MenuCopyService(); + const result = await menuCopyService.copyMenu( + testMenuObjid, + targetCompanyCode, + "test_user" + ); + + expect(result.success).toBe(true); + expect(result.copiedMenus).toBeGreaterThan(0); + expect(result.copiedScreens).toBeGreaterThan(0); + + // 복사된 메뉴 검증 + const copiedMenus = await getMenusByCompany(targetCompanyCode); + expect(copiedMenus.length).toBe(result.copiedMenus); + + // 복사된 화면 검증 + const copiedScreens = await getScreensByCompany(targetCompanyCode); + expect(copiedScreens.length).toBe(result.copiedScreens); + }); + + test("복사된 화면이 정상 작동", async () => { + // 복사된 화면에서 데이터 조회 가능한지 확인 + const screens = await getScreensByCompany(targetCompanyCode); + const firstScreen = screens[0]; + + const layouts = await getScreenLayouts(firstScreen.screen_id); + expect(layouts.length).toBeGreaterThan(0); + }); +}); +``` + +#### 2. 트랜잭션 롤백 테스트 +```typescript +describe("Menu Copy - Rollback", () => { + test("실패 시 롤백", async () => { + const invalidCompanyCode = "INVALID_COMPANY"; + + const menuCopyService = new MenuCopyService(); + + await expect( + menuCopyService.copyMenu(100, invalidCompanyCode, "test_user") + ).rejects.toThrow(); + + // 롤백 확인: 데이터가 생성되지 않았는지 + const menus = await getMenusByCompany(invalidCompanyCode); + expect(menus.length).toBe(0); + }); +}); +``` + +### E2E 테스트 (End-to-End Tests) + +#### 1. UI 테스트 +```typescript +describe("Menu Copy - E2E", () => { + test("메뉴 관리 화면에서 복사 버튼 클릭", async () => { + // 1. 로그인 + await page.goto("http://localhost:9771/login"); + await page.fill('input[name="userId"]', "wace"); + await page.fill('input[name="password"]', "qlalfqjsgh11"); + await page.click('button[type="submit"]'); + + // 2. 메뉴 관리 화면 이동 + await page.goto("http://localhost:9771/admin/menus"); + await page.waitForSelector(".menu-list"); + + // 3. 복사 버튼 클릭 + await page.click('button[aria-label="메뉴 복사"]'); + + // 4. 대상 회사 선택 + await page.selectOption('select[name="targetCompany"]', "COMPANY_B"); + + // 5. 복사 시작 + await page.click('button:has-text("복사 시작")'); + + // 6. 완료 메시지 확인 + await page.waitForSelector('text=복사 완료', { timeout: 120000 }); + + // 7. 복사된 메뉴 확인 + await page.selectOption('select[name="company"]', "COMPANY_B"); + await page.waitForSelector('.menu-list'); + const menuCount = await page.locator('.menu-item').count(); + expect(menuCount).toBeGreaterThan(0); + }); +}); +``` + +### 성능 테스트 + +#### 1. 대량 메뉴 복사 +```typescript +test("100개 메뉴 복사 성능", async () => { + const startTime = Date.now(); + + const result = await menuCopyService.copyMenu( + largeMenuObjid, // 하위 메뉴 100개 + "TEST_COMPANY", + "test_user" + ); + + const endTime = Date.now(); + const duration = endTime - startTime; + + expect(duration).toBeLessThan(120000); // 2분 이내 + expect(result.copiedMenus).toBe(100); +}); +``` + +#### 2. 동시 복사 요청 +```typescript +test("동시 복사 요청 처리", async () => { + const promises = Array.from({ length: 5 }, (_, i) => + menuCopyService.copyMenu( + testMenuObjid, + `TEST_COMPANY_${i}`, + "test_user" + ) + ); + + const results = await Promise.all(promises); + + expect(results.every(r => r.success)).toBe(true); +}); +``` + +--- + +## 구현 체크리스트 + +### 백엔드 +- [ ] `menuCopyService.ts` 생성 + - [ ] `collectMenuTree()` + - [ ] `collectScreens()` + - [ ] `collectFlows()` + - [ ] `collectCodes()` + - [ ] `extractReferencedScreens()` + - [ ] `copyFlows()` + - [ ] `copyScreens()` + - [ ] `copyMenus()` + - [ ] `copyCodes()` + - [ ] `createScreenMenuAssignments()` + - [ ] `updateReferencesInProperties()` + - [ ] `topologicalSortMenus()` + - [ ] `generateUniqueScreenCode()` +- [ ] `menuController.ts` 업데이트 + - [ ] `copyMenu()` 컨트롤러 추가 +- [ ] `admin.ts` 라우터 업데이트 + - [ ] `/menus/:menuObjid/copy` 라우트 추가 +- [ ] 단위 테스트 작성 +- [ ] 통합 테스트 작성 + +### 프론트엔드 +- [ ] `menu.ts` API 클라이언트 업데이트 + - [ ] `copyMenu()` 함수 추가 + - [ ] `MenuCopyResult` 인터페이스 추가 +- [ ] `MenuCopyDialog.tsx` 생성 + - [ ] 회사 선택 드롭다운 + - [ ] 복사 진행 상태 표시 + - [ ] 복사 결과 표시 +- [ ] `MenuManagement.tsx` 업데이트 + - [ ] 복사 버튼 추가 + - [ ] 다이얼로그 통합 +- [ ] E2E 테스트 작성 + +### 문서 +- [ ] API 문서 업데이트 +- [ ] 사용자 매뉴얼 작성 +- [ ] 개발자 가이드 작성 + +--- + +## 예상 소요 시간 + +| 단계 | 작업 | 예상 시간 | +|------|------|-----------| +| 1 | 백엔드 서비스 구현 | 6시간 | +| 2 | 백엔드 컨트롤러/라우터 | 1시간 | +| 3 | 백엔드 테스트 | 3시간 | +| 4 | 프론트엔드 API 클라이언트 | 0.5시간 | +| 5 | 프론트엔드 UI 구현 | 3시간 | +| 6 | 프론트엔드 통합 | 1시간 | +| 7 | E2E 테스트 | 2시간 | +| 8 | 문서 작성 | 1.5시간 | +| 9 | 버그 수정 및 최적화 | 2시간 | +| **총계** | | **20시간** | + +--- + +## 참고 사항 + +### 멀티테넌시 주의사항 +- 모든 쿼리에 `company_code` 필터링 적용 +- 최고 관리자(company_code = "*")만 메뉴 복사 가능 +- 복사 시 `company_code`를 대상 회사 코드로 변경 + +### 데이터 무결성 +- 외래키 제약조건 순서 준수 +- 트랜잭션으로 원자성 보장 +- 중복 데이터 체크 및 병합 + +### 성능 최적화 +- 배치 삽입 사용 (bulk insert) +- 불필요한 쿼리 최소화 +- ID 매핑 테이블로 참조 업데이트 + +### 보안 +- 권한 검증 (최고 관리자만) +- SQL 인젝션 방지 +- 입력값 검증 + +--- + +## 변경 이력 + +| 날짜 | 버전 | 작성자 | 변경 내용 | +|------|------|--------|----------| +| 2025-01-24 | 1.0 | AI | 초안 작성 | + + diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx new file mode 100644 index 00000000..138e835b --- /dev/null +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { menuApi, MenuCopyResult } from "@/lib/api/menu"; +import { apiClient } from "@/lib/api/client"; + +interface MenuCopyDialogProps { + menuObjid: number | null; + menuName: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onCopyComplete?: () => void; +} + +interface Company { + company_code: string; + company_name: string; +} + +export function MenuCopyDialog({ + menuObjid, + menuName, + open, + onOpenChange, + onCopyComplete, +}: MenuCopyDialogProps) { + const [targetCompanyCode, setTargetCompanyCode] = useState(""); + const [companies, setCompanies] = useState([]); + const [copying, setCopying] = useState(false); + const [result, setResult] = useState(null); + const [loadingCompanies, setLoadingCompanies] = useState(false); + + // 회사 목록 로드 + useEffect(() => { + if (open) { + loadCompanies(); + // 다이얼로그가 열릴 때마다 초기화 + setTargetCompanyCode(""); + setResult(null); + } + }, [open]); + + const loadCompanies = async () => { + try { + setLoadingCompanies(true); + const response = await apiClient.get("/admin/companies/db"); + if (response.data.success && response.data.data) { + // 최고 관리자(*) 회사 제외 + const filteredCompanies = response.data.data.filter( + (company: Company) => company.company_code !== "*" + ); + setCompanies(filteredCompanies); + } + } catch (error) { + console.error("회사 목록 조회 실패:", error); + toast.error("회사 목록을 불러올 수 없습니다"); + } finally { + setLoadingCompanies(false); + } + }; + + const handleCopy = async () => { + if (!menuObjid) { + toast.error("메뉴를 선택해주세요"); + return; + } + + if (!targetCompanyCode) { + toast.error("대상 회사를 선택해주세요"); + return; + } + + setCopying(true); + setResult(null); + + try { + const response = await menuApi.copyMenu(menuObjid, targetCompanyCode); + + if (response.success && response.data) { + setResult(response.data); + toast.success("메뉴 복사 완료!"); + + // 경고 메시지 표시 + if (response.data.warnings && response.data.warnings.length > 0) { + response.data.warnings.forEach((warning) => { + toast.warning(warning); + }); + } + + // 복사 완료 콜백 + if (onCopyComplete) { + onCopyComplete(); + } + } else { + toast.error(response.message || "메뉴 복사 실패"); + } + } catch (error: any) { + console.error("메뉴 복사 오류:", error); + toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다"); + } finally { + setCopying(false); + } + }; + + const handleClose = () => { + if (!copying) { + onOpenChange(false); + } + }; + + return ( + + + + + 메뉴 복사 + + + "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다. + + + +
+ {/* 회사 선택 */} + {!result && ( +
+ + +
+ )} + + {/* 복사 항목 안내 */} + {!result && ( +
+

복사되는 항목:

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

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

+
+ )} + + {/* 복사 결과 */} + {result && ( +
+

✅ 복사 완료!

+
+
+ 메뉴:{" "} + {result.copiedMenus}개 +
+
+ 화면:{" "} + {result.copiedScreens}개 +
+
+ 플로우:{" "} + {result.copiedFlows}개 +
+
+ 코드 카테고리:{" "} + {result.copiedCategories}개 +
+
+ 코드:{" "} + {result.copiedCodes}개 +
+
+
+ )} +
+ + + + {!result && ( + + )} + +
+
+ ); +} + diff --git a/frontend/components/admin/MenuManagement.tsx b/frontend/components/admin/MenuManagement.tsx index 6671504e..67e8bab6 100644 --- a/frontend/components/admin/MenuManagement.tsx +++ b/frontend/components/admin/MenuManagement.tsx @@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu"; import type { MenuItem } from "@/lib/api/menu"; import { MenuTable } from "./MenuTable"; import { MenuFormModal } from "./MenuFormModal"; +import { MenuCopyDialog } from "./MenuCopyDialog"; import { Button } from "@/components/ui/button"; import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; @@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext"; import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang"; import { useMultiLang } from "@/hooks/useMultiLang"; import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; // useAuth 추가 type MenuType = "admin" | "user"; export const MenuManagement: React.FC = () => { const { adminMenus, userMenus, refreshMenus } = useMenu(); + const { user } = useAuth(); // 현재 사용자 정보 가져오기 const [selectedMenuType, setSelectedMenuType] = useState("admin"); const [loading, setLoading] = useState(false); const [deleting, setDeleting] = useState(false); const [formModalOpen, setFormModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [copyDialogOpen, setCopyDialogOpen] = useState(false); const [selectedMenuId, setSelectedMenuId] = useState(""); + const [selectedMenuName, setSelectedMenuName] = useState(""); const [selectedMenus, setSelectedMenus] = useState>(new Set()); // 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시) @@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => { // getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용 const { userLang } = useMultiLang({ companyCode: "*" }); + // SUPER_ADMIN 여부 확인 + const isSuperAdmin = user?.userType === "SUPER_ADMIN"; + // 다국어 텍스트 상태 const [uiTexts, setUiTexts] = useState>({}); const [uiTextsLoading, setUiTextsLoading] = useState(false); @@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => { } }; + const handleCopyMenu = (menuId: string, menuName: string) => { + setSelectedMenuId(menuId); + setSelectedMenuName(menuName); + setCopyDialogOpen(true); + }; + + const handleCopyComplete = async () => { + // 복사 완료 후 메뉴 목록 새로고침 + await loadMenus(false); + toast.success("메뉴 복사가 완료되었습니다"); + }; + const handleToggleStatus = async (menuId: string) => { try { const response = await menuApi.toggleMenuStatus(menuId); @@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => { title="" onAddMenu={handleAddMenu} onEditMenu={handleEditMenu} + onCopyMenu={handleCopyMenu} onToggleStatus={handleToggleStatus} selectedMenus={selectedMenus} onMenuSelectionChange={handleMenuSelectionChange} @@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => { expandedMenus={expandedMenus} onToggleExpand={handleToggleExpand} uiTexts={uiTexts} + isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달 /> @@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => { + + ); }; diff --git a/frontend/components/admin/MenuTable.tsx b/frontend/components/admin/MenuTable.tsx index 9ca243bc..644c84f3 100644 --- a/frontend/components/admin/MenuTable.tsx +++ b/frontend/components/admin/MenuTable.tsx @@ -14,6 +14,7 @@ interface MenuTableProps { title: string; onAddMenu: (parentId: string, menuType: string, level: number) => void; onEditMenu: (menuId: string) => void; + onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가 onToggleStatus: (menuId: string) => void; selectedMenus: Set; onMenuSelectionChange: (menuId: string, checked: boolean) => void; @@ -22,6 +23,7 @@ interface MenuTableProps { onToggleExpand: (menuId: string) => void; // 다국어 텍스트 props 추가 uiTexts: Record; + isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가 } export const MenuTable: React.FC = ({ @@ -29,6 +31,7 @@ export const MenuTable: React.FC = ({ title, onAddMenu, onEditMenu, + onCopyMenu, onToggleStatus, selectedMenus, onMenuSelectionChange, @@ -36,6 +39,7 @@ export const MenuTable: React.FC = ({ expandedMenus, onToggleExpand, uiTexts, + isSuperAdmin = false, // 기본값 false }) => { // 다국어 텍스트 가져오기 함수 const getText = (key: string, fallback?: string): string => { @@ -281,14 +285,26 @@ export const MenuTable: React.FC = ({
{lev === 1 && ( - + <> + + {isSuperAdmin && ( + + )} + )} {lev === 2 && ( <> @@ -308,17 +324,39 @@ export const MenuTable: React.FC = ({ > {getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)} + {isSuperAdmin && ( + + )} )} {lev > 2 && ( - + <> + + {isSuperAdmin && ( + + )} + )}
diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index ad28996e..d8964257 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -162,4 +162,40 @@ export const menuApi = { throw error; } }, + + // 메뉴 복사 + copyMenu: async ( + menuObjid: number, + targetCompanyCode: string + ): Promise> => { + try { + const response = await apiClient.post( + `/admin/menus/${menuObjid}/copy`, + { targetCompanyCode } + ); + return response.data; + } catch (error: any) { + console.error("❌ 메뉴 복사 실패:", error); + return { + success: false, + message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다", + errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR", + }; + } + }, }; + +/** + * 메뉴 복사 결과 + */ +export interface MenuCopyResult { + copiedMenus: number; + copiedScreens: number; + copiedFlows: number; + copiedCategories: number; + copiedCodes: number; + menuIdMap: Record; + screenIdMap: Record; + flowIdMap: Record; + warnings?: string[]; +}