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

2223 lines
73 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

import { PoolClient } from "pg";
import { query, pool } from "../database/db";
import logger from "../utils/logger";
/**
* 메뉴 복사 결과
*/
export interface MenuCopyResult {
success: boolean;
copiedMenus: number;
copiedScreens: number;
copiedFlows: number;
copiedCodeCategories: number;
copiedCodes: number;
copiedNumberingRules: number;
copiedCategoryMappings: number;
copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정
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; // 테이블 타입관리 입력타입 설정
}
/**
* 메뉴 정보
*/
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) 메뉴에 직접 할당된 화면
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;
}
/**
* 플로우 수집
* - 화면 레이아웃에서 참조된 모든 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 }> = [];
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;
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}`
);
}
}
// 재귀 호출
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. 메뉴 삭제 (역순: 하위 메뉴부터)
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
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;
},
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
);
// === 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,
screenNameConfig,
numberingRuleIdMap
);
// === 4단계: 메뉴 복사 ===
logger.info("\n📂 [4단계] 메뉴 복사");
const menuIdMap = await this.copyMenus(
menus,
sourceMenuObjid, // 원본 최상위 메뉴 ID 전달
sourceCompanyCode,
targetCompanyCode,
screenIdMap,
userId,
client
);
// === 5단계: 화면-메뉴 할당 ===
logger.info("\n🔗 [5단계] 화면-메뉴 할당");
await this.createScreenMenuAssignments(
menus,
menuIdMap,
screenIdMap,
targetCompanyCode,
client
);
// === 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,
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}
- 코드 카테고리: ${copiedCodeCategories}
- 코드: ${copiedCodes}
- 채번규칙: ${copiedNumberingRules}
- 카테고리 매핑: ${copiedCategoryMappings}
- 테이블 타입 설정: ${copiedTableTypeColumns}
============================================
`);
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;
}
logger.info(`🔄 플로우 복사 중: ${flowIds.size}`);
logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`);
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];
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; // 스텝/연결 복사 생략 (기존 것 사용)
}
// 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,
]
);
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;
}
/**
* 화면 복사 (업데이트 또는 신규 생성)
* - 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}`);
// === 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 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) 기존 복사본 찾기: source_screen_id로 검색
let existingCopyResult = await client.query<{
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`,
[originalScreenId, targetCompanyCode]
);
// 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}`
);
}
}
// 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 (existingCopyResult.rows.length > 0) {
// === 기존 복사본이 있는 경우: 업데이트 ===
const existingScreen = existingCopyResult.rows[0];
const existingScreenId = existingScreen.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>();
for (const layout of layoutsResult.rows) {
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
componentIdMap.set(layout.component_id, newComponentId);
}
// 레이아웃 삽입
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
);
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)`,
[
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,
]
);
}
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
): 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;
}
// === 신규 메뉴 복사 ===
const newObjId = 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(`🔗 화면-메뉴 할당 중...`);
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}`);
}
/**
* 코드 카테고리 + 코드 복사
*/
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;
}
}