ERP-node/docs/메뉴_복사_기능_구현_계획서.md

47 KiB

메뉴 복사 기능 구현 계획서

📋 목차

  1. 개요
  2. 요구사항
  3. 데이터베이스 구조 분석
  4. 복사 대상 항목
  5. 복사 알고리즘
  6. 구현 단계
  7. API 명세
  8. UI/UX 설계
  9. 예외 처리
  10. 테스트 계획

개요

목적

메뉴관리 화면에서 복사 버튼 하나로 선택된 메뉴와 관련된 모든 리소스를 다른 회사로 복사하여, 복사 즉시 해당 회사에서 사용 가능하도록 합니다.

핵심 기능

  • 메뉴 트리 구조 복사 (부모-자식 관계 유지)
  • 화면 + 레이아웃 복사 (모달, 조건부 컨테이너 포함)
  • 플로우 제어 복사 (스텝, 연결, 조건)
  • 코드 카테고리 + 코드 정보 복사
  • 중복 화면 자동 제거
  • 참조 관계 자동 재매핑
  • company_code 자동 변경

요구사항

기능 요구사항

FR-1: 메뉴 복사

  • 설명: 선택된 메뉴와 하위 메뉴를 모두 복사
  • 입력: 원본 메뉴 objid, 대상 회사 company_code
  • 출력: 복사된 메뉴 목록
  • 제약: 메뉴 계층 구조 유지

FR-2: 화면 복사

  • 설명: 메뉴에 할당된 모든 화면 복사
  • 입력: 메뉴 objid 목록
  • 출력: 복사된 화면 목록
  • 제약: 중복 화면은 하나만 복사

FR-3: 화면 내부 참조 추적

  • 설명: 화면 레이아웃에서 참조되는 화면들을 재귀적으로 추적
  • 대상:
    • 모달 버튼의 targetScreenId
    • 조건부 컨테이너의 sections[].screenId
    • 모달 안의 모달 (중첩 구조)
  • 제약: 무한 루프 방지 (이미 방문한 화면 체크)

FR-4: 플로우 복사

  • 설명: 화면에서 참조되는 플로우를 모두 복사
  • 대상:
    • flow_definition (플로우 정의)
    • flow_step (스텝)
    • flow_step_connection (스텝 간 연결)
  • 제약: 스텝 ID 재매핑

FR-5: 코드 복사

  • 설명: 메뉴에 연결된 코드 카테고리와 코드 복사
  • 대상:
    • code_category (menu_objid 기준)
    • code_info (menu_objid 기준)
  • 제약: 중복 카테고리 병합

FR-6: 참조 ID 재매핑

  • 설명: 복사된 리소스의 ID를 원본 ID에서 새 ID로 자동 변경
  • 대상:
    • screen_id (화면 ID)
    • flow_id (플로우 ID)
    • menu_objid (메뉴 ID)
    • step_id (스텝 ID)
  • 방법: ID 매핑 테이블 사용

비기능 요구사항

NFR-1: 성능

  • 복사 시간: 메뉴 100개 기준 2분 이내
  • 트랜잭션: 전체 작업을 하나의 트랜잭션으로 처리

NFR-2: 신뢰성

  • 실패 시 롤백: 일부만 복사되는 것 방지
  • 중복 실행 방지: 같은 요청 중복 처리 방지

NFR-3: 사용성

  • 진행 상황 표시: 실시간 복사 진행률 표시
  • 결과 보고서: 복사된 항목 상세 리스트

데이터베이스 구조 분석

주요 테이블 및 관계

-- 1. 메뉴 (계층 구조)
menu_info
  ├─ objid (PK) - 메뉴 고유 ID
  ├─ parent_obj_id - 부모 메뉴 ID
  ├─ company_code (FK) - 회사 코드
  └─ screen_code - 할당된 화면 코드

-- 2. 화면 정의
screen_definitions
  ├─ screen_id (PK) - 화면 고유 ID
  ├─ screen_code (UNIQUE) - 화면 코드
  ├─ company_code (FK) - 회사 코드
  ├─ table_name - 연결된 테이블
  └─ layout_metadata (JSONB) - 레이아웃 메타데이터

-- 3. 화면 레이아웃 (컴포넌트)
screen_layouts
  ├─ layout_id (PK)
  ├─ screen_id (FK) - 화면 ID
  ├─ component_type - 컴포넌트 타입
  ├─ properties (JSONB) - 컴포넌트 속성
     ├─ componentConfig.action.targetScreenId (모달 참조)
     ├─ sections[].screenId (조건부 컨테이너)
     └─ dataflowConfig.flowConfig.flowId (플로우 참조)
  └─ parent_id - 부모 컴포넌트 ID

-- 4. 화면-메뉴 할당
screen_menu_assignments
  ├─ assignment_id (PK)
  ├─ screen_id (FK) - 화면 ID
  ├─ menu_objid (FK) - 메뉴 ID
  └─ company_code (FK) - 회사 코드

-- 5. 플로우 정의
flow_definition
  ├─ id (PK) - 플로우 ID
  ├─ name - 플로우 이름
  ├─ table_name - 연결된 테이블
  └─ company_code (FK) - 회사 코드

-- 6. 플로우 스텝
flow_step
  ├─ id (PK) - 스텝 ID
  ├─ flow_definition_id (FK) - 플로우 ID
  ├─ step_name - 스텝 이름
  ├─ step_order - 순서
  ├─ condition_json (JSONB) - 조건
  └─ integration_config (JSONB) - 통합 설정

-- 7. 플로우 스텝 연결
flow_step_connection
  ├─ id (PK)
  ├─ flow_definition_id (FK) - 플로우 ID
  ├─ from_step_id (FK) - 출발 스텝 ID
  ├─ to_step_id (FK) - 도착 스텝 ID
  └─ label - 연결 라벨

-- 8. 코드 카테고리
code_category
  ├─ category_code (PK)
  ├─ company_code (PK, FK)
  ├─ menu_objid (PK, FK) - 메뉴 ID
  ├─ category_name - 카테고리 이름
  └─ description - 설명

-- 9. 코드 정보
code_info
  ├─ code_category (PK, FK)
  ├─ company_code (PK, FK)
  ├─ menu_objid (PK, FK)
  ├─ code_value (PK) - 코드 
  ├─ code_name - 코드 이름
  └─ description - 설명

외래키 제약조건

-- 중요: 삽입 순서 고려 필요
1. company_mng (회사 정보) - 먼저 존재해야 
2. menu_info (메뉴)
3. screen_definitions (화면)
4. flow_definition (플로우)
5. screen_layouts (레이아웃)
6. screen_menu_assignments (화면-메뉴 할당)
7. flow_step (플로우 스텝)
8. flow_step_connection (스텝 연결)
9. code_category (코드 카테고리)
10. code_info (코드 정보)

복사 대상 항목

1단계: 메뉴 트리 수집

// 재귀적으로 하위 메뉴 수집
function collectMenuTree(rootMenuObjid: number): Menu[] {
  const result: Menu[] = [];
  const stack: number[] = [rootMenuObjid];
  
  while (stack.length > 0) {
    const currentObjid = stack.pop()!;
    const menu = getMenuByObjid(currentObjid);
    result.push(menu);
    
    // 자식 메뉴들을 스택에 추가
    const children = getChildMenus(currentObjid);
    stack.push(...children.map(c => c.objid));
  }
  
  return result;
}

수집 항목:

  • 원본 메뉴 objid
  • 하위 메뉴 objid 목록 (재귀)
  • 부모-자식 관계 매핑

2단계: 화면 수집 (중복 제거)

// 메뉴에 할당된 화면 + 참조 화면 수집
function collectScreens(menuObjids: number[]): Set<number> {
  const screenIds = new Set<number>();
  const visited = new Set<number>(); // 무한 루프 방지
  
  // 1) 메뉴에 직접 할당된 화면
  for (const menuObjid of menuObjids) {
    const assignments = getScreenMenuAssignments(menuObjid);
    assignments.forEach(a => screenIds.add(a.screen_id));
  }
  
  // 2) 화면 내부에서 참조되는 화면 (재귀)
  const queue = Array.from(screenIds);
  while (queue.length > 0) {
    const screenId = queue.shift()!;
    if (visited.has(screenId)) continue;
    visited.add(screenId);
    
    const referencedScreens = extractReferencedScreens(screenId);
    referencedScreens.forEach(refId => {
      if (!screenIds.has(refId)) {
        screenIds.add(refId);
        queue.push(refId);
      }
    });
  }
  
  return screenIds;
}

// 화면 레이아웃에서 참조 화면 추출
function extractReferencedScreens(screenId: number): number[] {
  const layouts = getScreenLayouts(screenId);
  const referenced: number[] = [];
  
  for (const layout of layouts) {
    const props = layout.properties;
    
    // 모달 버튼
    if (props?.componentConfig?.action?.targetScreenId) {
      referenced.push(props.componentConfig.action.targetScreenId);
    }
    
    // 조건부 컨테이너
    if (props?.sections) {
      for (const section of props.sections) {
        if (section.screenId) {
          referenced.push(section.screenId);
        }
      }
    }
  }
  
  return referenced;
}

수집 항목:

  • 직접 할당 화면 ID 목록
  • 모달 참조 화면 ID 목록
  • 조건부 컨테이너 내 화면 ID 목록
  • 중복 제거된 최종 화면 ID Set

3단계: 플로우 수집

// 화면에서 참조되는 플로우 수집
function collectFlows(screenIds: Set<number>): Set<number> {
  const flowIds = new Set<number>();
  
  for (const screenId of screenIds) {
    const layouts = getScreenLayouts(screenId);
    
    for (const layout of layouts) {
      const flowId = layout.properties?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
      if (flowId) {
        flowIds.add(flowId);
      }
    }
  }
  
  return flowIds;
}

수집 항목:

  • flow_definition.id 목록
  • 각 플로우의 flow_step 목록
  • 각 플로우의 flow_step_connection 목록

4단계: 코드 수집

// 메뉴에 연결된 코드 수집
function collectCodes(menuObjids: number[], companyCode: string): {
  categories: CodeCategory[];
  codes: CodeInfo[];
} {
  const categories: CodeCategory[] = [];
  const codes: CodeInfo[] = [];
  
  for (const menuObjid of menuObjids) {
    // 코드 카테고리
    const cats = getCodeCategories(menuObjid, companyCode);
    categories.push(...cats);
    
    // 각 카테고리의 코드 정보
    for (const cat of cats) {
      const infos = getCodeInfos(cat.category_code, menuObjid, companyCode);
      codes.push(...infos);
    }
  }
  
  return { categories, codes };
}

수집 항목:

  • code_category 목록 (menu_objid 기준)
  • code_info 목록 (menu_objid + category_code 기준)

복사 알고리즘

전체 프로세스

async function copyMenu(
  sourceMenuObjid: number,
  targetCompanyCode: string,
  userId: string
): Promise<CopyResult> {
  
  // 트랜잭션 시작
  const client = await pool.connect();
  await client.query('BEGIN');
  
  try {
    // 1단계: 수집 (Collection Phase)
    const menus = collectMenuTree(sourceMenuObjid);
    const screenIds = collectScreens(menus.map(m => m.objid));
    const flowIds = collectFlows(screenIds);
    const codes = collectCodes(menus.map(m => m.objid), menus[0].company_code);
    
    // 2단계: 플로우 복사 (Flow Copy Phase)
    const flowIdMap = await copyFlows(flowIds, targetCompanyCode, userId, client);
    
    // 3단계: 화면 복사 (Screen Copy Phase)
    const screenIdMap = await copyScreens(
      screenIds, 
      targetCompanyCode, 
      flowIdMap, // 플로우 ID 재매핑
      userId, 
      client
    );
    
    // 4단계: 메뉴 복사 (Menu Copy Phase)
    const menuIdMap = await copyMenus(
      menus, 
      targetCompanyCode, 
      screenIdMap, // 화면 ID 재매핑
      userId, 
      client
    );
    
    // 5단계: 화면-메뉴 할당 (Assignment Phase)
    await createScreenMenuAssignments(
      menus,
      menuIdMap,
      screenIdMap,
      targetCompanyCode,
      client
    );
    
    // 6단계: 코드 복사 (Code Copy Phase)
    await copyCodes(
      codes,
      menuIdMap,
      targetCompanyCode,
      userId,
      client
    );
    
    // 커밋
    await client.query('COMMIT');
    
    return {
      success: true,
      copiedMenus: Object.values(menuIdMap).length,
      copiedScreens: Object.values(screenIdMap).length,
      copiedFlows: Object.values(flowIdMap).length,
      copiedCategories: codes.categories.length,
      copiedCodes: codes.codes.length,
    };
    
  } catch (error) {
    // 롤백
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

플로우 복사 알고리즘

async function copyFlows(
  flowIds: Set<number>,
  targetCompanyCode: string,
  userId: string,
  client: PoolClient
): Promise<Map<number, number>> {
  
  const flowIdMap = new Map<number, number>(); // 원본 ID → 새 ID
  
  for (const originalFlowId of flowIds) {
    // 1) flow_definition 복사
    const flowDef = await getFlowDefinition(originalFlowId, client);
    const newFlowId = await insertFlowDefinition({
      ...flowDef,
      company_code: targetCompanyCode,
      created_by: userId,
    }, client);
    
    flowIdMap.set(originalFlowId, newFlowId);
    
    // 2) flow_step 복사
    const steps = await getFlowSteps(originalFlowId, client);
    const stepIdMap = new Map<number, number>(); // 스텝 ID 매핑
    
    for (const step of steps) {
      const newStepId = await insertFlowStep({
        ...step,
        flow_definition_id: newFlowId, // 새 플로우 ID
      }, client);
      
      stepIdMap.set(step.id, newStepId);
    }
    
    // 3) flow_step_connection 복사 (스텝 ID 재매핑)
    const connections = await getFlowStepConnections(originalFlowId, client);
    
    for (const conn of connections) {
      await insertFlowStepConnection({
        flow_definition_id: newFlowId,
        from_step_id: stepIdMap.get(conn.from_step_id)!, // 재매핑
        to_step_id: stepIdMap.get(conn.to_step_id)!,     // 재매핑
        label: conn.label,
      }, client);
    }
  }
  
  return flowIdMap;
}

화면 복사 알고리즘

async function copyScreens(
  screenIds: Set<number>,
  targetCompanyCode: string,
  flowIdMap: Map<number, number>, // 플로우 ID 재매핑
  userId: string,
  client: PoolClient
): Promise<Map<number, number>> {
  
  const screenIdMap = new Map<number, number>(); // 원본 ID → 새 ID
  
  for (const originalScreenId of screenIds) {
    // 1) screen_definitions 복사
    const screenDef = await getScreenDefinition(originalScreenId, client);
    
    // 새 screen_code 생성 (중복 방지)
    const newScreenCode = await generateUniqueScreenCode(targetCompanyCode, client);
    
    const newScreenId = await insertScreenDefinition({
      ...screenDef,
      screen_code: newScreenCode,
      company_code: targetCompanyCode,
      created_by: userId,
    }, client);
    
    screenIdMap.set(originalScreenId, newScreenId);
    
    // 2) screen_layouts 복사
    const layouts = await getScreenLayouts(originalScreenId, client);
    
    for (const layout of layouts) {
      // properties 내부 참조 업데이트
      const updatedProperties = updateReferencesInProperties(
        layout.properties,
        screenIdMap, // 화면 ID 재매핑
        flowIdMap    // 플로우 ID 재매핑
      );
      
      await insertScreenLayout({
        screen_id: newScreenId, // 새 화면 ID
        component_type: layout.component_type,
        component_id: layout.component_id,
        parent_id: layout.parent_id,
        position_x: layout.position_x,
        position_y: layout.position_y,
        width: layout.width,
        height: layout.height,
        properties: updatedProperties, // 업데이트된 속성
        display_order: layout.display_order,
        layout_type: layout.layout_type,
        layout_config: layout.layout_config,
        zones_config: layout.zones_config,
        zone_id: layout.zone_id,
      }, client);
    }
  }
  
  return screenIdMap;
}

// properties 내부 참조 업데이트
function updateReferencesInProperties(
  properties: any,
  screenIdMap: Map<number, number>,
  flowIdMap: Map<number, number>
): any {
  
  if (!properties) return properties;
  
  const updated = JSON.parse(JSON.stringify(properties)); // 깊은 복사
  
  // 1) 모달 버튼의 targetScreenId
  if (updated?.componentConfig?.action?.targetScreenId) {
    const oldId = updated.componentConfig.action.targetScreenId;
    const newId = screenIdMap.get(oldId);
    if (newId) {
      updated.componentConfig.action.targetScreenId = newId;
    }
  }
  
  // 2) 조건부 컨테이너의 sections[].screenId
  if (updated?.sections) {
    for (const section of updated.sections) {
      if (section.screenId) {
        const oldId = section.screenId;
        const newId = screenIdMap.get(oldId);
        if (newId) {
          section.screenId = newId;
        }
      }
    }
  }
  
  // 3) 플로우 제어의 flowId
  if (updated?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) {
    const oldId = updated.webTypeConfig.dataflowConfig.flowConfig.flowId;
    const newId = flowIdMap.get(oldId);
    if (newId) {
      updated.webTypeConfig.dataflowConfig.flowConfig.flowId = newId;
    }
  }
  
  return updated;
}

메뉴 복사 알고리즘

async function copyMenus(
  menus: Menu[],
  targetCompanyCode: string,
  screenIdMap: Map<number, number>,
  userId: string,
  client: PoolClient
): Promise<Map<number, number>> {
  
  const menuIdMap = new Map<number, number>(); // 원본 objid → 새 objid
  
  // 1) 메뉴를 깊이 순으로 정렬 (부모 먼저 삽입)
  const sortedMenus = topologicalSortMenus(menus);
  
  for (const menu of sortedMenus) {
    // screen_code 업데이트 (화면 ID 재매핑)
    const newScreenCode = menu.screen_code
      ? getNewScreenCode(screenIdMap, menu.screen_code)
      : null;
    
    // parent_obj_id 업데이트 (메뉴 ID 재매핑)
    const newParentObjId = menu.parent_obj_id
      ? menuIdMap.get(menu.parent_obj_id) || null
      : null;
    
    // 새 objid 생성
    const newObjId = await getNextMenuObjid(client);
    
    await insertMenu({
      objid: newObjId,
      menu_type: menu.menu_type,
      parent_obj_id: newParentObjId, // 재매핑
      menu_name_kor: menu.menu_name_kor,
      menu_name_eng: menu.menu_name_eng,
      seq: menu.seq,
      menu_url: menu.menu_url,
      menu_desc: menu.menu_desc,
      writer: userId,
      status: menu.status,
      system_name: menu.system_name,
      company_code: targetCompanyCode, // 새 회사 코드
      lang_key: menu.lang_key,
      lang_key_desc: menu.lang_key_desc,
      screen_code: newScreenCode, // 재매핑
      menu_code: menu.menu_code,
    }, client);
    
    menuIdMap.set(menu.objid, newObjId);
  }
  
  return menuIdMap;
}

// 위상 정렬 (부모 먼저)
function topologicalSortMenus(menus: Menu[]): Menu[] {
  const result: Menu[] = [];
  const visited = new Set<number>();
  
  function visit(menu: Menu) {
    if (visited.has(menu.objid)) return;
    
    // 부모 먼저 방문
    if (menu.parent_obj_id) {
      const parent = menus.find(m => m.objid === menu.parent_obj_id);
      if (parent) visit(parent);
    }
    
    visited.add(menu.objid);
    result.push(menu);
  }
  
  for (const menu of menus) {
    visit(menu);
  }
  
  return result;
}

코드 복사 알고리즘

async function copyCodes(
  codes: { categories: CodeCategory[]; codes: CodeInfo[] },
  menuIdMap: Map<number, number>,
  targetCompanyCode: string,
  userId: string,
  client: PoolClient
): Promise<void> {
  
  // 1) 코드 카테고리 복사 (중복 체크)
  for (const category of codes.categories) {
    const newMenuObjid = menuIdMap.get(category.menu_objid);
    if (!newMenuObjid) continue;
    
    // 중복 체크: 같은 category_code + company_code + menu_objid
    const exists = await checkCodeCategoryExists(
      category.category_code,
      targetCompanyCode,
      newMenuObjid,
      client
    );
    
    if (!exists) {
      await insertCodeCategory({
        category_code: category.category_code,
        category_name: category.category_name,
        category_name_eng: category.category_name_eng,
        description: category.description,
        sort_order: category.sort_order,
        is_active: category.is_active,
        company_code: targetCompanyCode, // 새 회사 코드
        menu_objid: newMenuObjid,        // 재매핑
        created_by: userId,
      }, client);
    }
  }
  
  // 2) 코드 정보 복사 (중복 체크)
  for (const code of codes.codes) {
    const newMenuObjid = menuIdMap.get(code.menu_objid);
    if (!newMenuObjid) continue;
    
    // 중복 체크: 같은 code_category + code_value + company_code + menu_objid
    const exists = await checkCodeInfoExists(
      code.code_category,
      code.code_value,
      targetCompanyCode,
      newMenuObjid,
      client
    );
    
    if (!exists) {
      await insertCodeInfo({
        code_category: code.code_category,
        code_value: code.code_value,
        code_name: code.code_name,
        code_name_eng: code.code_name_eng,
        description: code.description,
        sort_order: code.sort_order,
        is_active: code.is_active,
        company_code: targetCompanyCode, // 새 회사 코드
        menu_objid: newMenuObjid,        // 재매핑
        created_by: userId,
      }, client);
    }
  }
}

구현 단계

Phase 1: 백엔드 서비스 구현

파일: backend-node/src/services/menuCopyService.ts

1.1 데이터 수집 함수

  • collectMenuTree() - 메뉴 트리 수집
  • collectScreens() - 화면 수집 (중복 제거)
  • collectFlows() - 플로우 수집
  • collectCodes() - 코드 수집
  • extractReferencedScreens() - 화면 참조 추출

1.2 복사 함수

  • copyFlows() - 플로우 복사
  • copyScreens() - 화면 복사
  • copyMenus() - 메뉴 복사
  • copyCodes() - 코드 복사
  • createScreenMenuAssignments() - 화면-메뉴 할당

1.3 유틸리티 함수

  • updateReferencesInProperties() - JSONB 내부 참조 업데이트
  • topologicalSortMenus() - 메뉴 위상 정렬
  • generateUniqueScreenCode() - 고유 화면 코드 생성
  • getNextMenuObjid() - 다음 메뉴 objid

Phase 2: 백엔드 컨트롤러 구현

파일: backend-node/src/controllers/menuController.ts

// POST /api/admin/menus/:menuObjid/copy
export async function copyMenu(
  req: AuthenticatedRequest,
  res: Response
): Promise<void> {
  try {
    const { menuObjid } = req.params;
    const { targetCompanyCode } = req.body;
    const userId = req.user!.userId;
    
    // 권한 체크
    if (req.user!.companyCode !== "*") {
      // 최고 관리자만 가능
      res.status(403).json({
        success: false,
        message: "메뉴 복사는 최고 관리자만 가능합니다",
      });
      return;
    }
    
    // 복사 실행
    const menuCopyService = new MenuCopyService();
    const result = await menuCopyService.copyMenu(
      parseInt(menuObjid),
      targetCompanyCode,
      userId
    );
    
    res.json({
      success: true,
      message: "메뉴 복사 완료",
      data: result,
    });
    
  } catch (error: any) {
    logger.error("메뉴 복사 실패:", error);
    res.status(500).json({
      success: false,
      message: "메뉴 복사 중 오류가 발생했습니다",
      error: error.message,
    });
  }
}

Phase 3: 백엔드 라우터 등록

파일: backend-node/src/routes/admin.ts

// 메뉴 복사 API
router.post(
  "/menus/:menuObjid/copy",
  authenticate,
  copyMenu
);

Phase 4: 프론트엔드 API 클라이언트

파일: frontend/lib/api/menu.ts

/**
 * 메뉴 복사
 */
export async function copyMenu(
  menuObjid: number,
  targetCompanyCode: string
): Promise<ApiResponse<MenuCopyResult>> {
  try {
    const response = await apiClient.post(
      `/admin/menus/${menuObjid}/copy`,
      { targetCompanyCode }
    );
    return response.data;
  } catch (error: any) {
    return {
      success: false,
      error: error.message,
    };
  }
}

export interface MenuCopyResult {
  copiedMenus: number;
  copiedScreens: number;
  copiedFlows: number;
  copiedCategories: number;
  copiedCodes: number;
  warnings?: string[];
}

Phase 5: 프론트엔드 UI 구현

파일: frontend/components/admin/MenuCopyDialog.tsx

export function MenuCopyDialog({
  menuObjid,
  menuName,
  open,
  onOpenChange,
}: MenuCopyDialogProps) {
  const [targetCompanyCode, setTargetCompanyCode] = useState("");
  const [companies, setCompanies] = useState<Company[]>([]);
  const [copying, setCopying] = useState(false);
  const [result, setResult] = useState<MenuCopyResult | null>(null);
  
  // 회사 목록 로드
  useEffect(() => {
    if (open) {
      loadCompanies();
    }
  }, [open]);
  
  const handleCopy = async () => {
    if (!targetCompanyCode) {
      toast.error("대상 회사를 선택해주세요");
      return;
    }
    
    setCopying(true);
    setResult(null);
    
    const response = await copyMenu(menuObjid, targetCompanyCode);
    
    if (response.success && response.data) {
      setResult(response.data);
      toast.success("메뉴 복사 완료!");
    } else {
      toast.error(response.error || "메뉴 복사 실패");
    }
    
    setCopying(false);
  };
  
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
        <DialogHeader>
          <DialogTitle className="text-base sm:text-lg">
            메뉴 복사
          </DialogTitle>
          <DialogDescription className="text-xs sm:text-sm">
            "{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다.
          </DialogDescription>
        </DialogHeader>
        
        <div className="space-y-3 sm:space-y-4">
          {/* 회사 선택 */}
          <div>
            <Label htmlFor="company" className="text-xs sm:text-sm">
              대상 회사 *
            </Label>
            <Select
              value={targetCompanyCode}
              onValueChange={setTargetCompanyCode}
            >
              <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
                <SelectValue placeholder="회사 선택" />
              </SelectTrigger>
              <SelectContent>
                {companies.map((company) => (
                  <SelectItem
                    key={company.company_code}
                    value={company.company_code}
                    className="text-xs sm:text-sm"
                  >
                    {company.company_name} ({company.company_code})
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          
          {/* 복사 항목 안내 */}
          <div className="rounded-md border p-3 text-xs">
            <p className="font-medium mb-2">복사되는 항목:</p>
            <ul className="list-disc list-inside space-y-1 text-muted-foreground">
              <li>메뉴 구조 (하위 메뉴 포함)</li>
              <li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
              <li>플로우 제어 (스텝, 연결)</li>
              <li>코드 카테고리 + 코드</li>
            </ul>
          </div>
          
          {/* 복사 결과 */}
          {result && (
            <div className="rounded-md border border-success bg-success/10 p-3 text-xs">
              <p className="font-medium mb-2 text-success">복사 완료!</p>
              <ul className="space-y-1">
                <li>메뉴: {result.copiedMenus}</li>
                <li>화면: {result.copiedScreens}</li>
                <li>플로우: {result.copiedFlows}</li>
                <li>코드 카테고리: {result.copiedCategories}</li>
                <li>코드: {result.copiedCodes}</li>
              </ul>
            </div>
          )}
        </div>
        
        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            disabled={copying}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            {result ? "닫기" : "취소"}
          </Button>
          {!result && (
            <Button
              onClick={handleCopy}
              disabled={copying || !targetCompanyCode}
              className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
            >
              {copying ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  복사 ...
                </>
              ) : (
                "복사 시작"
              )}
            </Button>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Phase 6: 메뉴 관리 화면 통합

파일: frontend/components/admin/MenuManagement.tsx

// 복사 버튼 추가
<Button
  variant="outline"
  size="sm"
  onClick={() => {
    setSelectedMenu(menu);
    setCopyDialogOpen(true);
  }}
>
  <Copy className="mr-2 h-4 w-4" />
  복사
</Button>

// 다이얼로그
<MenuCopyDialog
  menuObjid={selectedMenu?.objid}
  menuName={selectedMenu?.menu_name_kor}
  open={copyDialogOpen}
  onOpenChange={setCopyDialogOpen}
/>

API 명세

POST /api/admin/menus/:menuObjid/copy

설명: 메뉴와 관련된 모든 리소스를 다른 회사로 복사

권한: 최고 관리자 전용 (company_code = "*")

요청:

POST /api/admin/menus/100/copy
Content-Type: application/json

{
  "targetCompanyCode": "COMPANY_B"
}

응답 (성공):

{
  "success": true,
  "message": "메뉴 복사 완료",
  "data": {
    "copiedMenus": 5,
    "copiedScreens": 12,
    "copiedFlows": 3,
    "copiedCategories": 8,
    "copiedCodes": 45,
    "menuIdMap": {
      "100": 200,
      "101": 201,
      "102": 202
    },
    "screenIdMap": {
      "10": 30,
      "11": 31,
      "12": 32
    },
    "flowIdMap": {
      "5": 10,
      "6": 11
    },
    "warnings": [
      "item_info 테이블에 데이터를 추가해야 합니다",
      "메뉴 권한 설정이 필요합니다"
    ]
  }
}

응답 (실패):

{
  "success": false,
  "message": "메뉴 복사 중 오류가 발생했습니다",
  "error": "대상 회사가 존재하지 않습니다"
}

에러 코드:

  • 403: 권한 없음 (최고 관리자 아님)
  • 404: 메뉴를 찾을 수 없음
  • 400: 잘못된 요청 (대상 회사 코드 누락)
  • 500: 서버 내부 오류

UI/UX 설계

1. 메뉴 관리 화면

┌─────────────────────────────────────────────┐
│  메뉴 관리                                    │
├─────────────────────────────────────────────┤
│  ┌─ 영업관리 (objid: 100)                   │
│  │  ├─ [편집] [삭제] [복사] ← 복사 버튼      │
│  │  ├─ 수주관리 (objid: 101)                │
│  │  │  └─ [편집] [삭제] [복사]               │
│  │  └─ 견적관리 (objid: 102)                │
│  │     └─ [편집] [삭제] [복사]               │
│  └─ ...                                      │
└─────────────────────────────────────────────┘

2. 복사 다이얼로그

초기 상태

┌─────────────────────────────────────────┐
│  메뉴 복사                        [X]    │
├─────────────────────────────────────────┤
│  "영업관리" 메뉴와 관련된 모든 리소스를  │
│  다른 회사로 복사합니다.                 │
│                                         │
│  대상 회사 *                            │
│  [회사 선택 ▼]                          │
│                                         │
│  ┌──────────────────────────────────┐  │
│  │ 복사되는 항목:                    │  │
│  │ • 메뉴 구조 (하위 메뉴 포함)      │  │
│  │ • 화면 + 레이아웃                │  │
│  │ • 플로우 제어                    │  │
│  │ • 코드 카테고리 + 코드           │  │
│  └──────────────────────────────────┘  │
│                                         │
│              [취소]  [복사 시작]         │
└─────────────────────────────────────────┘

복사 중

┌─────────────────────────────────────────┐
│  메뉴 복사                        [X]    │
├─────────────────────────────────────────┤
│  "영업관리" 메뉴와 관련된 모든 리소스를  │
│  다른 회사로 복사합니다.                 │
│                                         │
│  대상 회사: 회사B (COMPANY_B)           │
│                                         │
│  ┌──────────────────────────────────┐  │
│  │ ⚙️ 복사 진행 중...              │  │
│  │                                  │  │
│  │ ✅ 메뉴 수집 완료                │  │
│  │ ✅ 화면 수집 완료                │  │
│  │ ⏳ 플로우 복사 중...             │  │
│  │ ⬜ 화면 복사 대기                │  │
│  │ ⬜ 메뉴 복사 대기                │  │
│  │ ⬜ 코드 복사 대기                │  │
│  └──────────────────────────────────┘  │
│                                         │
│                   [취소 불가]            │
└─────────────────────────────────────────┘

복사 완료

┌─────────────────────────────────────────┐
│  메뉴 복사                        [X]    │
├─────────────────────────────────────────┤
│  "영업관리" 메뉴와 관련된 모든 리소스를  │
│  다른 회사로 복사합니다.                 │
│                                         │
│  대상 회사: 회사B (COMPANY_B)           │
│                                         │
│  ┌──────────────────────────────────┐  │
│  │ ✅ 복사 완료!                    │  │
│  │                                  │  │
│  │ • 메뉴: 5개                      │  │
│  │ • 화면: 12개                     │  │
│  │ • 플로우: 3개                    │  │
│  │ • 코드 카테고리: 8개             │  │
│  │ • 코드: 45개                     │  │
│  │                                  │  │
│  │ ⚠️ 주의사항:                    │  │
│  │ - 실제 데이터는 복사되지 않음     │  │
│  │ - 메뉴 권한 설정 필요            │  │
│  └──────────────────────────────────┘  │
│                                         │
│                        [닫기]            │
└─────────────────────────────────────────┘

3. 사용자 흐름

1. 메뉴 관리 화면 접속
   ↓
2. 복사할 메뉴 옆 [복사] 버튼 클릭
   ↓
3. 복사 다이얼로그 열림
   ↓
4. 대상 회사 선택
   ↓
5. [복사 시작] 버튼 클릭
   ↓
6. 진행 상황 표시 (30초 ~ 2분)
   ↓
7. 완료 메시지 확인
   ↓
8. [닫기] 버튼으로 다이얼로그 닫기

예외 처리

1. 권한 검증

if (req.user!.companyCode !== "*") {
  throw new Error("메뉴 복사는 최고 관리자만 가능합니다");
}

2. 메뉴 존재 여부

const menu = await getMenuByObjid(menuObjid, client);
if (!menu) {
  throw new Error("메뉴를 찾을 수 없습니다");
}

3. 대상 회사 존재 여부

const company = await getCompanyByCode(targetCompanyCode, client);
if (!company) {
  throw new Error("대상 회사가 존재하지 않습니다");
}

4. 중복 메뉴 체크

// 같은 이름의 메뉴가 이미 있는지 확인
const existingMenu = await getMenuByNameAndCompany(
  menu.menu_name_kor,
  targetCompanyCode,
  client
);

if (existingMenu) {
  // 경고만 표시하고 진행 (사용자가 이름 변경 가능)
  warnings.push(`같은 이름의 메뉴가 이미 존재합니다: ${menu.menu_name_kor}`);
}

5. 트랜잭션 롤백

try {
  await client.query('BEGIN');
  // ... 복사 작업
  await client.query('COMMIT');
} catch (error) {
  await client.query('ROLLBACK');
  logger.error("메뉴 복사 실패, 롤백됨:", error);
  throw error;
}

6. 무한 루프 방지

// 화면 참조 추적 시
const visited = new Set<number>();

function collectScreensRecursive(screenId: number) {
  if (visited.has(screenId)) return; // 이미 방문함
  visited.add(screenId);
  // ... 참조 화면 수집
}

7. JSONB 파싱 오류

try {
  const properties = JSON.parse(layout.properties);
  // ... properties 처리
} catch (error) {
  logger.warn(`JSONB 파싱 실패: layout_id=${layout.layout_id}`, error);
  // 원본 그대로 사용
}

8. 부분 실패 처리

// 플로우 복사 실패 시 경고만 표시하고 계속 진행
try {
  await copyFlows(flowIds, targetCompanyCode, userId, client);
} catch (error) {
  logger.error("플로우 복사 실패:", error);
  warnings.push("일부 플로우가 복사되지 않았습니다");
  // 계속 진행 (메뉴와 화면은 복사)
}

테스트 계획

단위 테스트 (Unit Tests)

1. 수집 함수 테스트

describe("MenuCopyService - Collection", () => {
  test("collectMenuTree: 하위 메뉴 재귀 수집", async () => {
    const menus = await collectMenuTree(100);
    expect(menus.length).toBeGreaterThan(1);
    expect(menus[0].objid).toBe(100);
  });
  
  test("collectScreens: 중복 제거", async () => {
    const screenIds = await collectScreens([100, 101]);
    const uniqueIds = new Set(screenIds);
    expect(screenIds.length).toBe(uniqueIds.size);
  });
  
  test("extractReferencedScreens: 모달 참조 추출", async () => {
    const referenced = extractReferencedScreens(10);
    expect(referenced).toContain(26); // 모달 화면 ID
  });
});

2. 복사 함수 테스트

describe("MenuCopyService - Copy", () => {
  test("copyFlows: 플로우 + 스텝 + 연결 복사", async () => {
    const flowIdMap = await copyFlows(
      new Set([5]),
      "TEST_COMPANY",
      "test_user",
      client
    );
    
    expect(flowIdMap.size).toBe(1);
    const newFlowId = flowIdMap.get(5);
    expect(newFlowId).toBeDefined();
    
    const steps = await getFlowSteps(newFlowId!, client);
    expect(steps.length).toBeGreaterThan(0);
  });
  
  test("copyScreens: properties 내부 참조 업데이트", async () => {
    const screenIdMap = await copyScreens(
      new Set([10]),
      "TEST_COMPANY",
      new Map(), // flowIdMap
      "test_user",
      client
    );
    
    const newScreenId = screenIdMap.get(10);
    const layouts = await getScreenLayouts(newScreenId!, client);
    
    // targetScreenId가 재매핑되었는지 확인
    const modalLayout = layouts.find(
      l => l.properties?.componentConfig?.action?.type === "modal"
    );
    expect(modalLayout?.properties.componentConfig.action.targetScreenId).not.toBe(26);
  });
});

3. ID 재매핑 테스트

describe("MenuCopyService - Remapping", () => {
  test("updateReferencesInProperties: 모달 참조 업데이트", () => {
    const properties = {
      componentConfig: {
        action: {
          type: "modal",
          targetScreenId: 26
        }
      }
    };
    
    const screenIdMap = new Map([[26, 50]]);
    const updated = updateReferencesInProperties(properties, screenIdMap, new Map());
    
    expect(updated.componentConfig.action.targetScreenId).toBe(50);
  });
  
  test("updateReferencesInProperties: 조건부 컨테이너 참조 업데이트", () => {
    const properties = {
      sections: [
        { id: "1", condition: "A", screenId: 10 },
        { id: "2", condition: "B", screenId: 11 }
      ]
    };
    
    const screenIdMap = new Map([[10, 30], [11, 31]]);
    const updated = updateReferencesInProperties(properties, screenIdMap, new Map());
    
    expect(updated.sections[0].screenId).toBe(30);
    expect(updated.sections[1].screenId).toBe(31);
  });
});

통합 테스트 (Integration Tests)

1. 전체 복사 플로우

describe("Menu Copy - Full Flow", () => {
  let testMenuObjid: number;
  let targetCompanyCode: string;
  
  beforeAll(async () => {
    // 테스트 데이터 준비
    testMenuObjid = await createTestMenu();
    targetCompanyCode = "TEST_COMPANY_" + Date.now();
    await createTestCompany(targetCompanyCode);
  });
  
  afterAll(async () => {
    // 테스트 데이터 정리
    await deleteTestData(targetCompanyCode);
  });
  
  test("메뉴 복사: 성공", async () => {
    const menuCopyService = new MenuCopyService();
    const result = await menuCopyService.copyMenu(
      testMenuObjid,
      targetCompanyCode,
      "test_user"
    );
    
    expect(result.success).toBe(true);
    expect(result.copiedMenus).toBeGreaterThan(0);
    expect(result.copiedScreens).toBeGreaterThan(0);
    
    // 복사된 메뉴 검증
    const copiedMenus = await getMenusByCompany(targetCompanyCode);
    expect(copiedMenus.length).toBe(result.copiedMenus);
    
    // 복사된 화면 검증
    const copiedScreens = await getScreensByCompany(targetCompanyCode);
    expect(copiedScreens.length).toBe(result.copiedScreens);
  });
  
  test("복사된 화면이 정상 작동", async () => {
    // 복사된 화면에서 데이터 조회 가능한지 확인
    const screens = await getScreensByCompany(targetCompanyCode);
    const firstScreen = screens[0];
    
    const layouts = await getScreenLayouts(firstScreen.screen_id);
    expect(layouts.length).toBeGreaterThan(0);
  });
});

2. 트랜잭션 롤백 테스트

describe("Menu Copy - Rollback", () => {
  test("실패 시 롤백", async () => {
    const invalidCompanyCode = "INVALID_COMPANY";
    
    const menuCopyService = new MenuCopyService();
    
    await expect(
      menuCopyService.copyMenu(100, invalidCompanyCode, "test_user")
    ).rejects.toThrow();
    
    // 롤백 확인: 데이터가 생성되지 않았는지
    const menus = await getMenusByCompany(invalidCompanyCode);
    expect(menus.length).toBe(0);
  });
});

E2E 테스트 (End-to-End Tests)

1. UI 테스트

describe("Menu Copy - E2E", () => {
  test("메뉴 관리 화면에서 복사 버튼 클릭", async () => {
    // 1. 로그인
    await page.goto("http://localhost:9771/login");
    await page.fill('input[name="userId"]', "wace");
    await page.fill('input[name="password"]', "qlalfqjsgh11");
    await page.click('button[type="submit"]');
    
    // 2. 메뉴 관리 화면 이동
    await page.goto("http://localhost:9771/admin/menus");
    await page.waitForSelector(".menu-list");
    
    // 3. 복사 버튼 클릭
    await page.click('button[aria-label="메뉴 복사"]');
    
    // 4. 대상 회사 선택
    await page.selectOption('select[name="targetCompany"]', "COMPANY_B");
    
    // 5. 복사 시작
    await page.click('button:has-text("복사 시작")');
    
    // 6. 완료 메시지 확인
    await page.waitForSelector('text=복사 완료', { timeout: 120000 });
    
    // 7. 복사된 메뉴 확인
    await page.selectOption('select[name="company"]', "COMPANY_B");
    await page.waitForSelector('.menu-list');
    const menuCount = await page.locator('.menu-item').count();
    expect(menuCount).toBeGreaterThan(0);
  });
});

성능 테스트

1. 대량 메뉴 복사

test("100개 메뉴 복사 성능", async () => {
  const startTime = Date.now();
  
  const result = await menuCopyService.copyMenu(
    largeMenuObjid, // 하위 메뉴 100개
    "TEST_COMPANY",
    "test_user"
  );
  
  const endTime = Date.now();
  const duration = endTime - startTime;
  
  expect(duration).toBeLessThan(120000); // 2분 이내
  expect(result.copiedMenus).toBe(100);
});

2. 동시 복사 요청

test("동시 복사 요청 처리", async () => {
  const promises = Array.from({ length: 5 }, (_, i) =>
    menuCopyService.copyMenu(
      testMenuObjid,
      `TEST_COMPANY_${i}`,
      "test_user"
    )
  );
  
  const results = await Promise.all(promises);
  
  expect(results.every(r => r.success)).toBe(true);
});

구현 체크리스트

백엔드

  • menuCopyService.ts 생성
    • collectMenuTree()
    • collectScreens()
    • collectFlows()
    • collectCodes()
    • extractReferencedScreens()
    • copyFlows()
    • copyScreens()
    • copyMenus()
    • copyCodes()
    • createScreenMenuAssignments()
    • updateReferencesInProperties()
    • topologicalSortMenus()
    • generateUniqueScreenCode()
  • menuController.ts 업데이트
    • copyMenu() 컨트롤러 추가
  • admin.ts 라우터 업데이트
    • /menus/:menuObjid/copy 라우트 추가
  • 단위 테스트 작성
  • 통합 테스트 작성

프론트엔드

  • menu.ts API 클라이언트 업데이트
    • copyMenu() 함수 추가
    • MenuCopyResult 인터페이스 추가
  • MenuCopyDialog.tsx 생성
    • 회사 선택 드롭다운
    • 복사 진행 상태 표시
    • 복사 결과 표시
  • MenuManagement.tsx 업데이트
    • 복사 버튼 추가
    • 다이얼로그 통합
  • E2E 테스트 작성

문서

  • API 문서 업데이트
  • 사용자 매뉴얼 작성
  • 개발자 가이드 작성

예상 소요 시간

단계 작업 예상 시간
1 백엔드 서비스 구현 6시간
2 백엔드 컨트롤러/라우터 1시간
3 백엔드 테스트 3시간
4 프론트엔드 API 클라이언트 0.5시간
5 프론트엔드 UI 구현 3시간
6 프론트엔드 통합 1시간
7 E2E 테스트 2시간
8 문서 작성 1.5시간
9 버그 수정 및 최적화 2시간
총계 20시간

참고 사항

멀티테넌시 주의사항

  • 모든 쿼리에 company_code 필터링 적용
  • 최고 관리자(company_code = "*")만 메뉴 복사 가능
  • 복사 시 company_code를 대상 회사 코드로 변경

데이터 무결성

  • 외래키 제약조건 순서 준수
  • 트랜잭션으로 원자성 보장
  • 중복 데이터 체크 및 병합

성능 최적화

  • 배치 삽입 사용 (bulk insert)
  • 불필요한 쿼리 최소화
  • ID 매핑 테이블로 참조 업데이트

보안

  • 권한 검증 (최고 관리자만)
  • SQL 인젝션 방지
  • 입력값 검증

변경 이력

날짜 버전 작성자 변경 내용
2025-01-24 1.0 AI 초안 작성