Files
vexplor/backend-node/src/services/menuCopyService.ts
kjs 14802f507f feat: 카테고리 설정 및 채번 규칙 복사 기능 추가
새로운 기능:
1. 카테고리 컬럼 매핑(category_column_mapping) 복사
2. 테이블 컬럼 카테고리 값(table_column_category_values) 복사
3. 채번 규칙(numbering_rules) 복사
4. 채번 규칙 파트(numbering_rule_parts) 복사

중복 처리:
- 모든 항목: 스킵(Skip) 정책 적용
- 이미 존재하는 데이터는 덮어쓰지 않고 건너뜀
- 카테고리 값: 부모-자식 관계 유지를 위해 기존 ID 매핑 저장

채번 규칙 특징:
- 구조(파트)는 그대로 복사
- 순번(current_sequence)은 1부터 초기화
- rule_id는 타임스탬프 기반으로 새로 생성 (항상 고유)

복사 프로세스:
- [7단계] 카테고리 설정 복사
- [8단계] 채번 규칙 복사

결과 로그:
- 컬럼 매핑, 카테고리 값, 규칙, 파트 개수 표시
- 스킵된 항목 개수도 함께 표시

이제 메뉴 복사 시 카테고리와 채번 규칙도 함께 복사되어
복사한 회사에서 바로 업무를 시작할 수 있습니다.

관련 파일:
- backend-node/src/services/menuCopyService.ts
2025-11-21 15:27:54 +09:00

1774 lines
51 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;
copiedCategories: number;
copiedCodes: number;
copiedCategorySettings: number;
copiedNumberingRules: number;
menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>;
warnings: string[];
}
/**
* 메뉴 정보
*/
interface Menu {
objid: number;
menu_type: number | null;
parent_obj_id: number | null;
menu_name_kor: string | null;
menu_name_eng: string | null;
seq: number | null;
menu_url: string | null;
menu_desc: string | null;
writer: string | null;
regdate: Date | null;
status: string | null;
system_name: string | null;
company_code: string | null;
lang_key: string | null;
lang_key_desc: string | null;
screen_code: string | null;
menu_code: string | null;
}
/**
* 화면 정의
*/
interface ScreenDefinition {
screen_id: number;
screen_name: string;
screen_code: string;
table_name: string;
company_code: string;
description: string | null;
is_active: string;
layout_metadata: any;
db_source_type: string | null;
db_connection_id: number | null;
}
/**
* 화면 레이아웃
*/
interface ScreenLayout {
layout_id: number;
screen_id: number;
component_type: string;
component_id: string;
parent_id: string | null;
position_x: number;
position_y: number;
width: number;
height: number;
properties: any;
display_order: number;
layout_type: string | null;
layout_config: any;
zones_config: any;
zone_id: string | null;
}
/**
* 플로우 정의
*/
interface FlowDefinition {
id: number;
name: string;
description: string | null;
table_name: string;
is_active: boolean;
company_code: string;
db_source_type: string | null;
db_connection_id: number | null;
}
/**
* 플로우 스텝
*/
interface FlowStep {
id: number;
flow_definition_id: number;
step_name: string;
step_order: number;
condition_json: any;
color: string | null;
position_x: number | null;
position_y: number | null;
table_name: string | null;
move_type: string | null;
status_column: string | null;
status_value: string | null;
target_table: string | null;
field_mappings: any;
required_fields: any;
integration_type: string | null;
integration_config: any;
display_config: any;
}
/**
* 플로우 스텝 연결
*/
interface FlowStepConnection {
id: number;
flow_definition_id: number;
from_step_id: number;
to_step_id: number;
label: string | null;
}
/**
* 코드 카테고리
*/
interface CodeCategory {
category_code: string;
category_name: string;
category_name_eng: string | null;
description: string | null;
sort_order: number | null;
is_active: string;
company_code: string;
menu_objid: number;
}
/**
* 코드 정보
*/
interface CodeInfo {
code_category: string;
code_value: string;
code_name: string;
code_name_eng: string | null;
description: string | null;
sort_order: number | null;
is_active: string;
company_code: string;
menu_objid: number;
}
/**
* 메뉴 복사 서비스
*/
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);
}
}
}
}
}
return referenced;
}
/**
* 화면 수집 (중복 제거, 재귀적 참조 추적)
*/
private async collectScreens(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<Set<number>> {
logger.info(
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
);
const screenIds = new Set<number>();
const visited = new Set<number>();
// 1) 메뉴에 직접 할당된 화면
for (const menuObjid of menuObjids) {
const assignmentsResult = await client.query<{ screen_id: number }>(
`SELECT DISTINCT screen_id
FROM screen_menu_assignments
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
for (const assignment of assignmentsResult.rows) {
screenIds.add(assignment.screen_id);
}
}
logger.info(`📌 직접 할당 화면: ${screenIds.size}`);
// 2) 화면 내부에서 참조되는 화면 (재귀)
const queue = Array.from(screenIds);
while (queue.length > 0) {
const screenId = queue.shift()!;
if (visited.has(screenId)) continue;
visited.add(screenId);
// 화면 레이아웃 조회
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
[screenId]
);
// 참조 화면 추출
const referencedScreens = this.extractReferencedScreens(
layoutsResult.rows
);
if (referencedScreens.length > 0) {
logger.info(
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
);
}
for (const refId of referencedScreens) {
if (!screenIds.has(refId)) {
screenIds.add(refId);
queue.push(refId);
}
}
}
logger.info(`✅ 화면 수집 완료: ${screenIds.size}개 (참조 포함)`);
return screenIds;
}
/**
* 플로우 수집
*/
private async collectFlows(
screenIds: Set<number>,
client: PoolClient
): Promise<Set<number>> {
logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`);
const flowIds = new Set<number>();
for (const screenId of screenIds) {
const layoutsResult = await client.query<ScreenLayout>(
`SELECT properties FROM screen_layouts WHERE screen_id = $1`,
[screenId]
);
for (const layout of layoutsResult.rows) {
const props = layout.properties;
// webTypeConfig.dataflowConfig.flowConfig.flowId
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
if (flowId) {
flowIds.add(flowId);
}
}
}
logger.info(`✅ 플로우 수집 완료: ${flowIds.size}`);
return flowIds;
}
/**
* 코드 수집
*/
private async collectCodes(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> {
logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`);
const categories: CodeCategory[] = [];
const codes: CodeInfo[] = [];
for (const menuObjid of menuObjids) {
// 코드 카테고리
const catsResult = await client.query<CodeCategory>(
`SELECT * FROM code_category
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
categories.push(...catsResult.rows);
// 각 카테고리의 코드 정보
for (const cat of catsResult.rows) {
const codesResult = await client.query<CodeInfo>(
`SELECT * FROM code_info
WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`,
[cat.category_code, menuObjid, sourceCompanyCode]
);
codes.push(...codesResult.rows);
}
}
logger.info(
`✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}`
);
return { categories, codes };
}
/**
* 카테고리 설정 수집
*/
private async collectCategorySettings(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{
columnMappings: any[];
categoryValues: any[];
}> {
logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`);
const columnMappings: any[] = [];
const categoryValues: any[] = [];
for (const menuObjid of menuObjids) {
// 카테고리 컬럼 매핑
const mappingsResult = await client.query(
`SELECT * FROM category_column_mapping
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
columnMappings.push(...mappingsResult.rows);
// 테이블 컬럼 카테고리 값
const valuesResult = await client.query(
`SELECT * FROM table_column_category_values
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
categoryValues.push(...valuesResult.rows);
}
logger.info(
`✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개, 카테고리 값 ${categoryValues.length}`
);
return { columnMappings, categoryValues };
}
/**
* 채번 규칙 수집
*/
private async collectNumberingRules(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{
rules: any[];
parts: any[];
}> {
logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`);
const rules: any[] = [];
const parts: any[] = [];
for (const menuObjid of menuObjids) {
// 채번 규칙
const rulesResult = await client.query(
`SELECT * FROM numbering_rules
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
rules.push(...rulesResult.rows);
// 각 규칙의 파트
for (const rule of rulesResult.rows) {
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2`,
[rule.rule_id, sourceCompanyCode]
);
parts.push(...partsResult.rows);
}
}
logger.info(
`✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}`
);
return { rules, parts };
}
/**
* 다음 메뉴 objid 생성
*/
private async getNextMenuObjid(client: PoolClient): Promise<number> {
const result = await client.query<{ max_objid: string }>(
`SELECT COALESCE(MAX(objid), 0)::text as max_objid FROM menu_info`
);
return parseInt(result.rows[0].max_objid, 10) + 1;
}
/**
* 고유 화면 코드 생성
*/
private async generateUniqueScreenCode(
targetCompanyCode: string,
client: PoolClient
): Promise<string> {
// {company_code}_{순번} 형식
const prefix = targetCompanyCode === "*" ? "*" : targetCompanyCode;
const result = await client.query<{ max_num: string }>(
`SELECT COALESCE(
MAX(
CASE
WHEN screen_code ~ '^${prefix}_[0-9]+$'
THEN CAST(SUBSTRING(screen_code FROM '${prefix}_([0-9]+)') AS INTEGER)
ELSE 0
END
), 0
)::text as max_num
FROM screen_definitions
WHERE company_code = $1`,
[targetCompanyCode]
);
const maxNum = parseInt(result.rows[0].max_num, 10);
const newNum = maxNum + 1;
return `${prefix}_${String(newNum).padStart(3, "0")}`;
}
/**
* properties 내부 참조 업데이트
*/
/**
* properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트
*/
private updateReferencesInProperties(
properties: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>
): any {
if (!properties) return properties;
// 깊은 복사
const updated = JSON.parse(JSON.stringify(properties));
// 재귀적으로 객체/배열 탐색
this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap);
return updated;
}
/**
* 재귀적으로 모든 ID 참조 업데이트
*/
private recursiveUpdateReferences(
obj: any,
screenIdMap: Map<number, number>,
flowIdMap: Map<number, number>,
path: string = ""
): void {
if (!obj || typeof obj !== "object") return;
// 배열인 경우
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
this.recursiveUpdateReferences(
item,
screenIdMap,
flowIdMap,
`${path}[${index}]`
);
});
return;
}
// 객체인 경우 - 키별로 처리
for (const key of Object.keys(obj)) {
const value = obj[key];
const currentPath = path ? `${path}.${key}` : key;
// screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열)
if (
key === "screen_id" ||
key === "screenId" ||
key === "targetScreenId"
) {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue)) {
const newId = screenIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
logger.info(
` 🔗 화면 참조 업데이트 (${currentPath}): ${value}${newId}`
);
}
}
}
// flowId 매핑 (숫자 또는 숫자 문자열)
if (key === "flowId") {
const numValue = typeof value === "number" ? value : parseInt(value);
if (!isNaN(numValue)) {
const newId = flowIdMap.get(numValue);
if (newId) {
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
logger.debug(
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value}${newId}`
);
}
}
}
// 재귀 호출
if (typeof value === "object" && value !== null) {
this.recursiveUpdateReferences(
value,
screenIdMap,
flowIdMap,
currentPath
);
}
}
}
/**
* 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리)
*
* 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제
*/
private async deleteExistingCopy(
sourceMenuObjid: number,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
logger.info("\n🗑 [0단계] 기존 복사본 확인 및 삭제");
// 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인
const sourceMenuResult = await client.query<Menu>(
`SELECT menu_name_kor, menu_name_eng
FROM menu_info
WHERE objid = $1`,
[sourceMenuObjid]
);
if (sourceMenuResult.rows.length === 0) {
logger.warn("⚠️ 원본 메뉴를 찾을 수 없습니다");
return;
}
const sourceMenu = sourceMenuResult.rows[0];
// 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭)
const existingMenuResult = await client.query<{ objid: number }>(
`SELECT objid
FROM menu_info
WHERE source_menu_objid = $1
AND company_code = $2
AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`,
[sourceMenuObjid, targetCompanyCode]
);
if (existingMenuResult.rows.length === 0) {
logger.info("✅ 기존 복사본 없음 - 새로 생성됩니다");
return;
}
const existingMenuObjid = existingMenuResult.rows[0].objid;
logger.info(
`🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})`
);
// 3. 기존 메뉴 트리 수집
const existingMenus = await this.collectMenuTree(existingMenuObjid, client);
const existingMenuIds = existingMenus.map((m) => m.objid);
logger.info(`📊 삭제 대상: 메뉴 ${existingMenus.length}`);
// 4. 관련 화면 ID 수집
const existingScreenIds = await client.query<{ screen_id: number }>(
`SELECT DISTINCT screen_id
FROM screen_menu_assignments
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
const screenIds = existingScreenIds.rows.map((r) => r.screen_id);
// 5. 삭제 순서 (외래키 제약 고려)
// 5-1. 화면 레이아웃 삭제
if (screenIds.length > 0) {
await client.query(
`DELETE FROM screen_layouts WHERE screen_id = ANY($1)`,
[screenIds]
);
logger.info(` ✅ 화면 레이아웃 삭제 완료`);
}
// 5-2. 화면-메뉴 할당 삭제
await client.query(
`DELETE FROM screen_menu_assignments
WHERE menu_objid = ANY($1) AND company_code = $2`,
[existingMenuIds, targetCompanyCode]
);
logger.info(` ✅ 화면-메뉴 할당 삭제 완료`);
// 5-3. 화면 정의 삭제
if (screenIds.length > 0) {
await client.query(
`DELETE FROM screen_definitions
WHERE screen_id = ANY($1) AND company_code = $2`,
[screenIds, targetCompanyCode]
);
logger.info(` ✅ 화면 정의 삭제 완료`);
}
// 5-4. 메뉴 권한 삭제
await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [
existingMenuIds,
]);
logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-5. 메뉴 삭제 (역순: 하위 메뉴부터)
for (let i = existingMenus.length - 1; i >= 0; i--) {
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
existingMenus[i].objid,
]);
}
logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}`);
logger.info("✅ 기존 복사본 삭제 완료 - 덮어쓰기 준비됨");
}
/**
* 메뉴 복사 (메인 함수)
*/
async copyMenu(
sourceMenuObjid: number,
targetCompanyCode: string,
userId: string
): 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);
const codes = await this.collectCodes(
menus.map((m) => m.objid),
sourceCompanyCode,
client
);
const categorySettings = await this.collectCategorySettings(
menus.map((m) => m.objid),
sourceCompanyCode,
client
);
const numberingRules = await this.collectNumberingRules(
menus.map((m) => m.objid),
sourceCompanyCode,
client
);
logger.info(`
📊 수집 완료:
- 메뉴: ${menus.length}
- 화면: ${screenIds.size}
- 플로우: ${flowIds.size}
- 코드 카테고리: ${codes.categories.length}
- 코드: ${codes.codes.length}
- 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}
- 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}
`);
// === 2단계: 플로우 복사 ===
logger.info("\n🔄 [2단계] 플로우 복사");
const flowIdMap = await this.copyFlows(
flowIds,
targetCompanyCode,
userId,
client
);
// === 3단계: 화면 복사 ===
logger.info("\n📄 [3단계] 화면 복사");
const screenIdMap = await this.copyScreens(
screenIds,
targetCompanyCode,
flowIdMap,
userId,
client
);
// === 4단계: 메뉴 복사 ===
logger.info("\n📂 [4단계] 메뉴 복사");
const menuIdMap = await this.copyMenus(
menus,
sourceMenuObjid, // 원본 최상위 메뉴 ID 전달
targetCompanyCode,
screenIdMap,
userId,
client
);
// === 5단계: 화면-메뉴 할당 ===
logger.info("\n🔗 [5단계] 화면-메뉴 할당");
await this.createScreenMenuAssignments(
menus,
menuIdMap,
screenIdMap,
targetCompanyCode,
client
);
// === 6단계: 코드 복사 ===
logger.info("\n📋 [6단계] 코드 복사");
await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client);
// === 7단계: 카테고리 설정 복사 ===
logger.info("\n📂 [7단계] 카테고리 설정 복사");
await this.copyCategorySettings(
categorySettings,
menuIdMap,
targetCompanyCode,
userId,
client
);
// === 8단계: 채번 규칙 복사 ===
logger.info("\n📋 [8단계] 채번 규칙 복사");
await this.copyNumberingRules(
numberingRules,
menuIdMap,
targetCompanyCode,
userId,
client
);
// 커밋
await client.query("COMMIT");
logger.info("✅ 트랜잭션 커밋 완료");
const result: MenuCopyResult = {
success: true,
copiedMenus: menuIdMap.size,
copiedScreens: screenIdMap.size,
copiedFlows: flowIdMap.size,
copiedCategories: codes.categories.length,
copiedCodes: codes.codes.length,
copiedCategorySettings:
categorySettings.columnMappings.length +
categorySettings.categoryValues.length,
copiedNumberingRules:
numberingRules.rules.length + numberingRules.parts.length,
menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap),
warnings,
};
logger.info(`
🎉 ============================================
메뉴 복사 완료!
- 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows}
- 코드 카테고리: ${result.copiedCategories}
- 코드: ${result.copiedCodes}
- 카테고리 설정: ${result.copiedCategorySettings}
- 채번 규칙: ${result.copiedNumberingRules}
============================================
`);
return result;
} catch (error: any) {
// 롤백
await client.query("ROLLBACK");
logger.error("❌ 메뉴 복사 실패, 롤백됨:", error);
throw error;
} finally {
client.release();
}
}
/**
* 플로우 복사
*/
private async copyFlows(
flowIds: Set<number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<Map<number, number>> {
const flowIdMap = new Map<number, number>();
if (flowIds.size === 0) {
logger.info("📭 복사할 플로우 없음");
return flowIdMap;
}
logger.info(`🔄 플로우 복사 중: ${flowIds.size}`);
for (const originalFlowId of flowIds) {
try {
// 1) flow_definition 조회
const flowDefResult = await client.query<FlowDefinition>(
`SELECT * FROM flow_definition WHERE id = $1`,
[originalFlowId]
);
if (flowDefResult.rows.length === 0) {
logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`);
continue;
}
const flowDef = flowDefResult.rows[0];
// 2) flow_definition 복사
const newFlowResult = await client.query<{ id: number }>(
`INSERT INTO flow_definition (
name, description, table_name, is_active,
company_code, created_by, db_source_type, db_connection_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
flowDef.name,
flowDef.description,
flowDef.table_name,
flowDef.is_active,
targetCompanyCode, // 새 회사 코드
userId,
flowDef.db_source_type,
flowDef.db_connection_id,
]
);
const newFlowId = newFlowResult.rows[0].id;
flowIdMap.set(originalFlowId, newFlowId);
logger.info(
` ✅ 플로우 복사: ${originalFlowId}${newFlowId} (${flowDef.name})`
);
// 3) flow_step 복사
const stepsResult = await client.query<FlowStep>(
`SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`,
[originalFlowId]
);
const stepIdMap = new Map<number, number>();
for (const step of stepsResult.rows) {
const newStepResult = await client.query<{ id: number }>(
`INSERT INTO flow_step (
flow_definition_id, step_name, step_order, condition_json,
color, position_x, position_y, table_name, move_type,
status_column, status_value, target_table, field_mappings,
required_fields, integration_type, integration_config, display_config
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id`,
[
newFlowId, // 새 플로우 ID
step.step_name,
step.step_order,
step.condition_json,
step.color,
step.position_x,
step.position_y,
step.table_name,
step.move_type,
step.status_column,
step.status_value,
step.target_table,
step.field_mappings,
step.required_fields,
step.integration_type,
step.integration_config,
step.display_config,
]
);
const newStepId = newStepResult.rows[0].id;
stepIdMap.set(step.id, newStepId);
}
logger.info(` ↳ 스텝 복사: ${stepIdMap.size}`);
// 4) flow_step_connection 복사 (스텝 ID 재매핑)
const connectionsResult = await client.query<FlowStepConnection>(
`SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`,
[originalFlowId]
);
for (const conn of connectionsResult.rows) {
const newFromStepId = stepIdMap.get(conn.from_step_id);
const newToStepId = stepIdMap.get(conn.to_step_id);
if (!newFromStepId || !newToStepId) {
logger.warn(
`⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id}${conn.to_step_id}`
);
continue;
}
await client.query(
`INSERT INTO flow_step_connection (
flow_definition_id, from_step_id, to_step_id, label
) VALUES ($1, $2, $3, $4)`,
[newFlowId, newFromStepId, newToStepId, conn.label]
);
}
logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}`);
} catch (error: any) {
logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error);
throw error;
}
}
logger.info(`✅ 플로우 복사 완료: ${flowIdMap.size}`);
return flowIdMap;
}
/**
* 화면 복사
*/
private async copyScreens(
screenIds: Set<number>,
targetCompanyCode: string,
flowIdMap: Map<number, number>,
userId: string,
client: PoolClient
): Promise<Map<number, number>> {
const screenIdMap = new Map<number, number>();
if (screenIds.size === 0) {
logger.info("📭 복사할 화면 없음");
return screenIdMap;
}
logger.info(`📄 화면 복사 중: ${screenIds.size}`);
// === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) ===
const screenDefsToProcess: Array<{
originalScreenId: number;
newScreenId: number;
screenDef: ScreenDefinition;
}> = [];
for (const originalScreenId of screenIds) {
try {
// 1) screen_definitions 조회
const screenDefResult = await client.query<ScreenDefinition>(
`SELECT * FROM screen_definitions WHERE screen_id = $1`,
[originalScreenId]
);
if (screenDefResult.rows.length === 0) {
logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`);
continue;
}
const screenDef = screenDefResult.rows[0];
// 2) 새 screen_code 생성
const newScreenCode = await this.generateUniqueScreenCode(
targetCompanyCode,
client
);
// 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
const newScreenResult = await client.query<{ screen_id: number }>(
`INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code,
description, is_active, layout_metadata,
db_source_type, db_connection_id, created_by,
deleted_date, deleted_by, delete_reason
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING screen_id`,
[
screenDef.screen_name,
newScreenCode, // 새 화면 코드
screenDef.table_name,
targetCompanyCode, // 새 회사 코드
screenDef.description,
screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화
screenDef.layout_metadata,
screenDef.db_source_type,
screenDef.db_connection_id,
userId,
null, // deleted_date: NULL (새 화면은 삭제되지 않음)
null, // deleted_by: NULL
null, // delete_reason: NULL
]
);
const newScreenId = newScreenResult.rows[0].screen_id;
screenIdMap.set(originalScreenId, newScreenId);
logger.info(
` ✅ 화면 정의 복사: ${originalScreenId}${newScreenId} (${screenDef.screen_name})`
);
// 저장해서 2단계에서 처리
screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef });
} catch (error: any) {
logger.error(
`❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`,
error
);
throw error;
}
}
// === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) ===
logger.info(
`\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)`
);
for (const {
originalScreenId,
newScreenId,
screenDef,
} of screenDefsToProcess) {
try {
// screen_layouts 복사
const layoutsResult = await client.query<ScreenLayout>(
`SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`,
[originalScreenId]
);
// 1단계: component_id 매핑 생성 (원본 → 새 ID)
const componentIdMap = new Map<string, string>();
for (const layout of layoutsResult.rows) {
const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
componentIdMap.set(layout.component_id, newComponentId);
}
// 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑)
for (const layout of layoutsResult.rows) {
const newComponentId = componentIdMap.get(layout.component_id)!;
// parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우)
const newParentId = layout.parent_id
? componentIdMap.get(layout.parent_id) || layout.parent_id
: null;
const newZoneId = layout.zone_id
? componentIdMap.get(layout.zone_id) || layout.zone_id
: null;
// properties 내부 참조 업데이트
const updatedProperties = this.updateReferencesInProperties(
layout.properties,
screenIdMap,
flowIdMap
);
await client.query(
`INSERT INTO screen_layouts (
screen_id, component_type, component_id, parent_id,
position_x, position_y, width, height, properties,
display_order, layout_type, layout_config, zones_config, zone_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
newScreenId, // 새 화면 ID
layout.component_type,
newComponentId, // 새 컴포넌트 ID
newParentId, // 매핑된 parent_id
layout.position_x,
layout.position_y,
layout.width,
layout.height,
updatedProperties, // 업데이트된 속성
layout.display_order,
layout.layout_type,
layout.layout_config,
layout.zones_config,
newZoneId, // 매핑된 zone_id
]
);
}
logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}`);
} catch (error: any) {
logger.error(
`❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`,
error
);
throw error;
}
}
logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}`);
return screenIdMap;
}
/**
* 메뉴 위상 정렬 (부모 먼저)
*/
private topologicalSortMenus(menus: Menu[]): Menu[] {
const result: Menu[] = [];
const visited = new Set<number>();
const menuMap = new Map<number, Menu>();
for (const menu of menus) {
menuMap.set(menu.objid, menu);
}
const visit = (menu: Menu) => {
if (visited.has(menu.objid)) return;
// 부모 먼저 방문
if (menu.parent_obj_id) {
const parent = menuMap.get(menu.parent_obj_id);
if (parent) {
visit(parent);
}
}
visited.add(menu.objid);
result.push(menu);
};
for (const menu of menus) {
visit(menu);
}
return result;
}
/**
* screen_code 재매핑
*/
private getNewScreenCode(
screenIdMap: Map<number, number>,
screenCode: string | null,
client: PoolClient
): string | null {
if (!screenCode) return null;
// screen_code로 screen_id 조회 (원본 회사)
// 간단하게 처리: 새 화면 코드는 이미 생성됨
return screenCode;
}
/**
* 메뉴 복사
*/
private async copyMenus(
menus: Menu[],
rootMenuObjid: number,
targetCompanyCode: string,
screenIdMap: Map<number, number>,
userId: string,
client: PoolClient
): Promise<Map<number, number>> {
const menuIdMap = new Map<number, number>();
if (menus.length === 0) {
logger.info("📭 복사할 메뉴 없음");
return menuIdMap;
}
logger.info(`📂 메뉴 복사 중: ${menus.length}`);
// 위상 정렬 (부모 먼저 삽입)
const sortedMenus = this.topologicalSortMenus(menus);
for (const menu of sortedMenus) {
try {
// 새 objid 생성
const newObjId = await this.getNextMenuObjid(client);
// parent_obj_id 재매핑
// NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일
let newParentObjId: number | null;
if (!menu.parent_obj_id || menu.parent_obj_id === 0) {
newParentObjId = 0; // 최상위 메뉴는 항상 0
} else {
newParentObjId =
menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id;
}
// source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용)
// BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교
const isRootMenu = String(menu.objid) === String(rootMenuObjid);
const sourceMenuObjid = isRootMenu ? menu.objid : null;
if (sourceMenuObjid) {
logger.info(
` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)`
);
}
// screen_code는 그대로 유지 (화면-메뉴 할당에서 처리)
await client.query(
`INSERT INTO menu_info (
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, status, system_name,
company_code, lang_key, lang_key_desc, screen_code, menu_code,
source_menu_objid
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
newObjId,
menu.menu_type,
newParentObjId, // 재매핑
menu.menu_name_kor,
menu.menu_name_eng,
menu.seq,
menu.menu_url,
menu.menu_desc,
userId,
menu.status,
menu.system_name,
targetCompanyCode, // 새 회사 코드
menu.lang_key,
menu.lang_key_desc,
menu.screen_code, // 그대로 유지
menu.menu_code,
sourceMenuObjid, // 원본 메뉴 ID (최상위만)
]
);
menuIdMap.set(menu.objid, newObjId);
logger.info(
` ✅ 메뉴 복사: ${menu.objid}${newObjId} (${menu.menu_name_kor})`
);
} catch (error: any) {
logger.error(`❌ 메뉴 복사 실패: objid=${menu.objid}`, error);
throw error;
}
}
logger.info(`✅ 메뉴 복사 완료: ${menuIdMap.size}`);
return menuIdMap;
}
/**
* 화면-메뉴 할당
*/
private async createScreenMenuAssignments(
menus: Menu[],
menuIdMap: Map<number, number>,
screenIdMap: Map<number, number>,
targetCompanyCode: string,
client: PoolClient
): Promise<void> {
logger.info(`🔗 화면-메뉴 할당 중...`);
let assignmentCount = 0;
for (const menu of menus) {
const newMenuObjid = menuIdMap.get(menu.objid);
if (!newMenuObjid) continue;
// 원본 메뉴에 할당된 화면 조회
const assignmentsResult = await client.query<{
screen_id: number;
display_order: number;
is_active: string;
}>(
`SELECT screen_id, display_order, is_active
FROM screen_menu_assignments
WHERE menu_objid = $1 AND company_code = $2`,
[menu.objid, menu.company_code]
);
for (const assignment of assignmentsResult.rows) {
const newScreenId = screenIdMap.get(assignment.screen_id);
if (!newScreenId) {
logger.warn(
`⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}`
);
continue;
}
// 새 할당 생성
await client.query(
`INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6)`,
[
newScreenId, // 재매핑
newMenuObjid, // 재매핑
targetCompanyCode,
assignment.display_order,
assignment.is_active,
"system",
]
);
assignmentCount++;
}
}
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`);
}
/**
* 코드 카테고리 중복 체크
*/
private async checkCodeCategoryExists(
categoryCode: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM code_category
WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3
) as exists`,
[categoryCode, companyCode, menuObjid]
);
return result.rows[0].exists;
}
/**
* 코드 정보 중복 체크
*/
private async checkCodeInfoExists(
categoryCode: string,
codeValue: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM code_info
WHERE code_category = $1 AND code_value = $2
AND company_code = $3 AND menu_objid = $4
) as exists`,
[categoryCode, codeValue, companyCode, menuObjid]
);
return result.rows[0].exists;
}
/**
* 코드 복사
*/
private async copyCodes(
codes: { categories: CodeCategory[]; codes: CodeInfo[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 코드 복사 중...`);
let categoryCount = 0;
let codeCount = 0;
let skippedCategories = 0;
let skippedCodes = 0;
// 1) 코드 카테고리 복사 (중복 체크)
for (const category of codes.categories) {
const newMenuObjid = menuIdMap.get(category.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const exists = await this.checkCodeCategoryExists(
category.category_code,
targetCompanyCode,
newMenuObjid,
client
);
if (exists) {
skippedCategories++;
logger.debug(
` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})`
);
continue;
}
// 카테고리 복사
await client.query(
`INSERT INTO code_category (
category_code, category_name, category_name_eng, description,
sort_order, is_active, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
category.category_code,
category.category_name,
category.category_name_eng,
category.description,
category.sort_order,
category.is_active,
targetCompanyCode, // 새 회사 코드
newMenuObjid, // 재매핑
userId,
]
);
categoryCount++;
}
// 2) 코드 정보 복사 (중복 체크)
for (const code of codes.codes) {
const newMenuObjid = menuIdMap.get(code.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const exists = await this.checkCodeInfoExists(
code.code_category,
code.code_value,
targetCompanyCode,
newMenuObjid,
client
);
if (exists) {
skippedCodes++;
logger.debug(
` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})`
);
continue;
}
// 코드 복사
await client.query(
`INSERT INTO code_info (
code_category, code_value, code_name, code_name_eng, description,
sort_order, is_active, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
code.code_category,
code.code_value,
code.code_name,
code.code_name_eng,
code.description,
code.sort_order,
code.is_active,
targetCompanyCode, // 새 회사 코드
newMenuObjid, // 재매핑
userId,
]
);
codeCount++;
}
logger.info(
`✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)`
);
}
/**
* 카테고리 설정 복사
*/
private async copyCategorySettings(
settings: { columnMappings: any[]; categoryValues: any[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📂 카테고리 설정 복사 중...`);
const valueIdMap = new Map<number, number>(); // 원본 value_id → 새 value_id
let mappingCount = 0;
let valueCount = 0;
// 1) 카테고리 컬럼 매핑 복사
for (const mapping of settings.columnMappings) {
const newMenuObjid = menuIdMap.get(mapping.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const existsResult = await client.query(
`SELECT mapping_id FROM category_column_mapping
WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`,
[mapping.table_name, mapping.physical_column_name, targetCompanyCode]
);
if (existsResult.rows.length > 0) {
logger.debug(
` ⏭️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.physical_column_name}`
);
continue;
}
await client.query(
`INSERT INTO category_column_mapping (
table_name, logical_column_name, physical_column_name,
menu_objid, company_code, description, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
mapping.table_name,
mapping.logical_column_name,
mapping.physical_column_name,
newMenuObjid,
targetCompanyCode,
mapping.description,
userId,
]
);
mappingCount++;
}
// 2) 테이블 컬럼 카테고리 값 복사 (부모-자식 관계 유지)
const sortedValues = settings.categoryValues.sort(
(a, b) => a.depth - b.depth
);
let skippedValues = 0;
for (const value of sortedValues) {
const newMenuObjid = menuIdMap.get(value.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const existsResult = 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 (existsResult.rows.length > 0) {
skippedValues++;
logger.debug(
` ⏭️ 카테고리 값 이미 존재: ${value.table_name}.${value.column_name}.${value.value_code}`
);
// 기존 값의 ID를 매핑에 저장 (자식 항목의 parent_id 재매핑용)
valueIdMap.set(value.value_id, existsResult.rows[0].value_id);
continue;
}
// 부모 ID 재매핑
let newParentValueId = null;
if (value.parent_value_id) {
newParentValueId = valueIdMap.get(value.parent_value_id) || null;
}
const result = 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, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING value_id`,
[
value.table_name,
value.column_name,
value.value_code,
value.value_label,
value.value_order,
newParentValueId,
value.depth,
value.description,
value.color,
value.icon,
value.is_active,
value.is_default,
targetCompanyCode,
newMenuObjid,
userId,
]
);
// ID 매핑 저장
const newValueId = result.rows[0].value_id;
valueIdMap.set(value.value_id, newValueId);
valueCount++;
}
logger.info(
`✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (${skippedValues}개 스킵)`
);
}
/**
* 채번 규칙 복사
*/
private async copyNumberingRules(
rules: { rules: any[]; parts: any[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 채번 규칙 복사 중...`);
const ruleIdMap = new Map<string, string>(); // 원본 rule_id → 새 rule_id
let ruleCount = 0;
let partCount = 0;
// 1) 채번 규칙 복사
for (const rule of rules.rules) {
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (!newMenuObjid) continue;
// 새 rule_id 생성 (타임스탬프 기반)
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
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, menu_objid, created_by, scope_type
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
newRuleId,
rule.rule_name,
rule.description,
rule.separator,
rule.reset_period,
1, // 시퀀스 초기화
rule.table_name,
rule.column_name,
targetCompanyCode,
newMenuObjid,
userId,
rule.scope_type,
]
);
ruleCount++;
}
// 2) 채번 규칙 파트 복사
for (const part of rules.parts) {
const newRuleId = ruleIdMap.get(part.rule_id);
if (!newRuleId) continue;
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
newRuleId,
part.part_order,
part.part_type,
part.generation_method,
part.auto_config,
part.manual_config,
targetCompanyCode,
]
);
partCount++;
}
logger.info(
`✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}`
);
}
}