From 51c788cae8a169e49205ebb96f92cba9e3a75b8e Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 09:26:44 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B5=EC=82=AC=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/menuCopyService.ts | 1288 ++++++++++-------- 1 file changed, 723 insertions(+), 565 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 683d71ba..5c4fde7f 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -662,9 +662,9 @@ export class MenuCopyService { ); // 화면 정의 삭제 - await client.query( - `DELETE FROM screen_definitions - WHERE screen_id = ANY($1) AND company_code = $2`, + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, [screensToDelete, targetCompanyCode] ); logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); @@ -794,10 +794,10 @@ export class MenuCopyService { const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, menuIdMap, // 실제 생성된 메뉴 ID 사용 - targetCompanyCode, - userId, - client - ); + targetCompanyCode, + userId, + client + ); copiedNumberingRules = ruleResult.copiedCount; numberingRuleIdMap = ruleResult.ruleIdMap; } @@ -938,144 +938,182 @@ export class MenuCopyService { return flowIdMap; } + const flowIdArray = Array.from(flowIds); logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); - logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); + logger.info(` 📋 복사 대상 flowIds: [${flowIdArray.join(", ")}]`); - for (const originalFlowId of flowIds) { - try { - // 1) 원본 flow_definition 조회 - const flowDefResult = await client.query( - `SELECT * FROM flow_definition WHERE id = $1`, - [originalFlowId] - ); + // === 최적화: 배치 조회 === + // 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])); - if (flowDefResult.rows.length === 0) { + // 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 flowDef = flowDefResult.rows[0]; - logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); + const key = `${flowDef.name}|${flowDef.table_name}`; + const existingId = existingFlowMap.get(key); - // 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] - ); + if (existingId) { + flowIdMap.set(originalFlowId, existingId); + logger.info(` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})`); + } else { + flowsToCopy.push(flowDef); + } + } - let newFlowId: number; + // 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 + ]); - 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 }>( + 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 ($1, $2, $3, $4, $5, $6, $7, $8) + ) VALUES ${flowValues} RETURNING id`, - [ - flowDef.name, - flowDef.description, - flowDef.table_name, - flowDef.is_active, - targetCompanyCode, // 새 회사 코드 - userId, - flowDef.db_source_type, - flowDef.db_connection_id, - ] - ); + flowParams + ); - newFlowId = newFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); + // 새 플로우 ID 매핑 + flowsToCopy.forEach((flowDef, index) => { + const newFlowId = newFlowsResult.rows[index].id; + flowIdMap.set(flowDef.id, newFlowId); + logger.info(` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})`); + }); - logger.info( - ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` - ); + // 5) 스텝 및 연결 복사 (복사된 플로우만) + const originalFlowIdsToCopy = flowsToCopy.map(f => f.id); - // 3) flow_step 복사 - const stepsResult = await client.query( - `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, - [originalFlowId] - ); + // 모든 스텝 한 번에 조회 + 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(); - for (const step of stepsResult.rows) { - const newStepResult = await client.query<{ id: number }>( + 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ) VALUES ${stepValues} 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, - ] + stepParams ); - const newStepId = newStepResult.rows[0].id; - stepIdMap.set(step.id, newStepId); + steps.forEach((step, index) => { + stepIdMap.set(step.id, newStepsResult.rows[index].id); + }); + + logger.info(` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사`); } - logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + allStepIdMaps.set(originalFlowId, stepIdMap); + } - // 4) flow_step_connection 복사 (스텝 ID 재매핑) - const connectionsResult = await client.query( - `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, - [originalFlowId] - ); + // 모든 연결 한 번에 조회 + 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; - for (const conn of connectionsResult.rows) { 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) { - logger.warn( - `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` - ); - continue; - } + 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 ($1, $2, $3, $4)`, - [newFlowId, newFromStepId, newToStepId, conn.label] + ) VALUES ${connValues}`, + connParams ); - } - logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); - } catch (error: any) { - logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); - throw error; + logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } } @@ -1752,7 +1790,7 @@ export class MenuCopyService { } /** - * 화면-메뉴 할당 + * 화면-메뉴 할당 (최적화: 배치 조회/삽입) */ private async createScreenMenuAssignments( menus: Menu[], @@ -1763,57 +1801,83 @@ export class MenuCopyService { ): Promise { logger.info(`🔗 화면-메뉴 할당 중...`); - let assignmentCount = 0; + if (menus.length === 0) { + return; + } - for (const menu of menus) { - const newMenuObjid = menuIdMap.get(menu.objid); - if (!newMenuObjid) continue; + // === 최적화: 배치 조회 === + // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 + const menuObjids = menus.map(m => m.objid); + const companyCodes = [...new Set(menus.map(m => m.company_code))]; - // 원본 메뉴에 할당된 화면 조회 - const assignmentsResult = await client.query<{ + const allAssignmentsResult = await client.query<{ + menu_objid: number; screen_id: number; display_order: number; is_active: string; }>( - `SELECT screen_id, display_order, is_active + `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menu.objid, menu.company_code] - ); + WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, + [menuObjids, companyCodes] + ); - for (const assignment of assignmentsResult.rows) { + 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}` - ); + 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 ($1, $2, $3, $4, $5, $6)`, - [ - newScreenId, // 재매핑 - newMenuObjid, // 재매핑 - targetCompanyCode, - assignment.display_order, - assignment.is_active, - "system", - ] - ); - - assignmentCount++; - } + ) VALUES ${assignmentValues}`, + assignmentParams + ); } - logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); } /** - * 코드 카테고리 + 코드 복사 + * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ private async copyCodeCategoriesAndCodes( menuObjids: number[], @@ -1825,92 +1889,112 @@ export class MenuCopyService { let copiedCategories = 0; let copiedCodes = 0; - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return { copiedCategories, copiedCodes }; + } - // 1. 코드 카테고리 조회 - const categoriesResult = await client.query( - `SELECT * FROM code_category WHERE menu_objid = $1`, - [menuObjid] + // === 최적화: 배치 조회 === + // 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 ); - 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] - ); + copiedCategories = categoriesToCopy.length; + logger.info(` ✅ 코드 카테고리 ${copiedCategories}개 복사`); + } - if (existingCategory.rows.length > 0) { - logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`); - continue; - } + // 5. 모든 원본 코드 한 번에 조회 + const allCodesResult = await client.query( + `SELECT * FROM code_info WHERE menu_objid = ANY($1)`, + [menuObjids] + ); - // 카테고리 복사 - 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}`); + if (allCodesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 없음`); + return { copiedCategories, copiedCodes }; + } - // 2. 해당 카테고리의 코드 조회 및 복사 - const codesResult = await client.query( - `SELECT * FROM code_info - WHERE code_category = $1 AND menu_objid = $2`, - [category.category_code, menuObjid] - ); + // 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}`) + ); - 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] - ); + // 7. 복사할 코드 필터링 + const codesToCopy = allCodesResult.rows.filter( + c => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + ); - if (existingCode.rows.length > 0) { - logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`); - continue; - } + // 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(", "); - 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}개 복사 완료`); - } + 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}개`); @@ -1918,7 +2002,7 @@ export class MenuCopyService { } /** - * 채번 규칙 복사 (ID 매핑 반환 버전) + * 채번 규칙 복사 (최적화: 배치 조회/삽입) * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 */ private async copyNumberingRulesWithMap( @@ -1931,90 +2015,111 @@ export class MenuCopyService { let copiedCount = 0; const ruleIdMap = new Map(); - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return { copiedCount, ruleIdMap }; + } - // 채번 규칙 조회 - const rulesResult = await client.query( - `SELECT * FROM numbering_rules WHERE menu_objid = $1`, - [menuObjid] - ); + // === 최적화: 배치 조회 === + // 1. 모든 원본 채번 규칙 한 번에 조회 + const allRulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`, + [menuObjids] + ); - 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 (allRulesResult.rows.length === 0) { + logger.info(` 📭 복사할 채번 규칙 없음`); + return { copiedCount, ruleIdMap }; + } - if (existingRule.rows.length > 0) { - logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); - // 기존 rule_id도 매핑에 추가 (동일한 ID 유지) - ruleIdMap.set(rule.rule_id, rule.rule_id); - continue; - } + // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 + const ruleIds = allRulesResult.rows.map(r => r.rule_id); + const existingRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = ANY($1) AND company_code = $2`, + [ruleIds, targetCompanyCode] + ); + const existingRuleIds = new Set(existingRulesResult.rows.map(r => r.rule_id)); - // 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거) + // 3. 복사할 규칙과 스킵할 규칙 분류 + const rulesToCopy: any[] = []; + const originalToNewRuleMap: Array<{ original: string; new: string }> = []; + + for (const rule of allRulesResult.rows) { + if (existingRuleIds.has(rule.rule_id)) { + // 기존 규칙은 동일한 ID로 매핑 + ruleIdMap.set(rule.rule_id, rule.rule_id); + logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + } else { + // 새 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); + originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); + rulesToCopy.push({ ...rule, 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); + return [ + r.newRuleId, r.rule_name, r.description, r.separator, r.reset_period, + 0, r.table_name, r.column_name, targetCompanyCode, + userId, newMenuObjid, r.scope_type, 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}개 복사`); + + // 5. 모든 원본 파트 한 번에 조회 + 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_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] + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ${partValues}`, + partParams ); - 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(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`); } } @@ -2023,7 +2128,7 @@ export class MenuCopyService { } /** - * 카테고리 매핑 + 값 복사 + * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) */ private async copyCategoryMappingsAndValues( menuObjids: number[], @@ -2034,122 +2139,171 @@ export class MenuCopyService { ): Promise { let copiedCount = 0; - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return copiedCount; + } - // 1. 카테고리 컬럼 매핑 조회 - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping WHERE menu_objid = $1`, - [menuObjid] + // === 최적화: 배치 조회 === + // 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. 복사할 매핑 필터링 및 배치 INSERT + const mappingsToCopy = allMappingsResult.rows.filter( + m => !existingMappingKeys.has(`${m.table_name}|${m.logical_column_name}`) + ); + + // 새 매핑 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 ); - 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] - ); + // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 + insertResult.rows.forEach((row, index) => { + const m = mappingsToCopy[index]; + existingMappingKeys.set(`${m.table_name}|${m.logical_column_name}`, row.mapping_id); + }); - let newMappingId: number; + copiedCount = mappingsToCopy.length; + logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); + } - 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}`); - } + // 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] + ); - // 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] - ); + if (allValuesResult.rows.length === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } - // 값 ID 매핑 (부모-자식 관계 유지를 위해) - const valueIdMap = new Map(); + // 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]) + ); - 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] - ); + // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) + const valueIdMap = new Map(); + let copiedValues = 0; - 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}개 처리`); - } + // 이미 존재하는 값들의 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( @@ -2163,8 +2317,8 @@ export class MenuCopyService { } logger.info(`📋 테이블 타입 설정 복사 시작`); - logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`); + // === 최적화: 배치 조회 === // 1. 복사된 화면에서 사용하는 테이블 목록 조회 const tablesResult = await client.query<{ table_name: string }>( `SELECT DISTINCT table_name FROM screen_definitions @@ -2180,66 +2334,61 @@ export class MenuCopyService { const tableNames = tablesResult.rows.map((r) => r.table_name); logger.info(` 사용 테이블: ${tableNames.join(", ")}`); - let copiedCount = 0; + // 2. 원본 회사의 모든 테이블 타입 설정 한 번에 조회 + const sourceSettingsResult = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, sourceCompanyCode] + ); - 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++; - } + if (sourceSettingsResult.rows.length === 0) { + logger.info(` ⚠️ 원본 회사 설정 없음`); + return 0; } - logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}개`); - return copiedCount; + // 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 (테이블 기반) */ @@ -2260,99 +2409,106 @@ export class MenuCopyService { [sourceCompanyCode] ); - logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); + if (groupsResult.rows.length === 0) { + logger.info(` 카테고리 값 연쇄 그룹: 0개`); + } else { + 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] + // 대상 회사의 기존 그룹 한 번에 조회 + 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]) ); - if (existing.rows.length > 0) { - // 이미 존재하면 스킵 (기존 설정 유지) - groupIdMap.set(group.group_id, existing.rows[0].group_id); - logger.info(` ↳ ${group.relation_name}: 이미 존재 (스킵)`); - continue; + // 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); + } } - // 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; + logger.info(` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개`); - // 새로 삽입 - 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, - ] - ); + // 그룹별로 삽입하고 매핑 저장 (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 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())`, + 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`, [ - newGroupId, - mapping.parent_value_code, - mapping.parent_value_label, - mapping.child_value_code, - mapping.child_value_label, - mapping.display_order, - targetCompanyCode, - "Y", + 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++; } - if (mappingsResult.rows.length > 0) { - logger.info(` ↳ 매핑 ${mappingsResult.rows.length}개 복사`); + // 모든 매핑 한 번에 조회 (복사할 그룹만) + 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}개 복사`); + } } } @@ -2363,55 +2519,57 @@ export class MenuCopyService { [sourceCompanyCode] ); - logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); + if (relationsResult.rows.length === 0) { + logger.info(` 기본 연쇄관계: 0개`); + } else { + 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] + // 대상 회사의 기존 관계 한 번에 조회 + 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) ); - if (existing.rows.length > 0) { - logger.info(` ↳ ${relation.relation_name}: 이미 존재 (스킵)`); - continue; + // 복사할 관계 필터링 + 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; } - - // 새로 삽입 - 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}개`);