From 945b65b870ff87f63b1b34ea110a3ac9730cf274 Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 12 May 2026 11:45:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=ED=80=80=EC=8A=A4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=A9=94=EB=89=B4=20+=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=ED=83=80=EC=9E=85=EA=B4=80=EB=A6=AC=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8/=EA=B2=80=EC=A6=9D=20+=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시스템 관리 > 시퀀스 관리 신규 메뉴 + 페이지(채번 룰 빌더) - ensureSequenceMngMenu 부팅 시드 - 테이블 타입관리 → 채번 룰 드롭다운 + 의존성 자동 검증 - 표시명/코멘트 UX 개선 - 설계 문서 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/src/app.ts | 8 + .../src/services/sequenceMngMenuMigration.ts | 145 +++ docs/table-type-and-screen-design.md | 185 +++ .../admin/systemMng/sequenceMng/page.tsx | 1078 +++++++++++++++++ .../admin/systemMng/tableMngList/page.tsx | 99 +- .../admin/table-type/ColumnDetailPanel.tsx | 141 ++- frontend/components/admin/table-type/types.ts | 10 +- .../components/layout/AdminPageRenderer.tsx | 2 + frontend/lib/utils/numberingRuleDeps.ts | 81 ++ 9 files changed, 1694 insertions(+), 55 deletions(-) create mode 100644 backend-node/src/services/sequenceMngMenuMigration.ts create mode 100644 docs/table-type-and-screen-design.md create mode 100644 frontend/app/(main)/admin/systemMng/sequenceMng/page.tsx create mode 100644 frontend/lib/utils/numberingRuleDeps.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 97a13d9b..64719bce 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -539,6 +539,14 @@ async function initializeServices() { logger.error(`❌ wace_plm 공통코드 시드 실패:`, error); } + // 시퀀스 관리 메뉴(시스템 관리 > 시퀀스 관리) 등록 — menu_info 멱등 시드 + try { + const { ensureSequenceMngMenu } = await import("./services/sequenceMngMenuMigration"); + await ensureSequenceMngMenu(); + } catch (error) { + logger.error(`❌ 시퀀스 관리 메뉴 시드 실패:`, error); + } + // 고객 CS 관리 테이블 점검 (customer_cs_mng + 공통코드 카테고리) try { const { ensureCustomerCsTables } = await import("./services/customerCsTableMigration"); diff --git a/backend-node/src/services/sequenceMngMenuMigration.ts b/backend-node/src/services/sequenceMngMenuMigration.ts new file mode 100644 index 00000000..d3e93d2b --- /dev/null +++ b/backend-node/src/services/sequenceMngMenuMigration.ts @@ -0,0 +1,145 @@ +/** + * 시퀀스 관리 메뉴(시스템 관리 > 시퀀스 관리) idempotent 시드. + * + * - 부팅 시 1회 실행. + * - menu_url='/admin/systemMng/sequenceMng' 이 이미 있으면 skip(정규화만). + * - 부모(시스템 관리) 찾기 — 다음 순서로 fallback: + * (1) menu_name_kor = '시스템 관리' / '시스템관리' + * (2) menu_url LIKE '/admin/systemMng/%' 자식들의 가장 빈도 높은 parent_obj_id + * (3) menu_url LIKE '/admin/system%' 자식들의 가장 빈도 높은 parent_obj_id + * (4) 같은 회사 코드의 메뉴 중 menu_name_kor LIKE '%시스템%' (느슨한 매칭) + * - 부모를 찾지 못하면 등록을 건너뛰고 진단 로그 남김(운영 환경에서 수동 등록 가능). + */ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const TARGET_URL = "/admin/systemMng/sequenceMng"; +const MENU_NAME_KOR = "시퀀스 관리"; +const MENU_NAME_ENG = "Sequence Management"; + +async function findSystemMngParentObjId(pool: ReturnType): Promise { + // (1) 이름이 정확히 '시스템 관리' or '시스템관리' + const byName = await pool.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE COALESCE(status,'active') = 'active' + AND (menu_name_kor = '시스템 관리' OR menu_name_kor = '시스템관리') + ORDER BY objid ASC + LIMIT 1`, + ); + if (byName.rowCount && byName.rowCount > 0) { + return Number(byName.rows[0].objid); + } + + // (2) /admin/systemMng/* 자식들의 부모 빈도 1위 + const byChildren1 = await pool.query<{ parent_obj_id: number; cnt: string }>( + `SELECT parent_obj_id, COUNT(*)::text AS cnt + FROM menu_info + WHERE menu_url LIKE '/admin/systemMng/%' + AND parent_obj_id IS NOT NULL + GROUP BY parent_obj_id + ORDER BY cnt DESC NULLS LAST + LIMIT 1`, + ); + if (byChildren1.rowCount && byChildren1.rowCount > 0) { + return Number(byChildren1.rows[0].parent_obj_id); + } + + // (3) /admin/system* 자식들 (대소문자/언더스코어 변형 대비) + const byChildren2 = await pool.query<{ parent_obj_id: number; cnt: string }>( + `SELECT parent_obj_id, COUNT(*)::text AS cnt + FROM menu_info + WHERE (menu_url ILIKE '/admin/system%' OR menu_url ILIKE '/admin/sys%') + AND parent_obj_id IS NOT NULL + GROUP BY parent_obj_id + ORDER BY cnt DESC NULLS LAST + LIMIT 1`, + ); + if (byChildren2.rowCount && byChildren2.rowCount > 0) { + return Number(byChildren2.rows[0].parent_obj_id); + } + + // (4) 느슨한 이름 매칭 + const byNameLike = await pool.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE COALESCE(status,'active') = 'active' + AND menu_name_kor LIKE '%시스템%' + ORDER BY objid ASC + LIMIT 1`, + ); + if (byNameLike.rowCount && byNameLike.rowCount > 0) { + return Number(byNameLike.rows[0].objid); + } + + return null; +} + +export async function ensureSequenceMngMenu(): Promise { + const pool = getPool(); + try { + // (1) 이미 있으면 종료 + const existing = await pool.query( + `SELECT objid FROM menu_info WHERE menu_url = $1 LIMIT 1`, + [TARGET_URL], + ); + if (existing.rowCount && existing.rowCount > 0) { + await pool.query( + `UPDATE menu_info + SET menu_name_kor = COALESCE(NULLIF(menu_name_kor,''), $1), + menu_name_eng = COALESCE(NULLIF(menu_name_eng,''), $2), + status = 'active' + WHERE menu_url = $3`, + [MENU_NAME_KOR, MENU_NAME_ENG, TARGET_URL], + ); + logger.info(`✅ 시퀀스 관리 메뉴 이미 존재 — 정규화만 수행`); + return; + } + + // (2) 부모 찾기 (다중 fallback) + const parentObjId = await findSystemMngParentObjId(pool); + if (parentObjId == null) { + logger.warn( + `[sequenceMngMenu] '시스템 관리' 메뉴를 찾지 못해 시퀀스 관리 시드를 건너뜁니다. ` + + `메뉴 관리 UI 에서 수동 등록하거나, menu_info 에 다음 행을 추가하세요:\n` + + ` parent_obj_id = (시스템 관리 OBJID), menu_name_kor = '시퀀스 관리',\n` + + ` menu_url = '/admin/systemMng/sequenceMng', status = 'active'.`, + ); + return; + } + + // (3) seq 와 objid 산출 + const maxSeq = await pool.query<{ max_seq: number | null }>( + `SELECT COALESCE(MAX(seq), 0)::int AS max_seq + FROM menu_info WHERE parent_obj_id = $1`, + [parentObjId], + ); + const nextSeq = (Number(maxSeq.rows[0]?.max_seq) || 0) + 1; + + const nextObjid = await pool.query<{ next: number }>( + `SELECT (COALESCE(MAX(objid::bigint), 100000) + 1)::int AS next FROM menu_info`, + ); + const objid = Number(nextObjid.rows[0].next); + + // (4) 부모 행에서 company_code/writer 승계 (참조 일관성) + const parentRow = await pool.query<{ company_code: string | null; writer: string | null }>( + `SELECT company_code, writer FROM menu_info WHERE objid = $1`, + [parentObjId], + ); + const companyCode = parentRow.rows[0]?.company_code ?? "*"; + const writer = parentRow.rows[0]?.writer ?? "system-seed"; + + // (5) INSERT + await pool.query( + `INSERT INTO menu_info + (objid, parent_obj_id, menu_name_kor, menu_name_eng, menu_url, seq, + menu_type, company_code, writer, regdate, status) + VALUES ($1, $2, $3, $4, $5, $6, 2, $7, $8, NOW(), 'active')`, + [objid, parentObjId, MENU_NAME_KOR, MENU_NAME_ENG, TARGET_URL, nextSeq, companyCode, writer], + ); + + logger.info( + `🌱 시퀀스 관리 메뉴 등록 완료: objid=${objid}, parent=${parentObjId}, seq=${nextSeq}, company=${companyCode}`, + ); + } catch (e: any) { + logger.warn(`[sequenceMngMenu] 메뉴 시드 실패(계속 진행): ${e?.message?.slice(0, 200)}`); + } +} diff --git a/docs/table-type-and-screen-design.md b/docs/table-type-and-screen-design.md new file mode 100644 index 00000000..76cda446 --- /dev/null +++ b/docs/table-type-and-screen-design.md @@ -0,0 +1,185 @@ +# 테이블 타입 관리 ↔ 화면관리 설계 방향성 + +> "저장 타입(테이블 타입 관리)" 과 "표시 UI(화면관리)" 를 명확히 분리한다. +> 한 컬럼이 어떤 **데이터인지**는 테이블 타입에서 정하고, +> 그 데이터를 화면에서 **어떻게 보여줄지**는 화면관리에서 정한다. + +--- + +## 1. 핵심 원칙 + +| 레이어 | 무엇을 결정하나 | 누가 정의하나 | +|---|---|---| +| **테이블 타입 관리** | 저장 단위(타입), 컬럼 코멘트, PK/NN/IDX/UQ, 참조 대상 | 개발/관리자 | +| **화면관리** | 입력 UI 컴포넌트(text/textarea/달력/체크박스/라디오/셀렉트…), 화면별 라벨, 정렬, 필수 여부, 기본값, 표시 옵션 | 개발/관리자 | + +저장 타입은 **간단하게**, 화면 UI 는 **풍부하게**. 한 저장 타입에 여러 UI 컴포넌트가 매핑된다. + +--- + +## 2. 테이블 생성 규칙 + +### 2.1 코멘트 필수 +- 모든 테이블에는 **코멘트가 필수**. 빈 값이면 저장 차단. +- 모든 컬럼에도 코멘트가 필수 (시스템 자동 생성 컬럼 제외). +- 코멘트는 PostgreSQL `COMMENT ON TABLE / COLUMN ... IS '...'` 로 DB 에 같이 박아 둔다. +- 화면관리는 이 코멘트를 **기본 라벨** 로 가져다 쓴다. 화면별로 라벨을 다르게 쓰고 싶으면 화면관리에서 재정의. + +### 2.2 시스템 자동 생성 컬럼 (테이블 생성 시 자동 부여, 사용자 편집 불가) +``` +id BIGINT PRIMARY KEY -- 행 식별자 +company_code VARCHAR(40) -- 멀티테넌트 회사 코드 +writer VARCHAR(80) -- 작성자(사용자 ID) +created_date TIMESTAMP DEFAULT NOW() -- 생성 일시 +updated_date TIMESTAMP -- 수정 일시 (트리거로 갱신) +``` +- 위 5개 컬럼은 테이블 생성 시 자동 삽입. +- 테이블 타입 관리 UI 에서 `🔒 잠금` 표시되고, 타입/PK/NN/IDX/UQ/코멘트 모두 수정 불가. +- 화면관리에서 이 컬럼들을 화면에 표시할지 여부는 화면 작성자가 결정. + +### 2.3 사용자 정의 컬럼 +- 위 메타 컬럼을 제외한 나머지는 **모두 사람이 직접 정의**. +- 컬럼명, 코멘트(=기본 라벨), 타입, PK/NN/IDX/UQ 를 사람이 입력. + +--- + +## 3. 컬럼 타입 (저장 단위) + +테이블 타입 관리에서 선택 가능한 **저장 타입**은 다음 8개로 제한한다. + +| 그룹 | 타입 | 코드값 | 설명 | DB 표현 (PostgreSQL) | +|---|---|---|---|---| +| **기본** | 텍스트 | `text` | 일반 문자열 | `VARCHAR(N)` 또는 `TEXT` | +| | 숫자 | `number` | 정수·소수 | `NUMERIC` / `INTEGER` | +| | 날짜 | `date` | 날짜/시간 | `TIMESTAMP` 또는 `DATE` | +| **참조** | 코드 | `code` | 공통코드(`comm_code`) 자식 코드 참조 | `VARCHAR` FK to `comm_code.code_id` | +| | 테이블 참조 | `entity` | 다른 테이블의 행 참조 | `BIGINT` FK to `.id` | +| **자동** | 채번 | `numbering` | 시퀀스 기반 자동 채번 | `VARCHAR` (예: `INSP-2026-0001`) | +| **첨부** | 파일 | `file` | 파일 업로드 | `VARCHAR` (파일 메타 OBJID) | +| | 이미지 | `image` | 이미지 업로드 | `VARCHAR` (파일 메타 OBJID) | + +### 제외된 타입과 그 이유 + +| 제외된 타입 | 이유 | 화면관리에서 어떻게 다루나 | +|---|---|---| +| 카테고리 | 공통코드(`code`) 와 동작이 동일 — 중복 개념 | `code` 타입으로 통합 | +| 체크박스 / 라디오 / 셀렉트 | **표시 UI** 변형. 저장 타입과는 무관 | `code` 또는 `entity` 타입 컬럼에 대해 화면관리에서 컴포넌트 선택 | +| 여러 줄 (textarea) | **표시 UI** 변형. 저장은 동일하게 `text` | `text` 타입 컬럼에 대해 화면관리에서 `text input` vs `textarea` 선택 | + +--- + +## 4. 화면관리 매핑 + +저장 타입별로 화면관리에서 선택 가능한 UI 컴포넌트는 다음과 같다. + +### 4.1 `text` (텍스트) +- **단일 행 입력** (``) +- **여러 줄 입력** (`