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

1661 lines
47 KiB
Markdown
Raw Permalink Normal View History

# 메뉴 복사 기능 구현 계획서
## 📋 목차
1. [개요](#개요)
2. [요구사항](#요구사항)
3. [데이터베이스 구조 분석](#데이터베이스-구조-분석)
4. [복사 대상 항목](#복사-대상-항목)
5. [복사 알고리즘](#복사-알고리즘)
6. [구현 단계](#구현-단계)
7. [API 명세](#api-명세)
8. [UI/UX 설계](#uiux-설계)
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: 사용성
- 진행 상황 표시: 실시간 복사 진행률 표시
- 결과 보고서: 복사된 항목 상세 리스트
---
## 데이터베이스 구조 분석
### 주요 테이블 및 관계
```sql
-- 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 - 설명
```
### 외래키 제약조건
```sql
-- 중요: 삽입 순서 고려 필요
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단계: 메뉴 트리 수집
```typescript
// 재귀적으로 하위 메뉴 수집
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단계: 화면 수집 (중복 제거)
```typescript
// 메뉴에 할당된 화면 + 참조 화면 수집
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단계: 플로우 수집
```typescript
// 화면에서 참조되는 플로우 수집
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단계: 코드 수집
```typescript
// 메뉴에 연결된 코드 수집
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 기준)
---
## 복사 알고리즘
### 전체 프로세스
```typescript
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();
}
}
```
### 플로우 복사 알고리즘
```typescript
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;
}
```
### 화면 복사 알고리즘
```typescript
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;
}
```
### 메뉴 복사 알고리즘
```typescript
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;
}
```
### 코드 복사 알고리즘
```typescript
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`
```typescript
// 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`
```typescript
// 메뉴 복사 API
router.post(
"/menus/:menuObjid/copy",
authenticate,
copyMenu
);
```
### Phase 4: 프론트엔드 API 클라이언트
**파일**: `frontend/lib/api/menu.ts`
```typescript
/**
* 메뉴 복사
*/
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`
```typescript
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`
```typescript
// 복사 버튼 추가
<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 = "*")
**요청**:
```typescript
POST /api/admin/menus/100/copy
Content-Type: application/json
{
"targetCompanyCode": "COMPANY_B"
}
```
**응답 (성공)**:
```typescript
{
"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 테이블에 데이터를 추가해야 합니다",
"메뉴 권한 설정이 필요합니다"
]
}
}
```
**응답 (실패)**:
```typescript
{
"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. 권한 검증
```typescript
if (req.user!.companyCode !== "*") {
throw new Error("메뉴 복사는 최고 관리자만 가능합니다");
}
```
### 2. 메뉴 존재 여부
```typescript
const menu = await getMenuByObjid(menuObjid, client);
if (!menu) {
throw new Error("메뉴를 찾을 수 없습니다");
}
```
### 3. 대상 회사 존재 여부
```typescript
const company = await getCompanyByCode(targetCompanyCode, client);
if (!company) {
throw new Error("대상 회사가 존재하지 않습니다");
}
```
### 4. 중복 메뉴 체크
```typescript
// 같은 이름의 메뉴가 이미 있는지 확인
const existingMenu = await getMenuByNameAndCompany(
menu.menu_name_kor,
targetCompanyCode,
client
);
if (existingMenu) {
// 경고만 표시하고 진행 (사용자가 이름 변경 가능)
warnings.push(`같은 이름의 메뉴가 이미 존재합니다: ${menu.menu_name_kor}`);
}
```
### 5. 트랜잭션 롤백
```typescript
try {
await client.query('BEGIN');
// ... 복사 작업
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
logger.error("메뉴 복사 실패, 롤백됨:", error);
throw error;
}
```
### 6. 무한 루프 방지
```typescript
// 화면 참조 추적 시
const visited = new Set<number>();
function collectScreensRecursive(screenId: number) {
if (visited.has(screenId)) return; // 이미 방문함
visited.add(screenId);
// ... 참조 화면 수집
}
```
### 7. JSONB 파싱 오류
```typescript
try {
const properties = JSON.parse(layout.properties);
// ... properties 처리
} catch (error) {
logger.warn(`JSONB 파싱 실패: layout_id=${layout.layout_id}`, error);
// 원본 그대로 사용
}
```
### 8. 부분 실패 처리
```typescript
// 플로우 복사 실패 시 경고만 표시하고 계속 진행
try {
await copyFlows(flowIds, targetCompanyCode, userId, client);
} catch (error) {
logger.error("플로우 복사 실패:", error);
warnings.push("일부 플로우가 복사되지 않았습니다");
// 계속 진행 (메뉴와 화면은 복사)
}
```
---
## 테스트 계획
### 단위 테스트 (Unit Tests)
#### 1. 수집 함수 테스트
```typescript
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. 복사 함수 테스트
```typescript
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 재매핑 테스트
```typescript
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. 전체 복사 플로우
```typescript
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. 트랜잭션 롤백 테스트
```typescript
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 테스트
```typescript
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. 대량 메뉴 복사
```typescript
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. 동시 복사 요청
```typescript
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 | 초안 작성 |