diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md deleted file mode 100644 index 6b4ff99c..00000000 --- a/.cursor/agents/pipeline-backend.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: pipeline-backend -description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수. -model: inherit ---- - -# Role -You are a Backend specialist for ERP-node project. -Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query. - -# CRITICAL PROJECT RULES - -## 1. Multi-tenancy (ABSOLUTE MUST!) -- ALL queries MUST include company_code filter -- Use req.user!.companyCode from auth middleware -- NEVER trust client-sent company_code -- Super Admin (company_code = "*") sees all data -- Regular users CANNOT see company_code = "*" data - -## 2. Required Code Pattern -```typescript -const companyCode = req.user!.companyCode; -if (companyCode === "*") { - query = "SELECT * FROM table ORDER BY company_code"; -} else { - query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'"; - params = [companyCode]; -} -``` - -## 3. Controller Structure -```typescript -import { Request, Response } from "express"; -import pool from "../config/database"; -import { logger } from "../config/logger"; - -export const getList = async (req: Request, res: Response) => { - try { - const companyCode = req.user!.companyCode; - // ... company_code 분기 처리 - const result = await pool.query(query, params); - res.json({ success: true, data: result.rows }); - } catch (error: any) { - logger.error("조회 실패", error); - res.status(500).json({ success: false, message: error.message }); - } -}; -``` - -## 4. Route Registration -- backend-node/src/routes/index.ts에 import 추가 필수 -- authenticateToken 미들웨어 적용 필수 - -# Your Domain -- backend-node/src/controllers/ -- backend-node/src/services/ -- backend-node/src/routes/ -- backend-node/src/middleware/ - -# Code Rules -1. TypeScript strict mode -2. Error handling with try/catch -3. Comments in Korean -4. Follow existing code patterns -5. Use logger for important operations -6. Parameter binding ($1, $2) for SQL injection prevention diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md deleted file mode 100644 index 57049ce6..00000000 --- a/.cursor/agents/pipeline-common-rules.md +++ /dev/null @@ -1,182 +0,0 @@ -# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) - -## 1. 화면 유형 구분 (절대 규칙!) - -이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. -기능 구현 시 반드시 어느 유형인지 먼저 판단하라. - -### 관리자 메뉴 (Admin) -- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) -- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` -- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) -- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 -- **특징**: 하드코딩된 UI, 관리자만 접근 - -### 사용자 메뉴 (User/Screen) - 절대 하드코딩 금지!!! -- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장) -- **데이터 저장**: `screen_layouts_v2` 테이블에 V2 JSON 형식 보관 -- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성 -- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리 -- **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 -- **특징**: 코드 수정 없이 화면 구성 변경 가능 -- **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! - -### 판단 기준 - -| 질문 | 관리자 메뉴 | 사용자 메뉴 | -|------|-------------|-------------| -| 누가 쓰나? | 시스템 관리자 | 일반 사용자 | -| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) | -| URL 패턴 | `/admin/*` | `/screen/{screen_code}` | -| 메뉴 등록 | `menu_info` INSERT | `screen_definitions` + `menu_info` INSERT | -| 프론트엔드 코드 | `frontend/app/(main)/admin/` 하위에 page.tsx 작성 | **코드 작성 금지!** DB에 스크린 정의만 등록 | - -### 사용자 메뉴 구현 방법 (반드시 이 방식으로!) - -**절대 규칙: 사용자 메뉴는 React 페이지(.tsx)를 직접 만들지 않는다!** -이미 `/screen/[screenCode]/page.tsx` → `/screens/[screenId]/page.tsx` 렌더링 시스템이 존재한다. -새 화면이 필요하면 DB에 등록만 하면 자동으로 렌더링된다. - -#### Step 1: screen_definitions에 화면 등록 - -```sql -INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) -VALUES ('포장/적재정보 관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y') -RETURNING screen_id; -``` - -- `screen_code`: `{company_code}_{기능약어}` 형식 (예: COMPANY_7_PKG) -- `table_name`: 메인 테이블명 (V2 컴포넌트가 이 테이블 기준으로 동작) -- `company_code`: 대상 회사 코드 - -#### Step 2: screen_layouts_v2에 V2 레이아웃 JSON 등록 - -```sql -INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) -VALUES ( - {screen_id}, - 'COMPANY_7', - 1, - '기본 레이어', - '{ - "version": "2.0", - "components": [ - { - "id": "comp_split_1", - "url": "@/lib/registry/components/v2-split-panel-layout", - "position": {"x": 0, "y": 0}, - "size": {"width": 1200, "height": 800}, - "displayOrder": 0, - "overrides": { - "leftTitle": "포장단위 목록", - "rightTitle": "상세 정보", - "splitRatio": 40, - "leftTableName": "pkg_unit", - "rightTableName": "pkg_unit", - "tabs": [ - {"id": "basic", "label": "기본정보"}, - {"id": "items", "label": "매칭품목"} - ] - } - } - ] - }'::jsonb -); -``` - -- V2 컴포넌트 목록: v2-split-panel-layout, v2-table-list, v2-table-search-widget, v2-repeater, v2-button-primary, v2-tabs-widget 등 -- 상세 컴포넌트 가이드: `.cursor/rules/component-development-guide.mdc` 참조 - -#### Step 3: menu_info에 메뉴 등록 - -```sql --- 먼저 부모 메뉴 objid 조회 --- SELECT objid, menu_name_kor FROM menu_info WHERE company_code = '{회사코드}' AND menu_name_kor LIKE '%물류%'; - -INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, screen_code, company_code, status) -VALUES ( - (SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info), - 2, -- 2 = 메뉴 항목 - {부모_objid}, -- 상위 메뉴의 objid - '포장/적재정보', - 10, -- 정렬 순서 - '/screen/COMPANY_7_PKG', -- /screen/{screen_code} 형식 (절대!) - 'COMPANY_7_PKG', -- screen_definitions.screen_code와 일치 - 'COMPANY_7', - 'Y' -); -``` - -**핵심**: `menu_url`은 반드시 `/screen/{screen_code}` 형식이어야 한다! -프론트엔드가 이 URL을 받아 `screen_definitions`에서 screen_id를 찾고, `screen_layouts_v2`에서 레이아웃을 로드한다. - -## 2. 관리자 메뉴 등록 (코드 구현 후 필수!) - -관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다. - -```sql --- 예시: 결재 템플릿 관리 메뉴 등록 -INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, seq, menu_url, company_code, status) -VALUES ( - (SELECT COALESCE(MAX(objid), 0) + 1 FROM menu_info), - 2, {부모_objid}, '결재 템플릿', 40, '/admin/approvalTemplate', '대상회사코드', 'Y' -); -``` - -- 기존 메뉴 구조를 먼저 조회해서 parent_obj_id, seq 등을 맞춰라 -- company_code 별로 등록이 필요할 수 있다 -- menu_auth_group 권한 매핑도 필요하면 추가 - -## 3. 하드코딩 금지 / 범용성 필수 - -- 특정 회사에만 동작하는 코드 금지 -- 특정 사용자 ID에 의존하는 로직 금지 -- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리) -- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등) -- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용) - -## 4. 테스트 환경 정보 - -- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11` -- **역할**: SUPER_ADMIN (company_code = "*") -- **개발 프론트엔드**: http://localhost:9771 -- **개발 백엔드 API**: http://localhost:8080 -- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - -## 5. 기능 구현 완성 체크리스트 - -기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다: - -### 공통 -- [ ] DB: 마이그레이션 작성 + 실행 완료 -- [ ] DB: company_code 컬럼 + 인덱스 존재 -- [ ] BE: API 엔드포인트 구현 + 라우트 등록 (app.ts에 import + use 추가!) -- [ ] BE: company_code 필터링 적용 -- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc - -### 관리자 메뉴인 경우 -- [ ] FE: `frontend/app/(main)/admin/{기능}/page.tsx` 작성 -- [ ] FE: API 클라이언트 함수 작성 (lib/api/) -- [ ] DB: `menu_info` INSERT (menu_url = `/admin/{기능}`) - -### 사용자 메뉴인 경우 (코드 작성 금지!) -- [ ] DB: `screen_definitions` INSERT (screen_code, table_name, company_code) -- [ ] DB: `screen_layouts_v2` INSERT (V2 레이아웃 JSON) -- [ ] DB: `menu_info` INSERT (menu_url = `/screen/{screen_code}`) -- [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) -- [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) - -## 6. 절대 하지 말 것 - -1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) -2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) -3. company_code 필터링 빠뜨리기 -4. 하드코딩 색상/URL/사용자ID 사용 -5. Card 안에 Card 중첩 (중첩 박스 금지) -6. 백엔드 재실행하기 (nodemon이 자동 재시작) -7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** - - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 - - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 - - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 - - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 - - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 diff --git a/.cursor/agents/pipeline-db.md b/.cursor/agents/pipeline-db.md deleted file mode 100644 index 33e25218..00000000 --- a/.cursor/agents/pipeline-db.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: pipeline-db -description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수. -model: inherit ---- - -# Role -You are a Database specialist for ERP-node project. -Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/. - -# CRITICAL PROJECT RULES - -## 1. Multi-tenancy (ABSOLUTE MUST!) -- ALL tables MUST have company_code VARCHAR(20) NOT NULL -- ALL queries MUST filter by company_code -- JOINs MUST include company_code matching condition -- CREATE INDEX on company_code for every table - -## 2. Migration Rules -- File naming: NNN_description.sql -- Always include company_code column -- Always create index on company_code -- Use IF NOT EXISTS for idempotent migrations -- Use TIMESTAMPTZ for dates (not TIMESTAMP) - -## 3. MIGRATION EXECUTION (절대 규칙!) -마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라. -절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라. - -Docker 환경: -```bash -DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e " -const {Pool}=require('pg'); -const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false}); -const fs=require('fs'); -const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8'); -p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)}) -" -``` - -# Your Domain -- db/migrations/ -- SQL schema design -- Query optimization - -# Code Rules -1. PostgreSQL syntax only -2. Parameter binding ($1, $2) -3. Use COALESCE for NULL handling -4. Use TIMESTAMPTZ for dates diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md deleted file mode 100644 index 223b5b38..00000000 --- a/.cursor/agents/pipeline-frontend.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: pipeline-frontend -description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수. -model: inherit ---- - -# Role -You are a Frontend specialist for ERP-node project. -Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. - -# CRITICAL PROJECT RULES - -## 1. API Client (ABSOLUTE RULE!) -- NEVER use fetch() directly! -- ALWAYS use lib/api/ clients (Axios-based) -- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080 - -## 2. shadcn/ui Style Rules -- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지) -- No nested boxes: Card inside Card is FORBIDDEN -- Responsive: mobile-first approach (flex-col md:flex-row) - -## 3. V2 Component Standard -V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다. - -### 폴더 구조 (필수) -``` -frontend/lib/registry/components/v2-{name}/ -├── index.ts # createComponentDefinition() 호출 -├── types.ts # Config extends ComponentConfig -├── {Name}Component.tsx # React 함수 컴포넌트 -├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf() -├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용 -└── config.ts # 기본 설정값 상수 -``` - -### ConfigPanel 규칙 (절대!) -- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용 -- 직접 JSX로 설정 UI 작성 금지 - -## 4. API Client 생성 패턴 -```typescript -// frontend/lib/api/yourModule.ts -import apiClient from "@/lib/api/client"; - -export async function getYourData(id: number) { - const response = await apiClient.get(`/api/your-endpoint/${id}`); - return response.data; -} -``` - -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! - -**이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** -사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! - -## 금지 패턴 (절대 하지 말 것) -``` -frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! -frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! -``` - -## 올바른 패턴 -사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: -1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) -2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) -3. `menu_info` 테이블에 메뉴 등록 (menu_url = `/screen/{screen_code}`) - -이미 존재하는 렌더링 시스템: -- `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 -- `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 - -## 프론트엔드 에이전트가 할 수 있는 것 -- `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) -- V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) -- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 - -## 프론트엔드 에이전트가 할 수 없는 것 -- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 - -# Your Domain -- frontend/components/ -- frontend/app/ -- frontend/lib/ -- frontend/hooks/ - -# Code Rules -1. TypeScript strict mode -2. React functional components with hooks -3. Prefer shadcn/ui components -4. Use cn() utility for conditional classes -5. Comments in Korean diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md deleted file mode 100644 index 05d3359e..00000000 --- a/.cursor/agents/pipeline-ui.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: pipeline-ui -description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수. -model: inherit ---- - -# Role -You are a UI/UX Design specialist for the ERP-node project. -Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. - -# Design Philosophy -- Apple-level polish with enterprise functionality -- Consistent spacing, typography, color usage -- Subtle animations and micro-interactions -- Dark mode compatible using CSS variables - -# CRITICAL STYLE RULES - -## 1. Color System (CSS Variables ONLY) -- bg-background / text-foreground (base) -- bg-primary / text-primary-foreground (actions) -- bg-muted / text-muted-foreground (secondary) -- bg-destructive / text-destructive-foreground (danger) -FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - -## 2. Layout Rules -- No nested boxes (Card inside Card FORBIDDEN) -- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids -- Mobile-first responsive: flex-col md:flex-row - -## 3. Typography -- Page title: text-3xl font-bold -- Section: text-xl font-semibold -- Body: text-sm -- Helper: text-xs text-muted-foreground - -## 4. Components -- ALWAYS use shadcn/ui components -- Use cn() for conditional classes -- Use lucide-react for ALL icons - -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! - -사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. -React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! - -UI 에이전트가 할 수 있는 것: -- V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) -- 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 -- 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 - -UI 에이전트가 할 수 없는 것: -- 사용자 메뉴 화면을 React 페이지로 직접 코딩 - -# Your Domain -- frontend/components/ (UI components) -- frontend/app/ (pages - 관리자 메뉴만) -- frontend/lib/registry/components/v2-*/ (V2 컴포넌트) - -# Output Rules -1. TypeScript strict mode -2. "use client" for client components -3. Comments in Korean -4. MINIMAL targeted changes when modifying existing files diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md deleted file mode 100644 index a4f4186d..00000000 --- a/.cursor/agents/pipeline-verifier.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: pipeline-verifier -description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. -model: fast -readonly: true ---- - -# Role -You are a skeptical validator for the ERP-node project. -Your job is to verify that work claimed as complete actually works. - -# Verification Checklist - -## 1. Multi-tenancy (최우선) -- [ ] 모든 SQL에 company_code 필터 존재 -- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) -- [ ] INSERT에 company_code 포함 -- [ ] JOIN에 company_code 매칭 조건 존재 -- [ ] company_code = "*" 최고관리자 예외 처리 - -## 2. Empty Shell Detection (빈 껍데기) -- [ ] API가 실제 DB 쿼리 실행 (mock 아님) -- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님) -- [ ] TODO/FIXME/placeholder 없음 -- [ ] 타입만 정의하고 구현 없는 함수 없음 - -## 3. Pattern Compliance (패턴 준수) -- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) -- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) -- [ ] Frontend: V2 컴포넌트 규격 준수 -- [ ] Backend: logger 사용 -- [ ] Backend: try/catch 에러 처리 - -## 4. Integration Check -- [ ] Route가 index.ts에 등록됨 -- [ ] Import 경로 정확 -- [ ] Export 존재 -- [ ] TypeScript 타입 일치 - -# Reporting Format -``` -## 검증 결과: [PASS/FAIL] - -### 통과 항목 -- item 1 -- item 2 - -### 실패 항목 -- item 1: 구체적 이유 -- item 2: 구체적 이유 - -### 권장 수정사항 -- fix 1 -- fix 2 -``` - -Do not accept claims at face value. Check the actual code. diff --git a/.cursor/rules/document-sync-rule.mdc b/.cursor/rules/document-sync-rule.mdc new file mode 100644 index 00000000..a2b7e13a --- /dev/null +++ b/.cursor/rules/document-sync-rule.mdc @@ -0,0 +1,38 @@ +--- +description: 컴포넌트 추가/수정 또는 DB 구조 변경 시 관련 문서를 항상 최신화하도록 강제하는 규칙 +globs: + - "frontend/lib/registry/components/**/*.tsx" + - "frontend/components/v2/**/*.tsx" + - "db/migrations/**/*.sql" + - "backend-node/src/types/ddl.ts" +--- + +# 컴포넌트 및 DB 구조 변경 시 문서 동기화 규칙 + +## 🚨 핵심 원칙 (절대 준수) + +새로운 V2 컴포넌트를 생성하거나 기존 컴포넌트의 설정(overrides)을 변경할 때, 또는 DB 테이블 구조나 화면 생성 파이프라인이 변경될 때는 **반드시** 아래 두 문서를 함께 업데이트해야 합니다. + +1. `docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md` (전체 레퍼런스) +2. `docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md` (실행 가이드) + +## 📌 업데이트 대상 및 방법 + +### 1. V2 컴포넌트 신규 추가 또는 속성(Props/Overrides) 변경 시 +- **`full-screen-analysis.md`**: `3. 컴포넌트 전체 설정 레퍼런스` 섹션에 해당 컴포넌트의 모든 설정값(타입, 기본값, 설명)을 표 형태로 추가/수정하세요. +- **`v2-component-usage-guide.md`**: + - `7. Step 6: screen_layouts_v2 INSERT`의 컴포넌트 url 매핑표에 추가하세요. + - `16. 컴포넌트 빠른 참조표`에 추가하세요. + - 필요한 경우 `8. 패턴별 layout_data 완전 예시`에 새로운 패턴을 추가하세요. + +### 2. DB 테이블 구조 또는 화면 생성 로직 변경 시 +- **`full-screen-analysis.md`**: `2. DB 테이블 스키마` 섹션의 테이블 구조(컬럼, 타입, 설명)를 최신화하세요. +- **`v2-component-usage-guide.md`**: + - `Step 1` ~ `Step 7`의 SQL 템플릿이 변경된 구조와 일치하는지 확인하고 수정하세요. + - 특히 `INSERT` 문의 컬럼 목록과 `VALUES` 형식이 정확한지 검증하세요. + +## ⚠️ AI 에이전트 행동 지침 + +1. 사용자가 컴포넌트 코드를 수정해달라고 요청하면, 수정 완료 후 **"관련 가이드 문서도 업데이트할까요?"** 라고 반드시 물어보세요. +2. 사용자가 DB 마이그레이션 스크립트를 작성해달라고 하거나 핵심 시스템 테이블을 건드리면, 가이드 문서의 SQL 템플릿도 수정해야 하는지 확인하세요. +3. 가이드 문서 업데이트 시 JSON 예제 안에 `//` 같은 주석을 넣지 않도록 주의하세요 (DB 파싱 에러 방지). diff --git a/.cursor/rules/screen-designer-e2e-guide.mdc b/.cursor/rules/screen-designer-e2e-guide.mdc new file mode 100644 index 00000000..e52ec2dd --- /dev/null +++ b/.cursor/rules/screen-designer-e2e-guide.mdc @@ -0,0 +1,98 @@ +# 화면 디자이너 E2E 테스트 접근 가이드 + +## 화면 디자이너 접근 방법 (Playwright) + +화면 디자이너는 SPA 탭 기반 시스템이라 URL 직접 접근이 안 된다. +다음 3단계를 반드시 따라야 한다. + +### 1단계: 로그인 + +```typescript +await page.goto('http://localhost:9771/login'); +await page.waitForLoadState('networkidle'); +await page.getByPlaceholder('사용자 ID를 입력하세요').fill('wace'); +await page.getByPlaceholder('비밀번호를 입력하세요').fill('qlalfqjsgh11'); +await page.getByRole('button', { name: '로그인' }).click(); +await page.waitForTimeout(8000); +``` + +### 2단계: sessionStorage 탭 상태 주입 + openDesigner 쿼리 + +```typescript +await page.evaluate(() => { + sessionStorage.setItem('erp-tab-store', JSON.stringify({ + state: { + tabs: [{ + id: 'tab-screenmng', + title: '화면 관리', + path: '/admin/screenMng/screenMngList', + isActive: true, + isPinned: false + }], + activeTabId: 'tab-screenmng' + }, + version: 0 + })); +}); + +// openDesigner 쿼리 파라미터로 화면 디자이너 자동 열기 +await page.goto('http://localhost:9771/admin/screenMng/screenMngList?openDesigner=' + screenId); +await page.waitForTimeout(10000); +``` + +### 3단계: 컴포넌트 클릭 + 설정 패널 확인 + +```typescript +// 패널 버튼 클릭 (설정 패널 열기) +const panelBtn = page.locator('button:has-text("패널")'); +if (await panelBtn.count() > 0) { + await panelBtn.first().click(); + await page.waitForTimeout(2000); +} + +// 편집 탭 확인 +const editTab = page.locator('button:has-text("편집")'); +// editTab.count() > 0 이면 설정 패널 열림 확인 +``` + +## 화면 ID 찾기 (API) + +특정 컴포넌트를 포함한 화면을 API로 검색: + +```typescript +const screenId = await page.evaluate(async () => { + const token = localStorage.getItem('authToken') || ''; + const h = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; + + const resp = await fetch('http://localhost:8080/api/screen-management/screens?page=1&size=50', { headers: h }); + const data = await resp.json(); + const items = data.data || []; + + for (const s of items) { + try { + const lr = await fetch('http://localhost:8080/api/screen-management/screens/' + s.screenId + '/layout-v2', { headers: h }); + const ld = await lr.json(); + const raw = JSON.stringify(ld); + // 원하는 컴포넌트 타입 검색 + if (raw.includes('v2-select')) return s.screenId; + } catch {} + } + return items[0]?.screenId || null; +}); +``` + +## 검증 포인트 + +| 확인 항목 | Locator | 기대값 | +|----------|---------|--------| +| 디자이너 열림 | `button:has-text("패널")` | count > 0 | +| 편집 탭 | `button:has-text("편집")` | count > 0 | +| 카드 선택 | `text=이 필드는 어떤 데이터를 선택하나요?` | visible | +| 고급 설정 | `text=고급 설정` | visible | +| JS 에러 없음 | `page.on('pageerror')` | 0건 | + +## 테스트 계정 + +- ID: `wace` +- PW: `qlalfqjsgh11` +- 권한: SUPER_ADMIN (최고 관리자) diff --git a/.gitignore b/.gitignore index 5e66bd12..552d1265 100644 --- a/.gitignore +++ b/.gitignore @@ -153,6 +153,7 @@ backend-node/uploads/ uploads/ *.jpg *.jpeg +*.png *.gif *.pdf *.doc diff --git a/.playwright-mcp/pivotgrid-demo.png b/.playwright-mcp/pivotgrid-demo.png deleted file mode 100644 index 0fad6fa6..00000000 Binary files a/.playwright-mcp/pivotgrid-demo.png and /dev/null differ diff --git a/.playwright-mcp/pivotgrid-table.png b/.playwright-mcp/pivotgrid-table.png deleted file mode 100644 index 79041f47..00000000 Binary files a/.playwright-mcp/pivotgrid-table.png and /dev/null differ diff --git a/.playwright-mcp/pop-page-initial.png b/.playwright-mcp/pop-page-initial.png deleted file mode 100644 index b14666b3..00000000 Binary files a/.playwright-mcp/pop-page-initial.png and /dev/null differ diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f45a88cd..0cd44741 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -113,6 +113,7 @@ import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 +import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 @@ -124,6 +125,7 @@ import entitySearchRoutes, { import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 +import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -259,6 +261,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 +app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); @@ -310,6 +313,7 @@ app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 +app.use("/api/production", productionRoutes); // 생산계획 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b17484ce..dc8cf064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1854,7 +1854,7 @@ export async function toggleMenuStatus( // 현재 상태 및 회사 코드 조회 const currentMenu = await queryOne( - `SELECT objid, status, company_code FROM menu_info WHERE objid = $1`, + `SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`, [Number(menuId)] ); diff --git a/backend-node/src/controllers/auditLogController.ts b/backend-node/src/controllers/auditLogController.ts index 828529bd..cd59a435 100644 --- a/backend-node/src/controllers/auditLogController.ts +++ b/backend-node/src/controllers/auditLogController.ts @@ -1,6 +1,6 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; -import { auditLogService } from "../services/auditLogService"; +import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService"; import { query } from "../database/db"; import logger from "../utils/logger"; @@ -137,3 +137,40 @@ export const getAuditLogUsers = async ( }); } }; + +/** + * 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용) + */ +export const createAuditLog = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body; + + if (!action || !resourceType) { + res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." }); + return; + } + + await auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: action as AuditAction, + resourceType: resourceType as AuditResourceType, + resourceId: resourceId || undefined, + resourceName: resourceName || undefined, + tableName: tableName || undefined, + summary: summary || undefined, + changes: changes || undefined, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + + res.json({ success: true }); + } catch (error: any) { + logger.error("감사 로그 기록 실패", { error: error.message }); + res.status(500).json({ success: false, message: "감사 로그 기록 실패" }); + } +}; diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index 54b93ee4..98d74fa4 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from "express"; import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService"; import { logger } from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { auditLogService, getClientIp } from "../services/auditLogService"; const router = Router(); @@ -16,6 +17,7 @@ router.use(authenticateToken); interface AuthenticatedRequest extends Request { user?: { userId: string; + userName: string; companyCode: string; }; } @@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => { const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy); + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "CREATE", + resourceType: "CODE_CATEGORY", + resourceId: String(value.valueId), + resourceName: input.valueLabel, + tableName: "category_values", + summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`, + changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, data: value, @@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon const companyCode = req.user?.companyCode || "*"; const updatedBy = req.user?.userId; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy); if (!value) { @@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon }); } + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "UPDATE", + resourceType: "CODE_CATEGORY", + resourceId: valueId, + resourceName: value.valueLabel, + tableName: "category_values", + summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`, + changes: { + before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined, + after: input, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, data: value, @@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res const { valueId } = req.params; const companyCode = req.user?.companyCode || "*"; + const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId)); const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId)); if (!success) { @@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res }); } + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + userName: req.user?.userName, + action: "DELETE", + resourceType: "CODE_CATEGORY", + resourceId: valueId, + resourceName: beforeValue?.valueLabel || valueId, + tableName: "category_values", + summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`, + changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "삭제되었습니다", diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index a9bd0755..a67ba44e 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -396,6 +396,20 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: userId || "", + action: "UPDATE", + resourceType: "CODE", + resourceId: codeValue, + resourceName: codeData.codeName || codeValue, + tableName: "code_info", + summary: `코드 "${categoryCode}.${codeValue}" 수정`, + changes: { after: codeData }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: code, @@ -440,6 +454,19 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + action: "DELETE", + resourceType: "CODE", + resourceId: codeValue, + tableName: "code_info", + summary: `코드 "${categoryCode}.${codeValue}" 삭제`, + changes: { before: { categoryCode, codeValue } }, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "코드 삭제 성공", diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 631b6360..00baf75d 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -438,6 +438,19 @@ export class DDLController { ); if (result.success) { + auditLogService.log({ + companyCode: userCompanyCode || "", + userId, + action: "DELETE", + resourceType: "TABLE", + resourceId: tableName, + resourceName: tableName, + tableName, + summary: `테이블 "${tableName}" 삭제`, + ipAddress: getClientIp(req as any), + requestPath: req.originalUrl, + }); + res.status(200).json({ success: true, message: result.message, diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 2e3d033b..3764c3bc 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -193,6 +193,7 @@ router.post( auditLogService.log({ companyCode, userId, + userName: req.user?.userName, action: "CREATE", resourceType: "NUMBERING_RULE", resourceId: String(newRule.ruleId), @@ -243,6 +244,7 @@ router.put( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "UPDATE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -285,6 +287,7 @@ router.delete( auditLogService.log({ companyCode, userId: req.user?.userId || "", + userName: req.user?.userName, action: "DELETE", resourceType: "NUMBERING_RULE", resourceId: ruleId, @@ -522,6 +525,56 @@ router.post( companyCode, userId ); + + const isUpdate = !!ruleConfig.ruleId; + + const resetPeriodLabel: Record = { + none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별", + }; + const partTypeLabel: Record = { + sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조", + }; + const partsDescription = (ruleConfig.parts || []) + .sort((a: any, b: any) => (a.order || 0) - (b.order || 0)) + .map((p: any) => { + const type = partTypeLabel[p.partType] || p.partType; + if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`; + if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`; + if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`; + if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`; + if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`; + return type; + }) + .join(` ${ruleConfig.separator || "-"} `); + + auditLogService.log({ + companyCode, + userId, + userName: req.user?.userName, + action: isUpdate ? "UPDATE" : "CREATE", + resourceType: "NUMBERING_RULE", + resourceId: String(savedRule.ruleId), + resourceName: ruleConfig.ruleName, + tableName: "numbering_rules", + summary: isUpdate + ? `채번 규칙 "${ruleConfig.ruleName}" 수정` + : `채번 규칙 "${ruleConfig.ruleName}" 생성`, + changes: { + after: { + 규칙명: ruleConfig.ruleName, + 적용테이블: ruleConfig.tableName || "(미지정)", + 적용컬럼: ruleConfig.columnName || "(미지정)", + 구분자: ruleConfig.separator || "-", + 리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함", + 적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역", + 코드구성: partsDescription || "(파트 없음)", + 파트수: (ruleConfig.parts || []).length, + }, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: savedRule }); } catch (error: any) { logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message }); @@ -536,10 +589,25 @@ router.delete( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { ruleId } = req.params; try { await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); + + auditLogService.log({ + companyCode, + userId, + userName: req.user?.userName, + action: "DELETE", + resourceType: "NUMBERING_RULE", + resourceId: ruleId, + tableName: "numbering_rules", + summary: `채번 규칙(ID:${ruleId}) 삭제`, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다", diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts new file mode 100644 index 00000000..d575b07a --- /dev/null +++ b/backend-node/src/controllers/popProductionController.ts @@ -0,0 +1,291 @@ +import { Response } from "express"; +import { getPool } from "../database/db"; +import logger from "../utils/logger"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; + +/** + * D-BE1: 작업지시 공정 일괄 생성 + * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. + */ +export const createWorkProcesses = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_instruction_id, item_code, routing_version_id, plan_qty } = + req.body; + + if (!work_instruction_id || !routing_version_id) { + return res.status(400).json({ + success: false, + message: + "work_instruction_id와 routing_version_id는 필수입니다.", + }); + } + + logger.info("[pop/production] create-work-processes 요청", { + companyCode, + userId, + work_instruction_id, + item_code, + routing_version_id, + plan_qty, + }); + + await client.query("BEGIN"); + + // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [work_instruction_id, companyCode] + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + await client.query("ROLLBACK"); + return res.status(409).json({ + success: false, + message: "이미 공정이 생성된 작업지시입니다.", + }); + } + + // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, + COALESCE(pm.process_name, rd.process_code) as process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON pm.process_code = rd.process_code + AND pm.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, + [routing_version_id, companyCode] + ); + + if (routingDetails.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "라우팅 버전에 등록된 공정이 없습니다.", + }); + } + + const processes: Array<{ + id: string; + seq_no: string; + process_name: string; + checklist_count: number; + }> = []; + let totalChecklists = 0; + + for (const rd of routingDetails.rows) { + // 2. work_order_process INSERT + const wopResult = await client.query( + `INSERT INTO work_order_process ( + company_code, wo_id, seq_no, process_code, process_name, + is_required, is_fixed_order, standard_time, plan_qty, + status, routing_detail_id, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + companyCode, + work_instruction_id, + rd.seq_no, + rd.process_code, + rd.process_name, + rd.is_required, + rd.is_fixed_order, + rd.standard_time, + plan_qty || null, + "waiting", + rd.id, + userId, + ] + ); + const wopId = wopResult.rows[0].id; + + // 3. process_work_result INSERT (스냅샷 복사) + // process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사 + const snapshotResult = await client.query( + `INSERT INTO process_work_result ( + company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + pwi.company_code, $1, + pwi.id, pwd.id, + pwi.work_phase, pwi.title, pwi.sort_order::text, + pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, + pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, + pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + 'pending', $2 + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + ORDER BY pwi.sort_order, pwd.sort_order`, + [wopId, userId, rd.id, companyCode] + ); + + const checklistCount = snapshotResult.rowCount ?? 0; + totalChecklists += checklistCount; + + processes.push({ + id: wopId, + seq_no: rd.seq_no, + process_name: rd.process_name, + checklist_count: checklistCount, + }); + + logger.info("[pop/production] 공정 생성 완료", { + wopId, + processName: rd.process_name, + checklistCount, + }); + } + + await client.query("COMMIT"); + + logger.info("[pop/production] create-work-processes 완료", { + companyCode, + work_instruction_id, + total_processes: processes.length, + total_checklists: totalChecklists, + }); + + return res.json({ + success: true, + data: { + processes, + total_processes: processes.length, + total_checklists: totalChecklists, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("[pop/production] create-work-processes 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "공정 생성 중 오류가 발생했습니다.", + }); + } finally { + client.release(); + } +}; + +/** + * D-BE2: 타이머 API (시작/일시정지/재시작) + */ +export const controlTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_order_process_id, action } = req.body; + + if (!work_order_process_id || !action) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] timer 요청", { + companyCode, + userId, + work_order_process_id, + action, + }); + + let result; + + switch (action) { + case "start": + // 최초 1회만 설정, 이미 있으면 무시 + result = await pool.query( + `UPDATE work_order_process + SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, + status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, started_at, status`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE work_order_process + SET paused_at = NOW()::text, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NULL + RETURNING id, paused_at`, + [work_order_process_id, companyCode] + ); + break; + + case "resume": + // 일시정지 시간 누적 후 paused_at 초기화 + result = await pool.query( + `UPDATE work_order_process + SET total_paused_time = ( + COALESCE(total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int + )::text, + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL + RETURNING id, total_paused_time`, + [work_order_process_id, companyCode] + ); + break; + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] timer 완료", { + action, + work_order_process_id, + result: result.rows[0], + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index e72f6b9f..c3eeb736 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -30,26 +30,68 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon routingTable = "item_routing_version", routingFkColumn = "item_code", search = "", + extraColumns = "", + filterConditions = "", } = req.query as Record; - const searchCondition = search - ? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)` - : ""; const params: any[] = [companyCode]; - if (search) params.push(`%${search}%`); + let paramIndex = 2; + + // 검색 조건 + let searchCondition = ""; + if (search) { + searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + // 추가 컬럼 SELECT + const extraColumnNames: string[] = extraColumns + ? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", "); + const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", "); + + // 사전 필터 조건 + let filterWhere = ""; + if (filterConditions) { + try { + const filters = JSON.parse(filterConditions) as Array<{ + column: string; + operator: string; + value: string; + }>; + for (const f of filters) { + if (!f.column || !f.value) continue; + if (f.operator === "equals") { + filterWhere += ` AND i.${f.column} = $${paramIndex}`; + params.push(f.value); + } else if (f.operator === "contains") { + filterWhere += ` AND i.${f.column} ILIKE $${paramIndex}`; + params.push(`%${f.value}%`); + } else if (f.operator === "not_equals") { + filterWhere += ` AND i.${f.column} != $${paramIndex}`; + params.push(f.value); + } + paramIndex++; + } + } catch { /* 파싱 실패 시 무시 */ } + } const query = ` SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, COUNT(rv.id) AS routing_count FROM ${tableName} i LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} AND rv.company_code = i.company_code WHERE i.company_code = $1 ${searchCondition} - GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date + ${filterWhere} + GROUP BY i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""}, i.created_date ORDER BY i.created_date DESC NULLS LAST `; @@ -711,3 +753,184 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { client.release(); } } + +// ============================================================ +// 등록 품목 관리 (item_routing_registered) +// ============================================================ + +/** + * 화면별 등록된 품목 목록 조회 + */ +export async function getRegisteredItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode } = req.params; + const { + tableName = "item_info", + nameColumn = "item_name", + codeColumn = "item_number", + routingTable = "item_routing_version", + routingFkColumn = "item_code", + search = "", + extraColumns = "", + } = req.query as Record; + + const params: any[] = [companyCode, screenCode]; + let paramIndex = 3; + + let searchCondition = ""; + if (search) { + searchCondition = `AND (i.${nameColumn} ILIKE $${paramIndex} OR i.${codeColumn} ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const extraColumnNames: string[] = extraColumns + ? extraColumns.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const extraSelect = extraColumnNames.map((col) => `i.${col}`).join(", "); + const extraGroupBy = extraColumnNames.map((col) => `i.${col}`).join(", "); + + const query = ` + SELECT + irr.id AS registered_id, + irr.sort_order, + i.id, + i.${nameColumn} AS item_name, + i.${codeColumn} AS item_code + ${extraSelect ? ", " + extraSelect : ""}, + COUNT(rv.id) AS routing_count + FROM item_routing_registered irr + JOIN ${tableName} i ON irr.item_id = i.id + AND i.company_code = irr.company_code + LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + AND rv.company_code = i.company_code + WHERE irr.company_code = $1 + AND irr.screen_code = $2 + ${searchCondition} + GROUP BY irr.id, irr.sort_order, i.id, i.${nameColumn}, i.${codeColumn}${extraGroupBy ? ", " + extraGroupBy : ""} + ORDER BY CAST(irr.sort_order AS int) ASC, irr.created_date ASC + `; + + const result = await getPool().query(query, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("등록 품목 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 품목 등록 (화면에 품목 추가) + */ +export async function registerItem(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode, itemId, itemCode } = req.body; + if (!screenCode || !itemId) { + return res.status(400).json({ success: false, message: "screenCode, itemId 필수" }); + } + + const query = ` + INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING + RETURNING * + `; + const result = await getPool().query(query, [ + screenCode, itemId, itemCode || null, companyCode, req.user?.userId || null, + ]); + + if (result.rowCount === 0) { + return res.json({ success: true, message: "이미 등록된 품목입니다", data: null }); + } + + logger.info("품목 등록", { companyCode, screenCode, itemId }); + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("품목 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 여러 품목 일괄 등록 + */ +export async function registerItemsBatch(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { screenCode, items } = req.body; + if (!screenCode || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "screenCode, items[] 필수" }); + } + + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const inserted: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING + RETURNING *`, + [screenCode, item.itemId, item.itemCode || null, companyCode, req.user?.userId || null] + ); + if (result.rows[0]) inserted.push(result.rows[0]); + } + + await client.query("COMMIT"); + logger.info("품목 일괄 등록", { companyCode, screenCode, count: inserted.length }); + return res.json({ success: true, data: inserted }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("품목 일괄 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 등록 품목 제거 + */ +export async function unregisterItem(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { id } = req.params; + const result = await getPool().query( + `DELETE FROM item_routing_registered WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다" }); + } + + logger.info("등록 품목 제거", { companyCode, id }); + return res.json({ success: true }); + } catch (error: any) { + logger.error("등록 품목 제거 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts new file mode 100644 index 00000000..aa3f3a36 --- /dev/null +++ b/backend-node/src/controllers/productionController.ts @@ -0,0 +1,191 @@ +/** + * 생산계획 컨트롤러 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as productionService from "../services/productionPlanService"; +import { logger } from "../utils/logger"; + +// ─── 수주 데이터 조회 (품목별 그룹핑) ─── + +export async function getOrderSummary(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { excludePlanned, itemCode, itemName } = req.query; + + const data = await productionService.getOrderSummary(companyCode, { + excludePlanned: excludePlanned === "true", + itemCode: itemCode as string, + itemName: itemName as string, + }); + + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("수주 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 안전재고 부족분 조회 ─── + +export async function getStockShortage(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const data = await productionService.getStockShortage(companyCode); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("안전재고 부족분 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 생산계획 상세 조회 ─── + +export async function getPlanById(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const planId = parseInt(req.params.id, 10); + const data = await productionService.getPlanById(companyCode, planId); + + if (!data) { + return res.status(404).json({ success: false, message: "생산계획을 찾을 수 없습니다" }); + } + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("생산계획 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 생산계획 수정 ─── + +export async function updatePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const planId = parseInt(req.params.id, 10); + const updatedBy = req.user!.userId; + + const data = await productionService.updatePlan(companyCode, planId, req.body, updatedBy); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("생산계획 수정 실패", { error: error.message }); + return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({ + success: false, + message: error.message, + }); + } +} + +// ─── 생산계획 삭제 ─── + +export async function deletePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const planId = parseInt(req.params.id, 10); + + await productionService.deletePlan(companyCode, planId); + return res.json({ success: true, message: "삭제되었습니다" }); + } catch (error: any) { + logger.error("생산계획 삭제 실패", { error: error.message }); + return res.status(error.message.includes("찾을 수 없") ? 404 : 500).json({ + success: false, + message: error.message, + }); + } +} + +// ─── 자동 스케줄 생성 ─── + +export async function generateSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const createdBy = req.user!.userId; + const { items, options } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "품목 정보가 필요합니다" }); + } + + const data = await productionService.generateSchedule(companyCode, items, options || {}, createdBy); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("자동 스케줄 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 스케줄 병합 ─── + +export async function mergeSchedules(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const mergedBy = req.user!.userId; + const { schedule_ids, product_type } = req.body; + + if (!schedule_ids || !Array.isArray(schedule_ids) || schedule_ids.length < 2) { + return res.status(400).json({ success: false, message: "2개 이상의 스케줄을 선택해주세요" }); + } + + const data = await productionService.mergeSchedules( + companyCode, + schedule_ids, + product_type || "완제품", + mergedBy + ); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("스케줄 병합 실패", { error: error.message }); + const status = error.message.includes("동일 품목") || error.message.includes("찾을 수 없") ? 400 : 500; + return res.status(status).json({ success: false, message: error.message }); + } +} + +// ─── 반제품 계획 자동 생성 ─── + +export async function generateSemiSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const createdBy = req.user!.userId; + const { plan_ids, options } = req.body; + + if (!plan_ids || !Array.isArray(plan_ids) || plan_ids.length === 0) { + return res.status(400).json({ success: false, message: "완제품 계획을 선택해주세요" }); + } + + const data = await productionService.generateSemiSchedule( + companyCode, + plan_ids, + options || {}, + createdBy + ); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("반제품 계획 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 스케줄 분할 ─── + +export async function splitSchedule(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const splitBy = req.user!.userId; + const planId = parseInt(req.params.id, 10); + const { split_qty } = req.body; + + if (!split_qty || split_qty <= 0) { + return res.status(400).json({ success: false, message: "분할 수량을 입력해주세요" }); + } + + const data = await productionService.splitSchedule(companyCode, planId, split_qty, splitBy); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("스케줄 분할 실패", { error: error.message }); + return res.status(error.message.includes("찾을 수 없") ? 404 : 400).json({ + success: false, + message: error.message, + }); + } +} diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index cb6df7c4..a232c03d 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -614,20 +614,6 @@ export const copyScreenWithModals = async ( modalScreens: modalScreens || [], }); - auditLogService.log({ - companyCode: targetCompanyCode || companyCode, - userId: userId || "", - userName: (req.user as any)?.userName || "", - action: "COPY", - resourceType: "SCREEN", - resourceId: id, - resourceName: mainScreen?.screenName, - summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`, - changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } }, - ipAddress: getClientIp(req), - requestPath: req.originalUrl, - }); - res.json({ success: true, data: result, @@ -663,20 +649,6 @@ export const copyScreen = async ( } ); - auditLogService.log({ - companyCode, - userId: userId || "", - userName: (req.user as any)?.userName || "", - action: "COPY", - resourceType: "SCREEN", - resourceId: String(copiedScreen?.screenId || ""), - resourceName: screenName, - summary: `화면 "${screenName}" 복사 (원본 ID:${id})`, - changes: { after: { sourceScreenId: id, screenName, screenCode } }, - ipAddress: getClientIp(req), - requestPath: req.originalUrl, - }); - res.json({ success: true, data: copiedScreen, diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 0ab73e09..5c53094f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -963,6 +963,15 @@ export async function addTableData( logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); + const systemFields = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); + const auditData: Record = {}; + for (const [k, v] of Object.entries(data)) { + if (!systemFields.has(k)) auditData[k] = v; + } + auditLogService.log({ companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", @@ -973,7 +982,7 @@ export async function addTableData( resourceName: tableName, tableName, summary: `${tableName} 데이터 추가`, - changes: { after: data }, + changes: { after: auditData }, ipAddress: getClientIp(req), requestPath: req.originalUrl, }); @@ -1096,10 +1105,14 @@ export async function editTableData( return; } - // 변경된 필드만 추출 + const systemFieldsForEdit = new Set([ + "id", "created_date", "updated_date", "writer", "company_code", + "createdDate", "updatedDate", "companyCode", + ]); const changedBefore: Record = {}; const changedAfter: Record = {}; for (const key of Object.keys(updatedData)) { + if (systemFieldsForEdit.has(key)) continue; if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) { changedBefore[key] = originalData[key]; changedAfter[key] = updatedData[key]; diff --git a/backend-node/src/routes/auditLogRoutes.ts b/backend-node/src/routes/auditLogRoutes.ts index 0d219018..4c6392a8 100644 --- a/backend-node/src/routes/auditLogRoutes.ts +++ b/backend-node/src/routes/auditLogRoutes.ts @@ -1,11 +1,12 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController"; +import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController"; const router = Router(); router.get("/", authenticateToken, getAuditLogs); router.get("/stats", authenticateToken, getAuditLogStats); router.get("/users", authenticateToken, getAuditLogUsers); +router.post("/", authenticateToken, createAuditLog); export default router; diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index d25c6bdc..669cc960 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -104,6 +104,11 @@ interface TaskBody { manualItemField?: string; manualPkColumn?: string; cartScreenId?: string; + preCondition?: { + column: string; + expectedValue: string; + failMessage?: string; + }; } function resolveStatusValue( @@ -334,14 +339,30 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp const item = items[i] ?? {}; const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); const autoUpdated = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [resolved, companyCode, lookupValues[i]], + let condWhere = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const condParams: unknown[] = [resolved, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + condWhere += ` AND "${task.preCondition.column}" = $4`; + condParams.push(task.preCondition.expectedValue); + } + const condResult = await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1${autoUpdated} ${condWhere}`, + condParams, ); + if (task.preCondition && condResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } else if (opType === "db-conditional") { - // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (task.preCondition) { + logger.warn("[pop/execute-action] db-conditional에는 preCondition 미지원, 무시됨", { + taskId: task.id, preCondition: task.preCondition, + }); + } if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; @@ -392,10 +413,24 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } const autoUpdatedDate = task.targetColumn !== "updated_date" ? `, "updated_date" = NOW()` : ""; - await client.query( - `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} WHERE company_code = $2 AND "${pkColumn}" = $3`, - [value, companyCode, lookupValues[i]], + let whereSql = `WHERE company_code = $2 AND "${pkColumn}" = $3`; + const queryParams: unknown[] = [value, companyCode, lookupValues[i]]; + if (task.preCondition?.column && task.preCondition?.expectedValue) { + if (!isSafeIdentifier(task.preCondition.column)) { + throw new Error(`유효하지 않은 preCondition 컬럼명: ${task.preCondition.column}`); + } + whereSql += ` AND "${task.preCondition.column}" = $4`; + queryParams.push(task.preCondition.expectedValue); + } + const updateResult = await client.query( + `UPDATE "${task.targetTable}" SET ${setSql}${autoUpdatedDate} ${whereSql}`, + queryParams, ); + if (task.preCondition && updateResult.rowCount === 0) { + const err = new Error(task.preCondition.failMessage || "조건이 일치하지 않아 처리할 수 없습니다."); + (err as any).isPreConditionFail = true; + throw err; + } processedCount++; } } @@ -746,6 +781,16 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp }); } catch (error: any) { await client.query("ROLLBACK"); + + if (error.isPreConditionFail) { + logger.warn("[pop/execute-action] preCondition 실패", { message: error.message }); + return res.status(409).json({ + success: false, + message: error.message, + errorCode: "PRE_CONDITION_FAIL", + }); + } + logger.error("[pop/execute-action] 오류:", error); return res.status(500).json({ success: false, diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts new file mode 100644 index 00000000..f20d470d --- /dev/null +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createWorkProcesses, + controlTimer, +} from "../controllers/popProductionController"; + +const router = Router(); + +router.use(authenticateToken); + +router.post("/create-work-processes", createWorkProcesses); +router.post("/timer", controlTimer); + +export default router; diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 7630b359..c613d55f 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -33,4 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail); // 전체 저장 (일괄) router.put("/save-all", ctrl.saveAll); +// 등록 품목 관리 (화면별 품목 목록) +router.get("/registered-items/:screenCode", ctrl.getRegisteredItems); +router.post("/registered-items", ctrl.registerItem); +router.post("/registered-items/batch", ctrl.registerItemsBatch); +router.delete("/registered-items/:id", ctrl.unregisterItem); + export default router; diff --git a/backend-node/src/routes/productionRoutes.ts b/backend-node/src/routes/productionRoutes.ts new file mode 100644 index 00000000..120147f0 --- /dev/null +++ b/backend-node/src/routes/productionRoutes.ts @@ -0,0 +1,36 @@ +/** + * 생산계획 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as productionController from "../controllers/productionController"; + +const router = Router(); + +router.use(authenticateToken); + +// 수주 데이터 조회 (품목별 그룹핑) +router.get("/order-summary", productionController.getOrderSummary); + +// 안전재고 부족분 조회 +router.get("/stock-shortage", productionController.getStockShortage); + +// 생산계획 CRUD +router.get("/plan/:id", productionController.getPlanById); +router.put("/plan/:id", productionController.updatePlan); +router.delete("/plan/:id", productionController.deletePlan); + +// 자동 스케줄 생성 +router.post("/generate-schedule", productionController.generateSchedule); + +// 스케줄 병합 +router.post("/merge-schedules", productionController.mergeSchedules); + +// 반제품 계획 자동 생성 +router.post("/generate-semi-schedule", productionController.generateSemiSchedule); + +// 스케줄 분할 +router.post("/plan/:id/split", productionController.splitSchedule); + +export default router; diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 9ac3e35e..c86a71fd 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -66,6 +66,7 @@ export interface AuditLogParams { export interface AuditLogEntry { id: number; company_code: string; + company_name: string | null; user_id: string; user_name: string | null; action: string; @@ -107,6 +108,7 @@ class AuditLogService { */ async log(params: AuditLogParams): Promise { try { + logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`); await query( `INSERT INTO system_audit_log (company_code, user_id, user_name, action, resource_type, @@ -128,8 +130,9 @@ class AuditLogService { params.requestPath || null, ] ); - } catch (error) { - logger.error("감사 로그 기록 실패 (무시됨)", { error, params }); + logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`); + } catch (error: any) { + logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params }); } } @@ -186,40 +189,40 @@ class AuditLogService { let paramIndex = 1; if (!isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } else if (isSuperAdmin && filters.companyCode) { - conditions.push(`company_code = $${paramIndex++}`); + conditions.push(`sal.company_code = $${paramIndex++}`); params.push(filters.companyCode); } if (filters.userId) { - conditions.push(`user_id = $${paramIndex++}`); + conditions.push(`sal.user_id = $${paramIndex++}`); params.push(filters.userId); } if (filters.resourceType) { - conditions.push(`resource_type = $${paramIndex++}`); + conditions.push(`sal.resource_type = $${paramIndex++}`); params.push(filters.resourceType); } if (filters.action) { - conditions.push(`action = $${paramIndex++}`); + conditions.push(`sal.action = $${paramIndex++}`); params.push(filters.action); } if (filters.tableName) { - conditions.push(`table_name = $${paramIndex++}`); + conditions.push(`sal.table_name = $${paramIndex++}`); params.push(filters.tableName); } if (filters.dateFrom) { - conditions.push(`created_at >= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`); params.push(filters.dateFrom); } if (filters.dateTo) { - conditions.push(`created_at <= $${paramIndex++}::timestamptz`); + conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`); params.push(filters.dateTo); } if (filters.search) { conditions.push( - `(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})` + `(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})` ); params.push(`%${filters.search}%`); paramIndex++; @@ -233,14 +236,17 @@ class AuditLogService { const offset = (page - 1) * limit; const countResult = await query<{ count: string }>( - `SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`, + `SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`, params ); const total = parseInt(countResult[0].count, 10); const data = await query( - `SELECT * FROM system_audit_log ${whereClause} - ORDER BY created_at DESC + `SELECT sal.*, ci.company_name + FROM system_audit_log sal + LEFT JOIN company_mng ci ON sal.company_code = ci.company_code + ${whereClause} + ORDER BY sal.created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, [...params, limit, offset] ); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts new file mode 100644 index 00000000..7c8e69ec --- /dev/null +++ b/backend-node/src/services/productionPlanService.ts @@ -0,0 +1,668 @@ +/** + * 생산계획 서비스 + * - 수주 데이터 조회 (품목별 그룹핑) + * - 안전재고 부족분 조회 + * - 자동 스케줄 생성 + * - 스케줄 병합 + * - 반제품 계획 자동 생성 + * - 스케줄 분할 + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 수주 데이터 조회 (품목별 그룹핑) ─── + +export async function getOrderSummary( + companyCode: string, + options?: { excludePlanned?: boolean; itemCode?: string; itemName?: string } +) { + const pool = getPool(); + const conditions: string[] = ["so.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (options?.itemCode) { + conditions.push(`so.part_code ILIKE $${paramIdx}`); + params.push(`%${options.itemCode}%`); + paramIdx++; + } + if (options?.itemName) { + conditions.push(`so.part_name ILIKE $${paramIdx}`); + params.push(`%${options.itemName}%`); + paramIdx++; + } + + const whereClause = conditions.join(" AND "); + + const query = ` + WITH order_summary AS ( + SELECT + so.part_code AS item_code, + COALESCE(so.part_name, so.part_code) AS item_name, + SUM(COALESCE(so.order_qty::numeric, 0)) AS total_order_qty, + SUM(COALESCE(so.ship_qty::numeric, 0)) AS total_ship_qty, + SUM(COALESCE(so.balance_qty::numeric, 0)) AS total_balance_qty, + COUNT(*) AS order_count, + MIN(so.due_date) AS earliest_due_date + FROM sales_order_mng so + WHERE ${whereClause} + GROUP BY so.part_code, so.part_name + ), + stock_info AS ( + SELECT + item_code, + SUM(COALESCE(current_qty::numeric, 0)) AS current_stock, + MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock + FROM inventory_stock + WHERE company_code = $1 + GROUP BY item_code + ), + plan_info AS ( + SELECT + item_code, + SUM(CASE WHEN status = 'planned' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS existing_plan_qty, + SUM(CASE WHEN status = 'in_progress' THEN COALESCE(plan_qty, 0) ELSE 0 END) AS in_progress_qty + FROM production_plan_mng + WHERE company_code = $1 + AND COALESCE(product_type, '완제품') = '완제품' + AND status NOT IN ('completed', 'cancelled') + GROUP BY item_code + ) + SELECT + os.item_code, + os.item_name, + os.total_order_qty, + os.total_ship_qty, + os.total_balance_qty, + os.order_count, + os.earliest_due_date, + COALESCE(si.current_stock, 0) AS current_stock, + COALESCE(si.safety_stock, 0) AS safety_stock, + COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty, + COALESCE(pi.in_progress_qty, 0) AS in_progress_qty, + GREATEST( + os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) + - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), + 0 + ) AS required_plan_qty + FROM order_summary os + LEFT JOIN stock_info si ON os.item_code = si.item_code + LEFT JOIN plan_info pi ON os.item_code = pi.item_code + ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} + ORDER BY os.item_code; + `; + + const result = await pool.query(query, params); + + // 그룹별 상세 수주 데이터도 함께 조회 + const detailWhere = conditions.map(c => c.replace(/so\./g, "")).join(" AND "); + const detailQuery = ` + SELECT + id, order_no, part_code, part_name, + COALESCE(order_qty::numeric, 0) AS order_qty, + COALESCE(ship_qty::numeric, 0) AS ship_qty, + COALESCE(balance_qty::numeric, 0) AS balance_qty, + due_date, status, partner_id, manager_name + FROM sales_order_mng + WHERE ${detailWhere} + ORDER BY part_code, due_date; + `; + const detailResult = await pool.query(detailQuery, params); + + // 그룹별로 상세 데이터 매핑 + const ordersByItem: Record = {}; + for (const row of detailResult.rows) { + const key = row.part_code || "__null__"; + if (!ordersByItem[key]) ordersByItem[key] = []; + ordersByItem[key].push(row); + } + + const data = result.rows.map((group: any) => ({ + ...group, + orders: ordersByItem[group.item_code || "__null__"] || [], + })); + + logger.info("수주 데이터 조회", { companyCode, groupCount: data.length }); + return data; +} + +// ─── 안전재고 부족분 조회 ─── + +export async function getStockShortage(companyCode: string) { + const pool = getPool(); + + const query = ` + SELECT + ist.item_code, + ii.item_name, + COALESCE(ist.current_qty::numeric, 0) AS current_qty, + COALESCE(ist.safety_qty::numeric, 0) AS safety_qty, + (COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty, + GREATEST( + COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0 + ) AS recommended_qty, + ist.last_in_date + FROM inventory_stock ist + LEFT JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code + WHERE ist.company_code = $1 + AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0) + ORDER BY shortage_qty ASC; + `; + + const result = await pool.query(query, [companyCode]); + logger.info("안전재고 부족분 조회", { companyCode, count: result.rowCount }); + return result.rows; +} + +// ─── 생산계획 CRUD ─── + +export async function getPlanById(companyCode: string, planId: number) { + const pool = getPool(); + const result = await pool.query( + `SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`, + [planId, companyCode] + ); + return result.rows[0] || null; +} + +export async function updatePlan( + companyCode: string, + planId: number, + data: Record, + updatedBy: string +) { + const pool = getPool(); + + const allowedFields = [ + "plan_qty", "start_date", "end_date", "due_date", + "equipment_id", "equipment_code", "equipment_name", + "manager_name", "work_shift", "priority", "remarks", "status", + "item_code", "item_name", "product_type", "order_no", + ]; + + const setClauses: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + for (const field of allowedFields) { + if (data[field] !== undefined) { + setClauses.push(`${field} = $${paramIdx}`); + params.push(data[field]); + paramIdx++; + } + } + + if (setClauses.length === 0) { + throw new Error("수정할 필드가 없습니다"); + } + + setClauses.push(`updated_date = NOW()`); + setClauses.push(`updated_by = $${paramIdx}`); + params.push(updatedBy); + paramIdx++; + + params.push(planId); + params.push(companyCode); + + const query = ` + UPDATE production_plan_mng + SET ${setClauses.join(", ")} + WHERE id = $${paramIdx - 1} AND company_code = $${paramIdx} + RETURNING * + `; + + const result = await pool.query(query, params); + if (result.rowCount === 0) { + throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다"); + } + logger.info("생산계획 수정", { companyCode, planId }); + return result.rows[0]; +} + +export async function deletePlan(companyCode: string, planId: number) { + const pool = getPool(); + const result = await pool.query( + `DELETE FROM production_plan_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + [planId, companyCode] + ); + if (result.rowCount === 0) { + throw new Error("생산계획을 찾을 수 없거나 권한이 없습니다"); + } + logger.info("생산계획 삭제", { companyCode, planId }); + return { id: planId }; +} + +// ─── 자동 스케줄 생성 ─── + +interface GenerateScheduleItem { + item_code: string; + item_name: string; + required_qty: number; + earliest_due_date: string; + hourly_capacity?: number; + daily_capacity?: number; + lead_time?: number; +} + +interface GenerateScheduleOptions { + safety_lead_time?: number; + recalculate_unstarted?: boolean; + product_type?: string; +} + +export async function generateSchedule( + companyCode: string, + items: GenerateScheduleItem[], + options: GenerateScheduleOptions, + createdBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + const productType = options.product_type || "완제품"; + const safetyLeadTime = options.safety_lead_time || 1; + + try { + await client.query("BEGIN"); + + let deletedCount = 0; + let keptCount = 0; + const newSchedules: any[] = []; + + for (const item of items) { + // 기존 미진행(planned) 스케줄 처리 + if (options.recalculate_unstarted) { + const deleteResult = await client.query( + `DELETE FROM production_plan_mng + WHERE company_code = $1 + AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status = 'planned' + RETURNING id`, + [companyCode, item.item_code, productType] + ); + deletedCount += deleteResult.rowCount || 0; + + const keptResult = await client.query( + `SELECT COUNT(*) AS cnt FROM production_plan_mng + WHERE company_code = $1 + AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status NOT IN ('planned', 'completed', 'cancelled')`, + [companyCode, item.item_code, productType] + ); + keptCount += parseInt(keptResult.rows[0].cnt, 10); + } + + // 생산일수 계산 + const dailyCapacity = item.daily_capacity || 800; + const requiredQty = item.required_qty; + if (requiredQty <= 0) continue; + + const productionDays = Math.ceil(requiredQty / dailyCapacity); + + // 시작일 = 납기일 - 생산일수 - 안전리드타임 + const dueDate = new Date(item.earliest_due_date); + const endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + + // 시작일이 오늘보다 이전이면 오늘로 조정 + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (startDate < today) { + startDate.setTime(today.getTime()); + endDate.setTime(startDate.getTime()); + endDate.setDate(endDate.getDate() + productionDays); + } + + // 계획번호 생성 + const planNoResult = await client.query( + `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no + FROM production_plan_mng WHERE company_code = $1`, + [companyCode] + ); + const nextNo = planNoResult.rows[0].next_no || 1; + const planNo = `PP-${String(nextNo).padStart(6, "0")}`; + + const insertResult = await client.query( + `INSERT INTO production_plan_mng ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, priority, hourly_capacity, daily_capacity, lead_time, + created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + $5, $6, $7, $8, $9, + 'planned', 'normal', $10, $11, $12, + $13, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, item.item_code, item.item_name, + productType, requiredQty, + startDate.toISOString().split("T")[0], + endDate.toISOString().split("T")[0], + item.earliest_due_date, + item.hourly_capacity || 100, + dailyCapacity, + item.lead_time || 1, + createdBy, + ] + ); + newSchedules.push(insertResult.rows[0]); + } + + await client.query("COMMIT"); + + const summary = { + total: newSchedules.length + keptCount, + new_count: newSchedules.length, + kept_count: keptCount, + deleted_count: deletedCount, + }; + + logger.info("자동 스케줄 생성 완료", { companyCode, summary }); + return { summary, schedules: newSchedules }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("자동 스케줄 생성 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} + +// ─── 스케줄 병합 ─── + +export async function mergeSchedules( + companyCode: string, + scheduleIds: number[], + productType: string, + mergedBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 대상 스케줄 조회 + const placeholders = scheduleIds.map((_, i) => `$${i + 2}`).join(", "); + const targetResult = await client.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND id IN (${placeholders}) + ORDER BY start_date`, + [companyCode, ...scheduleIds] + ); + + if (targetResult.rowCount !== scheduleIds.length) { + throw new Error("일부 스케줄을 찾을 수 없습니다"); + } + + const rows = targetResult.rows; + + // 동일 품목 검증 + const itemCodes = [...new Set(rows.map((r: any) => r.item_code))]; + if (itemCodes.length > 1) { + throw new Error("동일 품목의 스케줄만 병합할 수 있습니다"); + } + + // 병합 값 계산 + const totalQty = rows.reduce((sum: number, r: any) => sum + (parseFloat(r.plan_qty) || 0), 0); + const earliestStart = rows.reduce( + (min: string, r: any) => (!min || r.start_date < min ? r.start_date : min), + "" + ); + const latestEnd = rows.reduce( + (max: string, r: any) => (!max || r.end_date > max ? r.end_date : max), + "" + ); + const earliestDue = rows.reduce( + (min: string, r: any) => (!min || (r.due_date && r.due_date < min) ? r.due_date : min), + "" + ); + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))].join(", "); + + // 기존 삭제 + await client.query( + `DELETE FROM production_plan_mng WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...scheduleIds] + ); + + // 병합된 스케줄 생성 + const planNoResult = await client.query( + `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no + FROM production_plan_mng WHERE company_code = $1`, + [companyCode] + ); + const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + + const insertResult = await client.query( + `INSERT INTO production_plan_mng ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, order_no, created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + $5, $6, $7, $8, $9, + 'planned', $10, $11, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, rows[0].item_code, rows[0].item_name, + productType, totalQty, + earliestStart, latestEnd, earliestDue || null, + orderNos || null, mergedBy, + ] + ); + + await client.query("COMMIT"); + logger.info("스케줄 병합 완료", { + companyCode, + mergedFrom: scheduleIds, + mergedTo: insertResult.rows[0].id, + }); + return insertResult.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("스케줄 병합 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} + +// ─── 반제품 계획 자동 생성 ─── + +export async function generateSemiSchedule( + companyCode: string, + planIds: number[], + options: { considerStock?: boolean; excludeUsed?: boolean }, + createdBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 선택된 완제품 계획 조회 + const placeholders = planIds.map((_, i) => `$${i + 2}`).join(", "); + const plansResult = await client.query( + `SELECT * FROM production_plan_mng + WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...planIds] + ); + + const newSemiPlans: any[] = []; + + for (const plan of plansResult.rows) { + // BOM에서 해당 품목의 반제품 소요량 조회 + const bomQuery = ` + SELECT + bd.child_item_id, + ii.item_name AS child_item_name, + ii.item_code AS child_item_code, + bd.quantity AS bom_qty, + bd.unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code + WHERE b.company_code = $1 + AND b.item_code = $2 + AND COALESCE(b.status, 'active') = 'active' + `; + const bomResult = await client.query(bomQuery, [companyCode, plan.item_code]); + + for (const bomItem of bomResult.rows) { + let requiredQty = (parseFloat(plan.plan_qty) || 0) * (parseFloat(bomItem.bom_qty) || 1); + + // 재고 고려 + if (options.considerStock) { + const stockResult = await client.query( + `SELECT COALESCE(SUM(current_qty::numeric), 0) AS stock + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2`, + [companyCode, bomItem.child_item_code || bomItem.child_item_id] + ); + const stock = parseFloat(stockResult.rows[0].stock) || 0; + requiredQty = Math.max(requiredQty - stock, 0); + } + + if (requiredQty <= 0) continue; + + // 반제품 납기일 = 완제품 시작일 + const semiDueDate = plan.start_date; + const semiEndDate = plan.start_date; + const semiStartDate = new Date(plan.start_date); + semiStartDate.setDate(semiStartDate.getDate() - (plan.lead_time || 1)); + + const planNoResult = await client.query( + `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no + FROM production_plan_mng WHERE company_code = $1`, + [companyCode] + ); + const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + + const insertResult = await client.query( + `INSERT INTO production_plan_mng ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, parent_plan_id, created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + '반제품', $5, $6, $7, $8, + 'planned', $9, $10, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, + bomItem.child_item_code || bomItem.child_item_id, + bomItem.child_item_name || bomItem.child_item_id, + requiredQty, + semiStartDate.toISOString().split("T")[0], + typeof semiEndDate === "string" ? semiEndDate : semiEndDate.toISOString().split("T")[0], + typeof semiDueDate === "string" ? semiDueDate : semiDueDate.toISOString().split("T")[0], + plan.id, + createdBy, + ] + ); + newSemiPlans.push(insertResult.rows[0]); + } + } + + await client.query("COMMIT"); + logger.info("반제품 계획 생성 완료", { + companyCode, + parentPlanIds: planIds, + semiPlanCount: newSemiPlans.length, + }); + return { count: newSemiPlans.length, schedules: newSemiPlans }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("반제품 계획 생성 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} + +// ─── 스케줄 분할 ─── + +export async function splitSchedule( + companyCode: string, + planId: number, + splitQty: number, + splitBy: string +) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const planResult = await client.query( + `SELECT * FROM production_plan_mng WHERE id = $1 AND company_code = $2`, + [planId, companyCode] + ); + if (planResult.rowCount === 0) { + throw new Error("생산계획을 찾을 수 없습니다"); + } + + const plan = planResult.rows[0]; + const originalQty = parseFloat(plan.plan_qty) || 0; + + if (splitQty >= originalQty || splitQty <= 0) { + throw new Error("분할 수량은 0보다 크고 원래 수량보다 작아야 합니다"); + } + + // 원본 수량 감소 + await client.query( + `UPDATE production_plan_mng SET plan_qty = $1, updated_date = NOW(), updated_by = $2 + WHERE id = $3 AND company_code = $4`, + [originalQty - splitQty, splitBy, planId, companyCode] + ); + + // 분할된 새 계획 생성 + const planNoResult = await client.query( + `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no + FROM production_plan_mng WHERE company_code = $1`, + [companyCode] + ); + const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + + const insertResult = await client.query( + `INSERT INTO production_plan_mng ( + company_code, plan_no, plan_date, item_code, item_name, + product_type, plan_qty, start_date, end_date, due_date, + status, priority, equipment_id, equipment_code, equipment_name, + order_no, parent_plan_id, created_by, created_date, updated_date + ) VALUES ( + $1, $2, CURRENT_DATE, $3, $4, + $5, $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, NOW(), NOW() + ) RETURNING *`, + [ + companyCode, planNo, plan.item_code, plan.item_name, + plan.product_type, splitQty, + plan.start_date, plan.end_date, plan.due_date, + plan.status, plan.priority, plan.equipment_id, plan.equipment_code, plan.equipment_name, + plan.order_no, plan.parent_plan_id, + splitBy, + ] + ); + + await client.query("COMMIT"); + logger.info("스케줄 분할 완료", { companyCode, planId, splitQty }); + return { + original: { id: planId, plan_qty: originalQty - splitQty }, + split: insertResult.rows[0], + }; + } catch (error) { + await client.query("ROLLBACK"); + logger.error("스케줄 분할 실패", { companyCode, error }); + throw error; + } finally { + client.release(); + } +} diff --git a/docs/POP_작업진행_설계서.md b/docs/POP_작업진행_설계서.md new file mode 100644 index 00000000..3b77ddc5 --- /dev/null +++ b/docs/POP_작업진행_설계서.md @@ -0,0 +1,620 @@ +# POP 작업진행 관리 설계서 + +> 작성일: 2026-03-13 +> 목적: POP 시스템에서 작업지시 기반으로 라우팅/작업기준정보를 조회하고, 공정별 작업 진행 상태를 관리하는 구조 설계 + +--- + +## 1. 핵심 설계 원칙 + +**작업지시에 라우팅ID, 작업기준정보ID 등을 별도 컬럼으로 넣지 않는다.** + +- 작업지시(`work_instruction`)에는 `item_id`(품목 ID)만 있으면 충분 +- 품목 → 라우팅 → 작업기준정보는 마스터 데이터 체인으로 조회 +- 작업 진행 상태만 별도 테이블에서 관리 + +--- + +## 2. 기존 테이블 구조 (마스터 데이터) + +### 2-1. ER 다이어그램 + +> GitHub / VSCode Mermaid 플러그인에서 렌더링됩니다. + +```mermaid +erDiagram + %% ========== 마스터 데이터 (변경 없음) ========== + + item_info { + varchar id PK "UUID" + varchar item_number "품번" + varchar item_name "품명" + varchar company_code "회사코드" + } + + item_routing_version { + varchar id PK "UUID" + varchar item_code "품번 (= item_info.item_number)" + varchar version_name "버전명" + boolean is_default "기본버전 여부" + varchar company_code "회사코드" + } + + item_routing_detail { + varchar id PK "UUID" + varchar routing_version_id FK "→ item_routing_version.id" + varchar seq_no "공정순서 10,20,30..." + varchar process_code FK "→ process_mng.process_code" + varchar is_required "필수/선택" + varchar is_fixed_order "고정/선택" + varchar standard_time "표준시간(분)" + varchar company_code "회사코드" + } + + process_mng { + varchar id PK "UUID" + varchar process_code "공정코드" + varchar process_name "공정명" + varchar process_type "공정유형" + varchar company_code "회사코드" + } + + process_work_item { + varchar id PK "UUID" + varchar routing_detail_id FK "→ item_routing_detail.id" + varchar work_phase "PRE / IN / POST" + varchar title "작업항목명" + varchar is_required "Y/N" + int sort_order "정렬순서" + varchar company_code "회사코드" + } + + process_work_item_detail { + varchar id PK "UUID" + varchar work_item_id FK "→ process_work_item.id" + varchar detail_type "check/inspect/input/procedure/info" + varchar content "내용" + varchar input_type "입력타입" + varchar inspection_code "검사코드" + varchar unit "단위" + varchar lower_limit "하한값" + varchar upper_limit "상한값" + varchar company_code "회사코드" + } + + %% ========== 트랜잭션 데이터 ========== + + work_instruction { + varchar id PK "UUID" + varchar work_instruction_no "작업지시번호" + varchar item_id FK "→ item_info.id ★핵심★" + varchar status "waiting/in_progress/completed/cancelled" + varchar qty "지시수량" + varchar completed_qty "완성수량" + varchar worker "작업자" + varchar company_code "회사코드" + } + + work_order_process { + varchar id PK "UUID" + varchar wo_id FK "→ work_instruction.id" + varchar routing_detail_id FK "→ item_routing_detail.id ★추가★" + varchar seq_no "공정순서" + varchar process_code "공정코드" + varchar process_name "공정명" + varchar status "waiting/in_progress/completed/skipped" + varchar plan_qty "계획수량" + varchar good_qty "양품수량" + varchar defect_qty "불량수량" + timestamp started_at "시작시간" + timestamp completed_at "완료시간" + varchar company_code "회사코드" + } + + work_order_work_item { + varchar id PK "UUID ★신규★" + varchar company_code "회사코드" + varchar work_order_process_id FK "→ work_order_process.id" + varchar work_item_id FK "→ process_work_item.id" + varchar work_phase "PRE/IN/POST" + varchar status "pending/completed/skipped/failed" + varchar completed_by "완료자" + timestamp completed_at "완료시간" + } + + work_order_work_item_result { + varchar id PK "UUID ★신규★" + varchar company_code "회사코드" + varchar work_order_work_item_id FK "→ work_order_work_item.id" + varchar work_item_detail_id FK "→ process_work_item_detail.id" + varchar detail_type "check/inspect/input/procedure" + varchar result_value "결과값" + varchar is_passed "Y/N/null" + varchar recorded_by "기록자" + timestamp recorded_at "기록시간" + } + + %% ========== 관계 ========== + + %% 마스터 체인: 품목 → 라우팅 → 작업기준정보 + item_info ||--o{ item_routing_version : "item_number = item_code" + item_routing_version ||--o{ item_routing_detail : "id = routing_version_id" + item_routing_detail }o--|| process_mng : "process_code" + item_routing_detail ||--o{ process_work_item : "id = routing_detail_id" + process_work_item ||--o{ process_work_item_detail : "id = work_item_id" + + %% 트랜잭션: 작업지시 → 공정진행 → 작업기준정보 진행 + work_instruction }o--|| item_info : "item_id = id" + work_instruction ||--o{ work_order_process : "id = wo_id" + work_order_process }o--|| item_routing_detail : "routing_detail_id = id" + work_order_process ||--o{ work_order_work_item : "id = work_order_process_id" + work_order_work_item }o--|| process_work_item : "work_item_id = id" + work_order_work_item ||--o{ work_order_work_item_result : "id = work_order_work_item_id" + work_order_work_item_result }o--|| process_work_item_detail : "work_item_detail_id = id" +``` + +### 2-1-1. 관계 요약 (텍스트) + +``` +[마스터 데이터 체인 - 조회용, 변경 없음] + + item_info ─── 1:N ───→ item_routing_version ─── 1:N ───→ item_routing_detail + (품목) item_number (라우팅 버전) routing_ (공정별 상세) + = item_code version_id + │ + process_mng ◄───┘ process_code (공정 마스터) + │ + ├── 1:N ───→ process_work_item ─── 1:N ───→ process_work_item_detail + │ (작업기준정보) (작업기준정보 상세) + │ routing_detail_id work_item_id + │ +[트랜잭션 데이터 - 상태 관리] │ + │ + work_instruction ─── 1:N ───→ work_order_process ─┘ routing_detail_id (★추가★) + (작업지시) wo_id (공정별 진행) + item_id → item_info │ + ├── 1:N ───→ work_order_work_item ─── 1:N ───→ work_order_work_item_result + │ (작업기준정보 진행) (상세 결과값) + │ work_order_process_id work_order_work_item_id + │ work_item_id → process_work_item work_item_detail_id → process_work_item_detail + │ ★신규 테이블★ ★신규 테이블★ +``` + +### 2-2. 마스터 테이블 상세 + +#### item_info (품목 마스터) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| item_number | 품번 | item_routing_version.item_code와 매칭 | +| item_name | 품명 | | +| company_code | 회사코드 | 멀티테넌시 | + +#### item_routing_version (라우팅 버전) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| item_code | 품번 | item_info.item_number와 매칭 | +| version_name | 버전명 | 예: "기본 라우팅", "버전2" | +| is_default | 기본 버전 여부 | true/false, 기본 버전을 사용 | +| company_code | 회사코드 | | + +#### item_routing_detail (라우팅 상세 - 공정별) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| routing_version_id | FK → item_routing_version.id | | +| seq_no | 공정 순서 | 10, 20, 30... | +| process_code | 공정코드 | FK → process_mng.process_code | +| is_required | 필수/선택 | "필수" / "선택" | +| is_fixed_order | 순서고정 여부 | "고정" / "선택" | +| work_type | 작업유형 | | +| standard_time | 표준시간(분) | | +| outsource_supplier | 외주업체 | | +| company_code | 회사코드 | | + +#### process_work_item (작업기준정보) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| routing_detail_id | FK → item_routing_detail.id | | +| work_phase | 작업단계 | PRE(작업전) / IN(작업중) / POST(작업후) | +| title | 작업항목명 | 예: "장비 체크", "소재 준비" | +| is_required | 필수여부 | Y/N | +| sort_order | 정렬순서 | | +| description | 설명 | | +| company_code | 회사코드 | | + +#### process_work_item_detail (작업기준정보 상세) +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| work_item_id | FK → process_work_item.id | | +| detail_type | 상세유형 | check(체크) / inspect(검사) / input(입력) / procedure(절차) / info(정보) | +| content | 내용 | 예: "소음검사", "치수검사" | +| input_type | 입력타입 | select, text 등 | +| inspection_code | 검사코드 | | +| inspection_method | 검사방법 | | +| unit | 단위 | | +| lower_limit | 하한값 | | +| upper_limit | 상한값 | | +| is_required | 필수여부 | Y/N | +| sort_order | 정렬순서 | | +| company_code | 회사코드 | | + +--- + +## 3. 작업 진행 테이블 (트랜잭션 데이터) + +### 3-1. work_instruction (작업지시) - 기존 테이블 + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| work_instruction_no | 작업지시번호 | 예: WO-2026-001 | +| **item_id** | **FK → item_info.id** | **이것만으로 라우팅/작업기준정보 전부 조회 가능** | +| status | 작업지시 상태 | waiting / in_progress / completed / cancelled | +| qty | 지시수량 | | +| completed_qty | 완성수량 | | +| work_team | 작업팀 | | +| worker | 작업자 | | +| equipment_id | 설비 | | +| start_date | 시작일 | | +| end_date | 종료일 | | +| remark | 비고 | | +| company_code | 회사코드 | | + +> **routing 컬럼**: 현재 존재하지만 사용하지 않음 (null). 라우팅 버전을 지정하고 싶으면 이 컬럼에 `item_routing_version.id`를 넣어 특정 버전을 지정할 수 있음. 없으면 `is_default=true` 버전 자동 사용. + +### 3-2. work_order_process (공정별 진행) - 기존 테이블, 변경 필요 + +작업지시가 생성될 때, 해당 품목의 라우팅 공정을 복사해서 이 테이블에 INSERT. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | | +| wo_id | FK → work_instruction.id | 작업지시 참조 | +| **routing_detail_id** | **FK → item_routing_detail.id** | **추가 필요 - 라우팅 상세 참조** | +| seq_no | 공정 순서 | 라우팅에서 복사 | +| process_code | 공정코드 | 라우팅에서 복사 | +| process_name | 공정명 | 라우팅에서 복사 (비정규화, 조회 편의) | +| is_required | 필수여부 | 라우팅에서 복사 | +| is_fixed_order | 순서고정 | 라우팅에서 복사 | +| standard_time | 표준시간 | 라우팅에서 복사 | +| **status** | **공정 상태** | **waiting / in_progress / completed / skipped** | +| plan_qty | 계획수량 | | +| input_qty | 투입수량 | | +| good_qty | 양품수량 | | +| defect_qty | 불량수량 | | +| equipment_code | 사용설비 | | +| accepted_by | 접수자 | | +| accepted_at | 접수시간 | | +| started_at | 시작시간 | | +| completed_at | 완료시간 | | +| remark | 비고 | | +| company_code | 회사코드 | | + +### 3-3. work_order_work_item (작업기준정보별 진행) - 신규 테이블 + +POP에서 작업자가 각 작업기준정보 항목을 체크/입력할 때 사용. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | gen_random_uuid() | +| company_code | 회사코드 | 멀티테넌시 | +| work_order_process_id | FK → work_order_process.id | 어떤 작업지시의 어떤 공정인지 | +| work_item_id | FK → process_work_item.id | 어떤 작업기준정보인지 | +| work_phase | 작업단계 | PRE / IN / POST (마스터에서 복사) | +| status | 완료상태 | pending / completed / skipped / failed | +| completed_by | 완료자 | 작업자 ID | +| completed_at | 완료시간 | | +| created_date | 생성일 | | +| updated_date | 수정일 | | +| writer | 작성자 | | + +### 3-4. work_order_work_item_result (작업기준정보 상세 결과) - 신규 테이블 + +작업기준정보의 상세 항목(체크, 검사, 입력 등)에 대한 실제 결과값 저장. + +| 컬럼 | 설명 | 비고 | +|------|------|------| +| id | PK (UUID) | gen_random_uuid() | +| company_code | 회사코드 | 멀티테넌시 | +| work_order_work_item_id | FK → work_order_work_item.id | | +| work_item_detail_id | FK → process_work_item_detail.id | 어떤 상세항목인지 | +| detail_type | 상세유형 | check / inspect / input / procedure (마스터에서 복사) | +| result_value | 결과값 | 체크: "Y"/"N", 검사: 측정값, 입력: 입력값 | +| is_passed | 합격여부 | Y / N / null(해당없음) | +| remark | 비고 | 불합격 사유 등 | +| recorded_by | 기록자 | | +| recorded_at | 기록시간 | | +| created_date | 생성일 | | +| updated_date | 수정일 | | +| writer | 작성자 | | + +--- + +## 4. POP 데이터 플로우 + +### 4-1. 작업지시 등록 시 (ERP 측) + +``` +[작업지시 생성] + │ + ├── 1. work_instruction INSERT (item_id, qty, status='waiting' 등) + │ + ├── 2. item_id → item_info.item_number 조회 + │ + ├── 3. item_number → item_routing_version 조회 (is_default=true 또는 지정 버전) + │ + ├── 4. routing_version_id → item_routing_detail 조회 (공정 목록) + │ + └── 5. 각 공정별로 work_order_process INSERT + ├── wo_id = work_instruction.id + ├── routing_detail_id = item_routing_detail.id ← 핵심! + ├── seq_no, process_code, process_name 복사 + ├── status = 'waiting' + └── plan_qty = work_instruction.qty +``` + +### 4-2. POP 작업 조회 시 + +``` +[POP 화면: 작업지시 선택] + │ + ├── 1. work_instruction 목록 조회 (status = 'waiting' or 'in_progress') + │ + ├── 2. 선택한 작업지시의 공정 목록 조회 + │ SELECT wop.*, pm.process_name + │ FROM work_order_process wop + │ LEFT JOIN process_mng pm ON wop.process_code = pm.process_code + │ WHERE wop.wo_id = {작업지시ID} + │ ORDER BY CAST(wop.seq_no AS int) + │ + └── 3. 선택한 공정의 작업기준정보 조회 (마스터 데이터 참조) + SELECT pwi.*, pwid.* + FROM process_work_item pwi + LEFT JOIN process_work_item_detail pwid ON pwi.id = pwid.work_item_id + WHERE pwi.routing_detail_id = {work_order_process.routing_detail_id} + ORDER BY pwi.work_phase, pwi.sort_order, pwid.sort_order +``` + +### 4-3. POP 작업 실행 시 + +``` +[작업자가 공정 시작] + │ + ├── 1. work_order_process UPDATE + │ SET status = 'in_progress', started_at = NOW(), accepted_by = {작업자} + │ + ├── 2. work_instruction UPDATE (첫 공정 시작 시) + │ SET status = 'in_progress' + │ + ├── 3. 작업기준정보 항목별 체크/입력 시 + │ ├── work_order_work_item UPSERT (항목별 상태) + │ └── work_order_work_item_result UPSERT (상세 결과값) + │ + └── 4. 공정 완료 시 + ├── work_order_process UPDATE + │ SET status = 'completed', completed_at = NOW(), + │ good_qty = {양품}, defect_qty = {불량} + │ + └── (모든 공정 완료 시) + work_instruction UPDATE + SET status = 'completed', completed_qty = {최종양품} +``` + +--- + +## 5. 핵심 조회 쿼리 + +### 5-1. 작업지시 → 전체 공정 + 작업기준정보 한방 조회 + +```sql +-- 작업지시의 공정별 진행 현황 + 작업기준정보 +SELECT + wi.work_instruction_no, + wi.qty, + wi.status as wi_status, + ii.item_number, + ii.item_name, + wop.id as process_id, + wop.seq_no, + wop.process_code, + wop.process_name, + wop.status as process_status, + wop.plan_qty, + wop.good_qty, + wop.defect_qty, + wop.started_at, + wop.completed_at, + wop.routing_detail_id, + -- 작업기준정보는 routing_detail_id로 마스터 조회 + pwi.id as work_item_id, + pwi.work_phase, + pwi.title as work_item_title, + pwi.is_required as work_item_required +FROM work_instruction wi +JOIN item_info ii ON wi.item_id = ii.id +JOIN work_order_process wop ON wi.id = wop.wo_id +LEFT JOIN process_work_item pwi ON wop.routing_detail_id = pwi.routing_detail_id +WHERE wi.id = $1 + AND wi.company_code = $2 +ORDER BY CAST(wop.seq_no AS int), pwi.work_phase, pwi.sort_order; +``` + +### 5-2. 특정 공정의 작업기준정보 + 진행 상태 조회 + +```sql +-- POP에서 특정 공정 선택 시: 마스터 + 진행 상태 조인 +SELECT + pwi.id as work_item_id, + pwi.work_phase, + pwi.title, + pwi.is_required, + pwid.id as detail_id, + pwid.detail_type, + pwid.content, + pwid.input_type, + pwid.inspection_code, + pwid.inspection_method, + pwid.unit, + pwid.lower_limit, + pwid.upper_limit, + -- 진행 상태 + wowi.status as item_status, + wowi.completed_by, + wowi.completed_at, + -- 결과값 + wowir.result_value, + wowir.is_passed, + wowir.remark as result_remark +FROM process_work_item pwi +LEFT JOIN process_work_item_detail pwid + ON pwi.id = pwid.work_item_id +LEFT JOIN work_order_work_item wowi + ON wowi.work_item_id = pwi.id + AND wowi.work_order_process_id = $1 -- work_order_process.id +LEFT JOIN work_order_work_item_result wowir + ON wowir.work_order_work_item_id = wowi.id + AND wowir.work_item_detail_id = pwid.id +WHERE pwi.routing_detail_id = $2 -- work_order_process.routing_detail_id +ORDER BY + CASE pwi.work_phase WHEN 'PRE' THEN 1 WHEN 'IN' THEN 2 WHEN 'POST' THEN 3 END, + pwi.sort_order, + pwid.sort_order; +``` + +--- + +## 6. 변경사항 요약 + +### 6-1. 기존 테이블 변경 + +| 테이블 | 변경내용 | +|--------|---------| +| work_order_process | `routing_detail_id VARCHAR(500)` 컬럼 추가 | + +### 6-2. 신규 테이블 + +| 테이블 | 용도 | +|--------|------| +| work_order_work_item | 작업지시 공정별 작업기준정보 진행 상태 | +| work_order_work_item_result | 작업기준정보 상세 항목의 실제 결과값 | + +### 6-3. 건드리지 않는 것 + +| 테이블 | 이유 | +|--------|------| +| work_instruction | item_id만 있으면 충분. 라우팅/작업기준정보 ID 추가 불필요 | +| item_routing_version | 마스터 데이터, 변경 없음 | +| item_routing_detail | 마스터 데이터, 변경 없음 | +| process_work_item | 마스터 데이터, 변경 없음 | +| process_work_item_detail | 마스터 데이터, 변경 없음 | + +--- + +## 7. DDL (마이그레이션 SQL) + +```sql +-- 1. work_order_process에 routing_detail_id 추가 +ALTER TABLE work_order_process +ADD COLUMN IF NOT EXISTS routing_detail_id VARCHAR(500); + +CREATE INDEX IF NOT EXISTS idx_wop_routing_detail_id +ON work_order_process(routing_detail_id); + +-- 2. 작업기준정보별 진행 상태 테이블 +CREATE TABLE IF NOT EXISTS work_order_work_item ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + work_order_process_id VARCHAR(500) NOT NULL, + work_item_id VARCHAR(500) NOT NULL, + work_phase VARCHAR(500), + status VARCHAR(500) DEFAULT 'pending', + completed_by VARCHAR(500), + completed_at TIMESTAMP, + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) +); + +CREATE INDEX idx_wowi_process_id ON work_order_work_item(work_order_process_id); +CREATE INDEX idx_wowi_work_item_id ON work_order_work_item(work_item_id); +CREATE INDEX idx_wowi_company_code ON work_order_work_item(company_code); + +-- 3. 작업기준정보 상세 결과 테이블 +CREATE TABLE IF NOT EXISTS work_order_work_item_result ( + id VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + company_code VARCHAR(500) NOT NULL, + work_order_work_item_id VARCHAR(500) NOT NULL, + work_item_detail_id VARCHAR(500) NOT NULL, + detail_type VARCHAR(500), + result_value VARCHAR(500), + is_passed VARCHAR(500), + remark TEXT, + recorded_by VARCHAR(500), + recorded_at TIMESTAMP DEFAULT NOW(), + created_date TIMESTAMP DEFAULT NOW(), + updated_date TIMESTAMP DEFAULT NOW(), + writer VARCHAR(500) +); + +CREATE INDEX idx_wowir_work_order_work_item_id ON work_order_work_item_result(work_order_work_item_id); +CREATE INDEX idx_wowir_detail_id ON work_order_work_item_result(work_item_detail_id); +CREATE INDEX idx_wowir_company_code ON work_order_work_item_result(company_code); +``` + +--- + +## 8. 상태값 정의 + +### work_instruction.status (작업지시 상태) +| 값 | 의미 | +|----|------| +| waiting | 대기 | +| in_progress | 진행중 | +| completed | 완료 | +| cancelled | 취소 | + +### work_order_process.status (공정 상태) +| 값 | 의미 | +|----|------| +| waiting | 대기 (아직 시작 안 함) | +| in_progress | 진행중 (작업자가 시작) | +| completed | 완료 | +| skipped | 건너뜀 (선택 공정인 경우) | + +### work_order_work_item.status (작업기준정보 항목 상태) +| 값 | 의미 | +|----|------| +| pending | 미완료 | +| completed | 완료 | +| skipped | 건너뜀 | +| failed | 실패 (검사 불합격 등) | + +### work_order_work_item_result.is_passed (검사 합격여부) +| 값 | 의미 | +|----|------| +| Y | 합격 | +| N | 불합격 | +| null | 해당없음 (체크/입력 항목) | + +--- + +## 9. 설계 의도 요약 + +1. **마스터와 트랜잭션 분리**: 라우팅/작업기준정보는 마스터(템플릿), 실제 진행은 트랜잭션 테이블에서 관리 +2. **조회 경로**: `work_instruction.item_id` → `item_info.item_number` → `item_routing_version` → `item_routing_detail` → `process_work_item` → `process_work_item_detail` +3. **진행 경로**: `work_order_process.routing_detail_id`로 마스터 작업기준정보를 참조하되, 실제 진행/결과는 `work_order_work_item` + `work_order_work_item_result`에 저장 +4. **중복 저장 최소화**: 작업지시에 공정/작업기준정보 ID를 넣지 않음. 품목만 있으면 전부 파생 조회 가능 +5. **work_order_process**: 작업지시 생성 시 라우팅 공정을 복사하는 이유는 진행 중 수량/상태/시간 등 트랜잭션 데이터를 기록해야 하기 때문 (마스터가 변경되어도 이미 발행된 작업지시의 공정은 유지) + +--- + +## 10. 주의사항 + +- `work_order_process`에 공정 정보를 복사(스냅샷)하는 이유: 마스터 라우팅이 나중에 변경되어도 이미 진행 중인 작업지시의 공정 구성은 영향받지 않아야 함 +- `routing_detail_id`는 "이 공정이 어떤 마스터 라우팅에서 왔는지" 추적용. 작업기준정보 조회 키로 사용 +- POP에서 작업기준정보를 표시할 때는 항상 마스터(`process_work_item`)를 조회하고, 결과만 트랜잭션 테이블에 저장 +- 모든 테이블에 `company_code` 필수 (멀티테넌시) diff --git a/docs/screen-implementation-guide/00_analysis/full-screen-analysis.md b/docs/screen-implementation-guide/00_analysis/full-screen-analysis.md deleted file mode 100644 index 9b4a9908..00000000 --- a/docs/screen-implementation-guide/00_analysis/full-screen-analysis.md +++ /dev/null @@ -1,331 +0,0 @@ -# 화면 전체 분석 보고서 - -> **분석 대상**: `/Users/kimjuseok/Downloads/화면개발 8` 폴더 내 핵심 업무 화면 -> **분석 기준**: 메뉴별 분류, 3개 이상 재활용 가능한 컴포넌트 식별 -> **분석 일자**: 2026-01-30 - ---- - -## 1. 현재 사용 중인 V2 컴포넌트 목록 - -> **중요**: v2- 접두사가 붙은 컴포넌트만 사용합니다. - -### 입력 컴포넌트 -| ID | 이름 | 용도 | -|----|------|------| -| `v2-input` | V2 입력 | 텍스트, 숫자, 비밀번호, 이메일 등 입력 | -| `v2-select` | V2 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | -| `v2-date` | V2 날짜 | 날짜, 시간, 날짜범위 입력 | - -### 표시 컴포넌트 -| ID | 이름 | 용도 | -|----|------|------| -| `v2-text-display` | 텍스트 표시 | 라벨, 텍스트 표시 | -| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | -| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수 등 집계 표시 | - -### 테이블/데이터 컴포넌트 -| ID | 이름 | 용도 | -|----|------|------| -| `v2-table-list` | 테이블 리스트 | 데이터 테이블 표시, 페이지네이션, 정렬, 필터 | -| `v2-table-search-widget` | 검색 필터 | 화면 내 테이블 검색/필터/그룹 기능 | -| `v2-pivot-grid` | 피벗 그리드 | 다차원 데이터 분석 (피벗 테이블) | - -### 레이아웃 컴포넌트 -| ID | 이름 | 용도 | -|----|------|------| -| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 레이아웃 | -| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 배치 | -| `v2-section-card` | Section Card | 제목/테두리가 있는 그룹화 컨테이너 | -| `v2-section-paper` | Section Paper | 배경색 기반 미니멀 그룹화 컨테이너 | -| `v2-divider-line` | 구분선 | 영역 구분 | -| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 내부 컴포넌트 반복 렌더링 | -| `v2-repeater` | 리피터 | 반복 컨트롤 | - -### 액션/기타 컴포넌트 -| ID | 이름 | 용도 | -|----|------|------| -| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 버튼 | -| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 | -| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 | -| `v2-location-swap-selector` | 위치 교환 선택기 | 위치 교환 기능 | -| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | -| `v2-media` | 미디어 | 미디어 표시 | - -**총 23개 V2 컴포넌트** - ---- - -## 2. 화면 분류 (메뉴별) - -### 01. 기준정보 (master-data) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 회사정보 | 회사정보.html | 검색+테이블 | ✅ 완전 | -| 부서정보 | 부서정보.html | 검색+테이블 | ✅ 완전 | -| 품목정보 | 품목정보.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 | -| BOM관리 | BOM관리.html | 분할패널+트리 | ⚠️ 트리뷰 미지원 | -| 공정정보관리 | 공정정보관리.html | 분할패널+테이블 | ✅ 완전 | -| 공정작업기준 | 공정작업기준관리.html | 검색+테이블 | ✅ 완전 | -| 품목라우팅 | 품목라우팅관리.html | 분할패널+테이블 | ✅ 완전 | - -### 02. 영업관리 (sales) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 수주관리 | 수주관리.html | 분할패널+테이블 | ✅ 완전 | -| 견적관리 | 견적관리.html | 분할패널+테이블 | ✅ 완전 | -| 거래처관리 | 거래처관리.html | 분할패널+탭+그룹화 | ⚠️ 그룹화 미지원 | -| 판매품목정보 | 판매품목정보.html | 검색+테이블 | ✅ 완전 | -| 출하계획관리 | 출하계획관리.html | 검색+테이블 | ✅ 완전 | - -### 03. 생산관리 (production) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 생산계획관리 | 생산계획관리.html | 분할패널+탭+타임라인 | ❌ 타임라인 미지원 | -| 생산관리 | 생산관리.html | 검색+테이블 | ✅ 완전 | -| 생산실적관리 | 생산실적관리.html | 검색+테이블 | ✅ 완전 | -| 작업지시 | 작업지시.html | 탭+그룹화테이블+분할패널 | ⚠️ 그룹화 미지원 | -| 공정관리 | 공정관리.html | 분할패널+테이블 | ✅ 완전 | - -### 04. 구매관리 (purchase) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 발주관리 | 발주관리.html | 검색+테이블 | ✅ 완전 | -| 공급업체관리 | 공급업체관리.html | 검색+테이블 | ✅ 완전 | -| 구매입고 | pages/구매입고.html | 검색+테이블 | ✅ 완전 | - -### 05. 설비관리 (equipment) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 설비정보 | 설비정보.html | 분할패널+카드+탭 | ✅ v2-card-display 활용 | - -### 06. 물류관리 (logistics) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 창고관리 | 창고관리.html | 모바일앱스타일+iframe | ❌ 별도개발 필요 | -| 창고정보관리 | 창고정보관리.html | 검색+테이블 | ✅ 완전 | -| 입출고관리 | 입출고관리.html | 검색+테이블+그룹화 | ⚠️ 그룹화 미지원 | -| 재고현황 | 재고현황.html | 검색+테이블 | ✅ 완전 | - -### 07. 품질관리 (quality) -| 화면명 | 파일명 | 패턴 | 구현 가능 | -|--------|--------|------|----------| -| 검사기준 | 검사기준.html | 검색+테이블 | ✅ 완전 | -| 검사정보관리 | 검사정보관리.html | 탭+테이블 | ✅ 완전 | -| 검사장비관리 | 검사장비관리.html | 검색+테이블 | ✅ 완전 | -| 불량관리 | 불량관리.html | 검색+테이블 | ✅ 완전 | -| 클레임관리 | 클레임관리.html | 검색+테이블 | ✅ 완전 | - ---- - -## 3. 화면 UI 패턴 분석 - -### 패턴 A: 검색 + 테이블 (가장 기본) -**해당 화면**: 약 60% (15개 이상) - -**사용 컴포넌트**: -- `v2-table-search-widget`: 검색 필터 -- `v2-table-list`: 데이터 테이블 - -``` -┌─────────────────────────────────────────┐ -│ [검색필드들...] [조회] [엑셀] │ ← v2-table-search-widget -├─────────────────────────────────────────┤ -│ 테이블 제목 [신규등록] [삭제] │ -│ ────────────────────────────────────── │ -│ □ | 코드 | 이름 | 상태 | 등록일 | │ ← v2-table-list -│ □ | A001 | 테스트| 사용 | 2026-01-30 | │ -└─────────────────────────────────────────┘ -``` - -### 패턴 B: 분할 패널 (마스터-디테일) -**해당 화면**: 약 25% (8개) - -**사용 컴포넌트**: -- `v2-split-panel-layout`: 좌우 분할 -- `v2-table-list`: 마스터/디테일 테이블 -- `v2-tabs-widget`: 상세 탭 (선택) - -``` -┌──────────────────┬──────────────────────┐ -│ 마스터 리스트 │ 상세 정보 / 탭 │ -│ ─────────────── │ ┌────┬────┬────┐ │ -│ □ A001 제품A │ │기본│이력│첨부│ │ -│ □ A002 제품B ← │ └────┴────┴────┘ │ -│ □ A003 제품C │ [테이블 or 폼] │ -└──────────────────┴──────────────────────┘ -``` - -### 패턴 C: 탭 + 테이블 -**해당 화면**: 약 10% (3개) - -**사용 컴포넌트**: -- `v2-tabs-widget`: 탭 전환 -- `v2-table-list`: 탭별 테이블 - -``` -┌─────────────────────────────────────────┐ -│ [탭1] [탭2] [탭3] │ -├─────────────────────────────────────────┤ -│ [테이블 영역] │ -└─────────────────────────────────────────┘ -``` - -### 패턴 D: 특수 UI -**해당 화면**: 약 5% (2개) - -- 생산계획관리: 타임라인/간트 차트 → **v2-timeline 미존재** -- 창고관리: 모바일 앱 스타일 → **별도 개발 필요** - ---- - -## 4. 신규 컴포넌트 분석 (3개 이상 재활용 기준) - -### 4.1 v2-grouped-table (그룹화 테이블) -**재활용 화면 수**: 5개 이상 ✅ - -| 화면 | 그룹화 기준 | -|------|------------| -| 품목정보 | 품목구분, 카테고리 | -| 거래처관리 | 거래처유형, 지역 | -| 작업지시 | 작업일자, 공정 | -| 입출고관리 | 입출고구분, 창고 | -| 견적관리 | 상태, 거래처 | - -**기능 요구사항**: -- 특정 컬럼 기준 그룹핑 -- 그룹 접기/펼치기 -- 그룹 헤더에 집계 표시 -- 다중 그룹핑 지원 - -**구현 복잡도**: 중 - -### 4.2 v2-tree-view (트리 뷰) -**재활용 화면 수**: 3개 ✅ - -| 화면 | 트리 용도 | -|------|----------| -| BOM관리 | BOM 구조 (정전개/역전개) | -| 부서정보 | 조직도 | -| 메뉴관리 | 메뉴 계층 | - -**기능 요구사항**: -- 노드 접기/펼치기 -- 드래그앤드롭 (선택) -- 정전개/역전개 전환 -- 노드 선택 이벤트 - -**구현 복잡도**: 중상 - -### 4.3 v2-timeline-scheduler (타임라인) -**재활용 화면 수**: 1~2개 (기준 미달) - -| 화면 | 용도 | -|------|------| -| 생산계획관리 | 간트 차트 | -| 설비 가동 현황 | 타임라인 | - -**기능 요구사항**: -- 시간축 기반 배치 -- 드래그로 일정 변경 -- 공정별 색상 구분 -- 줌 인/아웃 - -**구현 복잡도**: 상 - -> **참고**: 3개 미만이므로 우선순위 하향 - ---- - -## 5. 컴포넌트 커버리지 - -### 현재 V2 컴포넌트로 구현 가능 -``` -┌─────────────────────────────────────────────────┐ -│ 17개 화면 (65%) │ -│ - 기본 검색 + 테이블 패턴 │ -│ - 분할 패널 │ -│ - 탭 전환 │ -│ - 카드 디스플레이 │ -└─────────────────────────────────────────────────┘ -``` - -### v2-grouped-table 개발 후 -``` -┌─────────────────────────────────────────────────┐ -│ +5개 화면 (22개, 85%) │ -│ - 품목정보, 거래처관리, 작업지시 │ -│ - 입출고관리, 견적관리 │ -└─────────────────────────────────────────────────┘ -``` - -### v2-tree-view 개발 후 -``` -┌─────────────────────────────────────────────────┐ -│ +2개 화면 (24개, 92%) │ -│ - BOM관리, 부서정보(계층) │ -└─────────────────────────────────────────────────┘ -``` - -### 별도 개발 필요 -``` -┌─────────────────────────────────────────────────┐ -│ 2개 화면 (8%) │ -│ - 생산계획관리 (타임라인) │ -│ - 창고관리 (모바일 앱 스타일) │ -└─────────────────────────────────────────────────┘ -``` - ---- - -## 6. 신규 컴포넌트 개발 우선순위 - -| 순위 | 컴포넌트 | 재활용 화면 수 | 복잡도 | ROI | -|------|----------|--------------|--------|-----| -| 1 | v2-grouped-table | 5+ | 중 | ⭐⭐⭐⭐⭐ | -| 2 | v2-tree-view | 3 | 중상 | ⭐⭐⭐⭐ | -| 3 | v2-timeline-scheduler | 1~2 | 상 | ⭐⭐ | - ---- - -## 7. 권장 구현 전략 - -### Phase 1: 즉시 구현 (현재 V2 컴포넌트) -- 회사정보, 부서정보 -- 발주관리, 공급업체관리 -- 검사기준, 검사장비관리, 불량관리 -- 창고정보관리, 재고현황 -- 공정작업기준관리 -- 수주관리, 견적관리, 공정관리 -- 설비정보 (v2-card-display 활용) -- 검사정보관리 - -### Phase 2: v2-grouped-table 개발 후 -- 품목정보, 거래처관리, 입출고관리 -- 작업지시 - -### Phase 3: v2-tree-view 개발 후 -- BOM관리 -- 부서정보 (계층 뷰) - -### Phase 4: 개별 개발 -- 생산계획관리 (타임라인) -- 창고관리 (모바일 스타일) - ---- - -## 8. 요약 - -| 항목 | 수치 | -|------|------| -| 전체 분석 화면 수 | 26개 | -| 현재 즉시 구현 가능 | 17개 (65%) | -| v2-grouped-table 추가 시 | 22개 (85%) | -| v2-tree-view 추가 시 | 24개 (92%) | -| 별도 개발 필요 | 2개 (8%) | - -**핵심 결론**: -1. **현재 V2 컴포넌트**로 65% 화면 구현 가능 -2. **v2-grouped-table** 1개 컴포넌트 개발로 85%까지 확대 -3. **v2-tree-view** 추가로 92% 도달 -4. 나머지 8%는 화면별 특수 UI (타임라인, 모바일 스타일)로 개별 개발 필요 diff --git a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md deleted file mode 100644 index b37abf5e..00000000 --- a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md +++ /dev/null @@ -1,631 +0,0 @@ -# V2 공통 컴포넌트 사용 가이드 - -> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드 -> **대상**: 화면 설계자, 개발자 -> **버전**: 1.1.0 -> **작성일**: 2026-02-23 (최종 업데이트) - ---- - -## 1. V2 컴포넌트로 가능한 것 / 불가능한 것 - -### 1.1 가능한 화면 유형 - -| 화면 유형 | 설명 | 대표 예시 | -|-----------|------|----------| -| 마스터 관리 | 단일 테이블 CRUD | 회사정보, 부서정보, 코드관리 | -| 마스터-디테일 | 좌측 선택 → 우측 상세 | 공정관리, 품목라우팅, 견적관리 | -| 탭 기반 화면 | 탭별 다른 테이블/뷰 | 검사정보관리, 거래처관리 | -| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 | -| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 | -| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 | -| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 | -| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 | - -### 1.2 불가능한 화면 유형 (별도 개발 필요) - -| 화면 유형 | 이유 | 해결 방안 | -|-----------|------|----------| -| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 | -| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 | -| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 | -| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 | - -> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다. - ---- - -## 2. V2 컴포넌트 전체 목록 (25개) - -### 2.1 입력 컴포넌트 (4개) - -| ID | 이름 | 용도 | 주요 옵션 | -|----|------|------|----------| -| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step | -| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading | -| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday | -| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - | - -### 2.2 표시 컴포넌트 (3개) - -| ID | 이름 | 용도 | 주요 옵션 | -|----|------|------|----------| -| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign | -| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) | -| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout | - -### 2.3 테이블/데이터 컴포넌트 (4개) - -| ID | 이름 | 용도 | 주요 옵션 | -|----|------|------|----------| -| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad | -| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title | -| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) | -| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - | - -### 2.4 레이아웃 컴포넌트 (7개) - -| ID | 이름 | 용도 | 주요 옵션 | -|----|------|------|----------| -| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs | -| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection | -| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding | -| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow | -| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness | -| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns | -| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - | - -### 2.5 액션/특수 컴포넌트 (7개) - -| ID | 이름 | 용도 | 주요 옵션 | -|----|------|------|----------| -| `v2-button-primary` | 기본 버튼 | 저장, 삭제 등 액션 | text, actionType, variant | -| `v2-numbering-rule` | 채번규칙 | 자동 코드/번호 생성 | rule, prefix, format | -| `v2-category-manager` | 카테고리 관리자 | 카테고리 관리 UI | - | -| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - | -| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - | -| `v2-media` | 미디어 | 이미지/동영상 표시 | - | -| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - | - ---- - -## 3. 화면 패턴별 컴포넌트 조합 - -### 3.1 패턴 A: 기본 마스터 화면 (가장 흔함) - -**적용 화면**: 코드관리, 사용자관리, 부서정보, 창고정보 등 - -``` -┌─────────────────────────────────────────────────┐ -│ v2-table-search-widget │ -│ [검색필드1] [검색필드2] [조회] [엑셀] │ -├─────────────────────────────────────────────────┤ -│ v2-table-list │ -│ 제목 [신규] [삭제] │ -│ ─────────────────────────────────────────────── │ -│ □ | 코드 | 이름 | 상태 | 등록일 | │ -└─────────────────────────────────────────────────┘ -``` - -**필수 컴포넌트**: -- `v2-table-search-widget` (1개) -- `v2-table-list` (1개) - -**설정 포인트**: -- 테이블명 지정 -- 검색 대상 컬럼 설정 -- 컬럼 표시/숨김 설정 - ---- - -### 3.2 패턴 B: 마스터-디테일 화면 - -**적용 화면**: 공정관리, 견적관리, 수주관리, 품목라우팅 등 - -``` -┌──────────────────┬──────────────────────────────┐ -│ v2-table-list │ v2-table-list 또는 폼 │ -│ (마스터) │ (디테일) │ -│ ─────────────── │ │ -│ □ A001 항목1 │ [상세 정보] │ -│ □ A002 항목2 ← │ │ -│ □ A003 항목3 │ │ -└──────────────────┴──────────────────────────────┘ - v2-split-panel-layout -``` - -**필수 컴포넌트**: -- `v2-split-panel-layout` (1개) -- `v2-table-list` (2개: 마스터, 디테일) - -**설정 포인트**: -- `splitRatio`: 좌우 비율 (기본 30:70) -- `relation.type`: join / detail / custom -- `relation.foreignKey`: 연결 키 컬럼 - ---- - -### 3.3 패턴 C: 마스터-디테일 + 탭 - -**적용 화면**: 거래처관리, 품목정보, 설비정보 등 - -``` -┌──────────────────┬──────────────────────────────┐ -│ v2-table-list │ v2-tabs-widget │ -│ (마스터) │ ┌────┬────┬────┐ │ -│ │ │기본│이력│첨부│ │ -│ □ A001 거래처1 │ └────┴────┴────┘ │ -│ □ A002 거래처2 ← │ [탭별 컨텐츠] │ -└──────────────────┴──────────────────────────────┘ -``` - -**필수 컴포넌트**: -- `v2-split-panel-layout` (1개) -- `v2-table-list` (1개: 마스터) -- `v2-tabs-widget` (1개) - -**설정 포인트**: -- 탭별 표시할 테이블/폼 설정 -- 마스터 선택 시 탭 컨텐츠 연동 - ---- - -### 3.4 패턴 D: 카드 뷰 - -**적용 화면**: 설비정보, 대시보드, 상품 카탈로그 등 - -``` -┌─────────────────────────────────────────────────┐ -│ v2-table-search-widget │ -├─────────────────────────────────────────────────┤ -│ v2-card-display │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ [이미지]│ │ [이미지]│ │ [이미지]│ │ -│ │ 제목 │ │ 제목 │ │ 제목 │ │ -│ │ 설명 │ │ 설명 │ │ 설명 │ │ -│ └─────────┘ └─────────┘ └─────────┘ │ -└─────────────────────────────────────────────────┘ -``` - -**필수 컴포넌트**: -- `v2-table-search-widget` (1개) -- `v2-card-display` (1개) - -**설정 포인트**: -- `cardsPerRow`: 한 행당 카드 수 -- `columnMapping`: 제목, 부제목, 이미지, 상태 필드 매핑 -- `cardStyle`: 이미지 위치, 크기 - ---- - -### 3.5 패턴 E: 피벗 분석 - -**적용 화면**: 매출분석, 재고현황, 생산실적 분석 등 - -``` -┌─────────────────────────────────────────────────┐ -│ v2-pivot-grid │ -│ │ 2024년 │ 2025년 │ 2026년 │ 합계 │ -│ ─────────────────────────────────────────────── │ -│ 지역A │ 1,000 │ 1,200 │ 1,500 │ 3,700 │ -│ 지역B │ 2,000 │ 2,500 │ 3,000 │ 7,500 │ -│ 합계 │ 3,000 │ 3,700 │ 4,500 │ 11,200 │ -└─────────────────────────────────────────────────┘ -``` - -**필수 컴포넌트**: -- `v2-pivot-grid` (1개) - -**설정 포인트**: -- `fields[].area`: row / column / data / filter -- `fields[].summaryType`: sum / avg / count / min / max -- `fields[].groupInterval`: 날짜 그룹화 (year/quarter/month) - ---- - -## 4. 회사별 개발 시 핵심 체크포인트 - -### 4.1 테이블 설계 확인 - -**가장 먼저 확인**: -1. 회사에서 사용할 테이블 목록 -2. 테이블 간 관계 (FK) -3. 조회 조건으로 쓸 컬럼 - -``` -✅ 체크리스트: -□ 테이블명이 DB에 존재하는가? -□ company_code 컬럼이 있는가? (멀티테넌시) -□ 마스터-디테일 관계의 FK가 정의되어 있는가? -□ 검색 대상 컬럼에 인덱스가 있는가? -``` - -### 4.2 화면 패턴 판단 - -**질문을 통한 판단**: - -| 질문 | 예 → 패턴 | -|------|----------| -| 단일 테이블만 조회/편집? | 패턴 A (기본 마스터) | -| 마스터 선택 시 디테일 표시? | 패턴 B (마스터-디테일) | -| 상세에 탭이 필요? | 패턴 C (마스터-디테일+탭) | -| 이미지+정보 카드 형태? | 패턴 D (카드 뷰) | -| 다차원 집계/분석? | 패턴 E (피벗) | - -### 4.3 컴포넌트 설정 필수 항목 - -#### v2-table-list 필수 설정 - -```typescript -{ - selectedTable: "테이블명", // 필수 - columns: [ // 표시할 컬럼 - { columnName: "id", displayName: "ID", visible: true, sortable: true }, - // ... - ], - pagination: { - enabled: true, - pageSize: 20, - showSizeSelector: true, - showPageInfo: true - }, - displayMode: "table", // "table" | "card" - checkbox: { - enabled: true, - multiple: true, - position: "left", - selectAll: true - }, - horizontalScroll: { // 가로 스크롤 설정 - enabled: true, - maxVisibleColumns: 8 - }, - linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동) - excludeFilter: {}, // 제외 필터 - autoLoad: true, // 자동 데이터 로드 - stickyHeader: false, // 헤더 고정 - autoWidth: true // 자동 너비 조정 -} -``` - -#### v2-split-panel-layout 필수 설정 - -```typescript -{ - leftPanel: { - displayMode: "table", // "list" | "table" | "custom" - tableName: "마스터_테이블명", - columns: [], // 컬럼 설정 - editButton: { // 수정 버튼 설정 - enabled: true, - mode: "auto", // "auto" | "modal" - modalScreenId: "" // 모달 모드 시 화면 ID - }, - addButton: { // 추가 버튼 설정 - enabled: true, - mode: "auto", - modalScreenId: "" - }, - deleteButton: { // 삭제 버튼 설정 - enabled: true, - buttonLabel: "삭제", - confirmMessage: "삭제하시겠습니까?" - }, - addModalColumns: [], // 추가 모달 전용 컬럼 - additionalTabs: [] // 추가 탭 설정 - }, - rightPanel: { - displayMode: "table", - tableName: "디테일_테이블명", - relation: { - type: "detail", // "join" | "detail" | "custom" - foreignKey: "master_id", // 연결 키 - leftColumn: "", // 좌측 연결 컬럼 - rightColumn: "", // 우측 연결 컬럼 - keys: [] // 복합 키 - } - }, - splitRatio: 30, // 좌측 비율 (0-100) - resizable: true, // 리사이즈 가능 - minLeftWidth: 200, // 좌측 최소 너비 - minRightWidth: 300, // 우측 최소 너비 - syncSelection: true, // 선택 동기화 - autoLoad: true // 자동 로드 -} -``` - -#### v2-split-panel-layout 커스텀 모드 (NEW) - -패널 내부에 자유롭게 컴포넌트를 배치할 수 있습니다. (v2-tabs-widget과 동일 구조) - -```typescript -{ - leftPanel: { - displayMode: "custom", // 커스텀 모드 활성화 - components: [ // 내부 컴포넌트 배열 - { - id: "btn-save", - componentType: "v2-button-primary", - label: "저장", - position: { x: 10, y: 10 }, - size: { width: 100, height: 40 }, - componentConfig: { buttonAction: "save" } - }, - { - id: "tbl-list", - componentType: "v2-table-list", - label: "목록", - position: { x: 10, y: 60 }, - size: { width: 400, height: 300 }, - componentConfig: { selectedTable: "테이블명" } - } - ] - }, - rightPanel: { - displayMode: "table" // 기존 모드 유지 - } -} -``` - -**디자인 모드 기능**: -- 컴포넌트 클릭 → 좌측 설정 패널에서 속성 편집 -- 드래그 핸들(상단)로 이동 -- 리사이즈 핸들(모서리)로 크기 조절 -- 실제 컴포넌트 미리보기 렌더링 - -#### v2-card-display 필수 설정 - -```typescript -{ - dataSource: "table", - columnMapping: { - title: "name", // 제목 필드 - subtitle: "code", // 부제목 필드 - image: "image_url", // 이미지 필드 (선택) - status: "status" // 상태 필드 (선택) - }, - cardsPerRow: 3 -} -``` - ---- - -## 5. 공통 컴포넌트 한계점 - -### 5.1 현재 불가능한 기능 - -| 기능 | 상태 | 대안 | -|------|------|------| -| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 | -| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 | -| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 | -| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 | - -> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다. - -### 5.2 권장하지 않는 조합 - -| 조합 | 이유 | -|------|------| -| 3단계 이상 중첩 분할 | 화면 복잡도 증가, 성능 저하 | -| 탭 안에 탭 | 사용성 저하 | -| 한 화면에 3개 이상 테이블 | 데이터 로딩 성능 | -| 피벗 + 상세 테이블 동시 | 데이터 과부하 | - ---- - -## 6. 제어관리 (비즈니스 로직) - 별도 설정 필수 - -> **핵심**: V2 컴포넌트는 **UI만 담당**합니다. 비즈니스 로직은 **제어관리**에서 별도 설정해야 합니다. - -### 6.1 UI vs 제어 분리 구조 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 화면 구성 │ -├─────────────────────────────┬───────────────────────────────────┤ -│ UI 레이아웃 │ 제어관리 │ -│ (screen_layouts_v2) │ (dataflow_diagrams) │ -├─────────────────────────────┼───────────────────────────────────┤ -│ • 컴포넌트 배치 │ • 버튼 클릭 시 액션 │ -│ • 검색 필드 구성 │ • INSERT/UPDATE/DELETE 설정 │ -│ • 테이블 컬럼 표시 │ • 조건부 실행 │ -│ • 카드/탭 레이아웃 │ • 다중 행 처리 │ -│ │ • 테이블 간 데이터 이동 │ -└─────────────────────────────┴───────────────────────────────────┘ -``` - -### 6.2 HTML에서 파악 가능/불가능 - -| 구분 | HTML에서 파악 | 이유 | -|------|--------------|------| -| 컴포넌트 배치 | ✅ 가능 | HTML 구조에서 보임 | -| 검색 필드 | ✅ 가능 | input 태그로 확인 | -| 테이블 컬럼 | ✅ 가능 | thead에서 확인 | -| **저장 테이블** | ❌ 불가능 | JS/백엔드에서 처리 | -| **버튼 액션** | ❌ 불가능 | 제어관리에서 설정 | -| **전/후 처리** | ❌ 불가능 | 제어관리에서 설정 | -| **다중 행 처리** | ❌ 불가능 | 제어관리에서 설정 | -| **테이블 간 관계** | ❌ 불가능 | DB/제어관리에서 설정 | - -### 6.3 제어관리 설정 항목 - -#### 트리거 타입 -- **버튼 클릭 전 (before)**: 클릭 직전 실행 -- **버튼 클릭 후 (after)**: 클릭 완료 후 실행 - -#### 액션 타입 -- **INSERT**: 새로운 데이터 삽입 -- **UPDATE**: 기존 데이터 수정 -- **DELETE**: 데이터 삭제 - -#### 조건 설정 -```typescript -// 예: 선택된 행의 상태가 '대기'인 경우에만 실행 -{ - field: "status", - operator: "=", - value: "대기", - dataType: "string" -} -``` - -#### 필드 매핑 -```typescript -// 예: 소스 테이블의 값을 타겟 테이블로 이동 -{ - sourceTable: "order_master", - sourceField: "order_no", - targetTable: "order_history", - targetField: "order_no" -} -``` - -### 6.4 제어관리 예시: 수주 확정 버튼 - -**시나리오**: 수주 목록에서 3건 선택 후 [확정] 버튼 클릭 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ [확정] 버튼 클릭 │ -├─────────────────────────────────────────────────────────────────┤ -│ 1. 조건 체크: status = '대기' 인 행만 │ -│ 2. UPDATE order_master SET status = '확정' WHERE id IN (선택) │ -│ 3. INSERT order_history (수주이력 테이블에 기록) │ -│ 4. 외부 시스템 호출 (ERP 연동) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**제어관리 설정**: -```json -{ - "triggerType": "after", - "actions": [ - { - "actionType": "update", - "targetTable": "order_master", - "conditions": [{ "field": "status", "operator": "=", "value": "대기" }], - "fieldMappings": [{ "targetField": "status", "defaultValue": "확정" }] - }, - { - "actionType": "insert", - "targetTable": "order_history", - "fieldMappings": [ - { "sourceField": "order_no", "targetField": "order_no" }, - { "sourceField": "customer_name", "targetField": "customer_name" } - ] - } - ] -} -``` - -### 6.5 회사별 개발 시 제어관리 체크리스트 - -``` -□ 버튼별 액션 정의 - - 어떤 버튼이 있는가? - - 각 버튼 클릭 시 무슨 동작? - -□ 저장/수정/삭제 대상 테이블 - - 메인 테이블은? - - 이력 테이블은? - - 연관 테이블은? - -□ 조건부 실행 - - 특정 상태일 때만 실행? - - 특정 값 체크 필요? - -□ 다중 행 처리 - - 여러 행 선택 후 일괄 처리? - - 각 행별 개별 처리? - -□ 외부 연동 - - ERP/MES 등 외부 시스템 호출? - - API 연동 필요? -``` - ---- - -## 7. 회사별 커스터마이징 영역 - -### 7.1 컴포넌트로 처리되는 영역 (표준화) - -| 영역 | 설명 | -|------|------| -| UI 레이아웃 | 컴포넌트 배치, 크기, 위치 | -| 검색 조건 | 화면 디자이너에서 설정 | -| 테이블 컬럼 | 표시/숨김, 순서, 너비 | -| 기본 CRUD | 조회, 저장, 삭제 자동 처리 | -| 페이지네이션 | 자동 처리 | -| 정렬/필터 | 자동 처리 | - -### 7.2 회사별 개발 필요 영역 - -| 영역 | 설명 | 개발 방법 | -|------|------|----------| -| 비즈니스 로직 | 저장 전/후 검증, 계산 | 데이터플로우 또는 백엔드 API | -| 특수 UI | 간트, 트리, 차트 등 | 별도 컴포넌트 개발 | -| 외부 연동 | ERP, MES 등 연계 | 외부 호출 설정 | -| 리포트/인쇄 | 전표, 라벨 출력 | 리포트 컴포넌트 | -| 결재 프로세스 | 승인/반려 흐름 | 워크플로우 설정 | - ---- - -## 8. 빠른 개발 가이드 - -### Step 1: 화면 분석 -1. 어떤 테이블을 사용하는가? -2. 테이블 간 관계는? -3. 어떤 패턴인가? (A/B/C/D/E) - -### Step 2: 컴포넌트 배치 -1. 화면 디자이너에서 패턴에 맞는 컴포넌트 배치 -2. 각 컴포넌트에 테이블/컬럼 설정 - -### Step 3: 연동 설정 -1. 마스터-디테일 관계 설정 (FK) -2. 검색 조건 설정 -3. 버튼 액션 설정 - -### Step 4: 테스트 -1. 데이터 조회 확인 -2. 마스터 선택 시 디테일 연동 확인 -3. 저장/삭제 동작 확인 - ---- - -## 9. 요약 - -### V2 컴포넌트 커버리지 - -| 화면 유형 | 지원 여부 | 주요 컴포넌트 | -|-----------|----------|--------------| -| 기본 CRUD | ✅ 완전 | v2-table-list, v2-table-search-widget | -| 마스터-디테일 | ✅ 완전 | v2-split-panel-layout | -| 탭 화면 | ✅ 완전 | v2-tabs-widget | -| 카드 뷰 | ✅ 완전 | v2-card-display | -| 피벗 분석 | ✅ 완전 | v2-pivot-grid | -| 그룹화 테이블 | ✅ 지원 | v2-table-grouped | -| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler | -| 파일 업로드 | ✅ 지원 | v2-file-upload | -| 트리 뷰 | ❌ 미지원 | 개발 필요 | - -### 개발 시 핵심 원칙 - -1. **테이블 먼저**: DB 테이블 구조 확인이 최우선 -2. **패턴 판단**: 5가지 패턴 중 어디에 해당하는지 판단 -3. **표준 조합**: 검증된 컴포넌트 조합 사용 -4. **한계 인식**: 불가능한 UI는 조기에 식별하여 별도 개발 계획 -5. **멀티테넌시**: 모든 테이블에 company_code 필터링 필수 -6. **제어관리 필수**: UI 완성 후 버튼별 비즈니스 로직 설정 필수 - -### UI vs 제어 구분 - -| 영역 | 담당 | 설정 위치 | -|------|------|----------| -| 화면 레이아웃 | V2 컴포넌트 | 화면 디자이너 | -| 비즈니스 로직 | 제어관리 | dataflow_diagrams | -| 외부 연동 | 외부호출 설정 | external_call_configs | - -**HTML에서 배낄 수 있는 것**: UI 구조만 -**별도 설정 필요한 것**: 저장 테이블, 버튼 액션, 조건 처리, 다중 행 처리 diff --git a/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md new file mode 100644 index 00000000..1ba0da01 --- /dev/null +++ b/docs/screen-implementation-guide/01_reference_guides/full-screen-analysis.md @@ -0,0 +1,952 @@ +# WACE 화면 시스템 - DB 스키마 & 컴포넌트 설정 전체 레퍼런스 + +> **최종 업데이트**: 2026-03-13 +> **용도**: AI 챗봇이 화면 생성 시 참조하는 DB 스키마, 컴포넌트 전체 설정 사전 +> **관련 문서**: `v2-component-usage-guide.md` (SQL 템플릿, 실행 예시) + +--- + +## 1. 시스템 아키텍처 + +### 렌더링 파이프라인 + +``` +[DB] screen_definitions + screen_layouts_v2 + → [Backend API] GET /api/screens/:screenId + → [layoutV2Converter] V2 JSON → Legacy 변환 (기본값 + overrides 병합) + → [ResponsiveGridRenderer] → DynamicComponentRenderer + → [ComponentRegistry] → 실제 React 컴포넌트 +``` + +### 테이블 관계도 + +``` +비즈니스 테이블 ←── table_labels (라벨) + ←── table_type_columns (컬럼 타입, company_code='*') + ←── column_labels (한글 라벨) + +screen_definitions ←── screen_layouts_v2 (layout_data JSON) +menu_info (메뉴 트리, menu_url → /screen/{screen_code}) + +[선택] dataflow_diagrams (비즈니스 로직) +[선택] numbering_rules + numbering_rule_parts (채번) +[선택] table_column_category_values (카테고리) +``` + +--- + +## 2. DB 테이블 스키마 + +### 2.1 비즈니스 테이블 필수 구조 + +> **[최우선 규칙] 비즈니스 테이블에 NOT NULL / UNIQUE 제약조건 절대 금지!** +> +> 멀티테넌시 환경에서 회사별로 필수값/유니크 규칙이 다를 수 있으므로, +> 제약조건은 DB 레벨이 아닌 **`table_type_columns`의 메타데이터(`is_nullable`, `is_unique`)로 논리적 제어**한다. +> DB에 직접 NOT NULL/UNIQUE/CHECK/FOREIGN KEY를 걸면 멀티테넌시가 깨진다. +> +> **허용**: `id` PRIMARY KEY, `DEFAULT` 값만 DB 레벨 설정 +> **금지**: 비즈니스 컬럼에 `NOT NULL`, `UNIQUE`, `CHECK`, `FOREIGN KEY` + +```sql +CREATE TABLE "{테이블명}" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + -- 모든 비즈니스 컬럼은 varchar(500), NOT NULL/UNIQUE 제약조건 금지 +); +``` + +### 2.2 table_labels + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| table_name | varchar PK | 테이블명 | +| table_label | varchar | 한글 라벨 | +| description | text | 설명 | +| use_log_table | varchar(1) | 'Y'/'N' | + +### 2.3 table_type_columns + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | serial PK | 자동 증가 | +| table_name | varchar | UNIQUE(+column_name+company_code) | +| column_name | varchar | 컬럼명 | +| company_code | varchar | `'*'` = 전체 공통 | +| input_type | varchar | text/number/date/code/entity/select/checkbox/radio/textarea/category/numbering | +| detail_settings | text | JSON (code/entity/select 상세) | +| is_nullable | varchar | `'Y'`/`'N'` (논리적 필수값 제어) | +| display_order | integer | -5~-1: 기본, 0~: 비즈니스 | +| column_label | varchar | 컬럼 한글 라벨 | +| description | text | 컬럼 설명 | +| is_visible | boolean | 화면 표시 여부 (기본 true) | +| code_category | varchar | input_type=code일 때 코드 카테고리 | +| code_value | varchar | 코드 값 | +| reference_table | varchar | input_type=entity일 때 참조 테이블 | +| reference_column | varchar | 참조 컬럼 | +| display_column | varchar | 참조 표시 컬럼 | +| is_unique | varchar | `'Y'`/`'N'` (논리적 유니크 제어) | +| category_ref | varchar | 카테고리 참조 | + +### 2.4 screen_definitions + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| screen_id | serial PK | 자동 증가 | +| screen_name | varchar NOT NULL | 화면명 | +| screen_code | varchar | **조건부 UNIQUE** (`WHERE is_active <> 'D'`) | +| table_name | varchar | 메인 테이블명 | +| company_code | varchar NOT NULL | 회사 코드 | +| description | text | 화면 설명 | +| is_active | char(1) | `'Y'`/`'N'`/`'D'` (D=삭제) | +| layout_metadata | jsonb | 레이아웃 메타데이터 | +| created_date | timestamp | 생성일시 | +| created_by | varchar | 생성자 | +| updated_date | timestamp | 수정일시 | +| updated_by | varchar | 수정자 | +| deleted_date | timestamp | 삭제일시 | +| deleted_by | varchar | 삭제자 | +| delete_reason | text | 삭제 사유 | +| db_source_type | varchar | `'internal'` (기본) / `'external'` | +| db_connection_id | integer | 외부 DB 연결 ID | +| data_source_type | varchar | `'database'` (기본) / `'rest_api'` | +| rest_api_connection_id | integer | REST API 연결 ID | +| rest_api_endpoint | varchar | REST API 엔드포인트 | +| rest_api_json_path | varchar | JSON 응답 경로 (기본 `'data'`) | +| source_screen_id | integer | 원본 화면 ID (복사본일 때) | + +> **screen_code UNIQUE 주의**: `is_active = 'D'`(삭제)인 화면은 UNIQUE 대상에서 제외된다. 삭제된 화면과 같은 코드로 새 화면을 만들 수 있지만, 활성 상태(`'Y'`/`'N'`)에서는 중복 불가. + +### 2.5 screen_layouts_v2 + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| layout_id | serial PK | 자동 증가 | +| screen_id | integer FK | UNIQUE(+company_code+layer_id) | +| company_code | varchar NOT NULL | 회사 코드 | +| layout_data | jsonb NOT NULL | 전체 레이아웃 JSON (기본 `'{}'`) | +| created_at | timestamptz | 생성일시 | +| updated_at | timestamptz | 수정일시 | +| layer_id | integer | 1=기본 레이어 (기본값 1) | +| layer_name | varchar | 레이어명 (기본 `'기본 레이어'`) | +| condition_config | jsonb | 레이어 조건부 표시 설정 | + +### 2.6 menu_info + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| objid | numeric PK | BIGINT 고유값 | +| menu_type | numeric | 0=화면, 1=폴더 | +| parent_obj_id | numeric | 부모 메뉴 objid | +| menu_name_kor | varchar | 메뉴명 (한글) | +| menu_name_eng | varchar | 메뉴명 (영문) | +| seq | numeric | 정렬 순서 | +| menu_url | varchar | `/screen/{screen_code}` | +| menu_desc | varchar | 메뉴 설명 | +| writer | varchar | 작성자 | +| regdate | timestamp | 등록일시 | +| status | varchar | 상태 (`'active'` 등) | +| company_code | varchar | 회사 코드 (기본 `'*'`) | +| screen_code | varchar | 연결 화면 코드 | +| system_name | varchar | 시스템명 | +| lang_key | varchar | 다국어 키 | +| lang_key_desc | varchar | 다국어 설명 키 | +| menu_code | varchar | 메뉴 코드 | +| source_menu_objid | bigint | 원본 메뉴 objid (복사본일 때) | +| screen_group_id | integer | 화면 그룹 ID | +| menu_icon | varchar | 메뉴 아이콘 | + +--- + +## 3. 컴포넌트 전체 설정 레퍼런스 (32개) + +> 아래 설정은 layout_data JSON의 각 컴포넌트 `overrides` 안에 들어가는 값이다. +> 기본값과 다른 부분만 overrides에 지정하면 된다. + +--- + +### 3.1 v2-table-list (데이터 테이블) + +**용도**: DB 테이블 데이터를 테이블/카드 형태로 조회/편집. 가장 핵심적인 컴포넌트. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| tableName | string | - | 조회할 DB 테이블명 | +| selectedTable | string | - | tableName 별칭 | +| displayMode | `"table"\|"card"` | `"table"` | 테이블 모드 또는 카드 모드 | +| autoLoad | boolean | `true` | 화면 로드 시 자동으로 데이터 조회 | +| isReadOnly | boolean | false | 읽기 전용 (편집 불가) | +| columns | ColumnConfig[] | `[]` | 표시할 컬럼 설정 배열 | +| title | string | - | 테이블 상단 제목 | +| showHeader | boolean | `true` | 테이블 헤더 행 표시 | +| showFooter | boolean | `true` | 테이블 푸터 표시 | +| height | string | `"auto"` | 높이 모드 (`"auto"`, `"fixed"`, `"viewport"`) | +| fixedHeight | number | - | height="fixed"일 때 고정 높이(px) | +| autoWidth | boolean | `true` | 컬럼 너비 자동 계산 | +| stickyHeader | boolean | `false` | 스크롤 시 헤더 고정 | + +**checkbox (체크박스 설정)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| enabled | boolean | `true` | 체크박스 사용 여부 | +| multiple | boolean | `true` | 다중 선택 허용 | +| position | `"left"\|"right"` | `"left"` | 체크박스 위치 | +| selectAll | boolean | `true` | 전체 선택 버튼 표시 | + +**pagination (페이지네이션)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| enabled | boolean | `true` | 페이지네이션 사용 | +| pageSize | number | `20` | 한 페이지당 행 수 | +| showSizeSelector | boolean | `true` | 페이지 크기 변경 드롭다운 | +| showPageInfo | boolean | `true` | "1-20 / 100건" 같은 정보 표시 | +| pageSizeOptions | number[] | `[10,20,50,100]` | 선택 가능한 페이지 크기 | + +**horizontalScroll (가로 스크롤)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| enabled | boolean | `true` | 가로 스크롤 사용 | +| maxVisibleColumns | number | `8` | 스크롤 없이 보이는 최대 컬럼 수 | +| minColumnWidth | number | `100` | 컬럼 최소 너비(px) | +| maxColumnWidth | number | `300` | 컬럼 최대 너비(px) | + +**tableStyle (스타일)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| theme | string | `"default"` | 테마 (`default`/`striped`/`bordered`/`minimal`) | +| headerStyle | string | `"default"` | 헤더 스타일 (`default`/`dark`/`light`) | +| rowHeight | string | `"normal"` | 행 높이 (`compact`/`normal`/`comfortable`) | +| alternateRows | boolean | `true` | 짝수/홀수 행 색상 교차 | +| hoverEffect | boolean | `true` | 마우스 호버 시 행 강조 | +| borderStyle | string | `"light"` | 테두리 (`none`/`light`/`heavy`) | + +**toolbar (툴바 버튼)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| showEditMode | boolean | `false` | 즉시저장/배치저장 모드 전환 버튼 | +| showExcel | boolean | `false` | Excel 내보내기 버튼 | +| showPdf | boolean | `false` | PDF 내보내기 버튼 | +| showSearch | boolean | `false` | 테이블 내 검색 | +| showRefresh | boolean | `false` | 상단 새로고침 버튼 | +| showPaginationRefresh | boolean | `true` | 하단 새로고침 버튼 | + +**filter (필터)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| enabled | boolean | `true` | 필터 기능 사용 | +| filters | array | `[]` | 사전 정의 필터 목록 | + +**ColumnConfig (columns 배열 요소)**: + +| 설정 | 타입 | 설명 | +|------|------|------| +| columnName | string | DB 컬럼명 | +| displayName | string | 화면 표시명 | +| visible | boolean | 표시 여부 | +| sortable | boolean | 정렬 가능 여부 | +| searchable | boolean | 검색 가능 여부 | +| editable | boolean | 인라인 편집 가능 여부 | +| width | number | 컬럼 너비(px) | +| align | `"left"\|"center"\|"right"` | 텍스트 정렬 | +| format | string | 포맷 (`text`/`number`/`date`/`currency`/`boolean`) | +| hidden | boolean | 숨김 (데이터는 로드하되 표시 안 함) | +| fixed | `"left"\|"right"\|false` | 컬럼 고정 위치 | +| thousandSeparator | boolean | 숫자 천 단위 콤마 | +| isEntityJoin | boolean | 엔티티 조인 사용 여부 | +| entityJoinInfo | object | 조인 정보 (`sourceTable`, `sourceColumn`, `referenceTable`, `joinAlias`) | + +**cardConfig (displayMode="card"일 때)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| idColumn | string | `"id"` | ID 컬럼 | +| titleColumn | string | `"name"` | 카드 제목 컬럼 | +| subtitleColumn | string | - | 부제목 컬럼 | +| descriptionColumn | string | - | 설명 컬럼 | +| imageColumn | string | - | 이미지 URL 컬럼 | +| cardsPerRow | number | `3` | 행당 카드 수 | +| cardSpacing | number | `16` | 카드 간격(px) | +| showActions | boolean | `true` | 카드 액션 버튼 표시 | + +--- + +### 3.2 v2-split-panel-layout (마스터-디테일 분할) + +**용도**: 좌측 마스터 테이블 선택 → 우측 디테일 테이블 연동. 가장 복잡한 컴포넌트. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| splitRatio | number | `30` | 좌측 패널 비율(0~100) | +| resizable | boolean | `true` | 사용자가 분할선 드래그로 비율 변경 가능 | +| minLeftWidth | number | `200` | 좌측 최소 너비(px) | +| minRightWidth | number | `300` | 우측 최소 너비(px) | +| autoLoad | boolean | `true` | 화면 로드 시 자동 데이터 조회 | +| syncSelection | boolean | `true` | 좌측 선택 시 우측 자동 갱신 | + +**leftPanel / rightPanel 공통 설정**: + +| 설정 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | DB 테이블명 | +| displayMode | `"list"\|"table"\|"custom"` | `list`: 리스트, `table`: 테이블, `custom`: 자유 배치 | +| columns | array | 컬럼 설정 (`name`, `label`, `width`, `sortable`, `align`, `isEntityJoin`, `joinInfo`) | +| showSearch | boolean | 패널 내 검색 바 표시 | +| showAdd | boolean | 추가 버튼 표시 | +| showEdit | boolean | 수정 버튼 표시 | +| showDelete | boolean | 삭제 버튼 표시 | +| addButton | object | `{ enabled, mode("auto"/"modal"), modalScreenId }` | +| editButton | object | `{ enabled, mode("auto"/"modal"), modalScreenId, buttonLabel }` | +| deleteButton | object | `{ enabled, buttonLabel, confirmMessage }` | +| addModalColumns | array | 추가 모달 전용 컬럼 (`name`, `label`, `required`) | +| dataFilter | object | `{ enabled, filters, matchType("all"/"any") }` | +| tableConfig | object | `{ showCheckbox, showRowNumber, rowHeight, headerHeight, striped, bordered, hoverable, stickyHeader }` | +| components | array | displayMode="custom"일 때 내부 컴포넌트 배열 | + +**rightPanel 전용 설정**: + +| 설정 | 타입 | 설명 | +|------|------|------| +| relation | object | 마스터-디테일 연결 관계 | +| relation.type | `"detail"\|"join"` | detail: FK 관계, join: 테이블 JOIN | +| relation.leftColumn | string | 좌측(마스터) 연결 컬럼 (보통 `"id"`) | +| relation.rightColumn | string | 우측(디테일) 연결 컬럼 (FK) | +| relation.foreignKey | string | FK 컬럼명 (rightColumn과 동일) | +| relation.keys | array | 복합키 `[{ leftColumn, rightColumn }]` | +| additionalTabs | array | 우측 패널에 탭 추가 (각 탭은 rightPanel과 동일 구조 + `tabId`, `label`) | +| addConfig | object | `{ targetTable, autoFillColumns, leftPanelColumn, targetColumn }` | +| deduplication | object | `{ enabled, groupByColumn, keepStrategy, sortColumn }` | +| summaryColumnCount | number | 요약 표시 컬럼 수 | + +--- + +### 3.3 v2-table-search-widget (검색 바) + +**용도**: 테이블 상단에 배치하여 검색/필터 기능 제공. 대상 테이블 컬럼을 자동 감지. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| autoSelectFirstTable | boolean | `true` | 화면 내 첫 번째 테이블 자동 연결 | +| showTableSelector | boolean | `true` | 테이블 선택 드롭다운 표시 | +| title | string | `"테이블 검색"` | 검색 바 제목 | +| filterMode | `"dynamic"\|"preset"` | `"dynamic"` | dynamic: 자동 필터, preset: 고정 필터 | +| presetFilters | array | `[]` | 고정 필터 목록 (`{ columnName, columnLabel, filterType, width }`) | +| targetPanelPosition | `"left"\|"right"\|"auto"` | `"left"` | split-panel에서 대상 패널 위치 | + +--- + +### 3.4 v2-input (텍스트/숫자 입력) + +**용도**: 텍스트, 숫자, 비밀번호, textarea, 슬라이더, 컬러, 버튼 등 단일 값 입력. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| inputType | string | `"text"` | 입력 유형: `text`/`number`/`password`/`slider`/`color`/`button`/`textarea` | +| format | string | `"none"` | 포맷 검증: `none`/`email`/`tel`/`url`/`currency`/`biz_no` | +| placeholder | string | `""` | 입력 힌트 텍스트 | +| required | boolean | `false` | 필수 입력 표시 | +| readonly | boolean | `false` | 읽기 전용 | +| disabled | boolean | `false` | 비활성화 | +| maxLength | number | - | 최대 입력 글자 수 | +| minLength | number | - | 최소 입력 글자 수 | +| pattern | string | - | 정규식 패턴 검증 | +| showCounter | boolean | `false` | 글자 수 카운터 표시 | +| min | number | - | 최소값 (number/slider) | +| max | number | - | 최대값 (number/slider) | +| step | number | - | 증감 단위 (number/slider) | +| buttonText | string | - | 버튼 텍스트 (inputType=button) | +| tableName | string | - | 바인딩 테이블명 | +| columnName | string | - | 바인딩 컬럼명 | + +--- + +### 3.5 v2-select (선택) + +**용도**: 드롭다운, 콤보박스, 라디오, 체크박스, 태그, 토글 등 선택형 입력. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| mode | string | `"dropdown"` | 선택 모드: `dropdown`/`combobox`/`radio`/`check`/`tag`/`tagbox`/`toggle`/`swap` | +| source | string | `"distinct"` | 데이터 소스: `static`/`code`/`db`/`api`/`entity`/`category`/`distinct`/`select` | +| options | array | `[]` | source=static일 때 옵션 목록 `[{ label, value }]` | +| codeGroup | string | - | source=code일 때 코드 그룹 | +| codeCategory | string | - | source=code일 때 코드 카테고리 | +| table | string | - | source=db일 때 테이블명 | +| valueColumn | string | - | source=db일 때 값 컬럼 | +| labelColumn | string | - | source=db일 때 표시 컬럼 | +| entityTable | string | - | source=entity일 때 엔티티 테이블 | +| entityValueField | string | - | source=entity일 때 값 필드 | +| entityLabelField | string | - | source=entity일 때 표시 필드 | +| searchable | boolean | `true` | 검색 가능 (combobox에서 기본 활성) | +| multiple | boolean | `false` | 다중 선택 허용 | +| maxSelect | number | - | 최대 선택 수 | +| allowClear | boolean | - | 선택 해제 허용 | +| placeholder | string | `"선택하세요"` | 힌트 텍스트 | +| required | boolean | `false` | 필수 선택 | +| readonly | boolean | `false` | 읽기 전용 | +| disabled | boolean | `false` | 비활성화 | +| cascading | object | - | 연쇄 선택 (상위 select 값에 따라 하위 옵션 변경) | +| hierarchical | boolean | - | 계층 구조 (부모-자식 관계) | +| parentField | string | - | 부모 필드명 | + +--- + +### 3.6 v2-date (날짜) + +**용도**: 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 입력. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dateType | string | `"date"` | 날짜 유형: `date`/`datetime`/`time`/`daterange`/`month`/`year` | +| format | string | `"YYYY-MM-DD"` | 표시/저장 형식 | +| placeholder | string | `"날짜 선택"` | 힌트 텍스트 | +| required | boolean | `false` | 필수 입력 | +| readonly | boolean | `false` | 읽기 전용 | +| disabled | boolean | `false` | 비활성화 | +| showTime | boolean | `false` | 시간 선택 표시 (datetime) | +| use24Hours | boolean | `true` | 24시간 형식 | +| range | boolean | - | 범위 선택 (시작~종료) | +| minDate | string | - | 선택 가능 최소 날짜 (ISO 8601) | +| maxDate | string | - | 선택 가능 최대 날짜 | +| showToday | boolean | - | 오늘 버튼 표시 | + +--- + +### 3.7 v2-button-primary (액션 버튼) + +**용도**: 저장, 삭제, 조회, 커스텀 등 액션 버튼. 제어관리(dataflow)와 연결 가능. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| text | string | `"저장"` | 버튼 텍스트 | +| actionType | string | `"button"` | 버튼 타입: `button`/`submit`/`reset` | +| variant | string | `"primary"` | 스타일: `primary`/`secondary`/`danger` | +| size | string | `"md"` | 크기: `sm`/`md`/`lg` | +| disabled | boolean | `false` | 비활성화 | +| action | object | - | 액션 설정 | +| action.type | string | `"save"` | 액션 유형: `save`/`delete`/`edit`/`copy`/`navigate`/`modal`/`control`/`custom` | +| action.successMessage | string | `"저장되었습니다."` | 성공 시 토스트 메시지 | +| action.errorMessage | string | `"오류가 발생했습니다."` | 실패 시 토스트 메시지 | +| webTypeConfig | object | - | 제어관리 연결 설정 | +| webTypeConfig.enableDataflowControl | boolean | - | 제어관리 활성화 | +| webTypeConfig.dataflowConfig | object | - | 제어관리 설정 | +| webTypeConfig.dataflowConfig.controlMode | string | - | `"relationship"`/`"flow"`/`"none"` | +| webTypeConfig.dataflowConfig.relationshipConfig | object | - | `{ relationshipId, executionTiming("before"/"after"/"replace") }` | +| webTypeConfig.dataflowConfig.flowConfig | object | - | `{ flowId, executionTiming }` | + +--- + +### 3.8 v2-table-grouped (그룹화 테이블) + +**용도**: 특정 컬럼 기준으로 데이터를 그룹화. 그룹별 접기/펼치기, 집계 표시. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| selectedTable | string | `""` | DB 테이블명 | +| columns | array | `[]` | 컬럼 설정 (v2-table-list와 동일) | +| showCheckbox | boolean | `false` | 체크박스 표시 | +| checkboxMode | `"single"\|"multi"` | `"multi"` | 체크박스 모드 | +| isReadOnly | boolean | `false` | 읽기 전용 | +| rowClickable | boolean | `true` | 행 클릭 가능 | +| showExpandAllButton | boolean | `true` | 전체 펼치기/접기 버튼 | +| groupHeaderStyle | string | `"default"` | 그룹 헤더 스타일 (`default`/`compact`/`card`) | +| emptyMessage | string | `"데이터가 없습니다."` | 빈 데이터 메시지 | +| height | string\|number | `"auto"` | 높이 | +| maxHeight | number | `600` | 최대 높이(px) | +| pagination.enabled | boolean | `false` | 페이지네이션 사용 | +| pagination.pageSize | number | `10` | 페이지 크기 | + +**groupConfig (그룹화 설정)**: + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| groupByColumn | string | `""` | **필수**. 그룹화 기준 컬럼 | +| groupLabelFormat | string | `"{value}"` | 그룹 라벨 포맷 | +| defaultExpanded | boolean | `true` | 초기 펼침 여부 | +| sortDirection | `"asc"\|"desc"` | `"asc"` | 그룹 정렬 방향 | +| summary.showCount | boolean | `true` | 그룹별 건수 표시 | +| summary.sumColumns | string[] | `[]` | 합계 표시할 컬럼 목록 | +| summary.avgColumns | string[] | - | 평균 표시 컬럼 | +| summary.maxColumns | string[] | - | 최대값 표시 컬럼 | +| summary.minColumns | string[] | - | 최소값 표시 컬럼 | + +--- + +### 3.9 v2-pivot-grid (피벗 분석) + +**용도**: 다차원 데이터 분석. 행/열/데이터/필터 영역에 필드를 배치하여 집계. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| fields | array | `[]` | **필수**. 피벗 필드 배열 | +| dataSource | object | - | 데이터 소스 (`type`, `tableName`, `joinConfigs`, `filterConditions`) | +| allowSortingBySummary | boolean | - | 집계값 기준 정렬 허용 | +| allowFiltering | boolean | - | 필터링 허용 | +| allowExpandAll | boolean | - | 전체 확장/축소 허용 | +| wordWrapEnabled | boolean | - | 텍스트 줄바꿈 | +| height | string\|number | - | 높이 | +| totals.showRowGrandTotals | boolean | - | 행 총합계 표시 | +| totals.showColumnGrandTotals | boolean | - | 열 총합계 표시 | +| chart.enabled | boolean | - | 차트 연동 표시 | +| chart.type | string | - | 차트 타입 (`bar`/`line`/`area`/`pie`/`stackedBar`) | + +**fields 배열 요소**: + +| 설정 | 타입 | 설명 | +|------|------|------| +| field | string | DB 컬럼명 | +| caption | string | 표시 라벨 | +| area | `"row"\|"column"\|"data"\|"filter"` | **필수**. 배치 영역 | +| summaryType | string | area=data일 때: `sum`/`count`/`avg`/`min`/`max`/`countDistinct` | +| groupInterval | string | 날짜 그룹화: `year`/`quarter`/`month`/`week`/`day` | +| sortBy | string | 정렬 기준: `value`/`caption` | +| sortOrder | string | 정렬 방향: `asc`/`desc`/`none` | + +--- + +### 3.10 v2-card-display (카드 뷰) + +**용도**: 테이블 데이터를 카드 형태로 표시. 이미지+제목+설명 구조. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dataSource | string | `"table"` | 데이터 소스: `table`/`static` | +| tableName | string | - | DB 테이블명 | +| cardsPerRow | number | `3` | 행당 카드 수 (1~6) | +| cardSpacing | number | `16` | 카드 간격(px) | +| columnMapping | object | `{}` | 필드 매핑 (`title`, `subtitle`, `description`, `image`, `status`) | +| cardStyle.showTitle | boolean | `true` | 제목 표시 | +| cardStyle.showSubtitle | boolean | `true` | 부제목 표시 | +| cardStyle.showDescription | boolean | `true` | 설명 표시 | +| cardStyle.showImage | boolean | `false` | 이미지 표시 | +| cardStyle.showActions | boolean | `true` | 액션 버튼 표시 | + +--- + +### 3.11 v2-timeline-scheduler (간트차트) + +**용도**: 시간축 기반 일정/계획 시각화. 드래그/리사이즈로 일정 편집. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| selectedTable | string | - | 스케줄 데이터 테이블 | +| resourceTable | string | `"equipment_mng"` | 리소스(설비/작업자) 테이블 | +| scheduleType | string | `"PRODUCTION"` | 스케줄 유형: `PRODUCTION`/`MAINTENANCE`/`SHIPPING`/`WORK_ASSIGN` | +| defaultZoomLevel | string | `"day"` | 초기 줌: `day`/`week`/`month` | +| editable | boolean | `true` | 편집 가능 | +| draggable | boolean | `true` | 드래그 이동 허용 | +| resizable | boolean | `true` | 기간 리사이즈 허용 | +| rowHeight | number | `50` | 행 높이(px) | +| headerHeight | number | `60` | 헤더 높이(px) | +| resourceColumnWidth | number | `150` | 리소스 컬럼 너비(px) | +| cellWidth.day | number | `60` | 일 단위 셀 너비 | +| cellWidth.week | number | `120` | 주 단위 셀 너비 | +| cellWidth.month | number | `40` | 월 단위 셀 너비 | +| showConflicts | boolean | `true` | 시간 겹침 충돌 표시 | +| showProgress | boolean | `true` | 진행률 바 표시 | +| showTodayLine | boolean | `true` | 오늘 날짜 표시선 | +| showToolbar | boolean | `true` | 상단 툴바 표시 | +| showAddButton | boolean | `true` | 추가 버튼 | +| height | number | `500` | 높이(px) | + +**fieldMapping (필수)**: + +| 설정 | 기본값 | 설명 | +|------|--------|------| +| id | `"schedule_id"` | 스케줄 PK 필드 | +| resourceId | `"resource_id"` | 리소스 FK 필드 | +| title | `"schedule_name"` | 제목 필드 | +| startDate | `"start_date"` | 시작일 필드 | +| endDate | `"end_date"` | 종료일 필드 | +| status | - | 상태 필드 | +| progress | - | 진행률 필드 (0~100) | + +**resourceFieldMapping**: + +| 설정 | 기본값 | 설명 | +|------|--------|------| +| id | `"equipment_code"` | 리소스 PK | +| name | `"equipment_name"` | 리소스 표시명 | +| group | - | 리소스 그룹 | + +**statusColors (상태별 색상)**: + +| 상태 | 기본 색상 | +|------|----------| +| planned | `"#3b82f6"` (파랑) | +| in_progress | `"#f59e0b"` (주황) | +| completed | `"#10b981"` (초록) | +| delayed | `"#ef4444"` (빨강) | +| cancelled | `"#6b7280"` (회색) | + +--- + +### 3.12 v2-tabs-widget (탭) + +**용도**: 탭 전환. 각 탭 내부에 컴포넌트 배치 가능. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| tabs | array | `[{id:"tab-1",label:"탭1",...}]` | 탭 배열 | +| defaultTab | string | `"tab-1"` | 기본 활성 탭 ID | +| orientation | string | `"horizontal"` | 탭 방향: `horizontal`/`vertical` | +| variant | string | `"default"` | 스타일: `default`/`pills`/`underline` | +| allowCloseable | boolean | `false` | 탭 닫기 버튼 표시 | +| persistSelection | boolean | `false` | 탭 선택 상태 localStorage 저장 | + +**tabs 배열 요소**: `{ id, label, order, disabled, icon, components[] }` +**components 요소**: `{ id, componentType, label, position, size, componentConfig }` + +--- + +### 3.13 v2-aggregation-widget (집계 카드) + +**용도**: 합계, 평균, 개수 등 집계값을 카드 형태로 표시. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dataSourceType | string | `"table"` | 데이터 소스: `table`/`component`/`selection` | +| tableName | string | - | 테이블명 | +| items | array | `[]` | 집계 항목 배열 | +| layout | string | `"horizontal"` | 배치: `horizontal`/`vertical` | +| showLabels | boolean | `true` | 라벨 표시 | +| showIcons | boolean | `true` | 아이콘 표시 | +| gap | string | `"16px"` | 항목 간격 | +| autoRefresh | boolean | `false` | 자동 새로고침 | +| refreshOnFormChange | boolean | `true` | 폼 변경 시 새로고침 | + +**items 요소**: `{ id, columnName, columnLabel, type("sum"/"avg"/"count"/"max"/"min"), format, decimalPlaces, prefix, suffix }` + +--- + +### 3.14 v2-status-count (상태별 건수) + +**용도**: 상태별 건수를 카드 형태로 표시. 대시보드/현황 화면용. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| title | string | `"상태 현황"` | 제목 | +| tableName | string | `""` | 대상 테이블 | +| statusColumn | string | `"status"` | 상태 컬럼명 | +| relationColumn | string | `""` | 관계 컬럼 (필터용) | +| items | array | - | 상태 항목 `[{ value, label, color }]` | +| showTotal | boolean | - | 합계 표시 | +| cardSize | string | `"md"` | 카드 크기: `sm`/`md`/`lg` | + +--- + +### 3.15 v2-text-display (텍스트 표시) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| text | string | `"텍스트를 입력하세요"` | 표시 텍스트 | +| fontSize | string | `"14px"` | 폰트 크기 | +| fontWeight | string | `"normal"` | 폰트 굵기 | +| color | string | `"#212121"` | 텍스트 색상 | +| textAlign | string | `"left"` | 정렬: `left`/`center`/`right` | +| backgroundColor | string | - | 배경색 | +| padding | string | - | 패딩 | + +--- + +### 3.16 v2-numbering-rule (자동 채번) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| ruleConfig | object | - | 채번 규칙 설정 | +| maxRules | number | `6` | 최대 파트 수 | +| readonly | boolean | `false` | 읽기 전용 | +| showPreview | boolean | `true` | 미리보기 표시 | +| showRuleList | boolean | `true` | 규칙 목록 표시 | +| cardLayout | string | `"vertical"` | 레이아웃: `vertical`/`horizontal` | + +--- + +### 3.17 v2-file-upload (파일 업로드) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| placeholder | string | `"파일을 선택하세요"` | 힌트 텍스트 | +| multiple | boolean | `true` | 다중 업로드 | +| accept | string | `"*/*"` | 허용 파일 형식 (예: `"image/*"`, `".pdf,.xlsx"`) | +| maxSize | number | `10485760` | 최대 파일 크기(bytes, 기본 10MB) | +| maxFiles | number | - | 최대 파일 수 | +| showPreview | boolean | - | 미리보기 표시 | +| showFileList | boolean | - | 파일 목록 표시 | +| allowDelete | boolean | - | 삭제 허용 | +| allowDownload | boolean | - | 다운로드 허용 | + +--- + +### 3.18 v2-section-card (그룹 컨테이너 - 테두리) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| title | string | `"섹션 제목"` | 제목 | +| description | string | `""` | 설명 | +| showHeader | boolean | `true` | 헤더 표시 | +| padding | string | `"md"` | 패딩: `none`/`sm`/`md`/`lg` | +| backgroundColor | string | `"default"` | 배경: `default`/`muted`/`transparent` | +| borderStyle | string | `"solid"` | 테두리: `solid`/`dashed`/`none` | +| collapsible | boolean | `false` | 접기/펼치기 가능 | +| defaultOpen | boolean | `true` | 기본 펼침 | + +--- + +### 3.19 v2-section-paper (그룹 컨테이너 - 배경색) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| backgroundColor | string | `"default"` | 배경: `default`/`muted`/`accent`/`primary`/`custom` | +| customColor | string | - | custom일 때 색상 | +| showBorder | boolean | `false` | 테두리 표시 | +| padding | string | `"md"` | 패딩: `none`/`sm`/`md`/`lg` | +| roundedCorners | string | `"md"` | 모서리: `none`/`sm`/`md`/`lg` | +| shadow | string | `"none"` | 그림자: `none`/`sm`/`md` | + +--- + +### 3.20 v2-divider-line (구분선) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| orientation | string | - | 방향 (가로/세로) | +| thickness | number | - | 두께 | + +--- + +### 3.21 v2-split-line (캔버스 분할선) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| resizable | boolean | `true` | 드래그 리사이즈 허용 | +| lineColor | string | `"#e2e8f0"` | 분할선 색상 | +| lineWidth | number | `4` | 분할선 두께(px) | + +--- + +### 3.22 v2-repeat-container (반복 렌더링) + +**용도**: 데이터 수만큼 내부 컴포넌트를 반복 렌더링. 카드 리스트 등에 사용. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dataSourceType | string | `"manual"` | 소스: `table-list`/`v2-repeater`/`externalData`/`manual` | +| dataSourceComponentId | string | - | 연결할 컴포넌트 ID | +| tableName | string | - | 테이블명 | +| layout | string | `"vertical"` | 배치: `vertical`/`horizontal`/`grid` | +| gridColumns | number | `2` | grid일 때 컬럼 수 | +| gap | string | `"16px"` | 아이템 간격 | +| showBorder | boolean | `true` | 카드 테두리 | +| showShadow | boolean | `false` | 카드 그림자 | +| borderRadius | string | `"8px"` | 모서리 둥글기 | +| backgroundColor | string | `"#ffffff"` | 배경색 | +| padding | string | `"16px"` | 패딩 | +| showItemTitle | boolean | `false` | 아이템 제목 표시 | +| itemTitleTemplate | string | `""` | 제목 템플릿 (예: `"{order_no} - {item}"`) | +| emptyMessage | string | `"데이터가 없습니다"` | 빈 상태 메시지 | +| clickable | boolean | `false` | 클릭 가능 | +| selectionMode | string | `"single"` | 선택 모드: `single`/`multiple` | +| usePaging | boolean | `false` | 페이징 사용 | +| pageSize | number | `10` | 페이지 크기 | + +--- + +### 3.23 v2-repeater (반복 데이터 관리) + +**용도**: 인라인/모달 모드로 반복 데이터(주문 상세 등) 관리. 행 추가/삭제/편집. + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| renderMode | string | `"inline"` | 모드: `inline` (인라인 편집) / `modal` (모달로 선택 추가) | +| mainTableName | string | - | 저장 대상 테이블 | +| foreignKeyColumn | string | - | 마스터 연결 FK 컬럼 | +| foreignKeySourceColumn | string | - | 마스터 PK 컬럼 | +| columns | array | `[]` | 컬럼 설정 | +| dataSource.tableName | string | - | 데이터 테이블 | +| dataSource.foreignKey | string | - | FK 컬럼 | +| dataSource.sourceTable | string | - | 모달용 소스 테이블 | +| modal.size | string | `"md"` | 모달 크기: `sm`/`md`/`lg`/`xl`/`full` | +| modal.title | string | - | 모달 제목 | +| modal.searchFields | string[] | - | 검색 필드 | +| features.showAddButton | boolean | `true` | 추가 버튼 | +| features.showDeleteButton | boolean | `true` | 삭제 버튼 | +| features.inlineEdit | boolean | `false` | 인라인 편집 | +| features.showRowNumber | boolean | `false` | 행 번호 표시 | +| calculationRules | array | - | 자동 계산 규칙 (예: 수량*단가=금액) | + +--- + +### 3.24 v2-approval-step (결재 스테퍼) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| targetTable | string | `""` | 결재 대상 테이블 | +| targetRecordIdField | string | `""` | 레코드 ID 필드 | +| displayMode | string | `"horizontal"` | 표시 방향: `horizontal`/`vertical` | +| showComment | boolean | `true` | 결재 코멘트 표시 | +| showTimestamp | boolean | `true` | 결재 시간 표시 | +| showDept | boolean | `true` | 부서 표시 | +| compact | boolean | `false` | 컴팩트 모드 | + +--- + +### 3.25 v2-bom-tree (BOM 트리) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| detailTable | string | `"bom_detail"` | BOM 디테일 테이블 | +| foreignKey | string | `"bom_id"` | BOM 마스터 FK | +| parentKey | string | `"parent_detail_id"` | 트리 부모 키 (자기참조) | + +--- + +### 3.26 v2-bom-item-editor (BOM 편집) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| detailTable | string | `"bom_detail"` | BOM 디테일 테이블 | +| sourceTable | string | `"item_info"` | 품목 소스 테이블 | +| foreignKey | string | `"bom_id"` | BOM 마스터 FK | +| parentKey | string | `"parent_detail_id"` | 트리 부모 키 | +| itemCodeField | string | `"item_number"` | 품목 코드 필드 | +| itemNameField | string | `"item_name"` | 품목명 필드 | +| itemTypeField | string | `"type"` | 품목 유형 필드 | +| itemUnitField | string | `"unit"` | 품목 단위 필드 | + +--- + +### 3.27 v2-category-manager (카테고리 관리) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| tableName | string | - | 대상 테이블 | +| columnName | string | - | 카테고리 컬럼 | +| menuObjid | number | - | 연결 메뉴 OBJID | +| viewMode | string | `"tree"` | 뷰 모드: `tree`/`list` | +| showViewModeToggle | boolean | `true` | 뷰 모드 토글 표시 | +| defaultExpandLevel | number | `1` | 기본 트리 펼침 레벨 | +| showInactiveItems | boolean | `false` | 비활성 항목 표시 | +| leftPanelWidth | number | `15` | 좌측 패널 너비 | + +--- + +### 3.28 v2-media (미디어) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| mediaType | string | `"file"` | 미디어 타입: `file`/`image`/`video`/`audio` | +| multiple | boolean | `false` | 다중 업로드 | +| preview | boolean | `true` | 미리보기 | +| maxSize | number | `10` | 최대 크기(MB) | +| accept | string | `"*/*"` | 허용 형식 | +| showFileList | boolean | `true` | 파일 목록 | +| dragDrop | boolean | `true` | 드래그앤드롭 | + +--- + +### 3.29 v2-location-swap-selector (위치 교환) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dataSource.type | string | `"static"` | 소스: `static`/`table`/`code` | +| dataSource.tableName | string | - | 장소 테이블 | +| dataSource.valueField | string | `"location_code"` | 값 필드 | +| dataSource.labelField | string | `"location_name"` | 표시 필드 | +| dataSource.staticOptions | array | - | 정적 옵션 `[{value, label}]` | +| departureField | string | `"departure"` | 출발지 저장 필드 | +| destinationField | string | `"destination"` | 도착지 저장 필드 | +| departureLabel | string | `"출발지"` | 출발지 라벨 | +| destinationLabel | string | `"도착지"` | 도착지 라벨 | +| showSwapButton | boolean | `true` | 교환 버튼 표시 | +| variant | string | `"card"` | UI: `card`/`inline`/`minimal` | + +--- + +### 3.30 v2-rack-structure (창고 랙) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| maxConditions | number | `10` | 최대 조건 수 | +| maxRows | number | `99` | 최대 열 수 | +| maxLevels | number | `20` | 최대 단 수 | +| codePattern | string | `"{warehouseCode}-{floor}{zone}-{row:02d}-{level}"` | 위치 코드 패턴 | +| namePattern | string | `"{zone}구역-{row:02d}열-{level}단"` | 위치 이름 패턴 | +| showTemplates | boolean | `true` | 템플릿 표시 | +| showPreview | boolean | `true` | 미리보기 | +| showStatistics | boolean | `true` | 통계 카드 | +| readonly | boolean | `false` | 읽기 전용 | + +--- + +### 3.31 v2-process-work-standard (공정 작업기준) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dataSource.itemTable | string | `"item_info"` | 품목 테이블 | +| dataSource.routingVersionTable | string | `"item_routing_version"` | 라우팅 버전 테이블 | +| dataSource.routingDetailTable | string | `"item_routing_detail"` | 라우팅 디테일 테이블 | +| dataSource.processTable | string | `"process_mng"` | 공정 테이블 | +| splitRatio | number | `30` | 좌우 분할 비율 | +| leftPanelTitle | string | `"품목 및 공정 선택"` | 좌측 패널 제목 | +| readonly | boolean | `false` | 읽기 전용 | +| itemListMode | string | `"all"` | 품목 모드: `all`/`registered` | + +--- + +### 3.32 v2-item-routing (품목 라우팅) + +| 설정 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| dataSource.itemTable | string | `"item_info"` | 품목 테이블 | +| dataSource.routingVersionTable | string | `"item_routing_version"` | 라우팅 버전 테이블 | +| dataSource.routingDetailTable | string | `"item_routing_detail"` | 라우팅 디테일 테이블 | +| dataSource.processTable | string | `"process_mng"` | 공정 테이블 | +| splitRatio | number | `40` | 좌우 분할 비율 | +| leftPanelTitle | string | `"품목 목록"` | 좌측 제목 | +| rightPanelTitle | string | `"공정 순서"` | 우측 제목 | +| readonly | boolean | `false` | 읽기 전용 | +| autoSelectFirstVersion | boolean | `true` | 첫 버전 자동 선택 | +| itemListMode | string | `"all"` | 품목 모드: `all`/`registered` | + +--- + +## 4. 패턴 의사결정 트리 + +``` +Q1. 시간축 기반 일정/간트차트? → v2-timeline-scheduler +Q2. 다차원 피벗 분석? → v2-pivot-grid +Q3. 그룹별 접기/펼치기? → v2-table-grouped +Q4. 카드 형태 표시? → v2-card-display +Q5. 마스터-디테일? + ├ 우측 멀티 탭? → v2-split-panel-layout + additionalTabs + └ 단일 디테일? → v2-split-panel-layout +Q6. 단일 테이블? → v2-table-search-widget + v2-table-list +``` + +--- + +## 5. 관계(relation) 레퍼런스 + +| 관계 유형 | 설정 | +|----------|------| +| 단순 FK | `{ type:"detail", leftColumn:"id", rightColumn:"{FK}", foreignKey:"{FK}" }` | +| 복합 키 | `{ type:"detail", keys:[{ leftColumn:"a", rightColumn:"b" }] }` | +| JOIN | `{ type:"join", leftColumn:"{col}", rightColumn:"{col}" }` | + +## 6. 엔티티 조인 + +FK 컬럼에 참조 테이블의 이름을 표시: + +**table_type_columns**: `input_type='entity'`, `detail_settings='{"referenceTable":"X","referenceColumn":"id","displayColumn":"name"}'` + +**layout_data columns**: `{ name:"fk_col", isEntityJoin:true, joinInfo:{ sourceTable:"A", sourceColumn:"fk_col", referenceTable:"X", joinAlias:"name" } }` diff --git a/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md new file mode 100644 index 00000000..14182a91 --- /dev/null +++ b/docs/screen-implementation-guide/01_reference_guides/v2-component-usage-guide.md @@ -0,0 +1,1146 @@ +# WACE 화면 구현 실행 가이드 (챗봇/AI 에이전트 전용) + +> **최종 업데이트**: 2026-03-13 +> **용도**: 사용자가 "수주관리 화면 만들어줘"라고 요청하면, 이 문서를 참조하여 SQL을 직접 생성하고 화면을 구현하는 AI 챗봇용 실행 가이드 +> **핵심**: 이 문서의 SQL 템플릿을 따라 INSERT하면 화면이 자동으로 생성된다 + +--- + +## 0. 절대 규칙 + +1. 사용자 업무 화면(수주, 생산, 품질 등)은 **React 코드(.tsx) 작성 금지** → DB INSERT로만 구현 +2. 모든 DB 컬럼은 **VARCHAR(500)** (날짜 컬럼만 TIMESTAMP) +3. 모든 테이블에 **기본 5개 컬럼** 필수: id, created_date, updated_date, writer, company_code +4. 모든 INSERT에 **ON CONFLICT** 절 필수 (중복 방지) +5. 컴포넌트는 반드시 **v2-** 접두사 사용 +6. **[최우선] 비즈니스 테이블 CREATE TABLE 시 NOT NULL / UNIQUE 제약조건 절대 금지!** + +> **왜 DB 레벨 제약조건을 걸면 안 되는가?** +> +> 이 시스템은 **멀티테넌시(Multi-Tenancy)** 환경이다. +> 각 회사(tenant)마다 같은 테이블을 공유하되, **필수값/유니크 규칙이 회사별로 다를 수 있다.** +> +> 따라서 제약조건은 DB에 직접 거는 것이 아니라, **관리자 메뉴에서 회사별 메타데이터**로 논리적으로 제어한다: +> - **필수값**: `table_type_columns.is_nullable = 'N'` → 애플리케이션 레벨에서 검증 +> - **유니크**: `table_type_columns.is_unique = 'Y'` → 애플리케이션 레벨에서 검증 +> +> DB 레벨에서 NOT NULL이나 UNIQUE를 걸면, **특정 회사에만 적용해야 할 규칙이 모든 회사에 강제되어** 멀티테넌시가 깨진다. +> +> **허용**: 기본 5개 컬럼의 `id` PRIMARY KEY, `DEFAULT` 값만 DB 레벨에서 설정 +> **금지**: 비즈니스 컬럼에 `NOT NULL`, `UNIQUE`, `CHECK`, `FOREIGN KEY` 등 DB 제약조건 직접 적용 + +--- + +## 1. 화면 생성 전체 파이프라인 + +사용자가 화면을 요청하면 아래 7단계를 순서대로 실행한다. + +``` +Step 1: 비즈니스 테이블 CREATE TABLE +Step 2: table_labels INSERT (테이블 라벨) +Step 3: table_type_columns INSERT (컬럼 타입 정의, company_code='*') +Step 4: column_labels INSERT (컬럼 한글 라벨) +Step 5: screen_definitions INSERT → screen_id 획득 +Step 6: screen_layouts_v2 INSERT (레이아웃 JSON) +Step 7: menu_info INSERT (메뉴 등록) +``` + +**선택적 추가 단계**: +- 채번 규칙이 필요하면: numbering_rules + numbering_rule_parts INSERT +- 카테고리가 필요하면: table_column_category_values INSERT +- 비즈니스 로직(버튼 액션)이 필요하면: dataflow_diagrams INSERT + +--- + +## 2. Step 1: 비즈니스 테이블 생성 (CREATE TABLE) + +### 템플릿 + +> **[최우선] 비즈니스 컬럼에 NOT NULL / UNIQUE / CHECK / FOREIGN KEY 제약조건 절대 금지!** +> 멀티테넌시 환경에서 회사별로 규칙이 다르므로, `table_type_columns`의 `is_nullable`, `is_unique` 메타데이터로 논리적 제어한다. + +```sql +CREATE TABLE "{테이블명}" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + + "{비즈니스_컬럼1}" varchar(500), + "{비즈니스_컬럼2}" varchar(500), + "{비즈니스_컬럼3}" varchar(500) + -- NOT NULL, UNIQUE, CHECK, FOREIGN KEY 금지! +); +``` + +### 마스터-디테일인 경우 (2개 테이블) + +```sql +-- 마스터 테이블 +CREATE TABLE "{마스터_테이블}" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + "{컬럼1}" varchar(500), + "{컬럼2}" varchar(500) + -- NOT NULL, UNIQUE, FOREIGN KEY 금지! +); + +-- 디테일 테이블 +CREATE TABLE "{디테일_테이블}" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + "{마스터_FK}" varchar(500), -- 마스터 테이블 id 참조 (FOREIGN KEY 제약조건은 걸지 않는다!) + "{컬럼1}" varchar(500), + "{컬럼2}" varchar(500) + -- NOT NULL, UNIQUE, FOREIGN KEY 금지! +); +``` + +**금지 사항**: +- INTEGER, NUMERIC, BOOLEAN, TEXT, DATE 등 DB 타입 직접 사용 금지. 반드시 VARCHAR(500). +- 비즈니스 컬럼에 NOT NULL, UNIQUE, CHECK, FOREIGN KEY 등 DB 레벨 제약조건 금지. + +--- + +## 3. Step 2: table_labels INSERT + +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('{테이블명}', '{한글_라벨}', '{설명}', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); +``` + +**예시**: +```sql +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('order_master', '수주 마스터', '수주 헤더 정보 관리', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); + +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) +VALUES ('order_detail', '수주 상세', '수주 품목별 상세 정보', now(), now()) +ON CONFLICT (table_name) +DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); +``` + +--- + +## 4. Step 3: table_type_columns INSERT + +> `company_code = '*'` 로 등록한다 (전체 공통 설정). + +### 기본 5개 컬럼 (모든 테이블 공통) + +```sql +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) +VALUES + ('{테이블명}', 'id', '*', 'text', '{}', 'N', 'Y', -5, 'ID', '기본키 (자동생성)', false, now(), now()), + ('{테이블명}', 'created_date', '*', 'date', '{}', 'Y', 'N', -4, '생성일시', '레코드 생성일시', false, now(), now()), + ('{테이블명}', 'updated_date', '*', 'date', '{}', 'Y', 'N', -3, '수정일시', '레코드 수정일시', false, now(), now()), + ('{테이블명}', 'writer', '*', 'text', '{}', 'Y', 'N', -2, '작성자', '레코드 작성자', false, now(), now()), + ('{테이블명}', 'company_code', '*', 'text', '{}', 'Y', 'N', -1, '회사코드', '회사 구분 코드', false, now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); +``` + +### 비즈니스 컬럼 (display_order 0부터) + +```sql +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) +VALUES + ('{테이블명}', '{컬럼명}', '*', '{input_type}', '{detail_settings_json}', 'Y', 'N', {순서}, '{한글라벨}', '{설명}', true, now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); +``` + +### input_type 선택 기준 + +| 데이터 성격 | input_type | detail_settings 예시 | +|------------|-----------|---------------------| +| 일반 텍스트 | `text` | `'{}'` | +| 숫자 (수량, 금액) | `number` | `'{}'` | +| 날짜 | `date` | `'{}'` | +| 여러 줄 텍스트 (비고) | `textarea` | `'{}'` | +| 공통코드 선택 (상태 등) | `code` | `'{"codeCategory":"STATUS_CODE"}'` | +| 다른 테이블 참조 (거래처 등) | `entity` | `'{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}'` | +| 정적 옵션 선택 | `select` | `'{"options":[{"label":"옵션1","value":"v1"},{"label":"옵션2","value":"v2"}]}'` | +| 체크박스 | `checkbox` | `'{}'` | +| 라디오 | `radio` | `'{}'` | +| 카테고리 | `category` | `'{"categoryRef":"CAT_ID"}'` | +| 자동 채번 | `numbering` | `'{"numberingRuleId":"rule_id"}'` | + +--- + +## 5. Step 4: column_labels INSERT + +> 레거시 호환용이지만 **필수 등록**이다. table_type_columns와 동일한 값을 넣되, `column_label`(한글명)을 추가. +> +> **주의**: `column_labels` 테이블의 UNIQUE 제약조건은 `(table_name, column_name, company_code)` 3개 컬럼이다. 반드시 `company_code`를 포함해야 한다. + +```sql +-- 기본 5개 컬럼 +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) +VALUES + ('{테이블명}', 'id', 'ID', 'text', '{}', '기본키 (자동생성)', -5, true, '*', now(), now()), + ('{테이블명}', 'created_date', '생성일시', 'date', '{}', '레코드 생성일시', -4, true, '*', now(), now()), + ('{테이블명}', 'updated_date', '수정일시', 'date', '{}', '레코드 수정일시', -3, true, '*', now(), now()), + ('{테이블명}', 'writer', '작성자', 'text', '{}', '레코드 작성자', -2, true, '*', now(), now()), + ('{테이블명}', 'company_code', '회사코드', 'text', '{}', '회사 구분 코드', -1, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- 비즈니스 컬럼 +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) +VALUES + ('{테이블명}', '{컬럼명}', '{한글라벨}', '{input_type}', '{detail_settings}', '{설명}', {순서}, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) +DO UPDATE SET column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); +``` + +--- + +## 6. Step 5: screen_definitions INSERT + +```sql +INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, description, is_active, + db_source_type, data_source_type, created_date +) +VALUES ( + '{화면명}', -- 예: '수주관리' + '{screen_code}', -- 예: 'COMPANY_A_ORDER_MNG' (회사코드_식별자) + '{메인_테이블명}', -- 예: 'order_master' + '{company_code}', -- 예: 'COMPANY_A' + '{설명}', + 'Y', + 'internal', + 'database', + now() +) +RETURNING screen_id; +``` + +**screen_code 규칙**: `{company_code}_{영문식별자}` (예: `ILSHIN_ORDER_MNG`, `COMPANY_19_ITEM_INFO`) + +**중요**: Step 6, 7에서 `screen_id`가 필요하다. 서브쿼리로 참조하면 하드코딩 실수를 방지할 수 있다: +```sql +(SELECT screen_id FROM screen_definitions WHERE screen_code = '{screen_code}') +``` + +> **screen_code 조건부 UNIQUE 규칙**: +> `screen_code`는 단순 UNIQUE가 아니라 **`WHERE is_active <> 'D'`** 조건부 UNIQUE이다. +> - 삭제된 화면(`is_active = 'D'`)과 동일한 코드로 새 화면을 만들 수 있다. +> - 활성 상태(`'Y'` 또는 `'N'`)에서는 같은 `screen_code`가 중복되면 에러가 발생한다. +> - 화면 삭제 시 `DELETE`가 아닌 `UPDATE SET is_active = 'D'`로 소프트 삭제하므로, 이전 코드의 재사용이 가능하다. + +--- + +## 7. Step 6: screen_layouts_v2 INSERT (핵심) + +### 기본 구조 + +```sql +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) +VALUES ( + (SELECT screen_id FROM screen_definitions WHERE screen_code = '{screen_code}'), + '{company_code}', + 1, -- 기본 레이어 + '기본 레이어', + '{layout_data_json}'::jsonb, + now(), + now() +) +ON CONFLICT (screen_id, company_code, layer_id) +DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); +``` + +### layout_data JSON 뼈대 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "{고유ID}", + "url": "@/lib/registry/components/{컴포넌트타입}", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { /* 컴포넌트별 설정 */ } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 컴포넌트 url 매핑표 + +| 컴포넌트 | url 값 | +|----------|--------| +| v2-table-list | `@/lib/registry/components/v2-table-list` | +| v2-table-search-widget | `@/lib/registry/components/v2-table-search-widget` | +| v2-split-panel-layout | `@/lib/registry/components/v2-split-panel-layout` | +| v2-table-grouped | `@/lib/registry/components/v2-table-grouped` | +| v2-tabs-widget | `@/lib/registry/components/v2-tabs-widget` | +| v2-button-primary | `@/lib/registry/components/v2-button-primary` | +| v2-input | `@/lib/registry/components/v2-input` | +| v2-select | `@/lib/registry/components/v2-select` | +| v2-date | `@/lib/registry/components/v2-date` | +| v2-card-display | `@/lib/registry/components/v2-card-display` | +| v2-pivot-grid | `@/lib/registry/components/v2-pivot-grid` | +| v2-timeline-scheduler | `@/lib/registry/components/v2-timeline-scheduler` | +| v2-text-display | `@/lib/registry/components/v2-text-display` | +| v2-aggregation-widget | `@/lib/registry/components/v2-aggregation-widget` | +| v2-numbering-rule | `@/lib/registry/components/v2-numbering-rule` | +| v2-file-upload | `@/lib/registry/components/v2-file-upload` | +| v2-section-card | `@/lib/registry/components/v2-section-card` | +| v2-divider-line | `@/lib/registry/components/v2-divider-line` | +| v2-bom-tree | `@/lib/registry/components/v2-bom-tree` | +| v2-approval-step | `@/lib/registry/components/v2-approval-step` | +| v2-status-count | `@/lib/registry/components/v2-status-count` | +| v2-section-paper | `@/lib/registry/components/v2-section-paper` | +| v2-split-line | `@/lib/registry/components/v2-split-line` | +| v2-repeat-container | `@/lib/registry/components/v2-repeat-container` | +| v2-repeater | `@/lib/registry/components/v2-repeater` | +| v2-category-manager | `@/lib/registry/components/v2-category-manager` | +| v2-media | `@/lib/registry/components/v2-media` | +| v2-location-swap-selector | `@/lib/registry/components/v2-location-swap-selector` | +| v2-rack-structure | `@/lib/registry/components/v2-rack-structure` | +| v2-process-work-standard | `@/lib/registry/components/v2-process-work-standard` | +| v2-item-routing | `@/lib/registry/components/v2-item-routing` | +| v2-bom-item-editor | `@/lib/registry/components/v2-bom-item-editor` | + +--- + +## 8. 패턴별 layout_data 완전 예시 + +### 8.1 패턴 A: 기본 마스터 (검색 + 테이블) + +**사용 조건**: 단일 테이블 CRUD, 마스터-디테일 관계 없음 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "search_1", + "url": "@/lib/registry/components/v2-table-search-widget", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 100 }, + "displayOrder": 0, + "overrides": { + "label": "검색", + "autoSelectFirstTable": true, + "showTableSelector": false + } + }, + { + "id": "table_1", + "url": "@/lib/registry/components/v2-table-list", + "position": { "x": 0, "y": 120 }, + "size": { "width": 1920, "height": 700 }, + "displayOrder": 1, + "overrides": { + "label": "{화면제목}", + "tableName": "{테이블명}", + "autoLoad": true, + "displayMode": "table", + "checkbox": { "enabled": true, "multiple": true, "position": "left", "selectAll": true }, + "pagination": { "enabled": true, "pageSize": 20, "showSizeSelector": true, "showPageInfo": true }, + "horizontalScroll": { "enabled": true, "maxVisibleColumns": 8 }, + "toolbar": { "showEditMode": true, "showExcel": true, "showRefresh": true } + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 8.2 패턴 B: 마스터-디테일 (좌우 분할) + +**사용 조건**: 좌측 마스터 테이블 선택 → 우측 디테일 테이블 연동. 두 테이블 간 FK 관계. + +```json +{ + "version": "2.0", + "components": [ + { + "id": "split_1", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 850 }, + "displayOrder": 0, + "overrides": { + "label": "{화면제목}", + "splitRatio": 35, + "resizable": true, + "autoLoad": true, + "syncSelection": true, + "leftPanel": { + "title": "{마스터_제목}", + "displayMode": "table", + "tableName": "{마스터_테이블명}", + "showSearch": true, + "showAdd": true, + "showEdit": false, + "showDelete": true, + "columns": [ + { "name": "{컬럼1}", "label": "{라벨1}", "width": 120, "sortable": true }, + { "name": "{컬럼2}", "label": "{라벨2}", "width": 150 }, + { "name": "{컬럼3}", "label": "{라벨3}", "width": 100 } + ], + "addButton": { "enabled": true, "mode": "auto" }, + "deleteButton": { "enabled": true, "confirmMessage": "선택한 항목을 삭제하시겠습니까?" } + }, + "rightPanel": { + "title": "{디테일_제목}", + "displayMode": "table", + "tableName": "{디테일_테이블명}", + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "{마스터FK_컬럼}", + "foreignKey": "{마스터FK_컬럼}" + }, + "columns": [ + { "name": "{컬럼1}", "label": "{라벨1}", "width": 120 }, + { "name": "{컬럼2}", "label": "{라벨2}", "width": 150 }, + { "name": "{컬럼3}", "label": "{라벨3}", "width": 100, "editable": true } + ], + "addButton": { "enabled": true, "mode": "auto" }, + "editButton": { "enabled": true, "mode": "auto" }, + "deleteButton": { "enabled": true, "confirmMessage": "삭제하시겠습니까?" } + } + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 8.3 패턴 C: 마스터-디테일 + 탭 + +**사용 조건**: 패턴 B에서 우측에 여러 종류의 상세를 탭으로 구분 + +패턴 B의 rightPanel에 **additionalTabs** 추가: + +```json +{ + "rightPanel": { + "title": "{디테일_제목}", + "displayMode": "table", + "tableName": "{기본탭_테이블}", + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "{FK_컬럼}", + "foreignKey": "{FK_컬럼}" + }, + "additionalTabs": [ + { + "tabId": "tab_basic", + "label": "기본정보", + "tableName": "{기본정보_테이블}", + "displayMode": "table", + "relation": { "type": "detail", "leftColumn": "id", "rightColumn": "{FK}", "foreignKey": "{FK}" }, + "columns": [ /* 컬럼 배열 */ ], + "addButton": { "enabled": true }, + "deleteButton": { "enabled": true } + }, + { + "tabId": "tab_history", + "label": "이력", + "tableName": "{이력_테이블}", + "displayMode": "table", + "relation": { "type": "detail", "leftColumn": "id", "rightColumn": "{FK}", "foreignKey": "{FK}" }, + "columns": [ /* 컬럼 배열 */ ] + }, + { + "tabId": "tab_files", + "label": "첨부파일", + "tableName": "{파일_테이블}", + "displayMode": "table", + "relation": { "type": "detail", "leftColumn": "id", "rightColumn": "{FK}", "foreignKey": "{FK}" }, + "columns": [ /* 컬럼 배열 */ ] + } + ] + } +} +``` + +### 8.4 패턴 D: 그룹화 테이블 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "grouped_1", + "url": "@/lib/registry/components/v2-table-grouped", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { + "label": "{화면제목}", + "selectedTable": "{테이블명}", + "groupConfig": { + "groupByColumn": "{그룹기준_컬럼}", + "groupLabelFormat": "{value}", + "defaultExpanded": true, + "sortDirection": "asc", + "summary": { "showCount": true, "sumColumns": ["{합계컬럼1}", "{합계컬럼2}"] } + }, + "columns": [ + { "columnName": "{컬럼1}", "displayName": "{라벨1}", "visible": true, "width": 120 }, + { "columnName": "{컬럼2}", "displayName": "{라벨2}", "visible": true, "width": 150 } + ], + "showCheckbox": true, + "showExpandAllButton": true + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +### 8.5 패턴 E: 타임라인/간트차트 + +```json +{ + "version": "2.0", + "components": [ + { + "id": "timeline_1", + "url": "@/lib/registry/components/v2-timeline-scheduler", + "position": { "x": 0, "y": 0 }, + "size": { "width": 1920, "height": 800 }, + "displayOrder": 0, + "overrides": { + "label": "{화면제목}", + "selectedTable": "{스케줄_테이블}", + "resourceTable": "{리소스_테이블}", + "fieldMapping": { + "id": "id", + "resourceId": "{리소스FK_컬럼}", + "title": "{제목_컬럼}", + "startDate": "{시작일_컬럼}", + "endDate": "{종료일_컬럼}", + "status": "{상태_컬럼}", + "progress": "{진행률_컬럼}" + }, + "resourceFieldMapping": { + "id": "id", + "name": "{리소스명_컬럼}", + "group": "{그룹_컬럼}" + }, + "defaultZoomLevel": "day", + "editable": true, + "allowDrag": true, + "allowResize": true + } + } + ], + "gridSettings": { "columns": 12, "gap": 16, "padding": 16 }, + "screenResolution": { "width": 1920, "height": 1080 } +} +``` + +--- + +## 9. Step 7: menu_info INSERT + +```sql +INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, + menu_name_kor, menu_name_eng, seq, + menu_url, menu_desc, writer, regdate, status, + company_code, screen_code +) +VALUES ( + {고유_objid}, + 0, + {부모_메뉴_objid}, + '{메뉴명_한글}', + '{메뉴명_영문}', + {정렬순서}, + '/screen/{screen_code}', + '{메뉴_설명}', + 'admin', + now(), + 'active', + '{company_code}', + '{screen_code}' +); +``` + +- `objid`: BIGINT 고유값. `extract(epoch from now())::bigint * 1000` 으로 생성 +- `menu_type`: `0` = 말단 메뉴(화면), `1` = 폴더 +- `parent_obj_id`: 상위 폴더 메뉴의 objid + +**objid 생성 규칙 및 주의사항**: + +기본 생성: `extract(epoch from now())::bigint * 1000` + +> **여러 메뉴를 한 트랜잭션에서 동시에 INSERT할 때 PK 중복 위험!** +> `now()`는 같은 트랜잭션 안에서 동일한 값을 반환하므로, 복수 INSERT 시 objid가 충돌한다. +> 반드시 순서값을 더해서 고유성을 보장할 것: +> +> ```sql +> -- 폴더 메뉴 +> extract(epoch from now())::bigint * 1000 + 1 +> -- 화면 메뉴 1 +> extract(epoch from now())::bigint * 1000 + 2 +> -- 화면 메뉴 2 +> extract(epoch from now())::bigint * 1000 + 3 +> ``` + +--- + +## 10. 선택적 단계: 채번 규칙 설정 + +자동으로 코드/번호를 생성해야 하는 컬럼이 있을 때 사용. + +### numbering_rules INSERT + +```sql +INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, updated_at, created_by +) +VALUES ( + '{rule_id}', -- 예: 'ORDER_NO_RULE' + '{규칙명}', -- 예: '수주번호 채번' + '{설명}', + '-', -- 구분자 + 'year', -- 'none', 'year', 'month', 'day' + 1, -- 시작 순번 + '{테이블명}', -- 예: 'order_master' + '{컬럼명}', -- 예: 'order_no' + '{company_code}', + now(), now(), 'admin' +); +``` + +### numbering_rule_parts INSERT (채번 구성 파트) + +```sql +-- 파트 1: 접두사 +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at) +VALUES ('{rule_id}', 1, 'prefix', 'auto', '{"prefix": "SO", "separatorAfter": "-"}'::jsonb, '{}'::jsonb, '{company_code}', now()); + +-- 파트 2: 날짜 +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at) +VALUES ('{rule_id}', 2, 'date', 'auto', '{"format": "YYYYMM", "separatorAfter": "-"}'::jsonb, '{}'::jsonb, '{company_code}', now()); + +-- 파트 3: 순번 +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at) +VALUES ('{rule_id}', 3, 'sequence', 'auto', '{"digits": 4, "startFrom": 1}'::jsonb, '{}'::jsonb, '{company_code}', now()); +``` + +**결과**: `SO-202603-0001`, `SO-202603-0002`, ... + +--- + +## 11. 선택적 단계: 카테고리 값 설정 + +상태, 유형 등을 카테고리로 관리할 때 사용. + +### table_column_category_values INSERT + +```sql +INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, company_code, created_by +) +VALUES + ('{테이블명}', '{컬럼명}', 'ACTIVE', '활성', 1, NULL, 1, '활성 상태', '#22c55e', '{company_code}', 'admin'), + ('{테이블명}', '{컬럼명}', 'INACTIVE', '비활성', 2, NULL, 1, '비활성 상태', '#ef4444', '{company_code}', 'admin'), + ('{테이블명}', '{컬럼명}', 'PENDING', '대기', 3, NULL, 1, '승인 대기', '#f59e0b', '{company_code}', 'admin'); +``` + +--- + +## 12. 패턴 판단 의사결정 트리 + +사용자가 화면을 요청하면 이 트리로 패턴을 결정한다. + +``` +Q1. 시간축 기반 일정/간트차트가 필요한가? +├─ YES → 패턴 E (타임라인) → v2-timeline-scheduler +└─ NO ↓ + +Q2. 다차원 집계/피벗 분석이 필요한가? +├─ YES → 피벗 → v2-pivot-grid +└─ NO ↓ + +Q3. 데이터를 그룹별로 접기/펼치기가 필요한가? +├─ YES → 패턴 D (그룹화) → v2-table-grouped +└─ NO ↓ + +Q4. 이미지+정보를 카드 형태로 표시하는가? +├─ YES → 카드뷰 → v2-card-display +└─ NO ↓ + +Q5. 마스터 테이블 선택 시 연관 디테일이 필요한가? +├─ YES → Q5-1. 디테일에 여러 탭이 필요한가? +│ ├─ YES → 패턴 C (마스터-디테일+탭) → v2-split-panel-layout + additionalTabs +│ └─ NO → 패턴 B (마스터-디테일) → v2-split-panel-layout +└─ NO → 패턴 A (기본 마스터) → v2-table-search-widget + v2-table-list +``` + +--- + +## 13. 화면 간 연결 관계 정의 + +### 13.1 마스터-디테일 관계 (v2-split-panel-layout) + +좌측 마스터 테이블의 행을 선택하면, 우측 디테일 테이블이 해당 FK로 필터링된다. + +**relation 설정**: + +> **JSON 안에 주석(`//`, `/* */`) 절대 금지!** PostgreSQL `::jsonb` 캐스팅 시 파싱 에러 발생. 설명은 반드시 JSON 바깥에 작성한다. + +- `type`: `"detail"` (FK 관계) +- `leftColumn`: 마스터 테이블의 PK 컬럼 (보통 `"id"`) +- `rightColumn`: 디테일 테이블의 FK 컬럼 +- `foreignKey`: `rightColumn`과 동일한 값 + +```json +{ + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "master_id", + "foreignKey": "master_id" + } +} +``` + +**복합 키인 경우**: + +```json +{ + "relation": { + "type": "detail", + "keys": [ + { "leftColumn": "order_no", "rightColumn": "order_no" }, + { "leftColumn": "company_code", "rightColumn": "company_code" } + ] + } +} +``` + +### 13.2 엔티티 조인 (테이블 참조 표시) + +디테일 테이블의 FK 컬럼에 다른 테이블의 이름을 표시하고 싶을 때. + +**table_type_columns에서 설정**: + +```sql +INSERT INTO table_type_columns (table_name, column_name, company_code, input_type, detail_settings, ...) +VALUES ('order_detail', 'item_id', '*', 'entity', + '{"referenceTable":"item_info","referenceColumn":"id","displayColumn":"item_name"}', ...); +``` + +**v2-table-list columns에서 설정**: + +```json +{ + "columns": [ + { + "name": "item_id", + "label": "품목", + "isEntityJoin": true, + "joinInfo": { + "sourceTable": "order_detail", + "sourceColumn": "item_id", + "referenceTable": "item_info", + "joinAlias": "item_name" + } + } + ] +} +``` + +### 13.3 모달 화면 연결 + +추가/편집 버튼 클릭 시 별도 모달 화면을 띄우는 경우. + +1. **모달용 screen_definitions INSERT** (별도 화면 생성) +2. split-panel의 addButton/editButton에서 연결: + +```json +{ + "addButton": { + "enabled": true, + "mode": "modal", + "modalScreenId": "{모달_screen_id}" + }, + "editButton": { + "enabled": true, + "mode": "modal", + "modalScreenId": "{모달_screen_id}" + } +} +``` + +--- + +## 14. 비즈니스 로직 설정 (제어관리) + +버튼 클릭 시 INSERT/UPDATE/DELETE, 상태 변경, 이력 기록 등이 필요한 경우. + +### 14.1 v2-button-primary overrides + +```json +{ + "id": "btn_confirm", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1700, "y": 10 }, + "size": { "width": 100, "height": 40 }, + "overrides": { + "text": "확정", + "variant": "primary", + "actionType": "button", + "action": { "type": "custom" }, + "webTypeConfig": { + "enableDataflowControl": true, + "dataflowConfig": { + "controlMode": "relationship", + "relationshipConfig": { + "relationshipId": "{관계_ID}", + "relationshipName": "{관계명}", + "executionTiming": "after" + } + } + } + } +} +``` + +### 14.2 dataflow_diagrams INSERT + +```sql +INSERT INTO dataflow_diagrams ( + diagram_name, company_code, + relationships, control, plan, node_positions +) +VALUES ( + '{관계도명}', + '{company_code}', + '[{"fromTable":"{소스_테이블}","toTable":"{타겟_테이블}","relationType":"data_save"}]'::jsonb, + '[{ + "conditions": [{"field":"status","operator":"=","value":"대기","dataType":"string"}], + "triggerType": "update" + }]'::jsonb, + '[{ + "actions": [ + { + "actionType": "update", + "targetTable": "{타겟_테이블}", + "conditions": [{"field":"status","operator":"=","value":"대기"}], + "fieldMappings": [{"targetField":"status","defaultValue":"확정"}] + }, + { + "actionType": "insert", + "targetTable": "{이력_테이블}", + "fieldMappings": [ + {"sourceField":"order_no","targetField":"order_no"}, + {"targetField":"action","defaultValue":"확정"} + ] + } + ] + }]'::jsonb, + '[]'::jsonb +) +RETURNING diagram_id; +``` + +**executionTiming 선택**: +- `before`: 메인 액션 전 → 조건 체크 (조건 불충족 시 메인 액션 중단) +- `after`: 메인 액션 후 → 후처리 (이력 기록, 상태 변경 등) +- `replace`: 메인 액션 대체 → 제어만 실행 + +--- + +## 15. 전체 예시: "수주관리 화면 만들어줘" + +### 요구사항 해석 +- 마스터: order_master (수주번호, 거래처, 수주일자, 상태) +- 디테일: order_detail (품목, 수량, 단가, 금액) +- 패턴: B (마스터-디테일) + +### 실행 SQL + +```sql +-- ===== Step 1: 테이블 생성 ===== +CREATE TABLE "order_master" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + "order_no" varchar(500), + "customer_id" varchar(500), + "order_date" varchar(500), + "delivery_date" varchar(500), + "status" varchar(500), + "total_amount" varchar(500), + "notes" varchar(500) +); + +CREATE TABLE "order_detail" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + "order_master_id" varchar(500), + "item_id" varchar(500), + "quantity" varchar(500), + "unit_price" varchar(500), + "amount" varchar(500), + "notes" varchar(500) +); + +-- ===== Step 2: table_labels ===== +INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date) VALUES + ('order_master', '수주 마스터', '수주 헤더 정보', now(), now()), + ('order_detail', '수주 상세', '수주 품목별 상세', now(), now()) +ON CONFLICT (table_name) DO UPDATE SET table_label = EXCLUDED.table_label, description = EXCLUDED.description, updated_date = now(); + +-- ===== Step 3: table_type_columns (확장 컬럼 포함) ===== +-- order_master 기본 + 비즈니스 컬럼 +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) VALUES + ('order_master', 'id', '*', 'text', '{}', 'N', 'Y', -5, 'ID', '기본키', false, now(), now()), + ('order_master', 'created_date', '*', 'date', '{}', 'Y', 'N', -4, '생성일시', '레코드 생성일시', false, now(), now()), + ('order_master', 'updated_date', '*', 'date', '{}', 'Y', 'N', -3, '수정일시', '레코드 수정일시', false, now(), now()), + ('order_master', 'writer', '*', 'text', '{}', 'Y', 'N', -2, '작성자', '레코드 작성자', false, now(), now()), + ('order_master', 'company_code', '*', 'text', '{}', 'Y', 'N', -1, '회사코드', '회사 구분 코드', false, now(), now()), + ('order_master', 'order_no', '*', 'text', '{}', 'N', 'Y', 0, '수주번호', '수주 식별번호', true, now(), now()), + ('order_master', 'customer_id', '*', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', 'N', 'N', 1, '거래처', '거래처 참조', true, now(), now()), + ('order_master', 'order_date', '*', 'date', '{}', 'N', 'N', 2, '수주일자', '', true, now(), now()), + ('order_master', 'delivery_date', '*', 'date', '{}', 'Y', 'N', 3, '납기일', '', true, now(), now()), + ('order_master', 'status', '*', 'code', '{"codeCategory":"ORDER_STATUS"}', 'Y', 'N', 4, '상태', '수주 상태', true, now(), now()), + ('order_master', 'total_amount', '*', 'number', '{}', 'Y', 'N', 5, '총금액', '', true, now(), now()), + ('order_master', 'notes', '*', 'textarea', '{}', 'Y', 'N', 6, '비고', '', true, now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- order_detail 기본 + 비즈니스 컬럼 +INSERT INTO table_type_columns ( + table_name, column_name, company_code, input_type, detail_settings, + is_nullable, is_unique, display_order, column_label, description, is_visible, + created_date, updated_date +) VALUES + ('order_detail', 'id', '*', 'text', '{}', 'N', 'Y', -5, 'ID', '기본키', false, now(), now()), + ('order_detail', 'created_date', '*', 'date', '{}', 'Y', 'N', -4, '생성일시', '레코드 생성일시', false, now(), now()), + ('order_detail', 'updated_date', '*', 'date', '{}', 'Y', 'N', -3, '수정일시', '레코드 수정일시', false, now(), now()), + ('order_detail', 'writer', '*', 'text', '{}', 'Y', 'N', -2, '작성자', '레코드 작성자', false, now(), now()), + ('order_detail', 'company_code', '*', 'text', '{}', 'Y', 'N', -1, '회사코드', '회사 구분 코드', false, now(), now()), + ('order_detail', 'order_master_id', '*', 'text', '{}', 'N', 'N', 0, '수주마스터ID', 'FK', false, now(), now()), + ('order_detail', 'item_id', '*', 'entity', '{"referenceTable":"item_info","referenceColumn":"id","displayColumn":"item_name"}', 'N', 'N', 1, '품목', '품목 참조', true, now(), now()), + ('order_detail', 'quantity', '*', 'number', '{}', 'N', 'N', 2, '수량', '', true, now(), now()), + ('order_detail', 'unit_price', '*', 'number', '{}', 'Y', 'N', 3, '단가', '', true, now(), now()), + ('order_detail', 'amount', '*', 'number', '{}', 'Y', 'N', 4, '금액', '', true, now(), now()), + ('order_detail', 'notes', '*', 'textarea', '{}', 'Y', 'N', 5, '비고', '', true, now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, + is_nullable = EXCLUDED.is_nullable, is_unique = EXCLUDED.is_unique, + display_order = EXCLUDED.display_order, column_label = EXCLUDED.column_label, + description = EXCLUDED.description, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- ===== Step 4: column_labels (company_code 필수!) ===== +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) VALUES + ('order_master', 'id', 'ID', 'text', '{}', '기본키', -5, true, '*', now(), now()), + ('order_master', 'created_date', '생성일시', 'date', '{}', '', -4, true, '*', now(), now()), + ('order_master', 'updated_date', '수정일시', 'date', '{}', '', -3, true, '*', now(), now()), + ('order_master', 'writer', '작성자', 'text', '{}', '', -2, true, '*', now(), now()), + ('order_master', 'company_code', '회사코드', 'text', '{}', '', -1, true, '*', now(), now()), + ('order_master', 'order_no', '수주번호', 'text', '{}', '수주 식별번호', 0, true, '*', now(), now()), + ('order_master', 'customer_id', '거래처', 'entity', '{"referenceTable":"customer_info","referenceColumn":"id","displayColumn":"customer_name"}', '거래처 참조', 1, true, '*', now(), now()), + ('order_master', 'order_date', '수주일자', 'date', '{}', '', 2, true, '*', now(), now()), + ('order_master', 'delivery_date', '납기일', 'date', '{}', '', 3, true, '*', now(), now()), + ('order_master', 'status', '상태', 'code', '{"codeCategory":"ORDER_STATUS"}', '수주 상태', 4, true, '*', now(), now()), + ('order_master', 'total_amount', '총금액', 'number', '{}', '', 5, true, '*', now(), now()), + ('order_master', 'notes', '비고', 'textarea', '{}', '', 6, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); + +INSERT INTO column_labels (table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, company_code, created_date, updated_date) VALUES + ('order_detail', 'id', 'ID', 'text', '{}', '기본키', -5, true, '*', now(), now()), + ('order_detail', 'created_date', '생성일시', 'date', '{}', '', -4, true, '*', now(), now()), + ('order_detail', 'updated_date', '수정일시', 'date', '{}', '', -3, true, '*', now(), now()), + ('order_detail', 'writer', '작성자', 'text', '{}', '', -2, true, '*', now(), now()), + ('order_detail', 'company_code', '회사코드', 'text', '{}', '', -1, true, '*', now(), now()), + ('order_detail', 'order_master_id', '수주마스터ID', 'text', '{}', 'FK', 0, true, '*', now(), now()), + ('order_detail', 'item_id', '품목', 'entity', '{"referenceTable":"item_info","referenceColumn":"id","displayColumn":"item_name"}', '품목 참조', 1, true, '*', now(), now()), + ('order_detail', 'quantity', '수량', 'number', '{}', '', 2, true, '*', now(), now()), + ('order_detail', 'unit_price', '단가', 'number', '{}', '', 3, true, '*', now(), now()), + ('order_detail', 'amount', '금액', 'number', '{}', '', 4, true, '*', now(), now()), + ('order_detail', 'notes', '비고', 'textarea', '{}', '', 5, true, '*', now(), now()) +ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET + column_label = EXCLUDED.column_label, input_type = EXCLUDED.input_type, + detail_settings = EXCLUDED.detail_settings, description = EXCLUDED.description, + display_order = EXCLUDED.display_order, is_visible = EXCLUDED.is_visible, updated_date = now(); + +-- ===== Step 5: screen_definitions ===== +INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, description, is_active, db_source_type, data_source_type, created_date) +VALUES ('수주관리', 'ILSHIN_ORDER_MNG', 'order_master', 'ILSHIN', '수주 마스터-디테일 관리', 'Y', 'internal', 'database', now()); + +-- ===== Step 6: screen_layouts_v2 (서브쿼리로 screen_id 참조) ===== +INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) +VALUES ( + (SELECT screen_id FROM screen_definitions WHERE screen_code = 'ILSHIN_ORDER_MNG'), + 'ILSHIN', 1, '기본 레이어', + '{ + "version": "2.0", + "components": [ + { + "id": "split_order", + "url": "@/lib/registry/components/v2-split-panel-layout", + "position": {"x": 0, "y": 0}, + "size": {"width": 1920, "height": 850}, + "displayOrder": 0, + "overrides": { + "label": "수주관리", + "splitRatio": 35, + "resizable": true, + "autoLoad": true, + "syncSelection": true, + "leftPanel": { + "title": "수주 목록", + "displayMode": "table", + "tableName": "order_master", + "showSearch": true, + "showAdd": true, + "showDelete": true, + "columns": [ + {"name": "order_no", "label": "수주번호", "width": 120, "sortable": true}, + {"name": "customer_id", "label": "거래처", "width": 150, "isEntityJoin": true, "joinInfo": {"sourceTable": "order_master", "sourceColumn": "customer_id", "referenceTable": "customer_info", "joinAlias": "customer_name"}}, + {"name": "order_date", "label": "수주일자", "width": 100}, + {"name": "status", "label": "상태", "width": 80}, + {"name": "total_amount", "label": "총금액", "width": 120} + ], + "addButton": {"enabled": true, "mode": "auto"}, + "deleteButton": {"enabled": true, "confirmMessage": "선택한 수주를 삭제하시겠습니까?"} + }, + "rightPanel": { + "title": "수주 상세", + "displayMode": "table", + "tableName": "order_detail", + "relation": { + "type": "detail", + "leftColumn": "id", + "rightColumn": "order_master_id", + "foreignKey": "order_master_id" + }, + "columns": [ + {"name": "item_id", "label": "품목", "width": 150, "isEntityJoin": true, "joinInfo": {"sourceTable": "order_detail", "sourceColumn": "item_id", "referenceTable": "item_info", "joinAlias": "item_name"}}, + {"name": "quantity", "label": "수량", "width": 80, "editable": true}, + {"name": "unit_price", "label": "단가", "width": 100, "editable": true}, + {"name": "amount", "label": "금액", "width": 100}, + {"name": "notes", "label": "비고", "width": 200, "editable": true} + ], + "addButton": {"enabled": true, "mode": "auto"}, + "editButton": {"enabled": true, "mode": "auto"}, + "deleteButton": {"enabled": true, "confirmMessage": "삭제하시겠습니까?"} + } + } + } + ], + "gridSettings": {"columns": 12, "gap": 16, "padding": 16}, + "screenResolution": {"width": 1920, "height": 1080} + }'::jsonb, + now(), now() +) +ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE SET layout_data = EXCLUDED.layout_data, updated_at = now(); + +-- ===== Step 7: menu_info (objid에 순서값 더해서 PK 충돌 방지) ===== +INSERT INTO menu_info ( + objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_url, menu_desc, writer, regdate, status, company_code, screen_code +) +VALUES ( + extract(epoch from now())::bigint * 1000 + 1, 0, {부모_메뉴_objid}, + '수주관리', 'Order Management', + 1, '/screen/ILSHIN_ORDER_MNG', '수주 마스터-디테일 관리', + 'admin', now(), 'active', 'ILSHIN', 'ILSHIN_ORDER_MNG' +); +``` + +--- + +## 16. 컴포넌트 빠른 참조표 + +| 요구사항 | 컴포넌트 url | 핵심 overrides | +|----------|-------------|---------------| +| 데이터 테이블 | v2-table-list | `tableName`, `columns`, `pagination` | +| 검색 바 | v2-table-search-widget | `autoSelectFirstTable` | +| 좌우 분할 | v2-split-panel-layout | `leftPanel`, `rightPanel`, `relation`, `splitRatio` | +| 그룹화 테이블 | v2-table-grouped | `groupConfig.groupByColumn`, `summary` | +| 간트차트 | v2-timeline-scheduler | `fieldMapping`, `resourceTable` | +| 피벗 분석 | v2-pivot-grid | `fields(area, summaryType)` | +| 카드 뷰 | v2-card-display | `columnMapping`, `cardsPerRow` | +| 액션 버튼 | v2-button-primary | `text`, `actionType`, `webTypeConfig.dataflowConfig` | +| 텍스트 입력 | v2-input | `inputType`, `tableName`, `columnName` | +| 선택 | v2-select | `mode`, `source` | +| 날짜 | v2-date | `dateType` | +| 자동 채번 | v2-numbering-rule | `rule` | +| BOM 트리 | v2-bom-tree | `detailTable`, `foreignKey`, `parentKey` | +| BOM 편집 | v2-bom-item-editor | `detailTable`, `sourceTable`, `itemCodeField` | +| 결재 스테퍼 | v2-approval-step | `targetTable`, `displayMode` | +| 파일 업로드 | v2-file-upload | `multiple`, `accept`, `maxSize` | +| 상태별 건수 | v2-status-count | `tableName`, `statusColumn`, `items` | +| 집계 카드 | v2-aggregation-widget | `tableName`, `items` | +| 반복 데이터 관리 | v2-repeater | `renderMode`, `mainTableName`, `foreignKeyColumn` | +| 반복 렌더링 | v2-repeat-container | `dataSourceType`, `layout`, `gridColumns` | +| 그룹 컨테이너 (테두리) | v2-section-card | `title`, `collapsible`, `borderStyle` | +| 그룹 컨테이너 (배경색) | v2-section-paper | `backgroundColor`, `shadow`, `padding` | +| 캔버스 분할선 | v2-split-line | `resizable`, `lineColor`, `lineWidth` | +| 카테고리 관리 | v2-category-manager | `tableName`, `columnName`, `menuObjid` | +| 미디어 | v2-media | `mediaType`, `multiple`, `maxSize` | +| 위치 교환 | v2-location-swap-selector | `dataSource`, `departureField`, `destinationField` | +| 창고 랙 | v2-rack-structure | `codePattern`, `namePattern`, `maxRows` | +| 공정 작업기준 | v2-process-work-standard | `dataSource.itemTable`, `dataSource.routingDetailTable` | +| 품목 라우팅 | v2-item-routing | `dataSource.itemTable`, `dataSource.routingDetailTable` | diff --git a/docs/screen-implementation-guide/03_production/production-plan-implementation.md b/docs/screen-implementation-guide/03_production/production-plan-implementation.md new file mode 100644 index 00000000..c487e59f --- /dev/null +++ b/docs/screen-implementation-guide/03_production/production-plan-implementation.md @@ -0,0 +1,856 @@ +# 생산계획관리 화면 구현 설계서 + +> **Screen Code**: `TOPSEAL_PP_MAIN` (screen_id: 3985) +> **메뉴 경로**: 생산관리 > 생산계획관리 +> **HTML 예시**: `00_화면개발_html/Cursor 폴더/화면개발/PC브라우저/생산/생산계획관리.html` +> **작성일**: 2026-03-13 + +--- + +## 1. 화면 전체 구조 + +``` ++---------------------------------------------------------------------+ +| 검색 섹션 (상단) | +| [품목코드] [품명] [계획기간(daterange)] [상태] | +| [사용자옵션] [엑셀업로드] [엑셀다운로드] | ++----------------------------------+--+-------------------------------+ +| 좌측 패널 (50%, 리사이즈) | | 우측 패널 (50%) | +| +------------------------------+ |리| +---------------------------+ | +| | [수주데이터] [안전재고 부족분] | |사| | [완제품] [반제품] | | +| +------------------------------+ |이| +---------------------------+ | +| | 수주 목록 헤더 | |즈| | 완제품 생산 타임라인 헤더 | | +| | [계획에없는품목만] [불러오기] | |핸| | [새로고침] [자동스케줄] | | +| | +---------------------------+| |들| | [병합] [반제품계획] [저장] | | +| | | 품목 그룹 테이블 || | | | +------------------------+| | +| | | - 품목별 그룹 행 (13컬럼) || | | | | 옵션 패널 || | +| | | -> 수주 상세 행 (7컬럼) || | | | | [리드타임] [기간] [재계산]|| | +| | | - 접기/펼치기 토글 || | | | +------------------------+| | +| | | - 체크박스 (그룹/개별) || | | | | 범례 || | +| | +---------------------------+| | | | +------------------------+| | +| +------------------------------+ | | | | 타임라인 스케줄러 || | +| | | | | (간트차트 형태) || | +| -- 안전재고 부족분 탭 -- | | | +------------------------+| | +| | 부족 품목 테이블 (8컬럼) | | | +---------------------------+ | +| | - 체크박스, 품목코드, 품명 | | | | +| | - 현재고, 안전재고, 부족수량 | | | -- 반제품 탭 -- | +| | - 권장생산량, 최종입고일 | | | | 옵션 + 안내 패널 | | +| +------------------------------+ | | | 반제품 타임라인 스케줄러 | | ++----------------------------------+--+-------------------------------+ +``` + +--- + +## 2. 사용 테이블 및 컬럼 매핑 + +### 2.1 메인 테이블 + +| 테이블명 | 용도 | PK | +|----------|------|-----| +| `production_plan_mng` | 생산계획 마스터 | `id` (serial) | +| `sales_order_mng` | 수주 데이터 (좌측 패널 조회용) | `id` (serial) | +| `item_info` | 품목 마스터 (참조) | `id` (uuid text) | +| `inventory_stock` | 재고 현황 (안전재고 부족분 탭) | `id` (uuid text) | +| `equipment_info` | 설비 정보 (타임라인 리소스) | `id` (serial) | +| `bom` / `bom_detail` | BOM 정보 (반제품 계획 생성) | `id` (uuid text) | +| `work_instruction` | 작업지시 (타임라인 연동) | 별도 확인 필요 | + +### 2.2 핵심 컬럼 매핑 - production_plan_mng + +| 컬럼명 | 타입 | 용도 | HTML 매핑 | +|--------|------|------|-----------| +| `id` | serial PK | 고유 ID | `schedule.id` | +| `company_code` | varchar | 멀티테넌시 | - | +| `plan_no` | varchar NOT NULL | 계획번호 | `SCH-{timestamp}` | +| `plan_date` | date | 계획 등록일 | 자동 | +| `item_code` | varchar NOT NULL | 품목코드 | `schedule.itemCode` | +| `item_name` | varchar | 품목명 | `schedule.itemName` | +| `product_type` | varchar | 완제품/반제품 | `'완제품'` or `'반제품'` | +| `plan_qty` | numeric NOT NULL | 계획 수량 | `schedule.quantity` | +| `completed_qty` | numeric | 완료 수량 | `schedule.completedQty` | +| `progress_rate` | numeric | 진행률(%) | `schedule.progressRate` | +| `start_date` | date NOT NULL | 시작일 | `schedule.startDate` | +| `end_date` | date NOT NULL | 종료일 | `schedule.endDate` | +| `due_date` | date | 납기일 | `schedule.dueDate` | +| `equipment_id` | integer | 설비 ID | `schedule.equipmentId` | +| `equipment_code` | varchar | 설비 코드 | - | +| `equipment_name` | varchar | 설비명 | `schedule.productionLine` | +| `status` | varchar | 상태 | `planned/in_progress/completed/work-order` | +| `priority` | varchar | 우선순위 | `normal/high/urgent` | +| `hourly_capacity` | numeric | 시간당 생산능력 | `schedule.hourlyCapacity` | +| `daily_capacity` | numeric | 일일 생산능력 | `schedule.dailyCapacity` | +| `lead_time` | integer | 리드타임(일) | `schedule.leadTime` | +| `work_shift` | varchar | 작업조 | `DAY/NIGHT/BOTH` | +| `work_order_no` | varchar | 작업지시번호 | `schedule.workOrderNo` | +| `manager_name` | varchar | 담당자 | `schedule.manager` | +| `order_no` | varchar | 연관 수주번호 | `schedule.orderInfo[].orderNo` | +| `parent_plan_id` | integer | 모 계획 ID (반제품용) | `schedule.parentPlanId` | +| `remarks` | text | 비고 | `schedule.remarks` | + +### 2.3 수주 데이터 조회용 - sales_order_mng + +| 컬럼명 | 용도 | 좌측 테이블 컬럼 매핑 | +|--------|------|----------------------| +| `order_no` | 수주번호 | 수주 상세 행 - 수주번호 | +| `part_code` | 품목코드 | 그룹 행 - 품목코드 (그룹 기준) | +| `part_name` | 품명 | 그룹 행 - 품목명 | +| `order_qty` | 수주량 | 총수주량 (SUM) | +| `ship_qty` | 출고량 | 출고량 (SUM) | +| `balance_qty` | 잔량 | 잔량 (SUM) | +| `due_date` | 납기일 | 수주 상세 행 - 납기일 | +| `partner_id` | 거래처 | 수주 상세 행 - 거래처 | +| `status` | 상태 | 상태 배지 (일반/긴급) | + +### 2.4 안전재고 부족분 조회용 - inventory_stock + item_info + +| 컬럼명 | 출처 | 좌측 테이블 컬럼 매핑 | +|--------|------|----------------------| +| `item_code` | inventory_stock | 품목코드 | +| `item_name` | item_info (JOIN) | 품목명 | +| `current_qty` | inventory_stock | 현재고 | +| `safety_qty` | inventory_stock | 안전재고 | +| `부족수량` | 계산값 (`safety_qty - current_qty`) | 부족수량 (음수면 부족) | +| `권장생산량` | 계산값 (`safety_qty * 2 - current_qty`) | 권장생산량 | +| `last_in_date` | inventory_stock | 최종입고일 | + +--- + +## 3. V2 컴포넌트 구현 가능/불가능 분석 + +### 3.1 구현 가능 (기존 V2 컴포넌트) + +| 기능 | V2 컴포넌트 | 현재 상태 | +|------|-------------|-----------| +| 좌우 분할 레이아웃 | `v2-split-panel-layout` (`displayMode: "custom"`) | layout_data에 이미 존재 | +| 검색 필터 | `v2-table-search-widget` | layout_data에 이미 존재 | +| 좌측/우측 탭 전환 | `v2-tabs-widget` | layout_data에 이미 존재 | +| 체크박스 선택 | `v2-table-grouped` (`showCheckbox: true`) | layout_data에 이미 존재 | +| 단순 그룹핑 테이블 | `v2-table-grouped` (`groupByColumn`) | layout_data에 이미 존재 | +| 타임라인 스케줄러 | `v2-timeline-scheduler` | layout_data에 이미 존재 | +| 버튼 액션 | `v2-button-primary` | layout_data에 이미 존재 | +| 안전재고 부족분 테이블 | `v2-table-list` 또는 `v2-table-grouped` | 미구성 (탭2에 컴포넌트 없음) | + +### 3.2 부분 구현 가능 (개선/확장 필요) + +| 기능 | 문제점 | 필요 작업 | +|------|--------|-----------| +| 수주 그룹 테이블 (2레벨) | `v2-table-grouped`는 **동일 컬럼 기준 그룹핑**만 지원. HTML은 그룹 행(13컬럼)과 상세 행(7컬럼)이 완전히 다른 구조 | 컴포넌트 확장 or 백엔드에서 집계 데이터를 별도 API로 제공 | +| 스케줄러 옵션 패널 | HTML의 안전리드타임/표시기간/재계산 옵션을 위한 전용 UI 없음 | `v2-input` + `v2-select` 조합으로 구성 가능 | +| 범례 UI | `v2-timeline-scheduler`에 statusColors 설정은 있지만 범례 UI 자체는 없음 | `v2-text-display` 또는 커스텀 구성 | +| 부족수량 빨간색 강조 | 조건부 서식(conditional formatting) 미지원 | 컴포넌트 확장 필요 | +| "계획에 없는 품목만" 필터 | 단순 테이블 필터가 아닌 교차 테이블 비교 필터 | 백엔드 API 필요 | + +### 3.3 신규 개발 필요 (현재 V2 컴포넌트로 불가능) + +| 기능 | 설명 | 구현 방안 | +|------|------|-----------| +| **자동 스케줄 생성 API** | 선택 품목의 필요생산계획량, 납기일, 설비 생산능력 기반으로 타임라인 자동 배치 | 백엔드 전용 API | +| **선택 계획 병합 API** | 동일 품목 복수 스케줄을 하나로 합산 | 백엔드 전용 API | +| **반제품 계획 자동 생성 API** | BOM 기반으로 완제품 계획에서 필요 반제품 소요량 계산 | 백엔드 전용 API (BOM + 재고 연계) | +| **수주 잔량/현재고 연산 조회 API** | 여러 테이블 JOIN + 집계 연산으로 좌측 패널 데이터 제공 | 백엔드 전용 API | +| **스케줄 상세 모달** | 기본정보, 근거정보, 생산정보, 계획기간, 계획분할, 설비할당 | 모달 화면 (`TOPSEAL_PP_MODAL` screen_id: 3986) 보강 | +| **설비 선택 모달** | 설비별 수량 할당 및 일정 등록 | 신규 모달 화면 필요 | +| **변경사항 확인 모달** | 자동 스케줄 생성 전후 비교 (신규/유지/삭제 건수 요약) | 신규 모달 또는 확인 다이얼로그 | + +--- + +## 4. 백엔드 API 설계 + +### 4.1 수주 데이터 조회 API (좌측 패널 - 수주데이터 탭) + +``` +GET /api/production/order-summary +``` + +**목적**: 수주 데이터를 **품목별로 그룹핑**하여 반환. 그룹 헤더에 집계값(총수주량, 출고량, 잔량, 현재고, 안전재고, 기생산계획량 등) 포함. + +**응답 구조**: +```json +{ + "success": true, + "data": [ + { + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "hourly_capacity": 100, + "daily_capacity": 800, + "lead_time": 1, + "total_order_qty": 1000, + "total_ship_qty": 300, + "total_balance_qty": 700, + "current_stock": 100, + "safety_stock": 150, + "plan_ship_qty": 0, + "existing_plan_qty": 0, + "in_progress_qty": 0, + "required_plan_qty": 750, + "orders": [ + { + "order_no": "SO-2025-101", + "partner_name": "ABC 상사", + "order_qty": 500, + "ship_qty": 200, + "balance_qty": 300, + "due_date": "2025-11-05", + "is_urgent": false + }, + { + "order_no": "SO-2025-102", + "partner_name": "XYZ 무역", + "order_qty": 500, + "ship_qty": 100, + "balance_qty": 400, + "due_date": "2025-11-10", + "is_urgent": false + } + ] + } + ] +} +``` + +**SQL 로직 (핵심)**: +```sql +WITH order_summary AS ( + SELECT + so.part_code AS item_code, + so.part_name AS item_name, + SUM(COALESCE(so.order_qty, 0)) AS total_order_qty, + SUM(COALESCE(so.ship_qty, 0)) AS total_ship_qty, + SUM(COALESCE(so.balance_qty, 0)) AS total_balance_qty + FROM sales_order_mng so + WHERE so.company_code = $1 + AND so.status NOT IN ('cancelled', 'completed') + AND so.balance_qty > 0 + GROUP BY so.part_code, so.part_name +), +stock_info AS ( + SELECT + item_code, + SUM(COALESCE(current_qty::numeric, 0)) AS current_stock, + MAX(COALESCE(safety_qty::numeric, 0)) AS safety_stock + FROM inventory_stock + WHERE company_code = $1 + GROUP BY item_code +), +plan_info AS ( + SELECT + item_code, + SUM(CASE WHEN status = 'planned' THEN plan_qty ELSE 0 END) AS existing_plan_qty, + SUM(CASE WHEN status = 'in_progress' THEN plan_qty ELSE 0 END) AS in_progress_qty + FROM production_plan_mng + WHERE company_code = $1 + AND product_type = '완제품' + AND status NOT IN ('completed', 'cancelled') + GROUP BY item_code +) +SELECT + os.*, + COALESCE(si.current_stock, 0) AS current_stock, + COALESCE(si.safety_stock, 0) AS safety_stock, + COALESCE(pi.existing_plan_qty, 0) AS existing_plan_qty, + COALESCE(pi.in_progress_qty, 0) AS in_progress_qty, + GREATEST( + os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) + - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), + 0 + ) AS required_plan_qty +FROM order_summary os +LEFT JOIN stock_info si ON os.item_code = si.item_code +LEFT JOIN plan_info pi ON os.item_code = pi.item_code +ORDER BY os.item_code; +``` + +**파라미터**: +- `company_code`: req.user.companyCode (자동) +- `exclude_planned` (optional): `true`이면 기존 계획이 있는 품목 제외 + +--- + +### 4.2 안전재고 부족분 조회 API (좌측 패널 - 안전재고 탭) + +``` +GET /api/production/stock-shortage +``` + +**응답 구조**: +```json +{ + "success": true, + "data": [ + { + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "current_qty": 50, + "safety_qty": 200, + "shortage_qty": -150, + "recommended_qty": 300, + "last_in_date": "2025-10-15" + } + ] +} +``` + +**SQL 로직**: +```sql +SELECT + ist.item_code, + ii.item_name, + COALESCE(ist.current_qty::numeric, 0) AS current_qty, + COALESCE(ist.safety_qty::numeric, 0) AS safety_qty, + (COALESCE(ist.current_qty::numeric, 0) - COALESCE(ist.safety_qty::numeric, 0)) AS shortage_qty, + GREATEST(COALESCE(ist.safety_qty::numeric, 0) * 2 - COALESCE(ist.current_qty::numeric, 0), 0) AS recommended_qty, + ist.last_in_date +FROM inventory_stock ist +JOIN item_info ii ON ist.item_code = ii.id AND ist.company_code = ii.company_code +WHERE ist.company_code = $1 + AND COALESCE(ist.current_qty::numeric, 0) < COALESCE(ist.safety_qty::numeric, 0) +ORDER BY shortage_qty ASC; +``` + +--- + +### 4.3 자동 스케줄 생성 API + +``` +POST /api/production/generate-schedule +``` + +**요청 body**: +```json +{ + "items": [ + { + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "required_qty": 750, + "earliest_due_date": "2025-11-05", + "hourly_capacity": 100, + "daily_capacity": 800, + "lead_time": 1, + "orders": [ + { "order_no": "SO-2025-101", "balance_qty": 300, "due_date": "2025-11-05" }, + { "order_no": "SO-2025-102", "balance_qty": 400, "due_date": "2025-11-10" } + ] + } + ], + "options": { + "safety_lead_time": 1, + "recalculate_unstarted": true, + "product_type": "완제품" + } +} +``` + +**비즈니스 로직**: +1. 각 품목의 필요생산계획량, 납기일, 일일생산능력을 기반으로 생산일수 계산 +2. `생산일수 = ceil(필요생산계획량 / 일일생산능력)` +3. `시작일 = 납기일 - 생산일수 - 안전리드타임` +4. 시작일이 오늘 이전이면 오늘로 조정 +5. `recalculate_unstarted = true`면 기존 진행중/작업지시/완료 스케줄은 유지, 미진행(planned)만 제거 후 재계산 +6. 결과를 `production_plan_mng`에 INSERT +7. 변경사항 요약(신규/유지/삭제 건수) 반환 + +**응답 구조**: +```json +{ + "success": true, + "data": { + "summary": { + "total": 3, + "new_count": 2, + "kept_count": 1, + "deleted_count": 1 + }, + "schedules": [ + { + "id": 101, + "plan_no": "PP-2025-0001", + "item_code": "ITEM-001", + "item_name": "탑씰 Type A", + "plan_qty": 750, + "start_date": "2025-10-30", + "end_date": "2025-11-03", + "due_date": "2025-11-05", + "status": "planned" + } + ] + } +} +``` + +--- + +### 4.4 스케줄 병합 API + +``` +POST /api/production/merge-schedules +``` + +**요청 body**: +```json +{ + "schedule_ids": [101, 102, 103], + "product_type": "완제품" +} +``` + +**비즈니스 로직**: +1. 선택된 스케줄이 모두 동일 품목인지 검증 +2. 완제품/반제품이 섞여있지 않은지 검증 +3. 수량 합산, 가장 빠른 시작일/납기일, 가장 늦은 종료일 적용 +4. 원본 스케줄 DELETE, 병합된 스케줄 INSERT +5. 수주 정보(order_no)는 병합 (중복 제거) + +--- + +### 4.5 반제품 계획 자동 생성 API + +``` +POST /api/production/generate-semi-schedule +``` + +**요청 body**: +```json +{ + "plan_ids": [101, 102], + "options": { + "consider_stock": true, + "keep_in_progress": false, + "exclude_used": true + } +} +``` + +**비즈니스 로직**: +1. 선택된 완제품 계획의 품목코드로 BOM 조회 +2. `bom` 테이블에서 해당 품목의 `item_id` → `bom_detail`에서 하위 반제품(`child_item_id`) 조회 +3. 각 반제품의 필요 수량 = `완제품 계획수량 x BOM 소요량(quantity)` +4. `consider_stock = true`면 현재고/안전재고 감안하여 순 필요량 계산 +5. `exclude_used = true`면 이미 투입된 반제품 수량 차감 +6. 모품목 생산 시작일 고려하여 반제품 납기일 설정 (시작일 - 반제품 리드타임) +7. `production_plan_mng`에 `product_type = '반제품'`, `parent_plan_id` 설정하여 INSERT + +--- + +### 4.6 스케줄 상세 저장/수정 API + +``` +PUT /api/production/plan/:id +``` + +**요청 body**: +```json +{ + "plan_qty": 750, + "start_date": "2025-10-30", + "end_date": "2025-11-03", + "equipment_id": 1, + "equipment_code": "LINE-01", + "equipment_name": "1호기", + "manager_name": "홍길동", + "work_shift": "DAY", + "priority": "high", + "remarks": "긴급 생산" +} +``` + +--- + +### 4.7 스케줄 분할 API + +``` +POST /api/production/split-schedule +``` + +**요청 body**: +```json +{ + "plan_id": 101, + "splits": [ + { "qty": 500, "start_date": "2025-10-30", "end_date": "2025-11-01" }, + { "qty": 250, "start_date": "2025-11-02", "end_date": "2025-11-03" } + ] +} +``` + +**비즈니스 로직**: +1. 분할 수량 합산이 원본 수량과 일치하는지 검증 +2. 원본 스케줄 DELETE +3. 분할된 각 조각을 신규 INSERT (동일 `order_no`, `item_code` 유지) + +--- + +## 5. 모달 화면 설계 + +### 5.1 스케줄 상세 모달 (screen_id: 3986 보강) + +**섹션 구성**: + +| 섹션 | 필드 | 타입 | 비고 | +|------|------|------|------| +| **기본 정보** | 품목코드, 품목명 | text (readonly) | 자동 채움 | +| **근거 정보** | 수주번호/거래처/납기일 목록 | text (readonly) | 연관 수주 정보 표시 | +| **생산 정보** | 총 생산수량 | number | 수정 가능 | +| | 납기일 (수주 기준) | date (readonly) | 가장 빠른 납기일 | +| **계획 기간** | 계획 시작일, 종료일 | date | 수정 가능 | +| | 생산 기간 | text (readonly) | 자동 계산 표시 | +| **계획 분할** | 분할 개수, 분할 수량 입력 | select, number | 분할하기 기능 | +| **설비 할당** | 설비 선택 버튼 | button → 모달 | 설비 선택 모달 오픈 | +| **생산 상태** | 상태 | select (disabled) | `planned/work-order/in_progress/completed` | +| **추가 정보** | 담당자, 작업지시번호, 비고 | text | 수정 가능 | +| **하단 버튼** | 삭제, 취소, 저장 | buttons | - | + +### 5.2 수주 불러오기 모달 + +**구성**: +- 선택된 품목 목록 표시 +- 주의사항 안내 +- 라디오 버튼: "기존 계획에 추가" / "별도 계획으로 생성" +- 취소/불러오기 버튼 + +### 5.3 안전재고 불러오기 모달 + +**구성**: 수주 불러오기 모달과 동일한 패턴 + +### 5.4 설비 선택 모달 + +**구성**: +- 총 수량 / 할당 수량 / 미할당 수량 요약 +- 설비 카드 그리드 (설비명, 생산능력, 할당 수량 입력, 시작일/종료일) +- 취소/저장 버튼 + +### 5.5 변경사항 확인 모달 + +**구성**: +- 경고 메시지 +- 변경사항 요약 카드 (총 계획, 신규 생성, 유지됨, 삭제됨) +- 변경사항 상세 목록 (품목별 변경 전/후 비교) +- 취소/확인 및 적용 버튼 + +--- + +## 6. 현재 layout_data 수정 필요 사항 + +### 6.1 현재 layout_data 구조 (screen_id: 3985, layout_id: 9192) + +``` +comp_search (v2-table-search-widget) - 검색 필터 +comp_split_panel (v2-split-panel-layout) + ├── leftPanel (custom mode) + │ ├── left_tabs (v2-tabs-widget) - [수주데이터, 안전재고 부족분] + │ ├── order_table (v2-table-grouped) - 수주 테이블 + │ └── btn_import (v2-button-primary) - 선택 품목 불러오기 + ├── rightPanel (custom mode) + │ ├── right_tabs (v2-tabs-widget) - [완제품, 반제품] + │ │ └── finished_tab.components + │ │ ├── v2-timeline-scheduler - 타임라인 + │ │ └── v2-button-primary - 스케줄 생성 + │ ├── btn_save (v2-button-primary) - 자동 스케줄 생성 + │ └── btn_clear (v2-button-primary) - 초기화 +comp_q0iqzkpx (v2-button-primary) - 하단 저장 버튼 (무의미) +``` + +### 6.2 수정 필요 사항 + +| 항목 | 현재 상태 | 필요 상태 | +|------|-----------|-----------| +| **좌측 - 안전재고 탭** | 컴포넌트 없음 (`"컴포넌트가 없습니다"` 표시) | `v2-table-list` 또는 별도 조회 API 연결된 테이블 추가 | +| **좌측 - order_table** | `selectedTable: "sales_order_mng"` (범용 API) | 전용 API (`/api/production/order-summary`)로 변경 필요 | +| **좌측 - 체크박스 필터** | 없음 | "계획에 없는 품목만" 체크박스 UI 추가 | +| **우측 - 반제품 탭** | 컴포넌트 없음 | 반제품 타임라인 + 옵션 패널 추가 | +| **우측 - 타임라인** | `selectedTable: "work_instruction"` | `selectedTable: "production_plan_mng"` + 필터 `product_type='완제품'` | +| **우측 - 옵션 패널** | 없음 | 안전리드타임, 표시기간, 재계산 체크박스 → `v2-input` 조합 | +| **우측 - 범례** | 없음 | `v2-text-display` 또는 커스텀 범례 컴포넌트 | +| **우측 - 버튼들** | 일부만 존재 | 병합, 반제품계획, 저장, 초기화 추가 | +| **하단 저장 버튼** | 존재 (무의미) | 제거 | +| **우측 패널 렌더링 버그** | 타임라인 미렌더링 | SplitPanelLayout custom 모드 디버깅 필요 | + +--- + +## 7. 구현 단계별 계획 + +### Phase 1: 기존 버그 수정 + 기본 구조 안정화 + +**목표**: 현재 layout_data로 화면이 최소한 정상 렌더링되게 만들기 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 1-1. 좌측 z-index 겹침 수정 | SplitPanelLayout의 custom 모드에서 내부 컴포넌트가 비대화형 div에 가려지는 이슈 | 중 | +| 1-2. 우측 타임라인 렌더링 수정 | tabs-widget 내부 timeline-scheduler가 렌더링되지 않는 이슈 | 중 | +| 1-3. 하단 저장 버튼 제거 | layout_data에서 `comp_q0iqzkpx` 제거 | 하 | +| 1-4. 타임라인 데이터 소스 수정 | `work_instruction` → `production_plan_mng`으로 변경 | 하 | + +### Phase 2: 백엔드 API 개발 + +**목표**: 화면에 필요한 데이터를 제공하는 전용 API 구축 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 2-1. 수주 데이터 조회 API | `GET /api/production/order-summary` (4.1 참조) | 중 | +| 2-2. 안전재고 부족분 API | `GET /api/production/stock-shortage` (4.2 참조) | 하 | +| 2-3. 자동 스케줄 생성 API | `POST /api/production/generate-schedule` (4.3 참조) | 상 | +| 2-4. 스케줄 CRUD API | `PUT/DELETE /api/production/plan/:id` (4.6 참조) | 중 | +| 2-5. 스케줄 병합 API | `POST /api/production/merge-schedules` (4.4 참조) | 중 | +| 2-6. 반제품 계획 자동 생성 API | `POST /api/production/generate-semi-schedule` (4.5 참조) | 상 | +| 2-7. 스케줄 분할 API | `POST /api/production/split-schedule` (4.7 참조) | 중 | + +### Phase 3: layout_data 보강 + 모달 화면 + +**목표**: 안전재고 탭, 반제품 탭, 모달들 구성 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 3-1. 안전재고 부족분 탭 구성 | `stock_tab`에 테이블 컴포넌트 + "선택 품목 불러오기" 버튼 추가 | 중 | +| 3-2. 반제품 탭 구성 | `semi_tab`에 타임라인 + 옵션 + 버튼 추가 | 중 | +| 3-3. 옵션 패널 구성 | v2-input 조합으로 안전리드타임, 표시기간, 체크박스 | 중 | +| 3-4. 버튼 액션 연결 | 자동 스케줄, 병합, 반제품계획, 저장, 초기화 → API 연결 | 중 | +| 3-5. 스케줄 상세 모달 보강 | screen_id: 3986 layout_data 수정 | 중 | +| 3-6. 수주/안전재고 불러오기 모달 | 신규 모달 screen 생성 | 중 | +| 3-7. 설비 선택 모달 | 신규 모달 screen 생성 | 중 | + +### Phase 4: v2-table-grouped 확장 (2레벨 트리 지원) + +**목표**: HTML 예시의 "품목 그룹 → 수주 상세" 2레벨 트리 테이블 구현 + +| 작업 | 상세 | 예상 난이도 | +|------|------|-------------| +| 4-1. 컴포넌트 확장 설계 | 그룹 행과 상세 행이 다른 컬럼 구조를 가질 수 있도록 설계 | 상 | +| 4-2. expandedRowRenderer 구현 | 그룹 행 펼침 시 별도 컬럼/데이터로 하위 행 렌더링 | 상 | +| 4-3. 그룹 행 집계 컬럼 설정 | 그룹 헤더에 SUM, 계산 필드 표시 (현재고, 안전재고, 필요생산계획 등) | 중 | +| 4-4. 조건부 서식 지원 | 부족수량 빨간색, 양수 초록색 등 | 중 | + +**대안**: Phase 4가 너무 복잡하면, 좌측 수주데이터를 2개 연동 테이블로 분리 (상단: 품목별 집계 테이블, 하단: 선택 품목의 수주 상세 테이블) 하는 방식도 검토 가능 + +--- + +## 8. 파일 생성/수정 목록 + +### 8.1 백엔드 + +| 파일 | 작업 | 비고 | +|------|------|------| +| `backend-node/src/routes/productionRoutes.ts` | 라우터 등록 | 신규 or 기존 확장 | +| `backend-node/src/controllers/productionController.ts` | API 핸들러 | 신규 or 기존 확장 | +| `backend-node/src/services/productionPlanService.ts` | 비즈니스 로직 서비스 | 신규 | + +### 8.2 DB (layout_data 수정) + +| 대상 | 작업 | +|------|------| +| `screen_layouts_v2` (screen_id: 3985) | layout_data JSON 수정 | +| `screen_layouts_v2` (screen_id: 3986) | 모달 layout_data 보강 | +| `screen_definitions` + `screen_layouts_v2` | 설비 선택 모달 신규 등록 | +| `screen_definitions` + `screen_layouts_v2` | 불러오기 모달 신규 등록 | + +### 8.3 프론트엔드 (API 클라이언트) + +| 파일 | 작업 | +|------|------| +| `frontend/lib/api/production.ts` | 생산계획 전용 API 클라이언트 함수 추가 | + +### 8.4 프론트엔드 (V2 컴포넌트 확장, Phase 4) + +| 파일 | 작업 | +|------|------| +| `frontend/lib/registry/components/v2-table-grouped/` | 2레벨 트리 지원 확장 | +| `frontend/lib/registry/components/v2-timeline-scheduler/` | 옵션 패널/범례 확장 (필요시) | + +--- + +## 9. 이벤트 흐름 (주요 시나리오) + +### 9.1 자동 스케줄 생성 흐름 + +``` +1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택 +2. 우측 "자동 스케줄 생성" 버튼 클릭 +3. (옵션 확인) 안전리드타임, 재계산 모드 체크 +4. POST /api/production/generate-schedule 호출 +5. (응답) 변경사항 확인 모달 표시 (신규/유지/삭제 건수) +6. 사용자 "확인 및 적용" 클릭 +7. 타임라인 스케줄러 새로고침 +8. 좌측 수주 목록의 "기생산계획량" 컬럼 갱신 +``` + +### 9.2 수주 불러오기 흐름 + +``` +1. 사용자가 좌측 수주데이터에서 품목 체크박스 선택 +2. "선택 품목 불러오기" 버튼 클릭 +3. 불러오기 모달 표시 (선택 품목 목록 + 추가방식 선택) +4. "기존 계획에 추가" or "별도 계획으로 생성" 선택 +5. "불러오기" 버튼 클릭 +6. POST /api/production/generate-schedule 호출 (단건) +7. 타임라인 새로고침 +``` + +### 9.3 타임라인 스케줄 클릭 → 상세 모달 + +``` +1. 사용자가 타임라인의 스케줄 바 클릭 +2. 스케줄 상세 모달 오픈 (TOPSEAL_PP_MODAL) +3. 기본정보(readonly), 근거정보(readonly), 생산정보(수정가능) 표시 +4. 계획기간 수정, 설비할당, 분할 등 작업 +5. "저장" → PUT /api/production/plan/:id +6. "삭제" → DELETE /api/production/plan/:id +7. 모달 닫기 → 타임라인 새로고침 +``` + +### 9.4 반제품 계획 생성 흐름 + +``` +1. 우측 완제품 탭에서 스케줄 체크박스 선택 +2. "선택 품목 → 반제품 계획" 버튼 클릭 +3. POST /api/production/generate-semi-schedule 호출 + - BOM 조회 → 필요 반제품 목록 + 소요량 계산 + - 재고 감안 → 순 필요량 계산 + - 반제품 계획 INSERT (product_type='반제품', parent_plan_id 설정) +4. 반제품 탭으로 자동 전환 +5. 반제품 타임라인 새로고침 +``` + +--- + +## 10. 검색 필드 설정 + +| 필드명 | 타입 | 라벨 | 대상 컬럼 | +|--------|------|------|-----------| +| `item_code` | text | 품목코드 | `part_code` (수주) / `item_code` (계획) | +| `item_name` | text | 품명 | `part_name` / `item_name` | +| `plan_date` | daterange | 계획기간 | `start_date` ~ `end_date` | +| `status` | select | 상태 | 전체 / 계획 / 진행 / 완료 | + +--- + +## 11. 권한 및 멀티테넌시 + +### 11.1 모든 API에 적용 + +```typescript +const companyCode = req.user!.companyCode; + +if (companyCode === '*') { + // 최고관리자: 모든 회사 데이터 조회 가능 +} else { + // 일반 회사: WHERE company_code = $1 필수 +} +``` + +### 11.2 데이터 격리 + +- `production_plan_mng.company_code` 필터 필수 +- `sales_order_mng.company_code` 필터 필수 +- `inventory_stock.company_code` 필터 필수 +- JOIN 시 양쪽 테이블 모두 `company_code` 조건 포함 + +--- + +## 12. 우선순위 정리 + +| 우선순위 | 작업 | 이유 | +|----------|------|------| +| **1 (긴급)** | Phase 1: 기존 렌더링 버그 수정 | 현재 화면 자체가 정상 동작하지 않음 | +| **2 (높음)** | Phase 2-1, 2-2: 수주/재고 조회 API | 좌측 패널의 핵심 데이터 | +| **3 (높음)** | Phase 2-3: 자동 스케줄 생성 API | 우측 패널의 핵심 기능 | +| **4 (중간)** | Phase 3: layout_data 보강 | 안전재고 탭, 반제품 탭, 모달 | +| **5 (중간)** | Phase 2-4~2-7: 나머지 API | 병합, 분할, 반제품 계획 | +| **6 (낮음)** | Phase 4: 2레벨 트리 테이블 확장 | 현재 단순 그룹핑으로도 기본 동작 | + +--- + +## 부록 A: HTML 예시의 모달 목록 + +| 모달명 | HTML ID | 용도 | +|--------|---------|------| +| 스케줄 상세 모달 | `scheduleModal` | 스케줄 기본정보/근거정보/생산정보/계획기간/분할/설비할당/상태/추가정보 | +| 수주 불러오기 모달 | `orderImportModal` | 선택 품목 목록 + 추가방식 선택 (기존추가/별도생성) | +| 안전재고 불러오기 모달 | `stockImportModal` | 부족 품목 목록 + 추가방식 선택 | +| 설비 선택 모달 | `equipmentSelectModal` | 설비 카드 + 수량할당 + 일정등록 | +| 변경사항 확인 모달 | `changeConfirmModal` | 자동스케줄 생성 결과 요약 + 상세 비교 | + +## 부록 B: HTML 예시의 JS 핵심 함수 목록 + +| 함수명 | 기능 | 매핑 API | +|--------|------|----------| +| `generateSchedule()` | 자동 스케줄 생성 (품목별 합산) | POST /api/production/generate-schedule | +| `saveSchedule()` | 스케줄 저장 (localStorage → DB) | POST /api/production/plan (bulk) | +| `mergeSelectedSchedules()` | 선택 계획 병합 | POST /api/production/merge-schedules | +| `generateSemiFromSelected()` | 반제품 계획 자동 생성 | POST /api/production/generate-semi-schedule | +| `saveScheduleFromModal()` | 모달에서 스케줄 저장 | PUT /api/production/plan/:id | +| `deleteScheduleFromModal()` | 모달에서 스케줄 삭제 | DELETE /api/production/plan/:id | +| `openOrderImportModal()` | 수주 불러오기 모달 열기 | - (프론트엔드 UI) | +| `importOrderItems()` | 수주 품목 불러오기 실행 | POST /api/production/generate-schedule | +| `openStockImportModal()` | 안전재고 불러오기 모달 열기 | - (프론트엔드 UI) | +| `importStockItems()` | 안전재고 품목 불러오기 실행 | POST /api/production/generate-schedule | +| `refreshOrderList()` | 수주 목록 새로고침 | GET /api/production/order-summary | +| `refreshStockList()` | 재고 부족 목록 새로고침 | GET /api/production/stock-shortage | +| `switchTab(tabName)` | 좌측 탭 전환 | - (프론트엔드 UI) | +| `switchTimelineTab(tabName)` | 우측 탭 전환 | - (프론트엔드 UI) | +| `toggleOrderDetails(itemGroup)` | 품목 그룹 펼치기/접기 | - (프론트엔드 UI) | +| `renderTimeline()` | 완제품 타임라인 렌더링 | - (프론트엔드 UI) | +| `renderSemiTimeline()` | 반제품 타임라인 렌더링 | - (프론트엔드 UI) | +| `executeSplit()` | 계획 분할 실행 | POST /api/production/split-schedule | +| `openEquipmentSelectModal()` | 설비 선택 모달 열기 | GET /api/equipment (기존) | +| `saveEquipmentSelection()` | 설비 할당 저장 | PUT /api/production/plan/:id | +| `applyScheduleChanges()` | 변경사항 확인 후 적용 | - (프론트엔드 상태 관리) | + +## 부록 C: 수주 데이터 테이블 컬럼 상세 + +### 그룹 행 (품목별 집계) + +| # | 컬럼 | 데이터 소스 | 정렬 | +|---|------|-------------|------| +| 1 | 체크박스 | - | center | +| 2 | 토글 (펼치기/접기) | - | center | +| 3 | 품목코드 | `sales_order_mng.part_code` (GROUP BY) | left | +| 4 | 품목명 | `sales_order_mng.part_name` | left | +| 5 | 총수주량 | `SUM(order_qty)` | right | +| 6 | 출고량 | `SUM(ship_qty)` | right | +| 7 | 잔량 | `SUM(balance_qty)` | right | +| 8 | 현재고 | `inventory_stock.current_qty` (JOIN) | right | +| 9 | 안전재고 | `inventory_stock.safety_qty` (JOIN) | right | +| 10 | 출하계획량 | `SUM(plan_ship_qty)` | right | +| 11 | 기생산계획량 | `production_plan_mng` 조회 (JOIN) | right | +| 12 | 생산진행 | `production_plan_mng` (status='in_progress') 조회 | right | +| 13 | 필요생산계획 | 계산값 (잔량+안전재고-현재고-기생산계획량-생산진행) | right, 빨간색 강조 | + +### 상세 행 (개별 수주) + +| # | 컬럼 | 데이터 소스 | +|---|------|-------------| +| 1 | (빈 칸) | - | +| 2 | (빈 칸) | - | +| 3-4 | 수주번호, 거래처, 상태배지 | `order_no`, `partner_id` → partner_name, `status` | +| 5 | 수주량 | `order_qty` | +| 6 | 출고량 | `ship_qty` | +| 7 | 잔량 | `balance_qty` | +| 8-13 | 납기일 (colspan) | `due_date` | + +## 부록 D: 타임라인 스케줄러 필드 매핑 + +### 완제품 타임라인 + +| 타임라인 필드 | production_plan_mng 컬럼 | 비고 | +|--------------|--------------------------|------| +| `id` | `id` | PK | +| `resourceId` | `item_code` | 품목 기준 리소스 (설비 기준이 아님) | +| `title` | `item_name` + `plan_qty` | 표시 텍스트 | +| `startDate` | `start_date` | 시작일 | +| `endDate` | `end_date` | 종료일 | +| `status` | `status` | planned/in_progress/completed/work-order | +| `progress` | `progress_rate` | 진행률(%) | + +### 반제품 타임라인 + +동일 구조, 단 `product_type = '반제품'` 필터 적용 + +### statusColors 매핑 + +| 상태 | 색상 | 의미 | +|------|------|------| +| `planned` | `#3b82f6` (파란색) | 계획됨 | +| `work-order` | `#f59e0b` (노란색) | 작업지시 | +| `in_progress` | `#10b981` (초록색) | 진행중 | +| `completed` | `#6b7280` (회색, 반투명) | 완료 | +| `delayed` | `#ef4444` (빨간색) | 지연 | diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx index 8fbe5e95..747d4640 100644 --- a/frontend/app/(main)/admin/audit-log/page.tsx +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record< NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" }, USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" }, ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, - PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" }, COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" }, CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" }, TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" }, NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" }, - BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" }, }; const ACTION_CONFIG: Record = { @@ -817,7 +815,7 @@ export default function AuditLogPage() { {entry.company_code && entry.company_code !== "*" && ( - [{entry.company_code}] + [{entry.company_name || entry.company_code}] )} @@ -862,9 +860,11 @@ export default function AuditLogPage() {
-

{selectedEntry.company_code}

+

+ {selectedEntry.company_name || selectedEntry.company_code} +