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

1276 lines
38 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;
menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
warnings: string[];
}
/**
* 메뉴 정보
*/
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;
}
/**
* 화면 레이아웃
*/
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})`);
}
}
}
}
}
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) 메뉴에 직접 할당된 화면
for (const menuObjid of menuObjids) {
const assignmentsResult = await client.query<{ screen_id: number }>(
`SELECT DISTINCT screen_id
FROM screen_menu_assignments
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
for (const assignment of assignmentsResult.rows) {
screenIds.add(assignment.screen_id);
}
}
logger.info(`📌 직접 할당 화면: ${screenIds.size}`);
// 2) 화면 내부에서 참조되는 화면 (재귀)
const queue = Array.from(screenIds);
while (queue.length > 0) {
const screenId = queue.shift()!;
if (visited.has(screenId)) continue;
visited.add(screenId);
// 화면 레이아웃 조회
const layoutsResult = await client.query<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;
}
/**
* 플로우 수집
*/
private async collectFlows(
screenIds: Set<number>,
client: PoolClient
): Promise<Set<number>> {
logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`);
const flowIds = new Set<number>();
for (const screenId of screenIds) {
const layoutsResult = await client.query<ScreenLayout>(
`SELECT properties FROM screen_layouts WHERE screen_id = $1`,
[screenId]
);
for (const layout of layoutsResult.rows) {
const props = layout.properties;
// webTypeConfig.dataflowConfig.flowConfig.flowId
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
if (flowId) {
flowIds.add(flowId);
}
}
}
logger.info(`✅ 플로우 수집 완료: ${flowIds.size}`);
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 재귀 업데이트
*/
private updateReferencesInProperties(
properties: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>
): any {
if (!properties) return properties;
// 깊은 복사
const updated = JSON.parse(JSON.stringify(properties));
// 재귀적으로 객체/배열 탐색
this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap);
return updated;
}
/**
* 재귀적으로 모든 ID 참조 업데이트
*/
private recursiveUpdateReferences(
obj: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
path: string = ""
): void {
if (!obj || typeof obj !== "object") return;
// 배열인 경우
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
this.recursiveUpdateReferences(
item,
screenIdMap,
flowIdMap,
`${path}[${index}]`
);
});
return;
}
// 객체인 경우 - 키별로 처리
for (const key of Object.keys(obj)) {
const value = obj[key];
const currentPath = path ? `${path}.${key}` : key;
// screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열)
if (
key === "screen_id" ||
key === "screenId" ||
key === "targetScreenId"
) {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue)) {
const newId = screenIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
logger.info(
` 🔗 화면 참조 업데이트 (${currentPath}): ${value}${newId}`
);
}
}
}
// flowId 매핑 (숫자 또는 숫자 문자열)
if (key === "flowId") {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue)) {
const newId = flowIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
logger.debug(
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value}${newId}`
);
}
}
}
// 재귀 호출
if (typeof value === "object" && value !== null) {
this.recursiveUpdateReferences(
value,
screenIdMap,
flowIdMap,
currentPath
);
}
}
}
/**
* 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리)
*
* 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제
*/
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
FROM menu_info
WHERE objid = $1`,
[sourceMenuObjid]
);
if (sourceMenuResult.rows.length === 0) {
logger.warn("⚠️ 원본 메뉴를 찾을 수 없습니다");
return;
}
const sourceMenu = sourceMenuResult.rows[0];
// 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭)
const existingMenuResult = await client.query<{ objid: number }>(
`SELECT objid
FROM menu_info
WHERE source_menu_objid = $1
AND company_code = $2
AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`,
[sourceMenuObjid, targetCompanyCode]
);
if (existingMenuResult.rows.length === 0) {
logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다");
return;
}
const existingMenuObjid = existingMenuResult.rows[0].objid;
logger.info(
`🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})`
);
// 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. 화면 레이아웃 삭제
if (screenIds.length > 0) {
await client.query(
`DELETE FROM screen_layouts WHERE screen_id = ANY($1)`,
[screenIds]
);
logger.info(` ✅ 화면 레이아웃 삭제 완료`);
}
// 5-2. 화면-메뉴 할당 삭제
await client.query(
`DELETE FROM screen_menu_assignments
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 화면-메뉴 할당 삭제 완료`);
// 5-3. 화면 정의 삭제
if (screenIds.length > 0) {
await client.query(
`DELETE FROM screen_definitions
WHERE screen_id = ANY($1) AND company_code = $2`,
[screenIds, targetCompanyCode]
);
logger.info(` ✅ 화면 정의 삭제 완료`);
}
// 5-4. 메뉴 권한 삭제
await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [
existingMenuIds,
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-5. 메뉴 삭제 (역순: 하위 메뉴부터)
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
for (let i = existingMenus.length - 1; i >= 0; i--) {
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
existingMenus[i].objid,
]);
}
logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}`);
logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨");
}
/**
* 메뉴 복사 (메인 함수)
*/
async copyMenu(
sourceMenuObjid: number,
targetCompanyCode: string,
userId: string,
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
}
): 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
);
// === 3단계: 화면 복사 ===
logger.info("\n📄 [3단계] 화면 복사");
const screenIdMap = await this.copyScreens(
screenIds,
targetCompanyCode,
flowIdMap,
userId,
client,
screenNameConfig
);
// === 4단계: 메뉴 복사 ===
logger.info("\n📂 [4단계] 메뉴 복사");
const menuIdMap = await this.copyMenus(
menus,
sourceMenuObjid, // 원본 최상위 메뉴 ID 전달
targetCompanyCode,
screenIdMap,
userId,
client
);
// === 5단계: 화면-메뉴 할당 ===
logger.info("\n🔗 [5단계] 화면-메뉴 할당");
await this.createScreenMenuAssignments(
menus,
menuIdMap,
screenIdMap,
targetCompanyCode,
client
);
// 커밋
await client.query("COMMIT");
logger.info("✅ 트랜잭션 커밋 완료");
const result: MenuCopyResult = {
success: true,
copiedMenus: menuIdMap.size,
copiedScreens: screenIdMap.size,
copiedFlows: flowIdMap.size,
menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap),
warnings,
};
logger.info(`
🎉 ============================================
메뉴 복사 완료!
- 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows}
⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다.
============================================
`);
return result;
} catch (error: any) {
// 롤백
await client.query("ROLLBACK");
logger.error("❌ 메뉴 복사 실패, 롤백됨:", error);
throw error;
} finally {
client.release();
}
}
/**
* 플로우 복사
*/
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;
}
logger.info(`🔄 플로우 복사 중: ${flowIds.size}`);
for (const originalFlowId of flowIds) {
try {
// 1) flow_definition 조회
const flowDefResult = await client.query<FlowDefinition>(
`SELECT * FROM flow_definition WHERE id = $1`,
[originalFlowId]
);
if (flowDefResult.rows.length === 0) {
logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`);
continue;
}
const flowDef = flowDefResult.rows[0];
// 2) flow_definition 복사
const newFlowResult = await client.query<{ id: number }>(
`INSERT INTO flow_definition (
name, description, table_name, is_active,
company_code, created_by, db_source_type, db_connection_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
flowDef.name,
flowDef.description,
flowDef.table_name,
flowDef.is_active,
targetCompanyCode, // 새 회사 코드
userId,
flowDef.db_source_type,
flowDef.db_connection_id,
]
);
const newFlowId = newFlowResult.rows[0].id;
flowIdMap.set(originalFlowId, newFlowId);
logger.info(
` ✅ 플로우 복사: ${originalFlowId}${newFlowId} (${flowDef.name})`
);
// 3) flow_step 복사
const stepsResult = await client.query<FlowStep>(
`SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`,
[originalFlowId]
);
const stepIdMap = new Map<number, number>();
for (const step of stepsResult.rows) {
const newStepResult = await client.query<{ id: number }>(
`INSERT INTO flow_step (
flow_definition_id, step_name, step_order, condition_json,
color, position_x, position_y, table_name, move_type,
status_column, status_value, target_table, field_mappings,
required_fields, integration_type, integration_config, display_config
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id`,
[
newFlowId, // 새 플로우 ID
step.step_name,
step.step_order,
step.condition_json,
step.color,
step.position_x,
step.position_y,
step.table_name,
step.move_type,
step.status_column,
step.status_value,
step.target_table,
step.field_mappings,
step.required_fields,
step.integration_type,
step.integration_config,
step.display_config,
]
);
const newStepId = newStepResult.rows[0].id;
stepIdMap.set(step.id, newStepId);
}
logger.info(` ↳ 스텝 복사: ${stepIdMap.size}`);
// 4) flow_step_connection 복사 (스텝 ID 재매핑)
const connectionsResult = await client.query<FlowStepConnection>(
`SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`,
[originalFlowId]
);
for (const conn of connectionsResult.rows) {
const newFromStepId = stepIdMap.get(conn.from_step_id);
const newToStepId = stepIdMap.get(conn.to_step_id);
if (!newFromStepId || !newToStepId) {
logger.warn(
`⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id}${conn.to_step_id}`
);
continue;
}
await client.query(
`INSERT INTO flow_step_connection (
flow_definition_id, from_step_id, to_step_id, label
) VALUES ($1, $2, $3, $4)`,
[newFlowId, newFromStepId, newToStepId, conn.label]
);
}
logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}`);
} catch (error: any) {
logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error);
throw error;
}
}
logger.info(`✅ 플로우 복사 완료: ${flowIdMap.size}`);
return flowIdMap;
}
/**
* 화면 복사
*/
private async copyScreens(
screenIds: Set<number>,
targetCompanyCode: string,
flowIdMap: Map<number, number>,
userId: string,
client: PoolClient,
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
}
): Promise<Map<number, number>> {
const screenIdMap = new Map<number, number>();
if (screenIds.size === 0) {
logger.info("📭 복사할 화면 없음");
return screenIdMap;
}
logger.info(`📄 화면 복사 중: ${screenIds.size}`);
// === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) ===
const screenDefsToProcess: Array<{
originalScreenId: number;
newScreenId: number;
screenDef: ScreenDefinition;
}> = [];
for (const originalScreenId of screenIds) {
try {
// 1) screen_definitions 조회
const screenDefResult = await client.query<ScreenDefinition>(
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
[originalScreenId]
);
if (screenDefResult.rows.length === 0) {
logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`);
continue;
}
const screenDef = screenDefResult.rows[0];
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
const existingScreenResult = await client.query<{ screen_id: number }>(
`SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
[screenDef.screen_code, targetCompanyCode]
);
if (existingScreenResult.rows.length > 0) {
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
const existingScreenId = existingScreenResult.rows[0].screen_id;
screenIdMap.set(originalScreenId, existingScreenId);
logger.info(
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId}${existingScreenId} (${screenDef.screen_code})`
);
continue; // 레이아웃 복사도 스킵
}
// 3) 새 screen_code 생성
const newScreenCode = await this.generateUniqueScreenCode(
targetCompanyCode,
client
);
// 4) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) {
// 1. 제거할 텍스트 제거
if (screenNameConfig.removeText?.trim()) {
transformedScreenName = transformedScreenName.replace(
new RegExp(screenNameConfig.removeText.trim(), "g"),
""
);
transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거
}
// 2. 접두사 추가
if (screenNameConfig.addPrefix?.trim()) {
transformedScreenName =
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
}
}
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
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, // deleted_date: NULL (새 화면은 삭제되지 않음)
null, // deleted_by: NULL
null, // delete_reason: NULL
]
);
const newScreenId = newScreenResult.rows[0].screen_id;
screenIdMap.set(originalScreenId, newScreenId);
logger.info(
` ✅ 화면 정의 복사: ${originalScreenId}${newScreenId} (${screenDef.screen_name})`
);
// 저장해서 2단계에서 처리
screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef });
} catch (error: any) {
logger.error(
`❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`,
error
);
throw error;
}
}
// === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) ===
logger.info(
`\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
);
for (const {
originalScreenId,
newScreenId,
screenDef,
} of screenDefsToProcess) {
try {
// screen_layouts 복사
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
// 1단계: component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>();
for (const layout of layoutsResult.rows) {
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
componentIdMap.set(layout.component_id, newComponentId);
}
// 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑)
for (const layout of layoutsResult.rows) {
const newComponentId = componentIdMap.get(layout.component_id)!;
// parent_id와 zone_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;
// properties 내부 참조 업데이트
const updatedProperties = this.updateReferencesInProperties(
layout.properties,
screenIdMap,
flowIdMap
);
await client.query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties,
display_order, layout_type, layout_config, zones_config, zone_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
newScreenId, // 새 화면 ID
layout.component_type,
newComponentId, // 새 컴포넌트 ID
newParentId, // 매핑된 parent_id
layout.position_x,
layout.position_y,
layout.width,
layout.height,
updatedProperties, // 업데이트된 속성
layout.display_order,
layout.layout_type,
layout.layout_config,
layout.zones_config,
newZoneId, // 매핑된 zone_id
]
);
}
logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}`);
} catch (error: any) {
logger.error(
`❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`,
error
);
throw error;
}
}
logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}`);
return screenIdMap;
}
/**
* 메뉴 위상 정렬 (부모 먼저)
*/
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;
}
/**
* 메뉴 복사
*/
private async copyMenus(
menus: Menu[],
rootMenuObjid: number,
targetCompanyCode: string,
screenIdMap: Map<number, number>,
userId: string,
client: PoolClient
): 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 {
// 새 objid 생성
const newObjId = await this.getNextMenuObjid(client);
// parent_obj_id 재매핑
// NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일
let newParentObjId: number | null;
if (!menu.parent_obj_id || menu.parent_obj_id === 0) {
newParentObjId = 0; // 최상위 메뉴는 항상 0
} else {
newParentObjId =
menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id;
}
// source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용)
// BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교
const isRootMenu = String(menu.objid) === String(rootMenuObjid);
const sourceMenuObjid = isRootMenu ? menu.objid : null;
if (sourceMenuObjid) {
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(`🔗 화면-메뉴 할당 중...`);
let assignmentCount = 0;
for (const menu of menus) {
const newMenuObjid = menuIdMap.get(menu.objid);
if (!newMenuObjid) continue;
// 원본 메뉴에 할당된 화면 조회
const assignmentsResult = await client.query<{
screen_id: number;
display_order: number;
is_active: string;
}>(
`SELECT screen_id, display_order, is_active
FROM screen_menu_assignments
WHERE menu_objid = $1 AND company_code = $2`,
[menu.objid, menu.company_code]
);
for (const assignment of assignmentsResult.rows) {
const newScreenId = screenIdMap.get(assignment.screen_id);
if (!newScreenId) {
logger.warn(
`⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}`
);
continue;
}
// 새 할당 생성
await client.query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6)`,
[
newScreenId, // 재매핑
newMenuObjid, // 재매핑
targetCompanyCode,
assignment.display_order,
assignment.is_active,
"system",
]
);
assignmentCount++;
}
}
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`);
}
}