ERP-node/backend-node/src/services/menuCopyService.ts

3328 lines
110 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
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<Menu[]> {
logger.info(`📂 메뉴 트리 수집 시작: rootMenuObjid=${rootMenuObjid}`);
const result: Menu[] = [];
const visited = new Set<number>();
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<Menu>(
`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<Menu>(
`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}`);
}
}
// 5) 모달 화면 ID (addModalScreenId, editModalScreenId, modalScreenId)
if (props?.componentConfig?.addModalScreenId) {
const addModalScreenId = props.componentConfig.addModalScreenId;
const numId =
typeof addModalScreenId === "number"
? addModalScreenId
: parseInt(addModalScreenId);
if (!isNaN(numId) && numId > 0) {
referenced.push(numId);
logger.debug(` 📋 추가 모달 화면 참조 발견: ${numId}`);
}
}
if (props?.componentConfig?.editModalScreenId) {
const editModalScreenId = props.componentConfig.editModalScreenId;
const numId =
typeof editModalScreenId === "number"
? editModalScreenId
: parseInt(editModalScreenId);
if (!isNaN(numId) && numId > 0) {
referenced.push(numId);
logger.debug(` 📝 수정 모달 화면 참조 발견: ${numId}`);
}
}
if (props?.componentConfig?.modalScreenId) {
const modalScreenId = props.componentConfig.modalScreenId;
const numId =
typeof modalScreenId === "number"
? modalScreenId
: parseInt(modalScreenId);
if (!isNaN(numId) && numId > 0) {
referenced.push(numId);
logger.debug(` 🔲 모달 화면 참조 발견: ${numId}`);
}
}
// 6) 재귀적으로 모든 properties에서 화면 ID 추출 (깊은 탐색)
this.extractScreenIdsFromObject(props, referenced);
}
return referenced;
}
/**
* 객체 내부에서 화면 ID를 재귀적으로 추출
*/
private extractScreenIdsFromObject(obj: any, referenced: number[]): void {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) {
this.extractScreenIdsFromObject(item, referenced);
}
return;
}
for (const key of Object.keys(obj)) {
const value = obj[key];
// 화면 ID 키 패턴 확인
if (
key === "screenId" ||
key === "targetScreenId" ||
key === "leftScreenId" ||
key === "rightScreenId" ||
key === "addModalScreenId" ||
key === "editModalScreenId" ||
key === "modalScreenId"
) {
const numId = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numId) && numId > 0 && !referenced.includes(numId)) {
referenced.push(numId);
}
}
// 재귀 탐색
if (typeof value === "object" && value !== null) {
this.extractScreenIdsFromObject(value, referenced);
}
}
}
/**
* 화면 수집 (중복 제거, 재귀적 참조 추적)
*/
private async collectScreens(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<Set<number>> {
logger.info(
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
);
const screenIds = new Set<number>();
const visited = new Set<number>();
// 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<ScreenLayout>(
`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<number>,
client: PoolClient
): Promise<Set<number>> {
logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`);
const flowIds = new Set<number>();
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<number> {
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<string> {
// {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<number, number>,
flowIdMap: Map<number, number>,
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): any {
if (!properties) return properties;
// 깊은 복사
const updated = JSON.parse(JSON.stringify(properties));
// 재귀적으로 객체/배열 탐색
this.recursiveUpdateReferences(
updated,
screenIdMap,
flowIdMap,
"",
numberingRuleIdMap,
menuIdMap
);
return updated;
}
/**
* 재귀적으로 모든 ID 참조 업데이트
*/
private recursiveUpdateReferences(
obj: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
path: string = "",
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): void {
if (!obj || typeof obj !== "object") return;
// 배열인 경우
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
this.recursiveUpdateReferences(
item,
screenIdMap,
flowIdMap,
`${path}[${index}]`,
numberingRuleIdMap,
menuIdMap
);
});
return;
}
// 객체인 경우 - 키별로 처리
for (const key of Object.keys(obj)) {
const value = obj[key];
const currentPath = path ? `${path}.${key}` : key;
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId, addModalScreenId, editModalScreenId, modalScreenId 매핑 (숫자 또는 숫자 문자열)
if (
key === "screen_id" ||
key === "screenId" ||
key === "targetScreenId" ||
key === "leftScreenId" ||
key === "rightScreenId" ||
key === "addModalScreenId" ||
key === "editModalScreenId" ||
key === "modalScreenId"
) {
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}`
);
} else {
// 매핑이 없으면 경고 로그 (복사되지 않은 화면 참조)
logger.warn(
` ⚠️ 화면 매핑 없음 (${currentPath}): ${value} - 원본 화면이 복사되지 않았을 수 있음`
);
}
}
}
// 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, ruleId 매핑 (문자열) - 채번규칙 참조
if (
(key === "numberingRuleId" || key === "ruleId") &&
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] = "";
}
}
// selectedMenuObjid 매핑 (메뉴 objid 참조)
if (key === "selectedMenuObjid" && menuIdMap) {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
const newId = menuIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId);
logger.info(
` 🔗 메뉴 참조 업데이트 (${currentPath}): ${value}${newId}`
);
} else {
// 매핑이 없으면 경고 로그 (복사되지 않은 메뉴 참조)
logger.warn(
` ⚠️ 메뉴 매핑 없음 (${currentPath}): ${value} - 원본 메뉴가 복사되지 않았을 수 있음`
);
}
}
}
// 재귀 호출
if (typeof value === "object" && value !== null) {
this.recursiveUpdateReferences(
value,
screenIdMap,
flowIdMap,
currentPath,
numberingRuleIdMap,
menuIdMap
);
}
}
}
/**
* 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리)
*
* 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제
* - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제
* - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지)
*/
private async deleteExistingCopy(
sourceMenuObjid: number,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
logger.info("\n🗑 [0단계] 기존 복사본 확인 및 삭제");
// 1. 원본 메뉴 정보 확인
const sourceMenuResult = await client.query<Menu>(
`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<MenuCopyResult> {
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<string, string>();
const menuObjids = menus.map((m) => m.objid);
// 메뉴 ID 맵을 먼저 생성 (일관된 ID 사용을 위해)
const tempMenuIdMap = new Map<number, number>();
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,
sourceCompanyCode,
targetCompanyCode,
Array.from(screenIds),
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,
menuIdMap
);
// === 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<number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<Map<number, number>> {
const flowIdMap = new Map<number, number>();
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<FlowDefinition>(
`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<FlowStep>(
`SELECT * FROM flow_step WHERE flow_definition_id = ANY($1) ORDER BY flow_definition_id, step_order`,
[originalFlowIdsToCopy]
);
// 플로우별 스텝 그룹핑
const stepsByFlow = new Map<number, FlowStep[]>();
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<number, Map<number, number>>(); // originalFlowId -> stepIdMap
for (const originalFlowId of originalFlowIdsToCopy) {
const newFlowId = flowIdMap.get(originalFlowId)!;
const steps = stepsByFlow.get(originalFlowId) || [];
const stepIdMap = new Map<number, 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 ${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<FlowStepConnection>(
`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<number>,
targetCompanyCode: string,
flowIdMap: Map<number, number>,
userId: string,
client: PoolClient,
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
},
numberingRuleIdMap?: Map<string, string>,
menuIdMap?: Map<number, number>
): Promise<Map<number, number>> {
const screenIdMap = new Map<number, number>();
if (screenIds.size === 0) {
logger.info("📭 복사할 화면 없음");
return screenIdMap;
}
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}`);
// === 0단계: 원본 화면 정의 배치 조회 ===
const screenIdArray = Array.from(screenIds);
const allScreenDefsResult = await client.query<ScreenDefinition>(
`SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`,
[screenIdArray]
);
const screenDefMap = new Map<number, ScreenDefinition>();
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<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
// 대상 레이아웃 조회
const targetLayoutsResult = await client.query<ScreenLayout>(
`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<ScreenLayout>(
`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<string, string>();
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,
menuIdMap
);
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<number>();
const menuMap = new Map<number, Menu>();
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<number, number>,
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<number | null> {
// 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<Menu>(
`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<number, number>,
userId: string,
client: PoolClient,
preAllocatedMenuIdMap?: Map<number, number> // 미리 할당된 메뉴 ID 맵 (옵션 데이터 복사에 사용된 경우)
): Promise<Map<number, number>> {
const menuIdMap = new Map<number, number>();
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<number, number>,
screenIdMap: Map<number, number>,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
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",
]);
const result = await client.query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, is_active, created_by
) VALUES ${assignmentValues}
ON CONFLICT (screen_id, menu_objid, company_code) DO NOTHING`,
assignmentParams
);
logger.info(
`✅ 화면-메뉴 할당 완료: ${result.rowCount}개 삽입 (${validAssignments.length - (result.rowCount || 0)}개 중복 무시)`
);
} else {
logger.info(`📭 화면-메뉴 할당할 항목 없음`);
}
}
/**
* 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입)
*/
private async copyCodeCategoriesAndCodes(
menuObjids: number[],
menuIdMap: Map<number, number>,
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<string, string>,
client: PoolClient
): Promise<void> {
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<string>();
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<string, string>();
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<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<{ copiedCount: number; ruleIdMap: Map<string, string> }> {
let copiedCount = 0;
const ruleIdMap = new Map<string, string>();
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 };
}
/**
* 카테고리 매핑 + 값 복사 (최적화: 배치 조회)
*
* 화면에서 사용하는 table_name + column_name 조합을 기준으로 카테고리 값 복사
* menu_objid 기준이 아닌 화면 컴포넌트 기준으로 복사하여 누락 방지
*/
private async copyCategoryMappingsAndValues(
menuObjids: number[],
menuIdMap: Map<number, number>,
sourceCompanyCode: string,
targetCompanyCode: string,
screenIds: number[],
userId: string,
client: PoolClient
): Promise<number> {
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. 화면에서 사용하는 카테고리 컬럼 조합 수집
// 복사된 화면의 레이아웃에서 webType='category'인 컴포넌트의 tableName, columnName 추출
const categoryColumnsResult = await client.query(
`SELECT DISTINCT
sl.properties->>'tableName' as table_name,
sl.properties->>'columnName' as column_name
FROM screen_layouts sl
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
WHERE sd.screen_id = ANY($1)
AND sl.properties->'componentConfig'->>'webType' = 'category'
AND sl.properties->>'tableName' IS NOT NULL
AND sl.properties->>'columnName' IS NOT NULL`,
[screenIds]
);
// 카테고리 매핑에서 사용하는 table_name, column_name도 추가
const mappingColumnsResult = await client.query(
`SELECT DISTINCT table_name, logical_column_name as column_name
FROM category_column_mapping
WHERE menu_objid = ANY($1)`,
[menuObjids]
);
// 두 결과 합치기
const categoryColumns = new Set<string>();
for (const row of categoryColumnsResult.rows) {
if (row.table_name && row.column_name) {
categoryColumns.add(`${row.table_name}|${row.column_name}`);
}
}
for (const row of mappingColumnsResult.rows) {
if (row.table_name && row.column_name) {
categoryColumns.add(`${row.table_name}|${row.column_name}`);
}
}
logger.info(
` 📋 화면에서 사용하는 카테고리 컬럼: ${categoryColumns.size}`
);
if (categoryColumns.size === 0) {
logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}`);
return copiedCount;
}
// 5. 원본 회사의 카테고리 값 조회 (table_name + column_name 기준)
// menu_objid 조건 대신 table_name + column_name + 원본 회사 코드로 조회
const columnConditions = Array.from(categoryColumns).map((col, i) => {
const [tableName, columnName] = col.split("|");
return `(table_name = $${i * 2 + 2} AND column_name = $${i * 2 + 3})`;
});
const columnParams: string[] = [];
for (const col of categoryColumns) {
const [tableName, columnName] = col.split("|");
columnParams.push(tableName, columnName);
}
const allValuesResult = await client.query(
`SELECT * FROM table_column_category_values
WHERE company_code = $1
AND (${columnConditions.join(" OR ")})
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
[sourceCompanyCode, ...columnParams]
);
if (allValuesResult.rows.length === 0) {
logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}`);
return copiedCount;
}
logger.info(` 📋 원본 카테고리 값: ${allValuesResult.rows.length}개 발견`);
// 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<number, number>();
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<number, any[]>();
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(", ");
// 기본 menu_objid: 매핑이 없을 경우 첫 번째 복사된 메뉴 사용
const defaultMenuObjid = menuIdMap.values().next().value || 0;
const valueParams = values.flatMap((v) => {
// 원본 menu_objid가 매핑에 있으면 사용, 없으면 기본값 사용
const newMenuObjid = menuIdMap.get(v.menu_objid) ?? defaultMenuObjid;
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<number> {
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<number, number>,
userId: string,
client: PoolClient
): Promise<number> {
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<number, number>();
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;
}
}