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; copiedCodeCategories: number; copiedCodes: number; copiedNumberingRules: number; copiedCategoryMappings: number; copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 copiedCascadingRelations: number; // 연쇄관계 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; warnings: string[]; } /** * 추가 복사 옵션 */ export interface AdditionalCopyOptions { copyCodeCategory?: boolean; copyNumberingRules?: boolean; copyCategoryMapping?: boolean; copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 copyCascadingRelation?: boolean; // 연쇄관계 설정 } /** * 메뉴 정보 */ 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; source_screen_id: number | null; // 원본 화면 ID (복사 추적용) } /** * 화면 레이아웃 */ 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; } /** * 메뉴 복사 서비스 */ 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?.componentConfig?.sections && Array.isArray(props.componentConfig.sections) ) { for (const section of props.componentConfig.sections) { if (section.screenId) { const screenId = section.screenId; const numId = typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); } } } } // 3) 탭 컴포넌트 (tabs 배열 내부의 screenId) if ( props?.componentConfig?.tabs && Array.isArray(props.componentConfig.tabs) ) { for (const tab of props.componentConfig.tabs) { if (tab.screenId) { const screenId = tab.screenId; const numId = typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); } } } } // 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId) if (props?.componentConfig?.leftScreenId) { const leftScreenId = props.componentConfig.leftScreenId; const numId = typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); } } if (props?.componentConfig?.rightScreenId) { const rightScreenId = props.componentConfig.rightScreenId; const numId = typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${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; } /** * 플로우 수집 * - 화면 레이아웃에서 참조된 모든 flowId 수집 * - dataflowConfig.flowConfig.flowId 및 selectedDiagramId 모두 수집 */ private async collectFlows( screenIds: Set, client: PoolClient ): Promise> { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; 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; const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; if (flowId && typeof flowId === "number" && flowId > 0) { if (!flowIds.has(flowId)) { flowIds.add(flowId); flowDetails.push({ flowId, flowName, screenId }); logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); } } // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { if (!flowIds.has(selectedDiagramId)) { flowIds.add(selectedDiagramId); flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); } } } } if (flowIds.size > 0) { logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`); } else { logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); } return flowIds; } /** * 다음 메뉴 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, numberingRuleId 재귀 업데이트 */ private updateReferencesInProperties( properties: any, screenIdMap: Map, flowIdMap: Map, numberingRuleIdMap?: Map ): any { if (!properties) return properties; // 깊은 복사 const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); return updated; } /** * 재귀적으로 모든 ID 참조 업데이트 */ private recursiveUpdateReferences( obj: any, screenIdMap: Map, flowIdMap: Map, path: string = "", numberingRuleIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; // 배열인 경우 if (Array.isArray(obj)) { obj.forEach((item, index) => { this.recursiveUpdateReferences( item, screenIdMap, flowIdMap, `${path}[${index}]`, numberingRuleIdMap ); }); return; } // 객체인 경우 - 키별로 처리 for (const key of Object.keys(obj)) { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || key === "targetScreenId" || key === "leftScreenId" || key === "rightScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); if (!isNaN(numValue) && numValue > 0) { const newId = screenIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 logger.info( ` 🔗 화면 참조 업데이트 (${currentPath}): ${value} → ${newId}` ); } } } // flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열) // selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환 if (key === "flowId" || key === "selectedDiagramId") { const numValue = typeof value === "number" ? value : parseInt(value); if (!isNaN(numValue) && numValue > 0) { const newId = flowIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 logger.info( ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` ); } else { // 매핑이 없으면 경고 로그 logger.warn( ` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음` ); } } } // 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, numberingRuleIdMap ); } } } /** * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) * * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 * - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제 * - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지) */ 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, parent_obj_id FROM menu_info WHERE objid = $1`, [sourceMenuObjid] ); if (sourceMenuResult.rows.length === 0) { logger.warn("⚠️ 원본 메뉴를 찾을 수 없습니다"); return; } 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; parent_obj_id: number | null }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 AND company_code = $2`, [sourceMenuObjid, targetCompanyCode] ); if (existingMenuResult.rows.length === 0) { logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다"); return; } 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}, 최상위: ${existingIsRoot})` ); // 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. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제) await client.query( `DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, [existingMenuIds, targetCompanyCode] ); logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); // 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외) if (screenIds.length > 0) { // 다른 메뉴에서도 사용 중인 화면 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] ); 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-3. 메뉴 권한 삭제 await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ existingMenuIds, ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 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, screenNameConfig?: { removeText?: string; addPrefix?: string; }, additionalCopyOptions?: AdditionalCopyOptions ): 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); logger.info(` 📊 수집 완료: - 메뉴: ${menus.length}개 - 화면: ${screenIds.size}개 - 플로우: ${flowIds.size}개 `); // === 2단계: 플로우 복사 === logger.info("\n🔄 [2단계] 플로우 복사"); const flowIdMap = await this.copyFlows( flowIds, targetCompanyCode, userId, client ); // 변수 초기화 let copiedCodeCategories = 0; let copiedCodes = 0; let copiedNumberingRules = 0; let copiedCategoryMappings = 0; let copiedTableTypeColumns = 0; let copiedCascadingRelations = 0; let numberingRuleIdMap = new Map(); const menuObjids = menus.map((m) => m.objid); // 메뉴 ID 맵을 먼저 생성 (일관된 ID 사용을 위해) const tempMenuIdMap = new Map(); let tempObjId = await this.getNextMenuObjid(client); for (const menu of menus) { tempMenuIdMap.set(menu.objid, tempObjId++); } // === 3단계: 메뉴 복사 (외래키 의존성 해결을 위해 먼저 실행) === // 채번 규칙, 코드 카테고리 등이 menu_info를 참조하므로 메뉴를 먼저 생성 logger.info("\n📂 [3단계] 메뉴 복사 (외래키 선행 조건)"); const menuIdMap = await this.copyMenus( menus, sourceMenuObjid, sourceCompanyCode, targetCompanyCode, new Map(), // screenIdMap은 아직 없음 (나중에 할당에서 처리) userId, client, tempMenuIdMap ); // === 4단계: 채번 규칙 복사 (메뉴 복사 후, 화면 복사 전) === if (additionalCopyOptions?.copyNumberingRules) { logger.info("\n📦 [4단계] 채번 규칙 복사"); const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, menuIdMap, // 실제 생성된 메뉴 ID 사용 targetCompanyCode, userId, client ); copiedNumberingRules = ruleResult.copiedCount; numberingRuleIdMap = ruleResult.ruleIdMap; } // === 4.1단계: 코드 카테고리 + 코드 복사 === if (additionalCopyOptions?.copyCodeCategory) { logger.info("\n📦 [4.1단계] 코드 카테고리 + 코드 복사"); const codeResult = await this.copyCodeCategoriesAndCodes( menuObjids, menuIdMap, targetCompanyCode, userId, client ); copiedCodeCategories = codeResult.copiedCategories; copiedCodes = codeResult.copiedCodes; } // === 4.2단계: 카테고리 매핑 + 값 복사 === if (additionalCopyOptions?.copyCategoryMapping) { logger.info("\n📦 [4.2단계] 카테고리 매핑 + 값 복사"); copiedCategoryMappings = await this.copyCategoryMappingsAndValues( menuObjids, menuIdMap, targetCompanyCode, userId, client ); } // === 4.3단계: 연쇄관계 복사 === if (additionalCopyOptions?.copyCascadingRelation) { logger.info("\n📦 [4.3단계] 연쇄관계 복사"); copiedCascadingRelations = await this.copyCascadingRelations( sourceCompanyCode, targetCompanyCode, menuIdMap, userId, client ); } // === 5단계: 화면 복사 === logger.info("\n📄 [5단계] 화면 복사"); const screenIdMap = await this.copyScreens( screenIds, targetCompanyCode, flowIdMap, userId, client, screenNameConfig, numberingRuleIdMap ); // === 6단계: 화면-메뉴 할당 === logger.info("\n🔗 [6단계] 화면-메뉴 할당"); await this.createScreenMenuAssignments( menus, menuIdMap, screenIdMap, targetCompanyCode, client ); // === 7단계: 테이블 타입 설정 복사 === if (additionalCopyOptions?.copyTableTypeColumns) { logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); copiedTableTypeColumns = await this.copyTableTypeColumns( Array.from(screenIdMap.keys()), sourceCompanyCode, targetCompanyCode, client ); } // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); const result: MenuCopyResult = { success: true, copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, copiedCodeCategories, copiedCodes, copiedNumberingRules, copiedCategoryMappings, copiedTableTypeColumns, copiedCascadingRelations, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), warnings, }; logger.info(` 🎉 ============================================ 메뉴 복사 완료! - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - 코드 카테고리: ${copiedCodeCategories}개 - 코드: ${copiedCodes}개 - 채번규칙: ${copiedNumberingRules}개 - 카테고리 매핑: ${copiedCategoryMappings}개 - 테이블 타입 설정: ${copiedTableTypeColumns}개 - 연쇄관계: ${copiedCascadingRelations}개 ============================================ `); return result; } catch (error: any) { // 롤백 await client.query("ROLLBACK"); logger.error("❌ 메뉴 복사 실패, 롤백됨:", error); throw error; } finally { client.release(); } } /** * 플로우 복사 * - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만) * - 없으면 새로 복사 */ 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}개`); logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); 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]; logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 const existingFlowResult = await client.query<{ id: number }>( `SELECT id FROM flow_definition WHERE company_code = $1 AND name = $2 AND table_name = $3 LIMIT 1`, [targetCompanyCode, flowDef.name, flowDef.table_name] ); let newFlowId: number; if (existingFlowResult.rows.length > 0) { // 기존 플로우가 있으면 재사용 newFlowId = existingFlowResult.rows[0].id; flowIdMap.set(originalFlowId, newFlowId); logger.info( ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` ); continue; // 스텝/연결 복사 생략 (기존 것 사용) } // 3) 새 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, ] ); 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; } /** * 화면 복사 (업데이트 또는 신규 생성) * - source_screen_id로 기존 복사본 찾기 * - 변경된 내용이 있으면 업데이트 * - 없으면 새로 복사 */ private async copyScreens( screenIds: Set, targetCompanyCode: string, flowIdMap: Map, userId: string, client: PoolClient, screenNameConfig?: { removeText?: string; addPrefix?: string; }, numberingRuleIdMap?: Map ): 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; targetScreenId: number; screenDef: ScreenDefinition; isUpdate: boolean; // 업데이트인지 신규 생성인지 }> = []; 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) 기존 복사본 찾기: source_screen_id로 검색 let existingCopyResult = await client.query<{ screen_id: number; screen_name: string; updated_date: Date; }>( `SELECT screen_id, screen_name, updated_date FROM screen_definitions WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, [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) { if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), "" ); transformedScreenName = transformedScreenName.trim(); } if (screenNameConfig.addPrefix?.trim()) { transformedScreenName = screenNameConfig.addPrefix.trim() + " " + transformedScreenName; } } if (existingCopyResult.rows.length > 0) { // === 기존 복사본이 있는 경우: 업데이트 === const existingScreen = existingCopyResult.rows[0]; const existingScreenId = existingScreen.screen_id; // 원본 레이아웃 조회 const sourceLayoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); // 대상 레이아웃 조회 const targetLayoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [existingScreenId] ); // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) const hasChanges = this.hasLayoutChanges( sourceLayoutsResult.rows, targetLayoutsResult.rows ); if (hasChanges) { // 변경 사항이 있으면 업데이트 logger.info( ` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` ); // screen_definitions 업데이트 await client.query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, layout_metadata = $5, db_source_type = $6, db_connection_id = $7, updated_by = $8, updated_date = NOW() WHERE screen_id = $9`, [ transformedScreenName, screenDef.table_name, screenDef.description, screenDef.is_active === "D" ? "Y" : screenDef.is_active, screenDef.layout_metadata, screenDef.db_source_type, screenDef.db_connection_id, userId, existingScreenId, ] ); screenIdMap.set(originalScreenId, existingScreenId); screenDefsToProcess.push({ originalScreenId, targetScreenId: existingScreenId, screenDef, isUpdate: true, }); } else { // 변경 사항이 없으면 스킵 screenIdMap.set(originalScreenId, existingScreenId); logger.info( ` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` ); } } else { // === 기존 복사본이 없는 경우: 신규 생성 === const newScreenCode = await this.generateUniqueScreenCode( targetCompanyCode, client ); 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, source_screen_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING screen_id`, [ transformedScreenName, 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, null, null, originalScreenId, // source_screen_id 저장 ] ); const newScreenId = newScreenResult.rows[0].screen_id; screenIdMap.set(originalScreenId, newScreenId); logger.info( ` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` ); screenDefsToProcess.push({ originalScreenId, targetScreenId: newScreenId, screenDef, isUpdate: false, }); } } catch (error: any) { logger.error( `❌ 화면 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === logger.info( `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { originalScreenId, targetScreenId, screenDef, isUpdate, } of screenDefsToProcess) { try { // 원본 레이아웃 조회 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); if (isUpdate) { // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 await client.query( `DELETE FROM screen_layouts WHERE screen_id = $1`, [targetScreenId] ); logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); } // 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); } // 레이아웃 삽입 for (const layout of layoutsResult.rows) { const newComponentId = componentIdMap.get(layout.component_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; const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, flowIdMap, numberingRuleIdMap ); 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)`, [ targetScreenId, layout.component_type, newComponentId, newParentId, layout.position_x, layout.position_y, layout.width, layout.height, updatedProperties, layout.display_order, layout.layout_type, layout.layout_config, layout.zones_config, newZoneId, ] ); } const action = isUpdate ? "업데이트" : "복사"; logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); } catch (error: any) { logger.error( `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } // 통계 출력 const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length; const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length; const skipCount = screenIds.size - screenDefsToProcess.length; logger.info(` ✅ 화면 처리 완료: - 신규 복사: ${newCount}개 - 업데이트: ${updateCount}개 - 스킵 (변경 없음): ${skipCount}개 - 총 매핑: ${screenIdMap.size}개 `); return screenIdMap; } /** * 레이아웃 변경 여부 확인 */ private hasLayoutChanges( sourceLayouts: ScreenLayout[], targetLayouts: ScreenLayout[] ): boolean { // 1. 레이아웃 개수가 다르면 변경됨 if (sourceLayouts.length !== targetLayouts.length) { return true; } // 2. 각 레이아웃의 주요 속성 비교 for (let i = 0; i < sourceLayouts.length; i++) { const source = sourceLayouts[i]; const target = targetLayouts[i]; // component_type이 다르면 변경됨 if (source.component_type !== target.component_type) { return true; } // 위치/크기가 다르면 변경됨 if ( source.position_x !== target.position_x || source.position_y !== target.position_y || source.width !== target.width || source.height !== target.height ) { return true; } // properties의 JSON 문자열 비교 (깊은 비교) const sourceProps = JSON.stringify(source.properties || {}); const targetProps = JSON.stringify(target.properties || {}); if (sourceProps !== targetProps) { return true; } } return false; } /** * 메뉴 위상 정렬 (부모 먼저) */ 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; } /** * 대상 회사에서 부모 메뉴 찾기 * - 원본 메뉴의 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, client: PoolClient, preAllocatedMenuIdMap?: Map // 미리 할당된 메뉴 ID 맵 (옵션 데이터 복사에 사용된 경우) ): 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 { // 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] ); // 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 { // 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} - 최상위로 생성됨` ); } } } if (existingCopyResult.rows.length > 0) { // === 이미 복사된 메뉴가 있는 경우: 재연결만 === const existingMenu = existingCopyResult.rows[0]; const existingObjId = existingMenu.objid; const existingParentId = existingMenu.parent_obj_id; // 부모가 다르면 업데이트 (고아 메뉴 재연결) 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; } // === 신규 메뉴 복사 === // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 const newObjId = preAllocatedMenuIdMap?.get(menu.objid) ?? 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} (복사 시작 메뉴)` ); } // 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 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; } /** * 연쇄관계 복사 * - category_value_cascading_group + category_value_cascading_mapping * - cascading_relation (테이블 기반) */ private async copyCascadingRelations( sourceCompanyCode: string, targetCompanyCode: string, menuIdMap: Map, userId: string, client: PoolClient ): Promise { logger.info(`📋 연쇄관계 복사 시작`); let copiedCount = 0; // === 1. category_value_cascading_group 복사 === const groupsResult = await client.query( `SELECT * FROM category_value_cascading_group WHERE company_code = $1 AND is_active = 'Y'`, [sourceCompanyCode] ); logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); // group_id 매핑 (매핑 복사 시 사용) const groupIdMap = new Map(); for (const group of groupsResult.rows) { // 대상 회사에 같은 relation_code가 있는지 확인 const existing = await client.query( `SELECT group_id FROM category_value_cascading_group WHERE relation_code = $1 AND company_code = $2`, [group.relation_code, targetCompanyCode] ); if (existing.rows.length > 0) { // 이미 존재하면 스킵 (기존 설정 유지) groupIdMap.set(group.group_id, existing.rows[0].group_id); logger.info(` ↳ ${group.relation_name}: 이미 존재 (스킵)`); continue; } // menu_objid 재매핑 const newParentMenuObjid = group.parent_menu_objid ? menuIdMap.get(Number(group.parent_menu_objid)) || null : null; const newChildMenuObjid = group.child_menu_objid ? menuIdMap.get(Number(group.child_menu_objid)) || null : null; // 새로 삽입 const insertResult = await client.query( `INSERT INTO category_value_cascading_group ( relation_code, relation_name, description, parent_table_name, parent_column_name, parent_menu_objid, child_table_name, child_column_name, child_menu_objid, clear_on_parent_change, show_group_label, empty_parent_message, no_options_message, company_code, is_active, created_by, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) RETURNING group_id`, [ group.relation_code, group.relation_name, group.description, group.parent_table_name, group.parent_column_name, newParentMenuObjid, group.child_table_name, group.child_column_name, newChildMenuObjid, group.clear_on_parent_change, group.show_group_label, group.empty_parent_message, group.no_options_message, targetCompanyCode, "Y", userId, ] ); const newGroupId = insertResult.rows[0].group_id; groupIdMap.set(group.group_id, newGroupId); logger.info(` ↳ ${group.relation_name}: 신규 추가 (ID: ${newGroupId})`); copiedCount++; // 해당 그룹의 매핑 복사 const mappingsResult = await client.query( `SELECT * FROM category_value_cascading_mapping WHERE group_id = $1 AND company_code = $2`, [group.group_id, sourceCompanyCode] ); for (const mapping of mappingsResult.rows) { await client.query( `INSERT INTO category_value_cascading_mapping ( group_id, parent_value_code, parent_value_label, child_value_code, child_value_label, display_order, company_code, is_active, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())`, [ newGroupId, mapping.parent_value_code, mapping.parent_value_label, mapping.child_value_code, mapping.child_value_label, mapping.display_order, targetCompanyCode, "Y", ] ); } if (mappingsResult.rows.length > 0) { logger.info(` ↳ 매핑 ${mappingsResult.rows.length}개 복사`); } } // === 2. cascading_relation 복사 (테이블 기반) === const relationsResult = await client.query( `SELECT * FROM cascading_relation WHERE company_code = $1 AND is_active = 'Y'`, [sourceCompanyCode] ); logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); for (const relation of relationsResult.rows) { // 대상 회사에 같은 relation_code가 있는지 확인 const existing = await client.query( `SELECT relation_id FROM cascading_relation WHERE relation_code = $1 AND company_code = $2`, [relation.relation_code, targetCompanyCode] ); if (existing.rows.length > 0) { logger.info(` ↳ ${relation.relation_name}: 이미 존재 (스킵)`); continue; } // 새로 삽입 await client.query( `INSERT INTO cascading_relation ( relation_code, relation_name, description, parent_table, parent_value_column, parent_label_column, child_table, child_filter_column, child_value_column, child_label_column, child_order_column, child_order_direction, empty_parent_message, no_options_message, loading_message, clear_on_parent_change, company_code, is_active, created_by, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, NOW())`, [ relation.relation_code, relation.relation_name, relation.description, relation.parent_table, relation.parent_value_column, relation.parent_label_column, relation.child_table, relation.child_filter_column, relation.child_value_column, relation.child_label_column, relation.child_order_column, relation.child_order_direction, relation.empty_parent_message, relation.no_options_message, relation.loading_message, relation.clear_on_parent_change, targetCompanyCode, "Y", userId, ] ); logger.info(` ↳ ${relation.relation_name}: 신규 추가`); copiedCount++; } logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); return copiedCount; } }