ERP-node/카테고리_채번_메뉴스코프_전환_통합_계획서.md

30 KiB

카테고리 및 채번규칙 메뉴 스코프 전환 통합 계획서

📋 현재 문제점 분석

테이블 기반 스코프의 근본적 한계

현재 상황:

  • 카테고리 시스템: 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. 카테고리 시스템 마이그레이션

기존 상태

-- 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 테이블에서는 이 카테고리를 사용할 수 없음

변경 후

-- 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

-- 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. 채번규칙 시스템 마이그레이션

기존 상태

-- numbering_rules 테이블
rule_id   | table_name    | scope_type | company_code
ITEM_CODE | item_info     | table      | COMPANY_A

문제: item_info 테이블을 사용하는 화면에서만 이 규칙 사용 가능

변경 후

-- 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

-- 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. 공통 유틸리티: 형제 메뉴 조회

// 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. 카테고리 서비스 수정

// 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. 채번규칙 서비스 수정

// 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. 컨트롤러 수정

// 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 클라이언트 수정

// 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 전달

// 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로 정리


이 계획서대로 구현하면 테이블 기반 스코프의 한계를 완전히 극복하고, 메뉴 구조 기반의 직관적인 데이터 관리 시스템을 구축할 수 있습니다.

구현을 시작할까요?