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) 메뉴에 직접 할당된 화면 - 배치 조회 const assignmentsResult = await client.query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, [menuObjids, 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; }> = []; // 배치 조회: 모든 화면의 레이아웃을 한 번에 조회 const screenIdArray = Array.from(screenIds); if (screenIdArray.length === 0) { return flowIds; } const layoutsResult = await client.query< ScreenLayout & { screen_id: number } >( `SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`, [screenIdArray] ); for (const layout of layoutsResult.rows) { const props = layout.properties; const screenId = layout.screen_id; // 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}` ); } else { // 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지) logger.warn( ` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정` ); obj[key] = ""; } } // 재귀 호출 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. 채번 규칙 처리 (체크 제약조건 고려) // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 const menuScopedRulesResult = await client.query( `SELECT rule_id FROM numbering_rules WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`, [existingMenuIds, targetCompanyCode] ); if (menuScopedRulesResult.rows.length > 0) { const menuScopedRuleIds = menuScopedRulesResult.rows.map( (r) => r.rule_id ); // 채번 규칙 파트 먼저 삭제 await client.query( `DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`, [menuScopedRuleIds] ); // 채번 규칙 삭제 await client.query( `DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [menuScopedRuleIds] ); logger.info( ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` ); } // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) const updatedNumberingRules = await client.query( `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = ANY($1) AND company_code = $2 AND (scope_type IS NULL OR scope_type != 'menu') RETURNING rule_id`, [existingMenuIds, targetCompanyCode] ); if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { logger.info( ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` ); } // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가) // 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제 const deletedCategoryMappings = await client.query( `DELETE FROM category_column_mapping WHERE menu_objid = ANY($1) AND company_code = $2 RETURNING mapping_id`, [existingMenuIds, targetCompanyCode] ); if ( deletedCategoryMappings.rowCount && deletedCategoryMappings.rowCount > 0 ) { logger.info( ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}개` ); } // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용) // 외래키 제약이 해제되었으므로 배치 삭제 가능 if (existingMenuIds.length > 0) { await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ existingMenuIds, ]); } 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 ); } // === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 === // 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 // 대상 회사에서 같은 이름의 채번규칙으로 매핑 if (screenIds.size > 0) { logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완"); await this.supplementNumberingRuleMapping( Array.from(screenIds), sourceCompanyCode, targetCompanyCode, numberingRuleIdMap, 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; } const flowIdArray = Array.from(flowIds); logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); logger.info(` 📋 복사 대상 flowIds: [${flowIdArray.join(", ")}]`); // === 최적화: 배치 조회 === // 1) 모든 원본 플로우 한 번에 조회 const allFlowDefsResult = await client.query( `SELECT * FROM flow_definition WHERE id = ANY($1)`, [flowIdArray] ); const flowDefMap = new Map(allFlowDefsResult.rows.map((f) => [f.id, f])); // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) const flowNames = allFlowDefsResult.rows.map((f) => f.name); const existingFlowsResult = await client.query<{ id: number; name: string; table_name: string; }>( `SELECT id, name, table_name FROM flow_definition WHERE company_code = $1 AND name = ANY($2)`, [targetCompanyCode, flowNames] ); const existingFlowMap = new Map( existingFlowsResult.rows.map((f) => [`${f.name}|${f.table_name}`, f.id]) ); // 3) 복사가 필요한 플로우 ID 목록 const flowsToCopy: FlowDefinition[] = []; for (const originalFlowId of flowIdArray) { const flowDef = flowDefMap.get(originalFlowId); if (!flowDef) { logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); continue; } const key = `${flowDef.name}|${flowDef.table_name}`; const existingId = existingFlowMap.get(key); if (existingId) { flowIdMap.set(originalFlowId, existingId); logger.info( ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})` ); } else { flowsToCopy.push(flowDef); } } // 4) 새 플로우 복사 (배치 처리) if (flowsToCopy.length > 0) { // 배치 INSERT로 플로우 생성 const flowValues = flowsToCopy .map( (f, i) => `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` ) .join(", "); const flowParams = flowsToCopy.flatMap((f) => [ f.name, f.description, f.table_name, f.is_active, targetCompanyCode, userId, f.db_source_type, f.db_connection_id, ]); const newFlowsResult = 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 ${flowValues} RETURNING id`, flowParams ); // 새 플로우 ID 매핑 flowsToCopy.forEach((flowDef, index) => { const newFlowId = newFlowsResult.rows[index].id; flowIdMap.set(flowDef.id, newFlowId); logger.info( ` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})` ); }); // 5) 스텝 및 연결 복사 (복사된 플로우만) const originalFlowIdsToCopy = flowsToCopy.map((f) => f.id); // 모든 스텝 한 번에 조회 const allStepsResult = await client.query( `SELECT * FROM flow_step WHERE flow_definition_id = ANY($1) ORDER BY flow_definition_id, step_order`, [originalFlowIdsToCopy] ); // 플로우별 스텝 그룹핑 const stepsByFlow = new Map(); for (const step of allStepsResult.rows) { if (!stepsByFlow.has(step.flow_definition_id)) { stepsByFlow.set(step.flow_definition_id, []); } stepsByFlow.get(step.flow_definition_id)!.push(step); } // 스텝 복사 (플로우별) const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap for (const originalFlowId of originalFlowIdsToCopy) { const newFlowId = flowIdMap.get(originalFlowId)!; const steps = stepsByFlow.get(originalFlowId) || []; const stepIdMap = new Map(); if (steps.length > 0) { // 배치 INSERT로 스텝 생성 const stepValues = steps .map( (_, i) => `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` ) .join(", "); const stepParams = steps.flatMap((s) => [ newFlowId, s.step_name, s.step_order, s.condition_json, s.color, s.position_x, s.position_y, s.table_name, s.move_type, s.status_column, s.status_value, s.target_table, s.field_mappings, s.required_fields, s.integration_type, s.integration_config, s.display_config, ]); const newStepsResult = 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 ${stepValues} RETURNING id`, stepParams ); steps.forEach((step, index) => { stepIdMap.set(step.id, newStepsResult.rows[index].id); }); logger.info( ` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사` ); } allStepIdMaps.set(originalFlowId, stepIdMap); } // 모든 연결 한 번에 조회 const allConnectionsResult = await client.query( `SELECT * FROM flow_step_connection WHERE flow_definition_id = ANY($1)`, [originalFlowIdsToCopy] ); // 연결 복사 (배치 INSERT) const connectionsToInsert: { newFlowId: number; newFromStepId: number; newToStepId: number; label: string; }[] = []; for (const conn of allConnectionsResult.rows) { const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); if (!stepIdMap) continue; const newFromStepId = stepIdMap.get(conn.from_step_id); const newToStepId = stepIdMap.get(conn.to_step_id); const newFlowId = flowIdMap.get(conn.flow_definition_id); if (newFromStepId && newToStepId && newFlowId) { connectionsToInsert.push({ newFlowId, newFromStepId, newToStepId, label: conn.label || "", }); } } if (connectionsToInsert.length > 0) { const connValues = connectionsToInsert .map( (_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` ) .join(", "); const connParams = connectionsToInsert.flatMap((c) => [ c.newFlowId, c.newFromStepId, c.newToStepId, c.label, ]); await client.query( `INSERT INTO flow_step_connection ( flow_definition_id, from_step_id, to_step_id, label ) VALUES ${connValues}`, connParams ); logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } } 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}개`); // === 0단계: 원본 화면 정의 배치 조회 === const screenIdArray = Array.from(screenIds); const allScreenDefsResult = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`, [screenIdArray] ); const screenDefMap = new Map(); for (const def of allScreenDefsResult.rows) { screenDefMap.set(def.screen_id, def); } // 대상 회사의 기존 복사본 배치 조회 (source_screen_id 기준) const existingCopiesResult = await client.query<{ screen_id: number; screen_name: string; source_screen_id: number; updated_date: Date; }>( `SELECT screen_id, screen_name, source_screen_id, updated_date FROM screen_definitions WHERE source_screen_id = ANY($1) AND company_code = $2 AND deleted_date IS NULL`, [screenIdArray, targetCompanyCode] ); const existingCopyMap = new Map< number, { screen_id: number; screen_name: string; updated_date: Date } >(); for (const copy of existingCopiesResult.rows) { existingCopyMap.set(copy.source_screen_id, copy); } // === 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 screenDef = screenDefMap.get(originalScreenId); if (!screenDef) { logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); continue; } // 2) 기존 복사본 찾기: 캐시에서 조회 (source_screen_id 기준) let existingCopy = existingCopyMap.get(originalScreenId); // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 if (!existingCopy && screenDef.screen_name) { const legacyCopyResult = 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 (legacyCopyResult.rows.length > 0) { existingCopy = legacyCopyResult.rows[0]; // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) await client.query( `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, [originalScreenId, existingCopy.screen_id] ); logger.info( ` 📝 기존 화면에 source_screen_id 추가: ${existingCopy.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 (existingCopy) { // === 기존 복사본이 있는 경우: 업데이트 === const existingScreenId = existingCopy.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(); const timestamp = Date.now(); layoutsResult.rows.forEach((layout, idx) => { const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; componentIdMap.set(layout.component_id, newComponentId); }); // 레이아웃 배치 삽입 준비 if (layoutsResult.rows.length > 0) { const layoutValues: string[] = []; const layoutParams: any[] = []; let paramIdx = 1; 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 ); layoutValues.push( `($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})` ); layoutParams.push( 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 ); paramIdx += 14; } // 배치 INSERT 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 ${layoutValues.join(", ")}`, layoutParams ); } 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(`🔗 화면-메뉴 할당 중...`); if (menus.length === 0) { return; } // === 최적화: 배치 조회 === // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 const menuObjids = menus.map((m) => m.objid); const companyCodes = [...new Set(menus.map((m) => m.company_code))]; const allAssignmentsResult = await client.query<{ menu_objid: number; screen_id: number; display_order: number; is_active: string; }>( `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, [menuObjids, companyCodes] ); if (allAssignmentsResult.rows.length === 0) { logger.info(` 📭 화면-메뉴 할당 없음`); return; } // 2. 유효한 할당만 필터링 const validAssignments: Array<{ newScreenId: number; newMenuObjid: number; displayOrder: number; isActive: string; }> = []; for (const assignment of allAssignmentsResult.rows) { const newMenuObjid = menuIdMap.get(assignment.menu_objid); const newScreenId = screenIdMap.get(assignment.screen_id); if (!newMenuObjid || !newScreenId) { if (!newScreenId) { logger.warn( `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` ); } continue; } validAssignments.push({ newScreenId, newMenuObjid, displayOrder: assignment.display_order, isActive: assignment.is_active, }); } // 3. 배치 INSERT if (validAssignments.length > 0) { const assignmentValues = validAssignments .map( (_, i) => `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` ) .join(", "); const assignmentParams = validAssignments.flatMap((a) => [ a.newScreenId, a.newMenuObjid, targetCompanyCode, a.displayOrder, a.isActive, "system", ]); await client.query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, is_active, created_by ) VALUES ${assignmentValues}`, assignmentParams ); } logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); } /** * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ private async copyCodeCategoriesAndCodes( menuObjids: number[], menuIdMap: Map, targetCompanyCode: string, userId: string, client: PoolClient ): Promise<{ copiedCategories: number; copiedCodes: number }> { let copiedCategories = 0; let copiedCodes = 0; if (menuObjids.length === 0) { return { copiedCategories, copiedCodes }; } // === 최적화: 배치 조회 === // 1. 모든 원본 카테고리 한 번에 조회 const allCategoriesResult = await client.query( `SELECT * FROM code_category WHERE menu_objid = ANY($1)`, [menuObjids] ); if (allCategoriesResult.rows.length === 0) { logger.info(` 📭 복사할 코드 카테고리 없음`); return { copiedCategories, copiedCodes }; } // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 const categoryCodes = allCategoriesResult.rows.map((c) => c.category_code); const existingCategoriesResult = await client.query( `SELECT category_code FROM code_category WHERE category_code = ANY($1) AND company_code = $2`, [categoryCodes, targetCompanyCode] ); const existingCategoryCodes = new Set( existingCategoriesResult.rows.map((c) => c.category_code) ); // 3. 복사할 카테고리 필터링 const categoriesToCopy = allCategoriesResult.rows.filter( (c) => !existingCategoryCodes.has(c.category_code) ); // 4. 배치 INSERT로 카테고리 복사 if (categoriesToCopy.length > 0) { const categoryValues = categoriesToCopy .map( (_, i) => `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` ) .join(", "); const categoryParams = categoriesToCopy.flatMap((c) => { const newMenuObjid = menuIdMap.get(c.menu_objid); return [ c.category_code, c.category_name, c.category_name_eng, c.description, c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid, ]; }); 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 ${categoryValues}`, categoryParams ); copiedCategories = categoriesToCopy.length; logger.info(` ✅ 코드 카테고리 ${copiedCategories}개 복사`); } // 5. 모든 원본 코드 한 번에 조회 const allCodesResult = await client.query( `SELECT * FROM code_info WHERE menu_objid = ANY($1)`, [menuObjids] ); if (allCodesResult.rows.length === 0) { logger.info(` 📭 복사할 코드 없음`); return { copiedCategories, copiedCodes }; } // 6. 대상 회사에 이미 존재하는 코드 한 번에 조회 const existingCodesResult = await client.query( `SELECT code_category, code_value FROM code_info WHERE menu_objid = ANY($1) AND company_code = $2`, [Array.from(menuIdMap.values()), targetCompanyCode] ); const existingCodeKeys = new Set( existingCodesResult.rows.map((c) => `${c.code_category}|${c.code_value}`) ); // 7. 복사할 코드 필터링 const codesToCopy = allCodesResult.rows.filter( (c) => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) ); // 8. 배치 INSERT로 코드 복사 if (codesToCopy.length > 0) { const codeValues = codesToCopy .map( (_, i) => `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` ) .join(", "); const codeParams = codesToCopy.flatMap((c) => { const newMenuObjid = menuIdMap.get(c.menu_objid); return [ c.code_category, c.code_value, c.code_name, c.code_name_eng, c.description, c.sort_order, c.is_active, userId, targetCompanyCode, newMenuObjid, ]; }); 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 ${codeValues}`, codeParams ); copiedCodes = codesToCopy.length; logger.info(` ✅ 코드 ${copiedCodes}개 복사`); } logger.info( `✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개` ); return { copiedCategories, copiedCodes }; } /** * 화면에서 참조하는 채번규칙 매핑 보완 * 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 * 대상 회사에서 같은 이름(rule_name)의 채번규칙으로 매핑 */ private async supplementNumberingRuleMapping( screenIds: number[], sourceCompanyCode: string, targetCompanyCode: string, numberingRuleIdMap: Map, client: PoolClient ): Promise { if (screenIds.length === 0) return; // 1. 화면 레이아웃에서 모든 채번규칙 ID 추출 const layoutsResult = await client.query( `SELECT properties::text as props FROM screen_layouts WHERE screen_id = ANY($1)`, [screenIds] ); const referencedRuleIds = new Set(); const ruleIdRegex = /"numberingRuleId"\s*:\s*"([^"]+)"/g; for (const row of layoutsResult.rows) { if (!row.props) continue; let match; while ((match = ruleIdRegex.exec(row.props)) !== null) { const ruleId = match[1]; // 이미 매핑된 것은 제외 if (ruleId && !numberingRuleIdMap.has(ruleId)) { referencedRuleIds.add(ruleId); } } } if (referencedRuleIds.size === 0) { logger.info(` 📭 추가 매핑 필요 없음`); return; } logger.info(` 🔍 매핑 필요한 채번규칙: ${referencedRuleIds.size}개`); // 2. 원본 채번규칙 정보 조회 (rule_name으로 대상 회사에서 찾기 위해) const sourceRulesResult = await client.query( `SELECT rule_id, rule_name, table_name FROM numbering_rules WHERE rule_id = ANY($1)`, [Array.from(referencedRuleIds)] ); if (sourceRulesResult.rows.length === 0) { logger.warn( ` ⚠️ 원본 채번규칙 조회 실패: ${Array.from(referencedRuleIds).join(", ")}` ); return; } // 3. 대상 회사에서 같은 이름의 채번규칙 찾기 const ruleNames = sourceRulesResult.rows.map((r) => r.rule_name); const targetRulesResult = await client.query( `SELECT rule_id, rule_name, table_name FROM numbering_rules WHERE rule_name = ANY($1) AND company_code = $2`, [ruleNames, targetCompanyCode] ); // rule_name -> target_rule_id 매핑 const targetRulesByName = new Map(); for (const r of targetRulesResult.rows) { // 같은 이름이 여러 개일 수 있으므로 첫 번째만 사용 if (!targetRulesByName.has(r.rule_name)) { targetRulesByName.set(r.rule_name, r.rule_id); } } // 4. 매핑 추가 let mappedCount = 0; for (const sourceRule of sourceRulesResult.rows) { const targetRuleId = targetRulesByName.get(sourceRule.rule_name); if (targetRuleId) { numberingRuleIdMap.set(sourceRule.rule_id, targetRuleId); logger.info( ` ✅ 채번규칙 매핑 추가: ${sourceRule.rule_id} (${sourceRule.rule_name}) → ${targetRuleId}` ); mappedCount++; } else { logger.warn( ` ⚠️ 대상 회사에 같은 이름의 채번규칙 없음: ${sourceRule.rule_name}` ); } } logger.info( ` ✅ 채번규칙 매핑 보완 완료: ${mappedCount}/${referencedRuleIds.size}개` ); } /** * 채번 규칙 복사 (최적화: 배치 조회/삽입) * 화면 복사 전에 호출되어 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(); if (menuObjids.length === 0) { return { copiedCount, ruleIdMap }; } // === 최적화: 배치 조회 === // 1. 모든 원본 채번 규칙 한 번에 조회 const allRulesResult = await client.query( `SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`, [menuObjids] ); if (allRulesResult.rows.length === 0) { logger.info(` 📭 복사할 채번 규칙 없음`); return { copiedCount, ruleIdMap }; } // 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요) const existingRulesResult = await client.query( `SELECT rule_id FROM numbering_rules WHERE company_code = $1`, [targetCompanyCode] ); const existingRuleIds = new Set( existingRulesResult.rows.map((r) => r.rule_id) ); // 3. 복사할 규칙과 스킵할 규칙 분류 const rulesToCopy: any[] = []; const originalToNewRuleMap: Array<{ original: string; new: string }> = []; // 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들 const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; for (const rule of allRulesResult.rows) { // 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가 // 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123 // 예: rule-123 -> rule-123 -> COMPANY_16_rule-123 // 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드 let baseName = rule.rule_id; // 회사코드 접두사 패턴들을 순서대로 제거 시도 // 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_) // 2. 일반 접두사_ 패턴 (예: WACE_) if (baseName.match(/^COMPANY_\d+_/)) { baseName = baseName.replace(/^COMPANY_\d+_/, ""); } else if (baseName.includes("_")) { baseName = baseName.replace(/^[^_]+_/, ""); } const newRuleId = `${targetCompanyCode}_${baseName}`; if (existingRuleIds.has(rule.rule_id)) { // 원본 ID가 이미 존재 (동일한 ID로 매핑) ruleIdMap.set(rule.rule_id, rule.rule_id); const newMenuObjid = menuIdMap.get(rule.menu_objid); if (newMenuObjid) { rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); } logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`); } else if (existingRuleIds.has(newRuleId)) { // 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑) ruleIdMap.set(rule.rule_id, newRuleId); const newMenuObjid = menuIdMap.get(rule.menu_objid); if (newMenuObjid) { rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid }); } logger.info( ` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}` ); } else { // 새로 복사 필요 ruleIdMap.set(rule.rule_id, newRuleId); originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); rulesToCopy.push({ ...rule, newRuleId }); logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`); } } // 4. 배치 INSERT로 채번 규칙 복사 if (rulesToCopy.length > 0) { const ruleValues = rulesToCopy .map( (_, i) => `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` ) .join(", "); const ruleParams = rulesToCopy.flatMap((r) => { const newMenuObjid = menuIdMap.get(r.menu_objid); // scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건) // menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로 // scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리 const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null; // scope_type 결정 로직: // 1. menu 스코프인데 menu_objid 매핑이 없는 경우 // - table_name이 있으면 'table' 스코프로 변경 // - table_name이 없으면 'global' 스코프로 변경 // 2. 그 외에는 원본 scope_type 유지 let finalScopeType = r.scope_type; if (r.scope_type === "menu" && finalMenuObjid === null) { if (r.table_name) { finalScopeType = "table"; // table_name이 있으면 table 스코프 } else { finalScopeType = "global"; // table_name도 없으면 global 스코프 } } return [ r.newRuleId, r.rule_name, r.description, r.separator, r.reset_period, 0, r.table_name, r.column_name, targetCompanyCode, userId, finalMenuObjid, finalScopeType, null, ]; }); 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 ${ruleValues}`, ruleParams ); copiedCount = rulesToCopy.length; logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); } // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 if (rulesToUpdate.length > 0) { // CASE WHEN을 사용한 배치 업데이트 // menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요 const caseWhen = rulesToUpdate .map( (_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric` ) .join(" "); const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId); const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]); await client.query( `UPDATE numbering_rules SET menu_objid = CASE ${caseWhen} END, updated_at = NOW() WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, [...params, ruleIdsForUpdate, targetCompanyCode] ); logger.info( ` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신` ); } // 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상) if (rulesToCopy.length > 0) { const originalRuleIds = rulesToCopy.map((r) => r.rule_id); const allPartsResult = await client.query( `SELECT * FROM numbering_rule_parts WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, [originalRuleIds] ); // 6. 배치 INSERT로 채번 규칙 파트 복사 if (allPartsResult.rows.length > 0) { // 원본 rule_id -> 새 rule_id 매핑 const ruleMapping = new Map( originalToNewRuleMap.map((m) => [m.original, m.new]) ); const partValues = allPartsResult.rows .map( (_, i) => `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` ) .join(", "); const partParams = allPartsResult.rows.flatMap((p) => [ ruleMapping.get(p.rule_id), p.part_order, p.part_type, p.generation_method, p.auto_config, p.manual_config, targetCompanyCode, ]); 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 ${partValues}`, partParams ); logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.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; if (menuObjids.length === 0) { return copiedCount; } // === 최적화: 배치 조회 === // 1. 모든 원본 카테고리 매핑 한 번에 조회 const allMappingsResult = await client.query( `SELECT * FROM category_column_mapping WHERE menu_objid = ANY($1)`, [menuObjids] ); if (allMappingsResult.rows.length === 0) { logger.info(` 📭 복사할 카테고리 매핑 없음`); return copiedCount; } // 2. 대상 회사에 이미 존재하는 매핑 한 번에 조회 const existingMappingsResult = await client.query( `SELECT mapping_id, table_name, logical_column_name FROM category_column_mapping WHERE company_code = $1`, [targetCompanyCode] ); const existingMappingKeys = new Map( existingMappingsResult.rows.map((m) => [ `${m.table_name}|${m.logical_column_name}`, m.mapping_id, ]) ); // 3. 복사할 매핑 필터링 및 기존 매핑 업데이트 대상 분류 const mappingsToCopy: any[] = []; const mappingsToUpdate: Array<{ mappingId: number; newMenuObjid: number }> = []; for (const m of allMappingsResult.rows) { const key = `${m.table_name}|${m.logical_column_name}`; if (existingMappingKeys.has(key)) { // 기존 매핑은 menu_objid만 업데이트 const existingMappingId = existingMappingKeys.get(key); const newMenuObjid = menuIdMap.get(m.menu_objid); if (existingMappingId && newMenuObjid) { mappingsToUpdate.push({ mappingId: existingMappingId, newMenuObjid }); } } else { mappingsToCopy.push(m); } } // 새 매핑 ID -> 원본 매핑 정보 추적 const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; if (mappingsToCopy.length > 0) { const mappingValues = mappingsToCopy .map( (_, i) => `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` ) .join(", "); const mappingParams = mappingsToCopy.flatMap((m) => { const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; mappingInsertInfo.push({ mapping: m, newMenuObjid }); return [ m.table_name, m.logical_column_name, m.physical_column_name, newMenuObjid, targetCompanyCode, m.description, userId, ]; }); 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 ${mappingValues} RETURNING mapping_id`, mappingParams ); // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 insertResult.rows.forEach((row, index) => { const m = mappingsToCopy[index]; existingMappingKeys.set( `${m.table_name}|${m.logical_column_name}`, row.mapping_id ); }); copiedCount = mappingsToCopy.length; logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); } // 3-1. 기존 카테고리 매핑의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 if (mappingsToUpdate.length > 0) { // CASE WHEN을 사용한 배치 업데이트 const caseWhen = mappingsToUpdate .map((_, i) => `WHEN mapping_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) .join(" "); const mappingIdsForUpdate = mappingsToUpdate.map((m) => m.mappingId); const params = mappingsToUpdate.flatMap((m) => [ m.mappingId, m.newMenuObjid, ]); await client.query( `UPDATE category_column_mapping SET menu_objid = CASE ${caseWhen} END WHERE mapping_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, [...params, mappingIdsForUpdate, targetCompanyCode] ); logger.info( ` ✅ 기존 카테고리 매핑 ${mappingsToUpdate.length}개 메뉴 연결 갱신` ); } // 4. 모든 원본 카테고리 값 한 번에 조회 const allValuesResult = await client.query( `SELECT * FROM table_column_category_values WHERE menu_objid = ANY($1) ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, [menuObjids] ); if (allValuesResult.rows.length === 0) { logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); return copiedCount; } // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 const existingValuesResult = await client.query( `SELECT value_id, table_name, column_name, value_code FROM table_column_category_values WHERE company_code = $1`, [targetCompanyCode] ); const existingValueKeys = new Map( existingValuesResult.rows.map((v) => [ `${v.table_name}|${v.column_name}|${v.value_code}`, v.value_id, ]) ); // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) const valueIdMap = new Map(); let copiedValues = 0; // 이미 존재하는 값들의 ID 매핑 for (const value of allValuesResult.rows) { const key = `${value.table_name}|${value.column_name}|${value.value_code}`; const existingId = existingValueKeys.get(key); if (existingId) { valueIdMap.set(value.value_id, existingId); } } // depth별로 그룹핑하여 배치 처리 (부모가 먼저 삽입되어야 함) const valuesByDepth = new Map(); for (const value of allValuesResult.rows) { const key = `${value.table_name}|${value.column_name}|${value.value_code}`; if (existingValueKeys.has(key)) continue; // 이미 존재하면 스킵 const depth = value.depth ?? 0; if (!valuesByDepth.has(depth)) { valuesByDepth.set(depth, []); } valuesByDepth.get(depth)!.push(value); } // depth 순서대로 처리 const sortedDepths = Array.from(valuesByDepth.keys()).sort((a, b) => a - b); for (const depth of sortedDepths) { const values = valuesByDepth.get(depth)!; if (values.length === 0) continue; const valueStrings = values .map( (_, i) => `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` ) .join(", "); const valueParams = values.flatMap((v) => { const newMenuObjid = menuIdMap.get(v.menu_objid); const newParentId = v.parent_value_id ? valueIdMap.get(v.parent_value_id) || null : null; return [ v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, newParentId, v.depth, v.description, v.color, v.icon, v.is_active, v.is_default, userId, targetCompanyCode, newMenuObjid, ]; }); 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, created_at, created_by, company_code, menu_objid ) VALUES ${valueStrings} RETURNING value_id`, valueParams ); // 새 value_id 매핑 insertResult.rows.forEach((row, index) => { valueIdMap.set(values[index].value_id, row.value_id); }); copiedValues += values.length; } if (copiedValues > 0) { logger.info(` ✅ 카테고리 값 ${copiedValues}개 복사`); } 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(`📋 테이블 타입 설정 복사 시작`); // === 최적화: 배치 조회 === // 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(", ")}`); // 2. 원본 회사의 모든 테이블 타입 설정 한 번에 조회 const sourceSettingsResult = await client.query( `SELECT * FROM table_type_columns WHERE table_name = ANY($1) AND company_code = $2`, [tableNames, sourceCompanyCode] ); if (sourceSettingsResult.rows.length === 0) { logger.info(` ⚠️ 원본 회사 설정 없음`); return 0; } // 3. 대상 회사의 기존 설정 한 번에 조회 const existingSettingsResult = await client.query( `SELECT table_name, column_name FROM table_type_columns WHERE table_name = ANY($1) AND company_code = $2`, [tableNames, targetCompanyCode] ); const existingKeys = new Set( existingSettingsResult.rows.map((s) => `${s.table_name}|${s.column_name}`) ); // 4. 복사할 설정 필터링 const settingsToCopy = sourceSettingsResult.rows.filter( (s) => !existingKeys.has(`${s.table_name}|${s.column_name}`) ); logger.info( ` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개` ); // 5. 배치 INSERT if (settingsToCopy.length > 0) { const settingValues = settingsToCopy .map( (_, i) => `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` ) .join(", "); const settingParams = settingsToCopy.flatMap((s) => [ s.table_name, s.column_name, s.input_type, s.detail_settings, s.is_nullable, s.display_order, targetCompanyCode, ]); 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 ${settingValues}`, settingParams ); } logger.info(`✅ 테이블 타입 설정 복사 완료: ${settingsToCopy.length}개`); return settingsToCopy.length; } /** * 연쇄관계 복사 (최적화: 배치 조회/삽입) * - 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] ); if (groupsResult.rows.length === 0) { logger.info(` 카테고리 값 연쇄 그룹: 0개`); } else { logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); // 대상 회사의 기존 그룹 한 번에 조회 const existingGroupsResult = await client.query( `SELECT group_id, relation_code FROM category_value_cascading_group WHERE company_code = $1`, [targetCompanyCode] ); const existingGroupsByCode = new Map( existingGroupsResult.rows.map((g) => [g.relation_code, g.group_id]) ); // group_id 매핑 const groupIdMap = new Map(); const groupsToCopy: any[] = []; for (const group of groupsResult.rows) { const existingGroupId = existingGroupsByCode.get(group.relation_code); if (existingGroupId) { groupIdMap.set(group.group_id, existingGroupId); } else { groupsToCopy.push(group); } } logger.info( ` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개` ); // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) for (const group of groupsToCopy) { 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); copiedCount++; } // 모든 매핑 한 번에 조회 (복사할 그룹만) const groupIdsToCopy = groupsToCopy.map((g) => g.group_id); if (groupIdsToCopy.length > 0) { const allMappingsResult = await client.query( `SELECT * FROM category_value_cascading_mapping WHERE group_id = ANY($1) AND company_code = $2 ORDER BY group_id, display_order`, [groupIdsToCopy, sourceCompanyCode] ); // 배치 INSERT if (allMappingsResult.rows.length > 0) { const mappingValues = allMappingsResult.rows .map( (_, i) => `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` ) .join(", "); const mappingParams = allMappingsResult.rows.flatMap((m) => { const newGroupId = groupIdMap.get(m.group_id); return [ newGroupId, m.parent_value_code, m.parent_value_label, m.child_value_code, m.child_value_label, m.display_order, targetCompanyCode, "Y", ]; }); 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 ${mappingValues}`, mappingParams ); logger.info(` ↳ 매핑 ${allMappingsResult.rows.length}개 복사`); } } } // === 2. cascading_relation 복사 (테이블 기반) === const relationsResult = await client.query( `SELECT * FROM cascading_relation WHERE company_code = $1 AND is_active = 'Y'`, [sourceCompanyCode] ); if (relationsResult.rows.length === 0) { logger.info(` 기본 연쇄관계: 0개`); } else { logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); // 대상 회사의 기존 관계 한 번에 조회 const existingRelationsResult = await client.query( `SELECT relation_code FROM cascading_relation WHERE company_code = $1`, [targetCompanyCode] ); const existingRelationCodes = new Set( existingRelationsResult.rows.map((r) => r.relation_code) ); // 복사할 관계 필터링 const relationsToCopy = relationsResult.rows.filter( (r) => !existingRelationCodes.has(r.relation_code) ); logger.info( ` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개` ); // 배치 INSERT if (relationsToCopy.length > 0) { const relationValues = relationsToCopy .map( (_, i) => `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` ) .join(", "); const relationParams = relationsToCopy.flatMap((r) => [ r.relation_code, r.relation_name, r.description, r.parent_table, r.parent_value_column, r.parent_label_column, r.child_table, r.child_filter_column, r.child_value_column, r.child_label_column, r.child_order_column, r.child_order_direction, r.empty_parent_message, r.no_options_message, r.loading_message, r.clear_on_parent_change, targetCompanyCode, "Y", userId, ]); 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 ${relationValues}`, relationParams ); copiedCount += relationsToCopy.length; } } logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); return copiedCount; } }