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/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/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 a3887ab8..9d05a1b7 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, @@ -521,6 +524,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 }); @@ -535,10 +588,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/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/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/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/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/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/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} +