3097 lines
101 KiB
TypeScript
3097 lines
101 KiB
TypeScript
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}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return 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>
|
||
): any {
|
||
if (!properties) return properties;
|
||
|
||
// 깊은 복사
|
||
const updated = JSON.parse(JSON.stringify(properties));
|
||
|
||
// 재귀적으로 객체/배열 탐색
|
||
this.recursiveUpdateReferences(
|
||
updated,
|
||
screenIdMap,
|
||
flowIdMap,
|
||
"",
|
||
numberingRuleIdMap
|
||
);
|
||
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* 재귀적으로 모든 ID 참조 업데이트
|
||
*/
|
||
private recursiveUpdateReferences(
|
||
obj: any,
|
||
screenIdMap: Map<number, number>,
|
||
flowIdMap: Map<number, number>,
|
||
path: string = "",
|
||
numberingRuleIdMap?: Map<string, string>
|
||
): void {
|
||
if (!obj || typeof obj !== "object") return;
|
||
|
||
// 배열인 경우
|
||
if (Array.isArray(obj)) {
|
||
obj.forEach((item, index) => {
|
||
this.recursiveUpdateReferences(
|
||
item,
|
||
screenIdMap,
|
||
flowIdMap,
|
||
`${path}[${index}]`,
|
||
numberingRuleIdMap
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 객체인 경우 - 키별로 처리
|
||
for (const key of Object.keys(obj)) {
|
||
const value = obj[key];
|
||
const currentPath = path ? `${path}.${key}` : key;
|
||
|
||
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
|
||
if (
|
||
key === "screen_id" ||
|
||
key === "screenId" ||
|
||
key === "targetScreenId" ||
|
||
key === "leftScreenId" ||
|
||
key === "rightScreenId"
|
||
) {
|
||
const numValue = typeof value === "number" ? value : parseInt(value);
|
||
if (!isNaN(numValue) && numValue > 0) {
|
||
const newId = screenIdMap.get(numValue);
|
||
if (newId) {
|
||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||
logger.info(
|
||
` 🔗 화면 참조 업데이트 (${currentPath}): ${value} → ${newId}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열)
|
||
// selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환
|
||
if (key === "flowId" || key === "selectedDiagramId") {
|
||
const numValue = typeof value === "number" ? value : parseInt(value);
|
||
if (!isNaN(numValue) && numValue > 0) {
|
||
const newId = flowIdMap.get(numValue);
|
||
if (newId) {
|
||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||
logger.info(
|
||
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}`
|
||
);
|
||
} else {
|
||
// 매핑이 없으면 경고 로그
|
||
logger.warn(
|
||
` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// numberingRuleId 매핑 (문자열)
|
||
if (
|
||
key === "numberingRuleId" &&
|
||
numberingRuleIdMap &&
|
||
typeof value === "string" &&
|
||
value
|
||
) {
|
||
const newRuleId = numberingRuleIdMap.get(value);
|
||
if (newRuleId) {
|
||
obj[key] = newRuleId;
|
||
logger.info(
|
||
` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}`
|
||
);
|
||
} else {
|
||
// 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지)
|
||
logger.warn(
|
||
` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정`
|
||
);
|
||
obj[key] = "";
|
||
}
|
||
}
|
||
|
||
// 재귀 호출
|
||
if (typeof value === "object" && value !== null) {
|
||
this.recursiveUpdateReferences(
|
||
value,
|
||
screenIdMap,
|
||
flowIdMap,
|
||
currentPath,
|
||
numberingRuleIdMap
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리)
|
||
*
|
||
* 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제
|
||
* - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제
|
||
* - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지)
|
||
*/
|
||
private async deleteExistingCopy(
|
||
sourceMenuObjid: number,
|
||
targetCompanyCode: string,
|
||
client: PoolClient
|
||
): Promise<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,
|
||
targetCompanyCode,
|
||
userId,
|
||
client
|
||
);
|
||
}
|
||
|
||
// === 4.3단계: 연쇄관계 복사 ===
|
||
if (additionalCopyOptions?.copyCascadingRelation) {
|
||
logger.info("\n📦 [4.3단계] 연쇄관계 복사");
|
||
copiedCascadingRelations = await this.copyCascadingRelations(
|
||
sourceCompanyCode,
|
||
targetCompanyCode,
|
||
menuIdMap,
|
||
userId,
|
||
client
|
||
);
|
||
}
|
||
|
||
// === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 ===
|
||
// 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을
|
||
// 대상 회사에서 같은 이름의 채번규칙으로 매핑
|
||
if (screenIds.size > 0) {
|
||
logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완");
|
||
await this.supplementNumberingRuleMapping(
|
||
Array.from(screenIds),
|
||
sourceCompanyCode,
|
||
targetCompanyCode,
|
||
numberingRuleIdMap,
|
||
client
|
||
);
|
||
}
|
||
|
||
// === 5단계: 화면 복사 ===
|
||
logger.info("\n📄 [5단계] 화면 복사");
|
||
const screenIdMap = await this.copyScreens(
|
||
screenIds,
|
||
targetCompanyCode,
|
||
flowIdMap,
|
||
userId,
|
||
client,
|
||
screenNameConfig,
|
||
numberingRuleIdMap
|
||
);
|
||
|
||
// === 6단계: 화면-메뉴 할당 ===
|
||
logger.info("\n🔗 [6단계] 화면-메뉴 할당");
|
||
await this.createScreenMenuAssignments(
|
||
menus,
|
||
menuIdMap,
|
||
screenIdMap,
|
||
targetCompanyCode,
|
||
client
|
||
);
|
||
|
||
// === 7단계: 테이블 타입 설정 복사 ===
|
||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||
copiedTableTypeColumns = await this.copyTableTypeColumns(
|
||
Array.from(screenIdMap.keys()),
|
||
sourceCompanyCode,
|
||
targetCompanyCode,
|
||
client
|
||
);
|
||
}
|
||
|
||
// 커밋
|
||
await client.query("COMMIT");
|
||
logger.info("✅ 트랜잭션 커밋 완료");
|
||
|
||
const result: MenuCopyResult = {
|
||
success: true,
|
||
copiedMenus: menuIdMap.size,
|
||
copiedScreens: screenIdMap.size,
|
||
copiedFlows: flowIdMap.size,
|
||
copiedCodeCategories,
|
||
copiedCodes,
|
||
copiedNumberingRules,
|
||
copiedCategoryMappings,
|
||
copiedTableTypeColumns,
|
||
copiedCascadingRelations,
|
||
menuIdMap: Object.fromEntries(menuIdMap),
|
||
screenIdMap: Object.fromEntries(screenIdMap),
|
||
flowIdMap: Object.fromEntries(flowIdMap),
|
||
warnings,
|
||
};
|
||
|
||
logger.info(`
|
||
🎉 ============================================
|
||
메뉴 복사 완료!
|
||
- 메뉴: ${result.copiedMenus}개
|
||
- 화면: ${result.copiedScreens}개
|
||
- 플로우: ${result.copiedFlows}개
|
||
- 코드 카테고리: ${copiedCodeCategories}개
|
||
- 코드: ${copiedCodes}개
|
||
- 채번규칙: ${copiedNumberingRules}개
|
||
- 카테고리 매핑: ${copiedCategoryMappings}개
|
||
- 테이블 타입 설정: ${copiedTableTypeColumns}개
|
||
- 연쇄관계: ${copiedCascadingRelations}개
|
||
============================================
|
||
`);
|
||
|
||
return result;
|
||
} catch (error: any) {
|
||
// 롤백
|
||
await client.query("ROLLBACK");
|
||
logger.error("❌ 메뉴 복사 실패, 롤백됨:", error);
|
||
throw error;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 플로우 복사
|
||
* - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만)
|
||
* - 없으면 새로 복사
|
||
*/
|
||
private async copyFlows(
|
||
flowIds: Set<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>
|
||
): 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
|
||
);
|
||
|
||
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",
|
||
]);
|
||
|
||
await client.query(
|
||
`INSERT INTO screen_menu_assignments (
|
||
screen_id, menu_objid, company_code, display_order, is_active, created_by
|
||
) VALUES ${assignmentValues}`,
|
||
assignmentParams
|
||
);
|
||
}
|
||
|
||
logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`);
|
||
}
|
||
|
||
/**
|
||
* 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입)
|
||
*/
|
||
private async copyCodeCategoriesAndCodes(
|
||
menuObjids: number[],
|
||
menuIdMap: Map<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. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회
|
||
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)
|
||
);
|
||
|
||
// 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) {
|
||
if (existingRuleIds.has(rule.rule_id)) {
|
||
// 기존 규칙은 동일한 ID로 매핑
|
||
ruleIdMap.set(rule.rule_id, rule.rule_id);
|
||
|
||
// 새 메뉴 ID로 연결 업데이트 필요
|
||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||
if (newMenuObjid) {
|
||
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
|
||
}
|
||
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}`;
|
||
|
||
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}개 복사`);
|
||
}
|
||
|
||
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
|
||
if (rulesToUpdate.length > 0) {
|
||
// CASE WHEN을 사용한 배치 업데이트
|
||
const caseWhen = rulesToUpdate
|
||
.map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`)
|
||
.join(" ");
|
||
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
|
||
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
|
||
|
||
await client.query(
|
||
`UPDATE numbering_rules
|
||
SET menu_objid = CASE ${caseWhen} END, updated_at = NOW()
|
||
WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`,
|
||
[...params, ruleIdsForUpdate, targetCompanyCode]
|
||
);
|
||
logger.info(
|
||
` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신`
|
||
);
|
||
}
|
||
|
||
// 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상)
|
||
if (rulesToCopy.length > 0) {
|
||
const originalRuleIds = rulesToCopy.map((r) => r.rule_id);
|
||
const allPartsResult = await client.query(
|
||
`SELECT * FROM numbering_rule_parts
|
||
WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`,
|
||
[originalRuleIds]
|
||
);
|
||
|
||
// 6. 배치 INSERT로 채번 규칙 파트 복사
|
||
if (allPartsResult.rows.length > 0) {
|
||
// 원본 rule_id -> 새 rule_id 매핑
|
||
const ruleMapping = new Map(
|
||
originalToNewRuleMap.map((m) => [m.original, m.new])
|
||
);
|
||
|
||
const partValues = allPartsResult.rows
|
||
.map(
|
||
(_, i) =>
|
||
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())`
|
||
)
|
||
.join(", ");
|
||
|
||
const partParams = allPartsResult.rows.flatMap((p) => [
|
||
ruleMapping.get(p.rule_id),
|
||
p.part_order,
|
||
p.part_type,
|
||
p.generation_method,
|
||
p.auto_config,
|
||
p.manual_config,
|
||
targetCompanyCode,
|
||
]);
|
||
|
||
await client.query(
|
||
`INSERT INTO numbering_rule_parts (
|
||
rule_id, part_order, part_type, generation_method,
|
||
auto_config, manual_config, company_code, created_at
|
||
) VALUES ${partValues}`,
|
||
partParams
|
||
);
|
||
|
||
logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`);
|
||
}
|
||
}
|
||
|
||
logger.info(
|
||
`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`
|
||
);
|
||
return { copiedCount, ruleIdMap };
|
||
}
|
||
|
||
/**
|
||
* 카테고리 매핑 + 값 복사 (최적화: 배치 조회)
|
||
*/
|
||
private async copyCategoryMappingsAndValues(
|
||
menuObjids: number[],
|
||
menuIdMap: Map<number, number>,
|
||
targetCompanyCode: string,
|
||
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. 모든 원본 카테고리 값 한 번에 조회
|
||
const allValuesResult = await client.query(
|
||
`SELECT * FROM table_column_category_values
|
||
WHERE menu_objid = ANY($1)
|
||
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
|
||
[menuObjids]
|
||
);
|
||
|
||
if (allValuesResult.rows.length === 0) {
|
||
logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`);
|
||
return copiedCount;
|
||
}
|
||
|
||
// 5. 대상 회사에 이미 존재하는 값 한 번에 조회
|
||
const existingValuesResult = await client.query(
|
||
`SELECT value_id, table_name, column_name, value_code
|
||
FROM table_column_category_values WHERE company_code = $1`,
|
||
[targetCompanyCode]
|
||
);
|
||
const existingValueKeys = new Map(
|
||
existingValuesResult.rows.map((v) => [
|
||
`${v.table_name}|${v.column_name}|${v.value_code}`,
|
||
v.value_id,
|
||
])
|
||
);
|
||
|
||
// 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리)
|
||
const valueIdMap = new Map<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(", ");
|
||
|
||
const valueParams = values.flatMap((v) => {
|
||
const newMenuObjid = menuIdMap.get(v.menu_objid);
|
||
const newParentId = v.parent_value_id
|
||
? valueIdMap.get(v.parent_value_id) || null
|
||
: null;
|
||
return [
|
||
v.table_name,
|
||
v.column_name,
|
||
v.value_code,
|
||
v.value_label,
|
||
v.value_order,
|
||
newParentId,
|
||
v.depth,
|
||
v.description,
|
||
v.color,
|
||
v.icon,
|
||
v.is_active,
|
||
v.is_default,
|
||
userId,
|
||
targetCompanyCode,
|
||
newMenuObjid,
|
||
];
|
||
});
|
||
|
||
const insertResult = await client.query(
|
||
`INSERT INTO table_column_category_values (
|
||
table_name, column_name, value_code, value_label, value_order,
|
||
parent_value_id, depth, description, color, icon,
|
||
is_active, is_default, created_at, created_by, company_code, menu_objid
|
||
) VALUES ${valueStrings}
|
||
RETURNING value_id`,
|
||
valueParams
|
||
);
|
||
|
||
// 새 value_id 매핑
|
||
insertResult.rows.forEach((row, index) => {
|
||
valueIdMap.set(values[index].value_id, row.value_id);
|
||
});
|
||
|
||
copiedValues += values.length;
|
||
}
|
||
|
||
if (copiedValues > 0) {
|
||
logger.info(` ✅ 카테고리 값 ${copiedValues}개 복사`);
|
||
}
|
||
|
||
logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`);
|
||
return copiedCount;
|
||
}
|
||
|
||
/**
|
||
* 테이블 타입관리 입력타입 설정 복사 (최적화: 배치 조회/삽입)
|
||
* - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사
|
||
*/
|
||
private async copyTableTypeColumns(
|
||
screenIds: number[],
|
||
sourceCompanyCode: string,
|
||
targetCompanyCode: string,
|
||
client: PoolClient
|
||
): Promise<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;
|
||
}
|
||
}
|