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

2223 lines
73 KiB
TypeScript
Raw Normal View History

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;
2025-12-18 10:55:26 +09:00
copiedCodeCategories: number;
copiedCodes: number;
copiedNumberingRules: number;
copiedCategoryMappings: number;
copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정
menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
warnings: string[];
}
2025-12-18 10:55:26 +09:00
/**
*
*/
export interface AdditionalCopyOptions {
copyCodeCategory?: boolean;
copyNumberingRules?: boolean;
copyCategoryMapping?: boolean;
copyTableTypeColumns?: 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;
2025-12-03 16:02:09 +09:00
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})`);
}
}
}
}
2025-12-03 16:02:09 +09:00
// 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) 메뉴에 직접 할당된 화면
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;
}
/**
*
2025-12-15 14:51:41 +09:00
* - 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>();
2025-12-15 14:51:41 +09:00
const flowDetails: Array<{ flowId: number; flowName: string; screenId: 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;
2025-12-15 14:51:41 +09:00
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}`);
}
}
}
}
2025-12-15 14:51:41 +09:00
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
*/
/**
2025-12-18 10:55:26 +09:00
* properties screen_id, screenId, targetScreenId, flowId, numberingRuleId
*/
private updateReferencesInProperties(
properties: any,
screenIdMap: Map<number, number>,
2025-12-18 10:55:26 +09:00
flowIdMap: Map<number, number>,
numberingRuleIdMap?: Map<string, string>
): any {
if (!properties) return properties;
// 깊은 복사
const updated = JSON.parse(JSON.stringify(properties));
// 재귀적으로 객체/배열 탐색
2025-12-18 10:55:26 +09:00
this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap);
return updated;
}
/**
* ID
*/
private recursiveUpdateReferences(
obj: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
2025-12-18 10:55:26 +09:00
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,
2025-12-18 10:55:26 +09:00
`${path}[${index}]`,
numberingRuleIdMap
);
});
return;
}
// 객체인 경우 - 키별로 처리
for (const key of Object.keys(obj)) {
const value = obj[key];
const currentPath = path ? `${path}.${key}` : key;
2025-12-03 16:02:09 +09:00
// screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열)
if (
key === "screen_id" ||
key === "screenId" ||
2025-12-03 16:02:09 +09:00
key === "targetScreenId" ||
key === "leftScreenId" ||
key === "rightScreenId"
) {
const numValue = typeof value === "number" ? value : parseInt(value);
2025-12-03 16:02:09 +09:00
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}`
);
}
}
}
2025-12-15 14:51:41 +09:00
// flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열)
// selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환
if (key === "flowId" || key === "selectedDiagramId") {
const numValue = typeof value === "number" ? value : parseInt(value);
2025-12-15 14:51:41 +09:00
if (!isNaN(numValue) && numValue > 0) {
const newId = flowIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
2025-12-15 14:51:41 +09:00
logger.info(
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value}${newId}`
);
} else {
// 매핑이 없으면 경고 로그
logger.warn(
` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음`
);
}
}
}
2025-12-18 10:55:26 +09:00
// numberingRuleId 매핑 (문자열)
if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) {
const newRuleId = numberingRuleIdMap.get(value);
if (newRuleId) {
obj[key] = newRuleId;
logger.info(
` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value}${newRuleId}`
);
}
}
// 재귀 호출
if (typeof value === "object" && value !== null) {
this.recursiveUpdateReferences(
value,
screenIdMap,
flowIdMap,
2025-12-18 10:55:26 +09:00
currentPath,
numberingRuleIdMap
);
}
}
}
/**
* ( )
*
*
2025-12-18 10:55:26 +09:00
* - 복사: 해당
* - 복사: 해당 ( )
*/
private async deleteExistingCopy(
sourceMenuObjid: number,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
logger.info("\n🗑 [0단계] 기존 복사본 확인 및 삭제");
2025-12-18 10:55:26 +09:00
// 1. 원본 메뉴 정보 확인
const sourceMenuResult = await client.query<Menu>(
2025-12-18 10:55:26 +09:00
`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];
2025-12-18 10:55:26 +09:00
const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0;
// 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭)
2025-12-18 10:55:26 +09:00
// 최상위/하위 구분 없이 모든 복사본 검색
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
2025-12-18 10:55:26 +09:00
AND company_code = $2`,
[sourceMenuObjid, targetCompanyCode]
);
if (existingMenuResult.rows.length === 0) {
logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다");
return;
}
const existingMenuObjid = existingMenuResult.rows[0].objid;
2025-12-18 10:55:26 +09:00
const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id ||
existingMenuResult.rows[0].parent_obj_id === 0;
logger.info(
2025-12-18 10:55:26 +09:00
`🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})`
);
2025-12-18 10:55:26 +09:00
// 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. 삭제 순서 (외래키 제약 고려)
2025-12-18 10:55:26 +09:00
// 5-1. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제)
await client.query(
`DELETE FROM screen_menu_assignments
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 화면-메뉴 할당 삭제 완료`);
2025-12-18 10:55:26 +09:00
// 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외)
if (screenIds.length > 0) {
2025-12-18 10:55:26 +09:00
// 다른 메뉴에서도 사용 중인 화면 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]
);
2025-12-18 10:55:26 +09:00
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}개 (다른 메뉴에서 사용 중)`);
}
}
2025-12-18 10:55:26 +09:00
// 5-3. 메뉴 권한 삭제
await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [
existingMenuIds,
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
2025-12-18 10:55:26 +09:00
// 5-4. 메뉴 삭제 (역순: 하위 메뉴부터)
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
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;
2025-12-18 10:55:26 +09:00
},
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
);
2025-12-18 10:55:26 +09:00
// === 2.5단계: 채번 규칙 복사 (화면 복사 전에 실행하여 참조 업데이트 가능) ===
let copiedCodeCategories = 0;
let copiedCodes = 0;
let copiedNumberingRules = 0;
let copiedCategoryMappings = 0;
let copiedTableTypeColumns = 0;
let numberingRuleIdMap = new Map<string, string>();
const menuObjids = menus.map((m) => m.objid);
// 메뉴 ID 맵을 먼저 생성 (채번 규칙 복사에 필요)
const tempMenuIdMap = new Map<number, number>();
let tempObjId = await this.getNextMenuObjid(client);
for (const menu of menus) {
tempMenuIdMap.set(menu.objid, tempObjId++);
}
if (additionalCopyOptions?.copyNumberingRules) {
logger.info("\n📦 [2.5단계] 채번 규칙 복사 (화면 복사 전)");
const ruleResult = await this.copyNumberingRulesWithMap(
menuObjids,
tempMenuIdMap,
targetCompanyCode,
userId,
client
);
copiedNumberingRules = ruleResult.copiedCount;
numberingRuleIdMap = ruleResult.ruleIdMap;
}
// === 3단계: 화면 복사 ===
logger.info("\n📄 [3단계] 화면 복사");
const screenIdMap = await this.copyScreens(
screenIds,
targetCompanyCode,
flowIdMap,
userId,
client,
2025-12-18 10:55:26 +09:00
screenNameConfig,
numberingRuleIdMap
);
// === 4단계: 메뉴 복사 ===
logger.info("\n📂 [4단계] 메뉴 복사");
const menuIdMap = await this.copyMenus(
menus,
sourceMenuObjid, // 원본 최상위 메뉴 ID 전달
2025-12-18 10:55:26 +09:00
sourceCompanyCode,
targetCompanyCode,
screenIdMap,
userId,
client
);
// === 5단계: 화면-메뉴 할당 ===
logger.info("\n🔗 [5단계] 화면-메뉴 할당");
await this.createScreenMenuAssignments(
menus,
menuIdMap,
screenIdMap,
targetCompanyCode,
client
);
2025-12-18 10:55:26 +09:00
// === 6단계: 추가 복사 옵션 처리 (코드 카테고리, 카테고리 매핑) ===
if (additionalCopyOptions) {
// 6-1. 코드 카테고리 + 코드 복사
if (additionalCopyOptions.copyCodeCategory) {
logger.info("\n📦 [6-1단계] 코드 카테고리 + 코드 복사");
const codeResult = await this.copyCodeCategoriesAndCodes(
menuObjids,
menuIdMap,
targetCompanyCode,
userId,
client
);
copiedCodeCategories = codeResult.copiedCategories;
copiedCodes = codeResult.copiedCodes;
}
// 6-2. 카테고리 매핑 + 값 복사
if (additionalCopyOptions.copyCategoryMapping) {
logger.info("\n📦 [6-2단계] 카테고리 매핑 + 값 복사");
copiedCategoryMappings = await this.copyCategoryMappingsAndValues(
menuObjids,
menuIdMap,
targetCompanyCode,
userId,
client
);
}
// 6-3. 테이블 타입관리 입력타입 설정 복사
if (additionalCopyOptions.copyTableTypeColumns) {
logger.info("\n📦 [6-3단계] 테이블 타입 설정 복사");
copiedTableTypeColumns = await this.copyTableTypeColumns(
Array.from(screenIdMap.keys()), // 원본 화면 IDs
sourceCompanyCode,
targetCompanyCode,
client
);
}
}
// 커밋
await client.query("COMMIT");
logger.info("✅ 트랜잭션 커밋 완료");
const result: MenuCopyResult = {
success: true,
copiedMenus: menuIdMap.size,
copiedScreens: screenIdMap.size,
copiedFlows: flowIdMap.size,
2025-12-18 10:55:26 +09:00
copiedCodeCategories,
copiedCodes,
copiedNumberingRules,
copiedCategoryMappings,
copiedTableTypeColumns,
menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap),
warnings,
};
logger.info(`
🎉 ============================================
!
- 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows}
2025-12-18 10:55:26 +09:00
- 카테고리: ${copiedCodeCategories}
- 코드: ${copiedCodes}
- 채번규칙: ${copiedNumberingRules}
- 매핑: ${copiedCategoryMappings}
- 설정: ${copiedTableTypeColumns}
============================================
`);
return result;
} catch (error: any) {
// 롤백
await client.query("ROLLBACK");
logger.error("❌ 메뉴 복사 실패, 롤백됨:", error);
throw error;
} finally {
client.release();
}
}
/**
*
2025-12-15 14:51:41 +09:00
* - + (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;
}
logger.info(`🔄 플로우 복사 중: ${flowIds.size}`);
2025-12-15 14:51:41 +09:00
logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`);
for (const originalFlowId of flowIds) {
try {
2025-12-15 14:51:41 +09:00
// 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];
2025-12-15 14:51:41 +09:00
logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`);
// 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인
const existingFlowResult = await client.query<{ id: number }>(
`SELECT id FROM flow_definition
WHERE company_code = $1 AND name = $2 AND table_name = $3
LIMIT 1`,
[targetCompanyCode, flowDef.name, flowDef.table_name]
);
let newFlowId: number;
if (existingFlowResult.rows.length > 0) {
// 기존 플로우가 있으면 재사용
newFlowId = existingFlowResult.rows[0].id;
flowIdMap.set(originalFlowId, newFlowId);
logger.info(
` ♻️ 기존 플로우 재사용: ${originalFlowId}${newFlowId} (${flowDef.name})`
);
continue; // 스텝/연결 복사 생략 (기존 것 사용)
}
2025-12-15 14:51:41 +09:00
// 3) 새 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,
]
);
2025-12-15 14:51:41 +09:00
newFlowId = newFlowResult.rows[0].id;
flowIdMap.set(originalFlowId, newFlowId);
logger.info(
2025-12-15 14:51:41 +09:00
` ✅ 플로우 신규 복사: ${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;
}
/**
2025-12-03 16:02:09 +09:00
* ( )
* - source_screen_id로
* -
* -
*/
private async copyScreens(
screenIds: Set<number>,
targetCompanyCode: string,
flowIdMap: Map<number, number>,
userId: string,
client: PoolClient,
screenNameConfig?: {
removeText?: string;
addPrefix?: string;
2025-12-18 10:55:26 +09:00
},
numberingRuleIdMap?: Map<string, string>
): Promise<Map<number, number>> {
const screenIdMap = new Map<number, number>();
if (screenIds.size === 0) {
logger.info("📭 복사할 화면 없음");
return screenIdMap;
}
2025-12-03 16:02:09 +09:00
logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}`);
2025-12-03 16:02:09 +09:00
// === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) ===
const screenDefsToProcess: Array<{
originalScreenId: number;
2025-12-03 16:02:09 +09:00
targetScreenId: number;
screenDef: ScreenDefinition;
2025-12-03 16:02:09 +09:00
isUpdate: boolean; // 업데이트인지 신규 생성인지
}> = [];
for (const originalScreenId of screenIds) {
try {
2025-12-03 16:02:09 +09:00
// 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];
2025-12-03 16:02:09 +09:00
// 2) 기존 복사본 찾기: source_screen_id로 검색
2025-12-18 10:55:26 +09:00
let existingCopyResult = await client.query<{
2025-12-03 16:02:09 +09:00
screen_id: number;
screen_name: string;
updated_date: Date;
}>(
`SELECT screen_id, screen_name, updated_date
FROM screen_definitions
WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL
LIMIT 1`,
2025-12-03 16:02:09 +09:00
[originalScreenId, targetCompanyCode]
);
2025-12-18 10:55:26 +09:00
// 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지
if (existingCopyResult.rows.length === 0 && screenDef.screen_name) {
existingCopyResult = 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 (existingCopyResult.rows.length > 0) {
// 기존 복사본에 source_screen_id 업데이트 (마이그레이션)
await client.query(
`UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`,
[originalScreenId, existingCopyResult.rows[0].screen_id]
);
logger.info(
` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id}${originalScreenId}`
);
}
}
2025-12-03 16:02:09 +09:00
// 3) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) {
if (screenNameConfig.removeText?.trim()) {
transformedScreenName = transformedScreenName.replace(
new RegExp(screenNameConfig.removeText.trim(), "g"),
""
);
2025-12-03 16:02:09 +09:00
transformedScreenName = transformedScreenName.trim();
}
if (screenNameConfig.addPrefix?.trim()) {
transformedScreenName =
screenNameConfig.addPrefix.trim() + " " + transformedScreenName;
}
}
2025-12-03 16:02:09 +09:00
if (existingCopyResult.rows.length > 0) {
// === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreen = existingCopyResult.rows[0];
const existingScreenId = existingScreen.screen_id;
2025-12-03 16:02:09 +09:00
// 원본 레이아웃 조회
const sourceLayoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
2025-12-03 16:02:09 +09:00
// 대상 레이아웃 조회
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})`
);
2025-12-03 16:02:09 +09:00
screenDefsToProcess.push({
originalScreenId,
targetScreenId: newScreenId,
screenDef,
isUpdate: false,
});
}
} catch (error: any) {
logger.error(
2025-12-03 16:02:09 +09:00
`❌ 화면 처리 실패: screen_id=${originalScreenId}`,
error
);
throw error;
}
}
2025-12-03 16:02:09 +09:00
// === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) ===
logger.info(
2025-12-03 16:02:09 +09:00
`\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
);
for (const {
originalScreenId,
2025-12-03 16:02:09 +09:00
targetScreenId,
screenDef,
2025-12-03 16:02:09 +09:00
isUpdate,
} of screenDefsToProcess) {
try {
2025-12-03 16:02:09 +09:00
// 원본 레이아웃 조회
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
2025-12-03 16:02:09 +09:00
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>();
for (const layout of layoutsResult.rows) {
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
componentIdMap.set(layout.component_id, newComponentId);
}
2025-12-03 16:02:09 +09:00
// 레이아웃 삽입
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,
2025-12-18 10:55:26 +09:00
flowIdMap,
numberingRuleIdMap
);
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)`,
[
2025-12-03 16:02:09 +09:00
targetScreenId,
layout.component_type,
2025-12-03 16:02:09 +09:00
newComponentId,
newParentId,
layout.position_x,
layout.position_y,
layout.width,
layout.height,
2025-12-03 16:02:09 +09:00
updatedProperties,
layout.display_order,
layout.layout_type,
layout.layout_config,
layout.zones_config,
2025-12-03 16:02:09 +09:00
newZoneId,
]
);
}
2025-12-03 16:02:09 +09:00
const action = isUpdate ? "업데이트" : "복사";
logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}`);
} catch (error: any) {
logger.error(
2025-12-03 16:02:09 +09:00
`❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`,
error
);
throw error;
}
}
2025-12-03 16:02:09 +09:00
// 통계 출력
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;
}
2025-12-03 16:02:09 +09:00
/**
*
*/
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;
}
2025-12-18 10:55:26 +09:00
/**
*
* - 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,
2025-12-18 10:55:26 +09:00
sourceCompanyCode: string,
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 {
2025-12-18 10:55:26 +09:00
// 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}`
);
}
}
2025-12-18 10:55:26 +09:00
// parent_obj_id 계산 (신규/재연결 모두 필요)
let newParentObjId: number | null;
if (!menu.parent_obj_id || menu.parent_obj_id === 0) {
newParentObjId = 0; // 최상위 메뉴는 항상 0
} else {
2025-12-18 10:55:26 +09:00
// 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} - 최상위로 생성됨`
);
}
}
}
2025-12-18 10:55:26 +09:00
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;
}
// === 신규 메뉴 복사 ===
const newObjId = await this.getNextMenuObjid(client);
// source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용)
const sourceMenuObjid = menu.objid;
const isRootMenu = String(menu.objid) === String(rootMenuObjid);
2025-12-18 10:55:26 +09:00
if (isRootMenu) {
logger.info(
2025-12-18 10:55:26 +09:00
` 📌 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}`);
}
2025-12-18 10:55:26 +09:00
/**
* +
*/
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;
for (const menuObjid of menuObjids) {
const newMenuObjid = menuIdMap.get(menuObjid);
if (!newMenuObjid) continue;
// 1. 코드 카테고리 조회
const categoriesResult = await client.query(
`SELECT * FROM code_category WHERE menu_objid = $1`,
[menuObjid]
);
for (const category of categoriesResult.rows) {
// 대상 회사에 같은 category_code가 이미 있는지 확인
const existingCategory = await client.query(
`SELECT category_code FROM code_category
WHERE category_code = $1 AND company_code = $2`,
[category.category_code, targetCompanyCode]
);
if (existingCategory.rows.length > 0) {
logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`);
continue;
}
// 카테고리 복사
await client.query(
`INSERT INTO code_category (
category_code, category_name, category_name_eng, description,
sort_order, is_active, created_date, created_by, company_code, menu_objid
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`,
[
category.category_code,
category.category_name,
category.category_name_eng,
category.description,
category.sort_order,
category.is_active,
userId,
targetCompanyCode,
newMenuObjid,
]
);
copiedCategories++;
logger.info(` ✅ 코드 카테고리 복사: ${category.category_code}`);
// 2. 해당 카테고리의 코드 조회 및 복사
const codesResult = await client.query(
`SELECT * FROM code_info
WHERE code_category = $1 AND menu_objid = $2`,
[category.category_code, menuObjid]
);
for (const code of codesResult.rows) {
// 대상 회사에 같은 code_value가 이미 있는지 확인
const existingCode = await client.query(
`SELECT code_value FROM code_info
WHERE code_category = $1 AND code_value = $2 AND company_code = $3`,
[category.category_code, code.code_value, targetCompanyCode]
);
if (existingCode.rows.length > 0) {
logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`);
continue;
}
await client.query(
`INSERT INTO code_info (
code_category, code_value, code_name, code_name_eng, description,
sort_order, is_active, created_date, created_by, company_code, menu_objid
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)`,
[
category.category_code,
code.code_value,
code.code_name,
code.code_name_eng,
code.description,
code.sort_order,
code.is_active,
userId,
targetCompanyCode,
newMenuObjid,
]
);
copiedCodes++;
}
logger.info(` ↳ 코드 ${codesResult.rows.length}개 복사 완료`);
}
}
logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}`);
return { copiedCategories, copiedCodes };
}
/**
* (ID )
* 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>();
for (const menuObjid of menuObjids) {
const newMenuObjid = menuIdMap.get(menuObjid);
if (!newMenuObjid) continue;
// 채번 규칙 조회
const rulesResult = await client.query(
`SELECT * FROM numbering_rules WHERE menu_objid = $1`,
[menuObjid]
);
for (const rule of rulesResult.rows) {
// 대상 회사에 같은 rule_id가 이미 있는지 확인
const existingRule = await client.query(
`SELECT rule_id FROM numbering_rules
WHERE rule_id = $1 AND company_code = $2`,
[rule.rule_id, targetCompanyCode]
);
if (existingRule.rows.length > 0) {
logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`);
// 기존 rule_id도 매핑에 추가 (동일한 ID 유지)
ruleIdMap.set(rule.rule_id, rule.rule_id);
continue;
}
// 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거)
const originalSuffix = rule.rule_id.includes('_')
? rule.rule_id.replace(/^[^_]*_/, '')
: rule.rule_id;
const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
// 매핑 저장 (원본 rule_id → 새 rule_id)
ruleIdMap.set(rule.rule_id, newRuleId);
// 채번 규칙 복사
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
created_at, created_by, menu_objid, scope_type, last_generated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12, $13)`,
[
newRuleId,
rule.rule_name,
rule.description,
rule.separator,
rule.reset_period,
0, // 시퀀스는 0부터 시작
rule.table_name,
rule.column_name,
targetCompanyCode,
userId,
newMenuObjid,
rule.scope_type,
null, // 마지막 생성일은 null로 초기화
]
);
copiedCount++;
logger.info(` ✅ 채번규칙 복사: ${rule.rule_id}${newRuleId}`);
// 채번 규칙 파트 복사
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
[rule.rule_id]
);
for (const part of partsResult.rows) {
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[
newRuleId,
part.part_order,
part.part_type,
part.generation_method,
part.auto_config,
part.manual_config,
targetCompanyCode,
]
);
}
logger.info(` ↳ 채번규칙 파트 ${partsResult.rows.length}개 복사`);
}
}
logger.info(`✅ 채번 규칙 복사 완료: ${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;
for (const menuObjid of menuObjids) {
const newMenuObjid = menuIdMap.get(menuObjid);
if (!newMenuObjid) continue;
// 1. 카테고리 컬럼 매핑 조회
const mappingsResult = await client.query(
`SELECT * FROM category_column_mapping WHERE menu_objid = $1`,
[menuObjid]
);
for (const mapping of mappingsResult.rows) {
// 대상 회사에 같은 매핑이 이미 있는지 확인
const existingMapping = await client.query(
`SELECT mapping_id FROM category_column_mapping
WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3`,
[mapping.table_name, mapping.logical_column_name, targetCompanyCode]
);
let newMappingId: number;
if (existingMapping.rows.length > 0) {
logger.info(` ♻️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.logical_column_name}`);
newMappingId = existingMapping.rows[0].mapping_id;
} else {
// 매핑 복사
const insertResult = await client.query(
`INSERT INTO category_column_mapping (
table_name, logical_column_name, physical_column_name,
menu_objid, company_code, description, created_at, created_by
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7)
RETURNING mapping_id`,
[
mapping.table_name,
mapping.logical_column_name,
mapping.physical_column_name,
newMenuObjid,
targetCompanyCode,
mapping.description,
userId,
]
);
newMappingId = insertResult.rows[0].mapping_id;
copiedCount++;
logger.info(` ✅ 카테고리 매핑 복사: ${mapping.table_name}.${mapping.logical_column_name}`);
}
// 2. 카테고리 값 조회 및 복사 (menu_objid 기준)
const valuesResult = await client.query(
`SELECT * FROM table_column_category_values
WHERE table_name = $1 AND column_name = $2 AND menu_objid = $3
ORDER BY parent_value_id NULLS FIRST, value_order`,
[mapping.table_name, mapping.logical_column_name, menuObjid]
);
// 값 ID 매핑 (부모-자식 관계 유지를 위해)
const valueIdMap = new Map<number, number>();
for (const value of valuesResult.rows) {
// 대상 회사에 같은 값이 이미 있는지 확인
const existingValue = await client.query(
`SELECT value_id FROM table_column_category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`,
[value.table_name, value.column_name, value.value_code, targetCompanyCode]
);
if (existingValue.rows.length > 0) {
valueIdMap.set(value.value_id, existingValue.rows[0].value_id);
continue;
}
// 부모 ID 재매핑
const newParentId = value.parent_value_id
? valueIdMap.get(value.parent_value_id) || null
: null;
const insertResult = await client.query(
`INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, created_at, created_by, menu_objid
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15)
RETURNING value_id`,
[
value.table_name,
value.column_name,
value.value_code,
value.value_label,
value.value_order,
newParentId,
value.depth,
value.description,
value.color,
value.icon,
value.is_active,
value.is_default,
targetCompanyCode,
userId,
newMenuObjid,
]
);
valueIdMap.set(value.value_id, insertResult.rows[0].value_id);
}
if (valuesResult.rows.length > 0) {
logger.info(` ↳ 카테고리 값 ${valuesResult.rows.length}개 처리`);
}
}
}
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(`📋 테이블 타입 설정 복사 시작`);
logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`);
// 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(", ")}`);
let copiedCount = 0;
for (const tableName of tableNames) {
// 2. 원본 회사의 테이블 타입 설정 조회
const sourceSettings = await client.query(
`SELECT * FROM table_type_columns
WHERE table_name = $1 AND company_code = $2`,
[tableName, sourceCompanyCode]
);
if (sourceSettings.rows.length === 0) {
logger.info(` ⚠️ ${tableName}: 원본 회사 설정 없음 (기본 설정 사용)`);
continue;
}
for (const setting of sourceSettings.rows) {
// 3. 대상 회사에 같은 설정이 이미 있는지 확인
const existing = await client.query(
`SELECT id FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = $3`,
[setting.table_name, setting.column_name, targetCompanyCode]
);
if (existing.rows.length > 0) {
// 이미 존재하면 스킵 (대상 회사에서 커스터마이징한 설정 유지)
logger.info(
`${setting.table_name}.${setting.column_name}: 이미 존재 (스킵)`
);
continue;
}
// 새로 삽입
await client.query(
`INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date, company_code
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`,
[
setting.table_name,
setting.column_name,
setting.input_type,
setting.detail_settings,
setting.is_nullable,
setting.display_order,
targetCompanyCode,
]
);
logger.info(
`${setting.table_name}.${setting.column_name}: 신규 추가`
);
copiedCount++;
}
}
logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}`);
return copiedCount;
}
}