✅ 주요 변경사항: - 백엔드: menuService.ts 추가 (형제 메뉴 조회 유틸리티) - 백엔드: numberingRuleService.getAvailableRulesForMenu() 메뉴 스코프 적용 - 백엔드: tableCategoryValueService 메뉴 스코프 준비 (menuObjid 파라미터 추가) - 프론트엔드: TextInputConfigPanel에 부모 메뉴 선택 UI 추가 - 프론트엔드: 메뉴별 채번규칙 필터링 (형제 메뉴 공유) 🔧 기술 세부사항: - getSiblingMenuObjids(): 같은 부모를 가진 형제 메뉴 OBJID 조회 - 채번규칙 우선순위: menu (형제) > table > global - 사용자 메뉴(menu_type='1') 레벨 2만 부모 메뉴로 선택 가능 📝 다음 단계: - 카테고리 컴포넌트도 메뉴 스코프로 전환 예정
1005 lines
30 KiB
Markdown
1005 lines
30 KiB
Markdown
# 카테고리 및 채번규칙 메뉴 스코프 전환 통합 계획서
|
|
|
|
## 📋 현재 문제점 분석
|
|
|
|
### 테이블 기반 스코프의 근본적 한계
|
|
|
|
**현재 상황**:
|
|
- 카테고리 시스템: `table_column_category_values` 테이블에서 `table_name + column_name`으로 데이터 조회
|
|
- 채번규칙 시스템: `numbering_rules` 테이블에서 `table_name`으로 데이터 조회
|
|
|
|
**발생하는 문제**:
|
|
|
|
```
|
|
영업관리 (menu_objid: 200)
|
|
├── 고객관리 (menu_objid: 201) - 테이블: customer_info
|
|
├── 계약관리 (menu_objid: 202) - 테이블: contract_info
|
|
├── 주문관리 (menu_objid: 203) - 테이블: order_info
|
|
└── 공통코드 관리 (menu_objid: 204) - 어떤 테이블 선택?
|
|
```
|
|
|
|
**문제 1**: 형제 메뉴 간 코드 공유 불가
|
|
- 고객관리, 계약관리, 주문관리가 모두 다른 테이블 사용
|
|
- 각 화면마다 **동일한 카테고리/채번규칙을 중복 생성**해야 함
|
|
- "고객 유형" 같은 공통 카테고리를 3번 만들어야 함
|
|
|
|
**문제 2**: 공통코드 관리 화면 불가능
|
|
- 영업관리 전체에서 사용할 공통코드를 관리하려면
|
|
- 특정 테이블 하나를 선택해야 하는데
|
|
- 그러면 다른 테이블을 사용하는 형제 메뉴에서 접근 불가
|
|
|
|
**문제 3**: 비효율적인 유지보수
|
|
- 같은 코드를 여러 테이블에 중복 관리
|
|
- 하나의 값을 수정하려면 모든 테이블에서 수정 필요
|
|
- 데이터 불일치 발생 가능
|
|
|
|
---
|
|
|
|
## ✅ 해결 방안: 메뉴 기반 스코프
|
|
|
|
### 핵심 개념
|
|
|
|
**메뉴 계층 구조를 데이터 스코프로 사용**:
|
|
- 카테고리/채번규칙 생성 시 `menu_objid`를 기록
|
|
- 같은 부모 메뉴를 가진 **형제 메뉴들**이 데이터를 공유
|
|
- 테이블과 무관하게 메뉴 구조에 따라 스코프 결정
|
|
|
|
### 메뉴 스코프 규칙
|
|
|
|
```
|
|
영업관리 (parent_id: 0, menu_objid: 200)
|
|
├── 고객관리 (parent_id: 200, menu_objid: 201)
|
|
├── 계약관리 (parent_id: 200, menu_objid: 202)
|
|
├── 주문관리 (parent_id: 200, menu_objid: 203)
|
|
└── 공통코드 관리 (parent_id: 200, menu_objid: 204) ← 여기서 생성
|
|
```
|
|
|
|
**스코프 규칙**:
|
|
1. 204번 메뉴에서 카테고리 생성 → `menu_objid = 204`로 저장
|
|
2. 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능**
|
|
3. 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가
|
|
|
|
### 이점
|
|
|
|
✅ **형제 메뉴 간 코드 공유**: 한 번 생성하면 모든 형제 메뉴에서 사용
|
|
✅ **공통코드 관리 화면 가능**: 전용 메뉴에서 일괄 관리
|
|
✅ **테이블 독립성**: 테이블이 달라도 같은 카테고리 사용 가능
|
|
✅ **직관적인 관리**: 메뉴 구조가 곧 데이터 스코프
|
|
✅ **유지보수 용이**: 한 곳에서 수정하면 모든 형제 메뉴에 반영
|
|
|
|
---
|
|
|
|
## 📐 데이터베이스 설계
|
|
|
|
### 1. 카테고리 시스템 마이그레이션
|
|
|
|
#### 기존 상태
|
|
```sql
|
|
-- table_column_category_values 테이블
|
|
table_name | column_name | value_code | company_code
|
|
customer_info | customer_type | REGULAR | COMPANY_A
|
|
customer_info | customer_type | VIP | COMPANY_A
|
|
```
|
|
|
|
**문제**: `contract_info` 테이블에서는 이 카테고리를 사용할 수 없음
|
|
|
|
#### 변경 후
|
|
```sql
|
|
-- table_column_category_values 테이블에 menu_objid 추가
|
|
table_name | column_name | value_code | menu_objid | company_code
|
|
customer_info | customer_type | REGULAR | 204 | COMPANY_A
|
|
customer_info | customer_type | VIP | 204 | COMPANY_A
|
|
```
|
|
|
|
**해결**: menu_objid=204의 형제 메뉴(201,202,203,204)에서 모두 사용 가능
|
|
|
|
#### 마이그레이션 SQL
|
|
|
|
```sql
|
|
-- db/migrations/048_convert_category_to_menu_scope.sql
|
|
|
|
-- 1. menu_objid 컬럼 추가 (NULL 허용)
|
|
ALTER TABLE table_column_category_values
|
|
ADD COLUMN IF NOT EXISTS menu_objid NUMERIC;
|
|
|
|
COMMENT ON COLUMN table_column_category_values.menu_objid
|
|
IS '카테고리를 생성한 메뉴 OBJID (형제 메뉴에서 공유)';
|
|
|
|
-- 2. 기존 데이터에 임시 menu_objid 설정
|
|
-- 첫 번째 메뉴의 objid를 가져와서 설정
|
|
DO $$
|
|
DECLARE
|
|
first_menu_objid NUMERIC;
|
|
BEGIN
|
|
SELECT objid INTO first_menu_objid FROM menu_info LIMIT 1;
|
|
|
|
IF first_menu_objid IS NOT NULL THEN
|
|
UPDATE table_column_category_values
|
|
SET menu_objid = first_menu_objid
|
|
WHERE menu_objid IS NULL;
|
|
|
|
RAISE NOTICE '기존 카테고리 데이터의 menu_objid를 %로 설정했습니다', first_menu_objid;
|
|
RAISE NOTICE '관리자가 수동으로 올바른 menu_objid로 변경해야 합니다';
|
|
END IF;
|
|
END $$;
|
|
|
|
-- 3. menu_objid를 NOT NULL로 변경
|
|
ALTER TABLE table_column_category_values
|
|
ALTER COLUMN menu_objid SET NOT NULL;
|
|
|
|
-- 4. 외래키 추가
|
|
ALTER TABLE table_column_category_values
|
|
ADD CONSTRAINT fk_category_value_menu
|
|
FOREIGN KEY (menu_objid) REFERENCES menu_info(objid)
|
|
ON DELETE CASCADE;
|
|
|
|
-- 5. 기존 UNIQUE 제약조건 삭제
|
|
ALTER TABLE table_column_category_values
|
|
DROP CONSTRAINT IF EXISTS unique_category_value;
|
|
|
|
ALTER TABLE table_column_category_values
|
|
DROP CONSTRAINT IF EXISTS table_column_category_values_table_name_column_name_value_key;
|
|
|
|
-- 6. 새로운 UNIQUE 제약조건 추가 (menu_objid 포함)
|
|
ALTER TABLE table_column_category_values
|
|
ADD CONSTRAINT unique_category_value
|
|
UNIQUE (table_name, column_name, value_code, menu_objid, company_code);
|
|
|
|
-- 7. 인덱스 추가 (성능 최적화)
|
|
CREATE INDEX IF NOT EXISTS idx_category_value_menu
|
|
ON table_column_category_values(menu_objid, table_name, column_name, company_code);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_category_value_company
|
|
ON table_column_category_values(company_code, table_name, column_name);
|
|
```
|
|
|
|
### 2. 채번규칙 시스템 마이그레이션
|
|
|
|
#### 기존 상태
|
|
```sql
|
|
-- numbering_rules 테이블
|
|
rule_id | table_name | scope_type | company_code
|
|
ITEM_CODE | item_info | table | COMPANY_A
|
|
```
|
|
|
|
**문제**: `item_info` 테이블을 사용하는 화면에서만 이 규칙 사용 가능
|
|
|
|
#### 변경 후
|
|
```sql
|
|
-- numbering_rules 테이블 (menu_objid 추가)
|
|
rule_id | table_name | scope_type | menu_objid | company_code
|
|
ITEM_CODE | item_info | menu | 204 | COMPANY_A
|
|
```
|
|
|
|
**해결**: menu_objid=204의 형제 메뉴에서 모두 사용 가능
|
|
|
|
#### 마이그레이션 SQL
|
|
|
|
```sql
|
|
-- db/migrations/049_convert_numbering_to_menu_scope.sql
|
|
|
|
-- 1. menu_objid 컬럼 추가 (이미 존재하면 스킵)
|
|
ALTER TABLE numbering_rules
|
|
ADD COLUMN IF NOT EXISTS menu_objid NUMERIC;
|
|
|
|
COMMENT ON COLUMN numbering_rules.menu_objid
|
|
IS '채번규칙을 생성한 메뉴 OBJID (형제 메뉴에서 공유)';
|
|
|
|
-- 2. 기존 데이터 마이그레이션
|
|
DO $$
|
|
DECLARE
|
|
first_menu_objid NUMERIC;
|
|
BEGIN
|
|
SELECT objid INTO first_menu_objid FROM menu_info LIMIT 1;
|
|
|
|
IF first_menu_objid IS NOT NULL THEN
|
|
-- scope_type='table'이고 menu_objid가 NULL인 규칙들을
|
|
-- scope_type='menu'로 변경하고 임시 menu_objid 설정
|
|
UPDATE numbering_rules
|
|
SET scope_type = 'menu',
|
|
menu_objid = first_menu_objid
|
|
WHERE scope_type = 'table'
|
|
AND menu_objid IS NULL;
|
|
|
|
RAISE NOTICE '기존 채번규칙의 scope_type을 menu로 변경하고 menu_objid를 %로 설정했습니다', first_menu_objid;
|
|
RAISE NOTICE '관리자가 수동으로 올바른 menu_objid로 변경해야 합니다';
|
|
END IF;
|
|
END $$;
|
|
|
|
-- 3. 제약조건 수정
|
|
-- menu 타입은 menu_objid 필수
|
|
ALTER TABLE numbering_rules
|
|
DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid;
|
|
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT check_menu_scope_requires_menu_objid
|
|
CHECK (
|
|
(scope_type != 'menu') OR
|
|
(scope_type = 'menu' AND menu_objid IS NOT NULL)
|
|
);
|
|
|
|
-- 4. 외래키 추가 (menu_objid → menu_info.objid)
|
|
ALTER TABLE numbering_rules
|
|
DROP CONSTRAINT IF EXISTS fk_numbering_rule_menu;
|
|
|
|
ALTER TABLE numbering_rules
|
|
ADD CONSTRAINT fk_numbering_rule_menu
|
|
FOREIGN KEY (menu_objid) REFERENCES menu_info(objid)
|
|
ON DELETE CASCADE;
|
|
|
|
-- 5. 인덱스 추가 (성능 최적화)
|
|
CREATE INDEX IF NOT EXISTS idx_numbering_rules_menu
|
|
ON numbering_rules(menu_objid, company_code);
|
|
```
|
|
|
|
---
|
|
|
|
## 🔧 백엔드 구현
|
|
|
|
### 1. 공통 유틸리티: 형제 메뉴 조회
|
|
|
|
```typescript
|
|
// backend-node/src/services/menuService.ts (신규 파일)
|
|
|
|
import { getPool } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
|
|
/**
|
|
* 메뉴의 형제 메뉴 OBJID 목록 조회
|
|
* (같은 부모를 가진 메뉴들)
|
|
*
|
|
* @param menuObjid 현재 메뉴의 OBJID
|
|
* @returns 형제 메뉴 OBJID 배열 (자기 자신 포함)
|
|
*/
|
|
export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
logger.info("형제 메뉴 조회 시작", { menuObjid });
|
|
|
|
// 1. 현재 메뉴의 부모 찾기
|
|
const parentQuery = `
|
|
SELECT parent_id FROM menu_info WHERE objid = $1
|
|
`;
|
|
const parentResult = await pool.query(parentQuery, [menuObjid]);
|
|
|
|
if (parentResult.rows.length === 0) {
|
|
logger.warn("메뉴를 찾을 수 없음", { menuObjid });
|
|
return [menuObjid]; // 메뉴가 없으면 자기 자신만
|
|
}
|
|
|
|
const parentId = parentResult.rows[0].parent_id;
|
|
|
|
if (!parentId || parentId === 0) {
|
|
// 최상위 메뉴인 경우 자기 자신만
|
|
logger.info("최상위 메뉴 (형제 없음)", { menuObjid });
|
|
return [menuObjid];
|
|
}
|
|
|
|
// 2. 같은 부모를 가진 형제 메뉴들 조회
|
|
const siblingsQuery = `
|
|
SELECT objid FROM menu_info WHERE parent_id = $1 ORDER BY objid
|
|
`;
|
|
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
|
|
|
|
const siblingObjids = siblingsResult.rows.map((row) => row.objid);
|
|
|
|
logger.info("형제 메뉴 조회 완료", {
|
|
menuObjid,
|
|
parentId,
|
|
siblingCount: siblingObjids.length,
|
|
siblings: siblingObjids,
|
|
});
|
|
|
|
return siblingObjids;
|
|
} catch (error: any) {
|
|
logger.error("형제 메뉴 조회 실패", { menuObjid, error: error.message });
|
|
// 에러 발생 시 안전하게 자기 자신만 반환
|
|
return [menuObjid];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
|
*
|
|
* @param menuObjids 메뉴 OBJID 배열
|
|
* @returns 모든 형제 메뉴 OBJID 배열 (중복 제거)
|
|
*/
|
|
export async function getAllSiblingMenuObjids(
|
|
menuObjids: number[]
|
|
): Promise<number[]> {
|
|
if (!menuObjids || menuObjids.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const allSiblings = new Set<number>();
|
|
|
|
for (const objid of menuObjids) {
|
|
const siblings = await getSiblingMenuObjids(objid);
|
|
siblings.forEach((s) => allSiblings.add(s));
|
|
}
|
|
|
|
return Array.from(allSiblings).sort((a, b) => a - b);
|
|
}
|
|
```
|
|
|
|
### 2. 카테고리 서비스 수정
|
|
|
|
```typescript
|
|
// backend-node/src/services/tableCategoryValueService.ts
|
|
|
|
import { getSiblingMenuObjids } from "./menuService";
|
|
|
|
class TableCategoryValueService {
|
|
/**
|
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
|
*/
|
|
async getCategoryValues(
|
|
tableName: string,
|
|
columnName: string,
|
|
menuObjid: number, // ← 추가
|
|
companyCode: string,
|
|
includeInactive: boolean = false
|
|
): Promise<TableCategoryValue[]> {
|
|
logger.info("카테고리 값 조회 (메뉴 스코프)", {
|
|
tableName,
|
|
columnName,
|
|
menuObjid,
|
|
companyCode,
|
|
});
|
|
|
|
const pool = getPool();
|
|
|
|
// 1. 형제 메뉴 OBJID 조회
|
|
const siblingObjids = await getSiblingMenuObjids(menuObjid);
|
|
|
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
|
|
|
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
|
let query: string;
|
|
let params: any[];
|
|
|
|
if (companyCode === "*") {
|
|
// 최고 관리자: 모든 회사 데이터 조회
|
|
query = `
|
|
SELECT
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
created_at AS "createdAt",
|
|
created_by AS "createdBy"
|
|
FROM table_column_category_values
|
|
WHERE table_name = $1
|
|
AND column_name = $2
|
|
AND menu_objid = ANY($3) -- ← 형제 메뉴 포함
|
|
${!includeInactive ? 'AND is_active = true' : ''}
|
|
ORDER BY value_order, value_label
|
|
`;
|
|
params = [tableName, columnName, siblingObjids];
|
|
} else {
|
|
// 일반 회사: 자신의 데이터만 조회
|
|
query = `
|
|
SELECT
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
created_at AS "createdAt",
|
|
created_by AS "createdBy"
|
|
FROM table_column_category_values
|
|
WHERE table_name = $1
|
|
AND column_name = $2
|
|
AND menu_objid = ANY($3) -- ← 형제 메뉴 포함
|
|
AND company_code = $4 -- ← 회사별 필터링
|
|
${!includeInactive ? 'AND is_active = true' : ''}
|
|
ORDER BY value_order, value_label
|
|
`;
|
|
params = [tableName, columnName, siblingObjids, companyCode];
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 추가 (menu_objid 저장)
|
|
*/
|
|
async addCategoryValue(
|
|
value: TableCategoryValue,
|
|
menuObjid: number, // ← 추가
|
|
companyCode: string,
|
|
userId: string
|
|
): Promise<TableCategoryValue> {
|
|
logger.info("카테고리 값 추가 (메뉴 스코프)", {
|
|
tableName: value.tableName,
|
|
columnName: value.columnName,
|
|
valueCode: value.valueCode,
|
|
menuObjid,
|
|
companyCode,
|
|
});
|
|
|
|
const pool = getPool();
|
|
|
|
const query = `
|
|
INSERT INTO table_column_category_values (
|
|
table_name, column_name,
|
|
value_code, value_label, value_order,
|
|
parent_value_id, depth,
|
|
description, color, icon,
|
|
is_active, is_default,
|
|
company_code, menu_objid, -- ← menu_objid 추가
|
|
created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
RETURNING
|
|
value_id AS "valueId",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
description,
|
|
color,
|
|
icon,
|
|
is_active AS "isActive",
|
|
is_default AS "isDefault",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
created_at AS "createdAt",
|
|
created_by AS "createdBy"
|
|
`;
|
|
|
|
const result = await pool.query(query, [
|
|
value.tableName,
|
|
value.columnName,
|
|
value.valueCode,
|
|
value.valueLabel,
|
|
value.valueOrder || 0,
|
|
value.parentValueId || null,
|
|
value.depth || 1,
|
|
value.description || null,
|
|
value.color || null,
|
|
value.icon || null,
|
|
value.isActive !== false,
|
|
value.isDefault || false,
|
|
companyCode,
|
|
menuObjid, // ← 카테고리 관리 화면의 menu_objid
|
|
userId,
|
|
]);
|
|
|
|
logger.info("카테고리 값 추가 성공", {
|
|
valueId: result.rows[0].valueId,
|
|
menuObjid,
|
|
});
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
// 수정, 삭제 메서드도 동일하게 menuObjid 파라미터 추가
|
|
}
|
|
|
|
export default TableCategoryValueService;
|
|
```
|
|
|
|
### 3. 채번규칙 서비스 수정
|
|
|
|
```typescript
|
|
// backend-node/src/services/numberingRuleService.ts
|
|
|
|
import { getSiblingMenuObjids } from "./menuService";
|
|
|
|
class NumberingRuleService {
|
|
/**
|
|
* 화면용 채번 규칙 조회 (메뉴 스코프 적용)
|
|
*/
|
|
async getAvailableRulesForScreen(
|
|
companyCode: string,
|
|
tableName: string,
|
|
menuObjid?: number
|
|
): Promise<NumberingRuleConfig[]> {
|
|
logger.info("화면용 채번 규칙 조회 (메뉴 스코프)", {
|
|
companyCode,
|
|
tableName,
|
|
menuObjid,
|
|
});
|
|
|
|
const pool = getPool();
|
|
|
|
// 1. 형제 메뉴 OBJID 조회
|
|
let siblingObjids: number[] = [];
|
|
if (menuObjid) {
|
|
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
|
}
|
|
|
|
// 2. 채번 규칙 조회 (우선순위: menu > table > global)
|
|
let query: string;
|
|
let params: any[];
|
|
|
|
if (companyCode === "*") {
|
|
// 최고 관리자: 모든 회사 데이터 조회 (company_code="*" 제외)
|
|
query = `
|
|
SELECT
|
|
rule_id AS "ruleId",
|
|
rule_name AS "ruleName",
|
|
description,
|
|
separator,
|
|
reset_period AS "resetPeriod",
|
|
current_sequence AS "currentSequence",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
scope_type AS "scopeType",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
created_by AS "createdBy"
|
|
FROM numbering_rules
|
|
WHERE company_code != '*'
|
|
AND (
|
|
${
|
|
siblingObjids.length > 0
|
|
? `(scope_type = 'menu' AND menu_objid = ANY($1)) OR`
|
|
: ""
|
|
}
|
|
(scope_type = 'table' AND table_name = $${siblingObjids.length > 0 ? 2 : 1})
|
|
OR (scope_type = 'global' AND table_name IS NULL)
|
|
)
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END,
|
|
created_at DESC
|
|
`;
|
|
params = siblingObjids.length > 0 ? [siblingObjids, tableName] : [tableName];
|
|
} else {
|
|
// 일반 회사: 자신의 규칙만 조회
|
|
query = `
|
|
SELECT
|
|
rule_id AS "ruleId",
|
|
rule_name AS "ruleName",
|
|
description,
|
|
separator,
|
|
reset_period AS "resetPeriod",
|
|
current_sequence AS "currentSequence",
|
|
table_name AS "tableName",
|
|
column_name AS "columnName",
|
|
company_code AS "companyCode",
|
|
menu_objid AS "menuObjid",
|
|
scope_type AS "scopeType",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
created_by AS "createdBy"
|
|
FROM numbering_rules
|
|
WHERE company_code = $1
|
|
AND (
|
|
${
|
|
siblingObjids.length > 0
|
|
? `(scope_type = 'menu' AND menu_objid = ANY($2)) OR`
|
|
: ""
|
|
}
|
|
(scope_type = 'table' AND table_name = $${siblingObjids.length > 0 ? 3 : 2})
|
|
OR (scope_type = 'global' AND table_name IS NULL)
|
|
)
|
|
ORDER BY
|
|
CASE scope_type
|
|
WHEN 'menu' THEN 1
|
|
WHEN 'table' THEN 2
|
|
WHEN 'global' THEN 3
|
|
END,
|
|
created_at DESC
|
|
`;
|
|
params = siblingObjids.length > 0
|
|
? [companyCode, siblingObjids, tableName]
|
|
: [companyCode, tableName];
|
|
}
|
|
|
|
const result = await pool.query(query, params);
|
|
|
|
// 각 규칙의 파트 정보 로드
|
|
for (const rule of result.rows) {
|
|
const partsQuery = `
|
|
SELECT
|
|
id,
|
|
part_order AS "order",
|
|
part_type AS "partType",
|
|
generation_method AS "generationMethod",
|
|
auto_config AS "autoConfig",
|
|
manual_config AS "manualConfig"
|
|
FROM numbering_rule_parts
|
|
WHERE rule_id = $1
|
|
AND company_code = $2
|
|
ORDER BY part_order
|
|
`;
|
|
|
|
const partsResult = await pool.query(partsQuery, [
|
|
rule.ruleId,
|
|
companyCode === "*" ? rule.companyCode : companyCode,
|
|
]);
|
|
|
|
rule.parts = partsResult.rows;
|
|
}
|
|
|
|
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`);
|
|
|
|
return result.rows;
|
|
}
|
|
}
|
|
|
|
export default NumberingRuleService;
|
|
```
|
|
|
|
### 4. 컨트롤러 수정
|
|
|
|
```typescript
|
|
// backend-node/src/controllers/tableCategoryValueController.ts
|
|
|
|
/**
|
|
* 카테고리 값 목록 조회
|
|
*/
|
|
export async function getCategoryValues(
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> {
|
|
try {
|
|
const { tableName, columnName } = req.params;
|
|
const { menuObjid, includeInactive } = req.query; // ← menuObjid 추가
|
|
const companyCode = req.user!.companyCode;
|
|
|
|
if (!menuObjid) {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: "menuObjid는 필수입니다",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const service = new TableCategoryValueService();
|
|
const values = await service.getCategoryValues(
|
|
tableName,
|
|
columnName,
|
|
Number(menuObjid), // ← menuObjid 전달
|
|
companyCode,
|
|
includeInactive === "true"
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: values,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("카테고리 값 조회 실패:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 값 조회 중 오류 발생",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 추가
|
|
*/
|
|
export async function addCategoryValue(
|
|
req: AuthenticatedRequest,
|
|
res: Response
|
|
): Promise<void> {
|
|
try {
|
|
const { menuObjid, ...value } = req.body; // ← menuObjid 추가
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
|
|
if (!menuObjid) {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: "menuObjid는 필수입니다",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const service = new TableCategoryValueService();
|
|
const newValue = await service.addCategoryValue(
|
|
value,
|
|
menuObjid, // ← menuObjid 전달
|
|
companyCode,
|
|
userId
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: newValue,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error("카테고리 값 추가 실패:", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 값 추가 중 오류 발생",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 프론트엔드 구현
|
|
|
|
### 1. API 클라이언트 수정
|
|
|
|
```typescript
|
|
// frontend/lib/api/tableCategoryValue.ts
|
|
|
|
/**
|
|
* 카테고리 값 목록 조회 (메뉴 스코프)
|
|
*/
|
|
export async function getCategoryValues(
|
|
tableName: string,
|
|
columnName: string,
|
|
menuObjid: number, // ← 추가
|
|
includeInactive: boolean = false
|
|
) {
|
|
try {
|
|
const response = await apiClient.get<{
|
|
success: boolean;
|
|
data: TableCategoryValue[];
|
|
}>(`/table-categories/${tableName}/${columnName}/values`, {
|
|
params: {
|
|
menuObjid, // ← menuObjid 쿼리 파라미터 추가
|
|
includeInactive,
|
|
},
|
|
});
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 값 조회 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 추가
|
|
*/
|
|
export async function addCategoryValue(
|
|
value: TableCategoryValue,
|
|
menuObjid: number // ← 추가
|
|
) {
|
|
try {
|
|
const response = await apiClient.post<{
|
|
success: boolean;
|
|
data: TableCategoryValue;
|
|
}>("/table-categories/values", {
|
|
...value,
|
|
menuObjid, // ← menuObjid 포함
|
|
});
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 값 추가 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. 화면관리 시스템에서 menuObjid 전달
|
|
|
|
```typescript
|
|
// frontend/components/screen/ScreenDesigner.tsx
|
|
|
|
export function ScreenDesigner() {
|
|
const [selectedScreen, setSelectedScreen] = useState<Screen | null>(null);
|
|
|
|
// 선택된 화면의 menuObjid 추출
|
|
const currentMenuObjid = selectedScreen?.menuObjid;
|
|
|
|
return (
|
|
<div>
|
|
{/* 모든 카테고리/채번 관련 컴포넌트에 menuObjid 전달 */}
|
|
<CategoryWidget
|
|
tableName={selectedScreen?.tableName}
|
|
menuObjid={currentMenuObjid} // ← menuObjid 전달
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 3. 컴포넌트 props 수정
|
|
|
|
모든 카테고리/채번 관련 컴포넌트에 `menuObjid: number` prop 추가:
|
|
|
|
- `CategoryColumnList`
|
|
- `CategoryValueManager`
|
|
- `NumberingRuleSelector`
|
|
- `TextTypeConfigPanel`
|
|
|
|
---
|
|
|
|
## 📊 사용 시나리오
|
|
|
|
### 시나리오: 영업관리 공통코드 관리
|
|
|
|
#### 1단계: 메뉴 구조
|
|
|
|
```
|
|
영업관리 (parent_id: 0, menu_objid: 200)
|
|
├── 고객관리 (parent_id: 200, menu_objid: 201) - customer_info 테이블
|
|
├── 계약관리 (parent_id: 200, menu_objid: 202) - contract_info 테이블
|
|
├── 주문관리 (parent_id: 200, menu_objid: 203) - order_info 테이블
|
|
└── 공통코드 관리 (parent_id: 200, menu_objid: 204) - 카테고리 관리 전용
|
|
```
|
|
|
|
#### 2단계: 카테고리 생성
|
|
|
|
1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_objid: 204)
|
|
2. **화면 생성**: 화면관리 시스템에서 화면 생성
|
|
3. **테이블 선택**: `customer_info` (어떤 테이블이든 상관없음)
|
|
4. **카테고리 값 추가**:
|
|
- 컬럼: `customer_type`
|
|
- 값: `REGULAR (일반 고객)`, `VIP (VIP 고객)`
|
|
- **저장 시 `menu_objid = 204`로 자동 저장**
|
|
|
|
#### 3단계: 형제 메뉴에서 사용
|
|
|
|
**고객관리 화면** (menu_objid: 201):
|
|
- ✅ `customer_type` 드롭다운에 `일반 고객`, `VIP 고객` 표시
|
|
- **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴
|
|
|
|
**계약관리 화면** (menu_objid: 202):
|
|
- ✅ `customer_type` 컬럼에 동일한 카테고리 사용 가능
|
|
- **이유**: 202와 204도 형제 메뉴
|
|
|
|
**구매관리 > 발주관리** (parent_id: 300):
|
|
- ❌ 영업관리의 카테고리는 표시되지 않음
|
|
- **이유**: 다른 부모 메뉴이므로 스코프가 다름
|
|
|
|
---
|
|
|
|
## 📝 구현 순서
|
|
|
|
### Phase 1: 데이터베이스 마이그레이션 (1시간)
|
|
|
|
- [ ] `048_convert_category_to_menu_scope.sql` 작성 및 실행
|
|
- [ ] `049_convert_numbering_to_menu_scope.sql` 작성 및 실행
|
|
- [ ] 기존 데이터 확인 및 임시 menu_objid 정리 계획 수립
|
|
|
|
### Phase 2: 백엔드 구현 (3-4시간)
|
|
|
|
- [ ] `menuService.ts` 신규 파일 생성 (`getSiblingMenuObjids()` 함수)
|
|
- [ ] `tableCategoryValueService.ts` 수정 (menuObjid 파라미터 추가)
|
|
- [ ] `numberingRuleService.ts` 수정 (menuObjid 파라미터 추가)
|
|
- [ ] 컨트롤러 수정 (쿼리 파라미터에서 menuObjid 추출)
|
|
- [ ] 백엔드 테스트
|
|
|
|
### Phase 3: 프론트엔드 API 클라이언트 (1시간)
|
|
|
|
- [ ] `tableCategoryValue.ts` API 클라이언트 수정
|
|
- [ ] `numberingRule.ts` API 클라이언트 수정
|
|
|
|
### Phase 4: 프론트엔드 컴포넌트 (3-4시간)
|
|
|
|
- [ ] `CategoryColumnList.tsx` 수정 (menuObjid prop 추가)
|
|
- [ ] `CategoryValueManager.tsx` 수정 (menuObjid prop 추가)
|
|
- [ ] `NumberingRuleSelector.tsx` 수정 (menuObjid prop 추가)
|
|
- [ ] `TextTypeConfigPanel.tsx` 수정 (menuObjid prop 추가)
|
|
- [ ] 모든 컴포넌트에서 API 호출 시 menuObjid 전달
|
|
|
|
### Phase 5: 화면관리 시스템 통합 (2시간)
|
|
|
|
- [ ] `ScreenDesigner.tsx`에서 menuObjid 추출 및 전달
|
|
- [ ] 카테고리 관리 화면 테스트
|
|
- [ ] 채번규칙 설정 화면 테스트
|
|
|
|
### Phase 6: 테스트 및 문서화 (2시간)
|
|
|
|
- [ ] 전체 플로우 테스트
|
|
- [ ] 메뉴 스코프 동작 검증
|
|
- [ ] 사용 가이드 작성
|
|
|
|
**총 예상 시간**: 12-15시간
|
|
|
|
---
|
|
|
|
## 🧪 테스트 체크리스트
|
|
|
|
### 데이터베이스 테스트
|
|
|
|
- [ ] 마이그레이션 정상 실행 확인
|
|
- [ ] menu_objid 외래키 제약조건 확인
|
|
- [ ] UNIQUE 제약조건 확인 (menu_objid 포함)
|
|
- [ ] 인덱스 생성 확인
|
|
|
|
### 백엔드 테스트
|
|
|
|
- [ ] `getSiblingMenuObjids()` 함수가 올바른 형제 메뉴 반환
|
|
- [ ] 최상위 메뉴의 경우 자기 자신만 반환
|
|
- [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함
|
|
- [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음
|
|
- [ ] 멀티테넌시 필터링 정상 작동
|
|
|
|
### 프론트엔드 테스트
|
|
|
|
- [ ] 카테고리 컬럼 목록 정상 표시
|
|
- [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함)
|
|
- [ ] 카테고리 값 추가 시 menuObjid 포함
|
|
- [ ] 채번규칙 목록 정상 표시 (형제 메뉴 포함)
|
|
- [ ] 모든 CRUD 작업 정상 작동
|
|
|
|
### 통합 테스트
|
|
|
|
- [ ] 영업관리 > 공통코드 관리에서 카테고리 생성
|
|
- [ ] 영업관리 > 고객관리에서 카테고리 사용 가능
|
|
- [ ] 영업관리 > 계약관리에서 카테고리 사용 가능
|
|
- [ ] 구매관리에서는 영업관리 카테고리 사용 불가
|
|
- [ ] 채번규칙도 동일하게 동작하는지 확인
|
|
|
|
---
|
|
|
|
## 💡 이점 요약
|
|
|
|
### 1. 형제 메뉴 간 데이터 공유
|
|
- 같은 부서의 화면들이 카테고리/채번규칙 공유
|
|
- 중복 생성 불필요
|
|
|
|
### 2. 공통코드 관리 화면 가능
|
|
- 전용 메뉴에서 일괄 관리
|
|
- 한 곳에서 수정하면 모든 형제 메뉴에 반영
|
|
|
|
### 3. 테이블 독립성
|
|
- 테이블이 달라도 같은 카테고리 사용 가능
|
|
- 테이블 구조 변경에 영향 없음
|
|
|
|
### 4. 직관적인 관리
|
|
- 메뉴 구조가 곧 데이터 스코프
|
|
- 이해하기 쉬운 권한 체계
|
|
|
|
### 5. 유지보수 용이
|
|
- 한 곳에서 수정하면 자동 반영
|
|
- 데이터 불일치 방지
|
|
|
|
---
|
|
|
|
## 🚀 다음 단계
|
|
|
|
### 1. 계획 승인
|
|
이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다.
|
|
|
|
### 2. 단계별 구현
|
|
Phase 1부터 순차적으로 구현하여 안정성 확보
|
|
|
|
### 3. 점진적 마이그레이션
|
|
기존 데이터를 점진적으로 올바른 menu_objid로 정리
|
|
|
|
---
|
|
|
|
**이 계획서대로 구현하면 테이블 기반 스코프의 한계를 완전히 극복하고, 메뉴 구조 기반의 직관적인 데이터 관리 시스템을 구축할 수 있습니다.**
|
|
|
|
구현을 시작할까요?
|
|
|