1661 lines
47 KiB
Markdown
1661 lines
47 KiB
Markdown
|
|
# 메뉴 복사 기능 구현 계획서
|
||
|
|
|
||
|
|
## 📋 목차
|
||
|
|
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 | 초안 작성 |
|
||
|
|
|
||
|
|
|