# 카테고리 및 채번규칙 메뉴 스코프 전환 통합 계획서 ## 📋 현재 문제점 분석 ### 테이블 기반 스코프의 근본적 한계 **현재 상황**: - 카테고리 시스템: `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 { 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 { if (!menuObjids || menuObjids.length === 0) { return []; } const allSiblings = new Set(); 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 { 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 { 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 { 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 { 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 { 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(null); // 선택된 화면의 menuObjid 추출 const currentMenuObjid = selectedScreen?.menuObjid; return (
{/* 모든 카테고리/채번 관련 컴포넌트에 menuObjid 전달 */}
); } ``` ### 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로 정리 --- **이 계획서대로 구현하면 테이블 기반 스코프의 한계를 완전히 극복하고, 메뉴 구조 기반의 직관적인 데이터 관리 시스템을 구축할 수 있습니다.** 구현을 시작할까요?