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

18 KiB

카테고리 메뉴 스코프 개선 계획서

1. 문제 정의

현재 문제점

카테고리 컴포넌트가 형제 메뉴 기반으로 카테고리를 조회하여 메뉴별 카테고리 구분 불가

구체적 상황

  • 기준정보 > 품목정보 (item_info 테이블 사용)
  • 영업관리 > 판매품목정보 (item_info 테이블 사용)
  • 두 메뉴가 같은 테이블(item_info)을 사용하지만, 표시되어야 할 카테고리는 달라야 함

현재 로직의 문제

// 형제 메뉴들이 사용하는 모든 테이블의 카테고리 컬럼 조회
const siblings = await getSiblingMenus(currentMenuId);
const categories = await getCategoriesFromTables(siblings.tables);

결과:

  • 기준정보 > 품목정보에서 보이지 않아야 할 카테고리가 표시됨
  • 영업관리 > 판매품목정보에서만 보여야 할 카테고리가 기준정보에도 표시됨

2. 해결 방안

핵심 아이디어

카테고리 값이 어떤 2레벨 메뉴에 속하는지 명시적으로 저장하고, 조회 시 2레벨 메뉴 기준으로 필터링

2.1 데이터베이스 구조 (이미 준비됨)

-- 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 카테고리 값 저장 방식

-- 기준정보 > 품목정보에서 사용할 카테고리
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

현재 로직:

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
  `;
}

개선된 로직:

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 찾기 함수

/**
 * 현재 메뉴의 최상위(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

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. 선택된 메뉴별로 카테고리 값 생성
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

/**
 * 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,
  });
}

라우트 등록:

router.get("/api/menus/second-level", authenticate, getSecondLevelMenus);

Phase 3: 프론트엔드 - 카테고리 컴포넌트 개선

3.1 카테고리 컴포넌트 조회 로직 변경

파일: frontend/lib/registry/components/category/CategoryComponent.tsx

현재 로직 (형제 메뉴 기반):

useEffect(() => {
  async function loadCategories() {
    // 형제 메뉴들의 테이블에서 카테고리 조회
    const response = await apiClient.get(`/api/categories/${tableName}`);
  }
  loadCategories();
}, [tableName]);

개선된 로직 (2레벨 메뉴 기반):

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

/**
 * 특정 메뉴 스코프의 카테고리 컬럼 조회
 * 
 * @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가 설정되지 않음

해결: 관리자가 테이블 타입 관리에서 기존 카테고리 값들의 메뉴 스코프를 선택하도록 유도

마이그레이션 스크립트 (선택사항)

-- 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 미설정 경고

// 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 롤백

git revert <commit-hash>

6.2 데이터베이스 롤백

-- menu_objid 조건 제거 (임시)
-- 기존 로직으로 돌아감

6.3 프론트엔드 롤백

  • 카테고리 조회 시 menuObjid 파라미터 제거
  • 형제 메뉴 기반 로직으로 복원

7. 구현 우선순위

Phase 1 (필수)

  1. 백엔드: getTopLevelMenuId() 함수 구현
  2. 백엔드: 카테고리 조회 API에 menu_objid 필터링 추가
  3. 백엔드: 카테고리 값 생성 시 menu_objid 필수 처리
  4. 백엔드: 2레벨 메뉴 목록 조회 API 추가

Phase 2 (필수)

  1. 프론트엔드: 테이블 타입 관리에서 메뉴 선택 UI 추가
  2. 프론트엔드: 카테고리 컴포넌트 조회 로직 변경

Phase 3 (권장)

  1. 데이터 마이그레이션: 기존 카테고리 값에 menu_objid 설정
  2. UI: menu_objid 미설정 경고 표시
  3. 테스트: 시나리오별 검증

Phase 4 (선택)

  1. 관리자 대시보드: 카테고리 사용 현황 통계
  2. 벌크 업데이트: 여러 카테고리의 메뉴 스코프 일괄 변경

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에서 메뉴 선택 기능 추가

이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.