ERP-node/docs/카테고리_메뉴스코프_개선_계획서.md

674 lines
18 KiB
Markdown

# 카테고리 메뉴 스코프 개선 계획서
## 1. 문제 정의
### 현재 문제점
**카테고리 컴포넌트가 형제 메뉴 기반으로 카테고리를 조회하여 메뉴별 카테고리 구분 불가**
#### 구체적 상황
- `기준정보 > 품목정보` (item_info 테이블 사용)
- `영업관리 > 판매품목정보` (item_info 테이블 사용)
- 두 메뉴가 같은 테이블(`item_info`)을 사용하지만, 표시되어야 할 카테고리는 달라야 함
#### 현재 로직의 문제
```typescript
// 형제 메뉴들이 사용하는 모든 테이블의 카테고리 컬럼 조회
const siblings = await getSiblingMenus(currentMenuId);
const categories = await getCategoriesFromTables(siblings.tables);
```
**결과**:
- `기준정보 > 품목정보`에서 보이지 않아야 할 카테고리가 표시됨
- `영업관리 > 판매품목정보`에서만 보여야 할 카테고리가 `기준정보`에도 표시됨
---
## 2. 해결 방안
### 핵심 아이디어
**카테고리 값이 어떤 2레벨 메뉴에 속하는지 명시적으로 저장하고, 조회 시 2레벨 메뉴 기준으로 필터링**
### 2.1 데이터베이스 구조 (이미 준비됨)
```sql
-- table_column_category_values 테이블에 이미 menu_objid 컬럼 존재
SELECT * FROM table_column_category_values;
-- 컬럼 구조
-- value_id: PK
-- table_name: 테이블명
-- column_name: 컬럼명
-- value_code: 카테고리 코드
-- value_label: 카테고리 라벨
-- menu_objid: 2레벨 메뉴 OBJID (핵심!)
-- company_code: 회사 코드
-- ...
```
### 2.2 메뉴 계층 구조
```
1레벨 메뉴 (기준정보, objid=1)
├── 2레벨 메뉴 (회사정보, objid=101)
├── 2레벨 메뉴 (부서관리, objid=102)
└── 2레벨 메뉴 (품목정보, objid=103) ← 여기에 item_info 테이블 사용
1레벨 메뉴 (영업관리, objid=2)
├── 2레벨 메뉴 (견적관리, objid=201)
├── 2레벨 메뉴 (수주관리, objid=202)
└── 2레벨 메뉴 (판매품목정보, objid=203) ← 여기도 item_info 테이블 사용
```
### 2.3 카테고리 값 저장 방식
```sql
-- 기준정보 > 품목정보에서 사용할 카테고리
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid, company_code)
VALUES
('item_info', 'category_type', 'STOCK_ITEM', '재고품목', 103, 'COMPANY_A'),
('item_info', 'category_type', 'ASSET', '자산', 103, 'COMPANY_A');
-- 영업관리 > 판매품목정보에서 사용할 카테고리
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid, company_code)
VALUES
('item_info', 'category_type', 'SALES_ITEM', '판매품목', 203, 'COMPANY_A'),
('item_info', 'category_type', 'SERVICE', '서비스', 203, 'COMPANY_A');
```
---
## 3. 구현 단계
### Phase 1: 백엔드 API 수정
#### 3.1 카테고리 조회 API 개선
**파일**: `backend-node/src/controllers/categoryController.ts`
**현재 로직**:
```typescript
export async function getCategoryColumns(req: Request, res: Response) {
const { tableName } = req.params;
const companyCode = req.user!.companyCode;
// 형제 메뉴들의 테이블에서 카테고리 컬럼 조회
const siblings = await getSiblingMenuTables(menuObjid);
const query = `
SELECT DISTINCT
table_name,
column_name,
value_code,
value_label
FROM table_column_category_values
WHERE table_name IN (${siblings.join(',')})
AND company_code = $1
`;
}
```
**개선된 로직**:
```typescript
export async function getCategoryColumns(req: Request, res: Response) {
const { tableName, menuObjid } = req.params; // menuObjid 추가
const companyCode = req.user!.companyCode;
// 2레벨 메뉴 OBJID 찾기
const topLevelMenuId = await getTopLevelMenuId(menuObjid);
const query = `
SELECT
table_name,
column_name,
value_code,
value_label,
value_order,
parent_value_id,
depth,
description,
color,
icon
FROM table_column_category_values
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
AND is_active = true
ORDER BY value_order, value_label
`;
const result = await pool.query(query, [tableName, topLevelMenuId, companyCode]);
logger.info("카테고리 컬럼 조회 (메뉴 스코프)", {
tableName,
menuObjid: topLevelMenuId,
companyCode,
categoryCount: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
}
```
#### 3.2 2레벨 메뉴 OBJID 찾기 함수
```typescript
/**
* 현재 메뉴의 최상위(2레벨) 메뉴 OBJID 찾기
*
* @param menuObjid - 현재 메뉴 OBJID
* @returns 2레벨 메뉴 OBJID
*/
async function getTopLevelMenuId(menuObjid: number): Promise<number> {
const query = `
WITH RECURSIVE menu_hierarchy AS (
-- 현재 메뉴
SELECT
objid,
parent_obj_id,
menu_type,
1 as level
FROM menu_info
WHERE objid = $1
UNION ALL
-- 부모 메뉴들 재귀 조회
SELECT
m.objid,
m.parent_obj_id,
m.menu_type,
mh.level + 1
FROM menu_info m
JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
)
SELECT objid
FROM menu_hierarchy
WHERE parent_obj_id IS NULL OR menu_type = 1 -- 1레벨 메뉴의 자식 = 2레벨
ORDER BY level DESC
LIMIT 1;
`;
const result = await pool.query(query, [menuObjid]);
if (result.rowCount === 0) {
throw new Error(`메뉴를 찾을 수 없습니다: ${menuObjid}`);
}
return result.rows[0].objid;
}
```
#### 3.3 카테고리 값 생성 API 개선
**파일**: `backend-node/src/controllers/categoryController.ts`
```typescript
export async function createCategoryValue(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const {
tableName,
columnName,
valueCode,
valueLabel,
menuObjid, // 필수로 추가
parentValueId,
depth,
description,
color,
icon,
} = req.body;
// 입력 검증
if (!tableName || !columnName || !valueCode || !valueLabel || !menuObjid) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다",
});
}
const query = `
INSERT INTO table_column_category_values (
table_name,
column_name,
value_code,
value_label,
menu_objid,
parent_value_id,
depth,
description,
color,
icon,
company_code,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *
`;
const result = await pool.query(query, [
tableName,
columnName,
valueCode,
valueLabel,
menuObjid,
parentValueId || null,
depth || 1,
description || null,
color || null,
icon || null,
companyCode,
req.user!.userId,
req.user!.userId,
]);
logger.info("카테고리 값 생성", {
tableName,
columnName,
valueCode,
menuObjid,
companyCode,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
### Phase 2: 프론트엔드 - 테이블 타입 관리 UI 개선
#### 2.1 테이블 타입 관리 컴포넌트 수정
**파일**: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
**추가할 기능**:
1. 카테고리 컬럼 설정 시 "적용할 메뉴 선택" 기능
2. 2레벨 메뉴 목록 조회 및 다중 선택 UI
3. 선택된 메뉴별로 카테고리 값 생성
```typescript
interface CategoryMenuScope {
menuObjid: number;
menuName: string;
parentMenuName: string;
isSelected: boolean;
}
function CategoryColumnConfig({ tableName, columnName }: Props) {
const [menuScopes, setMenuScopes] = useState<CategoryMenuScope[]>([]);
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
// 2레벨 메뉴 목록 조회
useEffect(() => {
async function loadMenuScopes() {
const response = await apiClient.get("/api/menus/second-level");
if (response.data.success) {
setMenuScopes(response.data.data);
}
}
loadMenuScopes();
}, []);
// 카테고리 값 저장 시 선택된 메뉴들에 대해 각각 저장
const handleSaveCategoryValue = async (categoryData: any) => {
for (const menuObjid of selectedMenus) {
await apiClient.post("/api/categories/values", {
...categoryData,
tableName,
columnName,
menuObjid, // 메뉴별로 저장
});
}
toast.success("카테고리가 선택된 메뉴에 저장되었습니다");
};
return (
<div className="space-y-4">
<Label>적용할 메뉴 선택</Label>
<div className="border rounded-lg p-4 space-y-2 max-h-60 overflow-y-auto">
{menuScopes.map((scope) => (
<div key={scope.menuObjid} className="flex items-center gap-2">
<Checkbox
checked={selectedMenus.includes(scope.menuObjid)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedMenus([...selectedMenus, scope.menuObjid]);
} else {
setSelectedMenus(selectedMenus.filter(id => id !== scope.menuObjid));
}
}}
/>
<span className="text-sm">
{scope.parentMenuName} {scope.menuName}
</span>
</div>
))}
</div>
{selectedMenus.length === 0 && (
<p className="text-xs text-destructive">
최소 하나 이상의 메뉴를 선택해주세요
</p>
)}
{/* 카테고리 값 추가 UI */}
<CategoryValueEditor
onSave={handleSaveCategoryValue}
disabled={selectedMenus.length === 0}
/>
</div>
);
}
```
#### 2.2 2레벨 메뉴 조회 API 추가
**파일**: `backend-node/src/controllers/menuController.ts`
```typescript
/**
* 2레벨 메뉴 목록 조회
* (카테고리 스코프 선택용)
*/
export async function getSecondLevelMenus(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const query = `
SELECT
m2.objid as menu_objid,
m2.menu_name_kor as menu_name,
m1.menu_name_kor as parent_menu_name,
m2.screen_code
FROM menu_info m2
JOIN menu_info m1 ON m2.parent_obj_id = m1.objid
WHERE m2.menu_type = 2 -- 2레벨 메뉴
AND (m2.company_code = $1 OR m2.company_code = '*')
AND m2.status = 'Y'
ORDER BY m1.seq, m2.seq
`;
const result = await pool.query(query, [companyCode]);
return res.json({
success: true,
data: result.rows,
});
}
```
**라우트 등록**:
```typescript
router.get("/api/menus/second-level", authenticate, getSecondLevelMenus);
```
### Phase 3: 프론트엔드 - 카테고리 컴포넌트 개선
#### 3.1 카테고리 컴포넌트 조회 로직 변경
**파일**: `frontend/lib/registry/components/category/CategoryComponent.tsx`
**현재 로직** (형제 메뉴 기반):
```typescript
useEffect(() => {
async function loadCategories() {
// 형제 메뉴들의 테이블에서 카테고리 조회
const response = await apiClient.get(`/api/categories/${tableName}`);
}
loadCategories();
}, [tableName]);
```
**개선된 로직** (2레벨 메뉴 기반):
```typescript
useEffect(() => {
async function loadCategories() {
// 현재 메뉴 OBJID 가져오기
const menuObjid = screenConfig.menuObjid; // 또는 URL에서 파싱
if (!menuObjid) {
console.warn("메뉴 OBJID를 찾을 수 없습니다");
return;
}
// 2레벨 메뉴 기준으로 카테고리 조회
const response = await apiClient.get(
`/api/categories/${tableName}/menu/${menuObjid}`
);
if (response.data.success) {
setCategories(response.data.data);
}
}
loadCategories();
}, [tableName, screenConfig.menuObjid]);
```
#### 3.2 API 클라이언트 함수 추가
**파일**: `frontend/lib/api/category.ts`
```typescript
/**
* 특정 메뉴 스코프의 카테고리 컬럼 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
*/
export async function getCategoriesByMenuScope(
tableName: string,
menuObjid: number
): Promise<ApiResponse<CategoryColumn[]>> {
try {
const response = await apiClient.get(
`/api/categories/${tableName}/menu/${menuObjid}`
);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
```
---
## 4. 데이터 마이그레이션
### 4.1 기존 카테고리 값에 menu_objid 설정
**문제**: 기존 카테고리 값들은 `menu_objid`가 설정되지 않음
**해결**: 관리자가 테이블 타입 관리에서 기존 카테고리 값들의 메뉴 스코프를 선택하도록 유도
#### 마이그레이션 스크립트 (선택사항)
```sql
-- db/migrations/053_backfill_category_menu_objid.sql
-- Step 1: 기존 카테고리 값 확인
SELECT
value_id,
table_name,
column_name,
value_label,
menu_objid,
company_code
FROM table_column_category_values
WHERE menu_objid IS NULL OR menu_objid = 0;
-- Step 2: 기본값 설정 (예시)
-- 관리자가 수동으로 올바른 menu_objid를 설정해야 함
UPDATE table_column_category_values
SET menu_objid = 103 -- 예: 기준정보 > 품목정보
WHERE table_name = 'item_info'
AND column_name = 'category_type'
AND value_code IN ('STOCK_ITEM', 'ASSET')
AND (menu_objid IS NULL OR menu_objid = 0);
UPDATE table_column_category_values
SET menu_objid = 203 -- 예: 영업관리 > 판매품목정보
WHERE table_name = 'item_info'
AND column_name = 'category_type'
AND value_code IN ('SALES_ITEM', 'SERVICE')
AND (menu_objid IS NULL OR menu_objid = 0);
```
### 4.2 UI에서 menu_objid 미설정 경고
```typescript
// TableTypeManagement.tsx
function CategoryColumnList({ categoryColumns }: Props) {
const unassignedCategories = categoryColumns.filter(
(col) => !col.menuObjid || col.menuObjid === 0
);
if (unassignedCategories.length > 0) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>메뉴 스코프 미설정</AlertTitle>
<AlertDescription>
{unassignedCategories.length}개의 카테고리 값에 메뉴가 설정되지 않았습니다.
<br />
카테고리가 어떤 메뉴에서 사용될지 설정해주세요.
</AlertDescription>
</Alert>
);
}
return <div>...</div>;
}
```
---
## 5. 테스트 시나리오
### 5.1 기본 기능 테스트
1. **2레벨 메뉴 조회**
- `GET /api/menus/second-level`
- 기준정보, 영업관리 등 2레벨 메뉴들이 조회되는지 확인
2. **카테고리 값 생성 (메뉴 스코프 포함)**
- 테이블 타입 관리에서 카테고리 컬럼 선택
- 적용할 메뉴 선택 (예: 영업관리 > 판매품목정보)
- 카테고리 값 추가
- DB에 올바른 `menu_objid`로 저장되는지 확인
3. **카테고리 컴포넌트 조회 (메뉴별 필터링)**
- `기준정보 > 품목정보` 화면 접속
- 해당 메뉴의 카테고리만 표시되는지 확인
- `영업관리 > 판매품목정보` 화면 접속
- 다른 카테고리가 표시되는지 확인
### 5.2 엣지 케이스 테스트
1. **menu_objid 미설정 카테고리**
- 기존 카테고리 값 (menu_objid = NULL)
- 경고 메시지 표시
- 조회 시 제외되는지 확인
2. **여러 메뉴에 동일 카테고리**
- 하나의 카테고리를 여러 메뉴에 적용
- 각 메뉴에서 독립적으로 관리되는지 확인
3. **최고 관리자 권한**
- 최고 관리자는 모든 메뉴의 카테고리를 볼 수 있는지 확인
---
## 6. 롤백 계획
만약 문제가 발생하면 다음 단계로 롤백:
### 6.1 백엔드 API 롤백
```bash
git revert <commit-hash>
```
### 6.2 데이터베이스 롤백
```sql
-- menu_objid 조건 제거 (임시)
-- 기존 로직으로 돌아감
```
### 6.3 프론트엔드 롤백
- 카테고리 조회 시 `menuObjid` 파라미터 제거
- 형제 메뉴 기반 로직으로 복원
---
## 7. 구현 우선순위
### Phase 1 (필수)
1. ✅ 백엔드: `getTopLevelMenuId()` 함수 구현
2. ✅ 백엔드: 카테고리 조회 API에 `menu_objid` 필터링 추가
3. ✅ 백엔드: 카테고리 값 생성 시 `menu_objid` 필수 처리
4. ✅ 백엔드: 2레벨 메뉴 목록 조회 API 추가
### Phase 2 (필수)
5. ✅ 프론트엔드: 테이블 타입 관리에서 메뉴 선택 UI 추가
6. ✅ 프론트엔드: 카테고리 컴포넌트 조회 로직 변경
### Phase 3 (권장)
7. ⏳ 데이터 마이그레이션: 기존 카테고리 값에 `menu_objid` 설정
8. ⏳ UI: `menu_objid` 미설정 경고 표시
9. ⏳ 테스트: 시나리오별 검증
### Phase 4 (선택)
10. ⏳ 관리자 대시보드: 카테고리 사용 현황 통계
11. ⏳ 벌크 업데이트: 여러 카테고리의 메뉴 스코프 일괄 변경
---
## 8. 기대 효과
### 8.1 문제 해결
- ✅ 같은 테이블을 사용하는 메뉴들도 서로 다른 카테고리 사용 가능
- ✅ 메뉴별 카테고리 독립성 보장
- ✅ 관리자가 메뉴별로 카테고리를 명확히 제어
### 8.2 사용자 경험 개선
- ✅ 카테고리가 메뉴 문맥에 맞게 표시됨
- ✅ 불필요한 카테고리가 표시되지 않음
- ✅ 직관적인 카테고리 관리 UI
### 8.3 확장성
- ✅ 향후 메뉴별 세밀한 권한 제어 가능
- ✅ 메뉴별 카테고리 통계 및 분석 가능
- ✅ 다른 컴포넌트에도 유사한 메뉴 스코프 패턴 적용 가능
---
## 9. 참고 자료
- 데이터베이스 스키마: `table_column_category_values` 테이블
- 메뉴 구조: `menu_info` 테이블
- 카테고리 컴포넌트: `frontend/lib/registry/components/category/CategoryComponent.tsx`
- 테이블 타입 관리: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
- API 클라이언트: `frontend/lib/api/category.ts`
---
## 10. 결론
사용자가 제안한 **"테이블 타입 관리에서 카테고리 컬럼 설정 시 2레벨 메뉴 선택"** 방식이 가장 현실적이고 효과적인 해결책입니다.
**핵심 변경사항**:
1. 카테고리 값 저장 시 `menu_objid` 필수 입력
2. 카테고리 조회 시 2레벨 메뉴 기준 필터링 (형제 메뉴 기준 제거)
3. 테이블 타입 관리 UI에서 메뉴 선택 기능 추가
이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.