From eacfe60f89557a2f063960123491f0ed55aa73ab Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Mar 2026 17:05:36 +0900 Subject: [PATCH] =?UTF-8?q?WIP:=20preset=20+=20inspection=20(=EC=9E=84?= =?UTF-8?q?=EC=8B=9C,=20=EB=82=98=EC=A4=91=EC=97=90=20squash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/routes/inspectionResultRoutes.ts | 191 +++++ docs/MES_구조_및_PC_POP_연동_가이드.md | 763 ++++++++++++++++++ docs/POP_화면_배포서버_마이그레이션_가이드.md | 603 ++++++++++++++ .../pop/viewer/PopViewerWithModals.tsx | 12 +- .../pop-card-list/InspectionModal.tsx | 481 +++++++++++ .../pop-card-list/PopCardListComponent.tsx | 91 ++- .../pop-card-list/PopCardListConfig.tsx | 290 +++++++ frontend/lib/registry/pop-components/types.ts | 28 + 9 files changed, 2454 insertions(+), 7 deletions(-) create mode 100644 backend-node/src/routes/inspectionResultRoutes.ts create mode 100644 docs/MES_구조_및_PC_POP_연동_가이드.md create mode 100644 docs/POP_화면_배포서버_마이그레이션_가이드.md create mode 100644 frontend/lib/registry/pop-components/pop-card-list/InspectionModal.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37d337a4..fdf5be40 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -127,6 +127,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) +import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -287,6 +288,7 @@ 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/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/routes/inspectionResultRoutes.ts b/backend-node/src/routes/inspectionResultRoutes.ts new file mode 100644 index 00000000..1b2c7cde --- /dev/null +++ b/backend-node/src/routes/inspectionResultRoutes.ts @@ -0,0 +1,191 @@ +import { Router, Request, Response } from "express"; +import { getPool } from "../database/db"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +router.use(authenticateToken); + +// ---- 검사 기준 조회 (item_inspection_info) ---- +// GET /api/pop/inspection-result/info?itemCode=ITEM-001&inspectionType=입고검사 +router.get("/info", async (req: Request, res: Response) => { + const pool = getPool(); + const companyCode = (req as any).user?.companyCode; + const { itemCode, itemId, inspectionType } = req.query; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const conditions: string[] = ["company_code = $1", "is_active = 'Y'"]; + const params: unknown[] = [companyCode]; + let idx = 2; + + if (itemCode) { + conditions.push(`item_code = $${idx++}`); + params.push(itemCode); + } + if (itemId) { + conditions.push(`item_id = $${idx++}`); + params.push(itemId); + } + if (inspectionType) { + conditions.push(`inspection_type = $${idx++}`); + params.push(inspectionType); + } + + const sql = ` + SELECT id, item_id, item_code, item_name, + inspection_type, inspection_item_name, inspection_standard, + inspection_method, pass_criteria, is_required, sort_order, memo + FROM item_inspection_info + WHERE ${conditions.join(" AND ")} + ORDER BY sort_order, inspection_item_name + `; + + try { + const result = await pool.query(sql, params); + return res.json({ success: true, data: result.rows }); + } catch (err: any) { + return res.status(500).json({ success: false, message: err.message }); + } +}); + +// ---- 검사 결과 조회 ---- +// GET /api/pop/inspection-result?referenceId=xxx&referenceTable=yyy&screenId=zzz +router.get("/", async (req: Request, res: Response) => { + const pool = getPool(); + const companyCode = (req as any).user?.companyCode; + const { referenceId, referenceTable, screenId } = req.query; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const conditions: string[] = ["company_code = $1"]; + const params: unknown[] = [companyCode]; + let idx = 2; + + if (referenceId) { + conditions.push(`reference_id = $${idx++}`); + params.push(referenceId); + } + if (referenceTable) { + conditions.push(`reference_table = $${idx++}`); + params.push(referenceTable); + } + if (screenId) { + conditions.push(`screen_id = $${idx++}`); + params.push(screenId); + } + + const sql = ` + SELECT * + FROM inspection_result + WHERE ${conditions.join(" AND ")} + ORDER BY created_date DESC + `; + + try { + const result = await pool.query(sql, params); + return res.json({ success: true, data: result.rows }); + } catch (err: any) { + return res.status(500).json({ success: false, message: err.message }); + } +}); + +// ---- 검사 결과 저장 (INSERT or UPDATE) ---- +// POST /api/pop/inspection-result +router.post("/", async (req: Request, res: Response) => { + const pool = getPool(); + const companyCode = (req as any).user?.companyCode; + const writer = (req as any).user?.userId; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { + referenceTable, + referenceId, + screenId, + itemId, + itemCode, + itemName, + inspectionType, + items, // 검사 항목별 결과 배열 + overallJudgment, + memo, + isCompleted, + } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "검사 항목이 없습니다" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기) + if (referenceId && referenceTable) { + await client.query( + `DELETE FROM inspection_result + WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`, + [companyCode, referenceId, referenceTable] + ); + } + + const insertedIds: string[] = []; + for (const item of items) { + const completedFlag = isCompleted ? "Y" : "N"; + const completedDate = isCompleted ? new Date() : null; + const insertSql = ` + INSERT INTO inspection_result ( + company_code, writer, + reference_table, reference_id, screen_id, + inspection_info_id, item_id, item_code, item_name, + inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required, + measured_value, judgment, overall_judgment, memo, + is_completed, completed_date + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 + ) + RETURNING id + `; + const result = await client.query(insertSql, [ + companyCode, + writer, + referenceTable || null, + referenceId || null, + screenId || null, + item.inspectionInfoId || null, + itemId || item.itemId || null, + itemCode || item.itemCode || null, + itemName || item.itemName || null, + inspectionType || item.inspectionType || null, + item.inspectionItemName || null, + item.inspectionStandard || null, + item.passCriteria || null, + item.isRequired || "Y", + item.measuredValue || null, + item.judgment || null, + overallJudgment || null, + memo || null, + completedFlag, + completedDate, + ]); + insertedIds.push(result.rows[0].id); + } + + await client.query("COMMIT"); + return res.json({ success: true, data: { ids: insertedIds } }); + } catch (err: any) { + await client.query("ROLLBACK"); + return res.status(500).json({ success: false, message: err.message }); + } finally { + client.release(); + } +}); + +export default router; diff --git a/docs/MES_구조_및_PC_POP_연동_가이드.md b/docs/MES_구조_및_PC_POP_연동_가이드.md new file mode 100644 index 00000000..9e2fac45 --- /dev/null +++ b/docs/MES_구조_및_PC_POP_연동_가이드.md @@ -0,0 +1,763 @@ +# MES 구조 및 PC-POP 연동 가이드 + +> 작성일: 2026-03-20 +> 대상: PC 화면 개발자 / POP 연동 담당자 +> 목적: PC에서 작업지시를 등록할 때, POP(생산실적관리)에 공정이 자동 연동되는 전체 구조를 설명 + +--- + +## 1. 전체 구조 개요 + +### 1.1 시스템 구성 + +``` +[PC 영역 - 브라우저] [POP 영역 - 태블릿] + 작업지시 등록 화면 생산실적관리 화면 (4480) + (screen 4155, 4493) 카드 리스트 + 상세 모달 + | | + | (1) work_instruction INSERT | (3) 카드 리스트 조회 + | (2) create-work-processes 호출 | (4) 접수/실적/확정 + v v +================================================================= + [백엔드 - Express + PostgreSQL] + /api/data/work_instruction (범용 CRUD) + /api/pop/production/* (MES 전용 API 10개) +================================================================= +``` + +### 1.2 데이터 흐름 요약 + +``` +PC 등록 서버 자동 처리 POP 표시 +--------- ---------------- ---------- +1. 품목 선택 3. 라우팅 공정 조회 5. 카드 목록 +2. 작업지시 INSERT ---> 4. work_order_process 6. 접수 + + create-work-processes N건 일괄 INSERT ---> 7. 실적 입력 + API 호출 + process_work_result 8. 완료 확정 + 체크리스트 복사 +``` + +--- + +## 2. DB 테이블 상세 구조 + +### 2.1 테이블 관계도 + +``` +[마스터 데이터 - PC에서 사전 등록] + + item_info process_mng defect_standard_mng + (품목 마스터) (공정 마스터) (불량 유형 마스터) + | | + v v + item_routing_version -----> item_routing_detail + (품목별 라우팅 버전) (공정 순서 정의) + | + v + process_work_item -----> process_work_item_detail + (공정별 작업항목) (체크리스트 상세) + + +[트랜잭션 데이터 - PC 등록 + POP 실행] + + work_instruction ─── 1:N ──> work_order_process ─── 1:N ──> process_work_result + (작업지시 마스터) (공정별 작업 단위) (체크리스트 결과) + | + parent_process_id (자기참조) + | + work_order_process (분할 행) + (접수/재작업 분할 카드) + | + work_order_process_log + (변경 이력 - 트리거 자동) +``` + +### 2.2 work_instruction (작업지시 마스터) - 19컬럼 + +> PC에서 등록하는 핵심 테이블. POP에서는 읽기 전용으로 참조. + +| 컬럼 | 타입 | 기본값 | 역할 | 비고 | +|------|------|--------|------|------| +| `id` | varchar | gen_random_uuid() | PK | UUID 자동 생성 | +| `work_instruction_no` | varchar | - | 작업지시번호 | 사용자 채번 (예: WI-20260320-001) | +| `item_id` | varchar | - | 품목 FK | -> item_info.id | +| `status` | varchar | - | 작업지시 상태 | waiting / in_progress / completed / cancelled | +| `progress_status` | varchar | - | 진행상태 | POP에서 완료 시 'completed'로 자동 갱신 | +| `qty` | varchar | - | 지시수량 | 핵심. POP 접수 상한의 기준 | +| `completed_qty` | varchar | '0' | 완성수량 | 마지막 공정 양품 합계로 자동 갱신 | +| `routing` | varchar | - | 라우팅 참조 | 현재 미사용 (비어있음) | +| `worker` | varchar | - | 작업자 | | +| `work_team` | varchar | - | 작업팀 | | +| `equipment_id` | varchar | - | 설비 FK | | +| `start_date` | varchar | - | 시작일 | | +| `end_date` | varchar | - | 종료일(납기) | | +| `reason` | varchar | - | 사유 | | +| `remark` | text | - | 비고 | | +| `company_code` | varchar | - | 멀티테넌시 | 필수 | +| `created_date` | timestamp | now() | 생성일 | | +| `updated_date` | timestamp | now() | 수정일 | | +| `writer` | varchar | - | 작성자 | | + +### 2.3 work_order_process (공정별 작업 단위) - 37컬럼 + +> create-work-processes API 호출 시 자동 생성. POP에서 접수/실적/완료를 처리하는 핵심 테이블. + +| 컬럼 그룹 | 컬럼 | 타입 | 기본값 | 역할 | +|-----------|------|------|--------|------| +| **연결** | `wo_id` | varchar | - | -> work_instruction.id (작업지시 FK) | +| | `seq_no` | varchar | - | 공정 순서 (1, 2, 3...) | +| | `routing_detail_id` | varchar | - | -> item_routing_detail.id (라우팅 스냅샷) | +| | `parent_process_id` | varchar | NULL | 분할 시 마스터 행 참조 (NULL = 마스터) | +| **공정정보** | `process_code` | varchar | - | 공정코드 (예: P002) | +| | `process_name` | varchar | - | 공정명 (예: 가공) | +| | `is_required` | varchar | - | 필수 여부 | +| | `is_fixed_order` | varchar | - | 순서 고정 여부 | +| | `standard_time` | varchar | - | 표준시간 | +| | `equipment_code` | varchar | - | 사용 설비 | +| **수량** | `plan_qty` | varchar | - | 계획수량 | +| | `input_qty` | varchar | - | 접수량 (접수 시 설정) | +| | `good_qty` | varchar | - | 양품수량 (누적) | +| | `defect_qty` | varchar | - | 불량수량 (누적) | +| | `total_production_qty` | varchar | - | 총 생산수량 (누적) | +| | `concession_qty` | varchar | '0' | 특채수량 (양품에 합산 + 별도 추적) | +| **상태** | `status` | varchar | - | waiting / acceptable / in_progress / completed | +| | `result_status` | varchar | 'draft' | draft / confirmed | +| **타이머** | `started_at` | varchar | - | 작업 시작 시각 | +| | `paused_at` | varchar | - | 일시정지 시각 | +| | `total_paused_time` | varchar | 0 | 누적 일시정지 시간(초) | +| | `completed_at` | varchar | - | 완료 시각 | +| | `actual_work_time` | varchar | NULL | 실 작업시간(초) | +| **작업자** | `accepted_by` | varchar | - | 접수자 | +| | `accepted_at` | varchar | - | 접수 시각 | +| | `completed_by` | varchar | NULL | 완료 처리자 | +| **실적** | `defect_detail` | varchar | - | 불량 상세 JSON (코드/수량/처분) | +| | `result_note` | varchar | - | 실적 메모 | +| | `attachments` | varchar | - | 첨부파일 | +| **재작업** | `is_rework` | varchar | 'N' | 재작업 카드 여부 (Y/N) | +| | `rework_source_id` | varchar | NULL | 재작업 원본 행 참조 | +| **표준** | `company_code`, `created_date`, `updated_date`, `writer`, `remark` | - | - | 표준 컬럼 | + +### 2.4 process_work_result (체크리스트/검사 결과) - 35컬럼 + +> 공정별 체크리스트. create-work-processes 시 마스터 템플릿에서 복사. + +| 컬럼 그룹 | 컬럼 | 역할 | +|-----------|------|------| +| **연결** | `work_order_process_id` | -> work_order_process.id | +| | `source_work_item_id` | -> process_work_item.id (템플릿 원본) | +| | `source_detail_id` | -> process_work_item_detail.id (템플릿 상세) | +| **작업항목** | `work_phase` | 작업 단계 (PRE/IN/POST) | +| | `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` | 소요시간(분) | +| **결과** | `result_value` | 입력 결과값 | +| | `is_passed` | 합격 여부 | +| | `status` | pending / completed | +| | `recorded_by` | 기록자 | +| | `recorded_at` | 기록 시각 | +| **그룹 타이머** | `group_started_at`, `group_paused_at` | 그룹 시작/정지 | +| | `group_total_paused_time`, `group_completed_at` | 누적 정지/완료 | + +### 2.5 마스터 데이터 테이블 + +#### item_info (품목 마스터) + +| 주요 컬럼 | 역할 | +|-----------|------| +| `id` | PK (UUID) | +| `item_number` | 품목코드 (라우팅 연결 키) | +| `item_name` | 품목명 | +| `type` | 품목 유형 | +| `division` | 구분 | + +#### process_mng (공정 마스터) + +| 주요 컬럼 | 역할 | +|-----------|------| +| `process_code` | 공정코드 (PK 역할) | +| `process_name` | 공정명 (예: 가공, 검사, 포장) | +| `process_type` | 공정 유형 | +| `use_yn` | 사용 여부 | + +#### item_routing_version (품목별 라우팅 버전) + +| 주요 컬럼 | 역할 | +|-----------|------| +| `id` | PK (UUID) | +| `item_code` | -> item_info.item_number | +| `version_name` | 버전명 (예: 기본 라우팅, v1) | +| `is_default` | 기본 버전 여부 (boolean) | + +#### item_routing_detail (공정 순서 정의) + +| 주요 컬럼 | 역할 | +|-----------|------| +| `id` | PK (UUID) | +| `routing_version_id` | -> item_routing_version.id | +| `seq_no` | 공정 순서 (1, 2, 3...) | +| `process_code` | -> process_mng.process_code | +| `is_required` | 필수 여부 | +| `is_fixed_order` | 순서 고정 여부 | +| `standard_time` | 표준시간 | + +--- + +## 3. PC에서 작업지시 등록 -> POP 연동 흐름 + +### 3.1 전체 시퀀스 다이어그램 + +``` +[PC 사용자] [프론트엔드] [백엔드] [DB] + | | | | + | 1. 품목 선택 | | | + |------------------>| | | + | | 2. 라우팅 버전 조회 | | + | |--- GET /api/data/ | | + | | item_routing_ |--- SELECT | + | | version | item_routing_ | + | | ?item_code=XXX ->| version -->| + | |<-------------------|<----------------------| + | | | | + | 3. 정보 입력 | | | + | (수량/납기/etc) | | | + |------------------>| | | + | | | | + | 4. "등록" 클릭 | | | + |------------------>| | | + | | 5. 작업지시 INSERT | | + | |--- POST /api/data/ | | + | | work_instruction |--- INSERT | + | | --->| work_instruction -->| + | |<-------------------|<-- RETURNING id ------| + | | | | + | | 6. 공정 일괄 생성 | | + | |--- POST /api/pop/ | | + | | production/ | | + | | create-work- | | + | | processes -->| | + | | |-- SELECT item_routing | + | | | _detail + process_mng| + | | |<----------------------| + | | | | + | | |-- FOR EACH 공정: | + | | | INSERT work_order_ | + | | | process -->| + | | | INSERT process_work_ | + | | | result (체크리스트)->| + | | | | + | |<-- 성공 응답 ------| | + |<-- 등록 완료 -----| | | + | | | | + | | [이 시점부터 POP에서 조회 가능] | +``` + +### 3.2 Step 1: 작업지시 INSERT (필수) + +**API**: `POST /api/data/work_instruction` + +**필수 데이터**: + +```json +{ + "item_id": "품목 UUID (item_info.id)", + "qty": "지시수량 (예: 500)", + "status": "waiting", + "work_instruction_no": "작업지시번호 (예: WI-20260320-001)" +} +``` + +**선택 데이터**: + +```json +{ + "worker": "작업자", + "work_team": "작업팀", + "equipment_id": "설비 UUID", + "start_date": "시작일", + "end_date": "종료일(납기)", + "remark": "비고" +} +``` + +**응답 예시**: + +```json +{ + "success": true, + "data": { + "id": "a1b2c3d4-...", + "work_instruction_no": "WI-20260320-001", + "status": "waiting" + } +} +``` + +> 이 시점에서는 work_instruction 행만 생성되고, POP에서 공정 카드가 표시되지 않는다. + +### 3.3 Step 2: 공정 일괄 생성 (핵심 - 반드시 호출해야 POP 연동됨) + +**API**: `POST /api/pop/production/create-work-processes` + +**인증**: JWT 토큰 필수 (Authorization 헤더) + +**필수 파라미터**: + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `work_instruction_id` | string | Step 1에서 받은 작업지시 ID | +| `routing_version_id` | string | 선택한 라우팅 버전 UUID | + +**선택 파라미터**: + +| 파라미터 | 타입 | 설명 | +|---------|------|------| +| `item_code` | string | 품목코드 (참고용) | +| `plan_qty` | string | 계획수량 (work_order_process.plan_qty에 저장) | + +**요청 예시**: + +```json +{ + "work_instruction_id": "a1b2c3d4-...작업지시ID", + "routing_version_id": "e5f6g7h8-...라우팅버전ID", + "plan_qty": "500" +} +``` + +**서버 내부 동작**: + +1. **중복 방지**: 해당 work_instruction_id로 이미 공정이 있으면 409 에러 +2. **라우팅 조회**: `item_routing_detail` + `process_mng` JOIN으로 공정 목록 취득 +3. **공정별 INSERT**: seq_no 순서대로 work_order_process 행 생성 + - 1공정: `status = 'acceptable'` (POP에서 즉시 접수 가능) + - 2~N공정: `status = 'waiting'` (앞공정 완료 대기) +4. **체크리스트 복사**: 각 공정의 `routing_detail_id`에 연결된 `process_work_item` + `process_work_item_detail`을 `process_work_result`로 복사 + +**응답 예시**: + +```json +{ + "success": true, + "data": { + "processes": [ + { "id": "uuid-1", "seq_no": "1", "process_name": "가공", "checklist_count": 3 }, + { "id": "uuid-2", "seq_no": "2", "process_name": "검사", "checklist_count": 5 }, + { "id": "uuid-3", "seq_no": "3", "process_name": "포장", "checklist_count": 2 } + ], + "total_processes": 3, + "total_checklists": 10 + } +} +``` + +**에러 케이스**: + +| HTTP | 상황 | 메시지 | +|------|------|--------| +| 400 | 필수 파라미터 누락 | "work_instruction_id와 routing_version_id는 필수입니다." | +| 409 | 이미 공정이 존재 | "이미 공정이 생성된 작업지시입니다." | +| 404 | 라우팅에 공정 없음 | "라우팅 버전에 등록된 공정이 없습니다." | + +### 3.4 Step 1 + Step 2를 하나의 트랜잭션으로 묶는 방법 + +> PC 화면에서 "등록" 버튼 1회 클릭으로 두 API를 순차 호출해야 한다. + +```javascript +// PC 프론트엔드 예시 코드 +async function registerWorkInstruction(formData) { + // Step 1: 작업지시 INSERT + const wiResponse = await apiClient.post("/api/data/work_instruction", { + item_id: formData.itemId, + qty: formData.qty, + status: "waiting", + work_instruction_no: formData.wiNo, + worker: formData.worker, + start_date: formData.startDate, + end_date: formData.endDate, + }); + + if (!wiResponse.data.success) { + throw new Error("작업지시 등록 실패: " + wiResponse.data.message); + } + + const workInstructionId = wiResponse.data.data.id; + + // Step 2: 공정 일괄 생성 (이것이 POP 연동의 핵심) + const processResponse = await apiClient.post( + "/api/pop/production/create-work-processes", + { + work_instruction_id: workInstructionId, + routing_version_id: formData.routingVersionId, + plan_qty: formData.qty, + } + ); + + if (!processResponse.data.success) { + // 실패 시 작업지시도 삭제 또는 상태 변경 필요 + throw new Error("공정 생성 실패: " + processResponse.data.message); + } + + return { + workInstruction: wiResponse.data.data, + processes: processResponse.data.data.processes, + }; +} +``` + +### 3.5 PC 화면에서 필요한 사전 데이터 조회 + +#### 3.5.1 품목 목록 조회 + +``` +GET /api/data/item_info +``` + +#### 3.5.2 선택한 품목의 라우팅 버전 목록 조회 + +``` +GET /api/data/item_routing_version?item_code={선택한 품목의 item_number} +``` + +> `is_default = true`인 버전을 자동 선택하면 UX가 좋다. + +#### 3.5.3 라우팅 버전의 공정 목록 미리보기 (선택사항) + +``` +GET /api/data/item_routing_detail?routing_version_id={선택한 버전 ID} +``` + +> 등록 전에 사용자에게 "이 라우팅에 포함된 공정 목록"을 보여줄 수 있다. + +--- + +## 4. POP 워크플로우 상세 + +### 4.1 공정 상태 전이 다이어그램 + +``` +[PC 등록] + | + v + waiting ---------> acceptable ---------> in_progress ---------> completed + (대기중) (접수가능) (진행중) (완료) + | | | + | (접수 취소) | | (실적 저장 -> 자동완료) + |<--------------------+ | + | | (수동 확정) + | +----> completed + confirmed + | + | (앞공정에서 양품 발생 시 자동 전환) + | + waiting ---> acceptable +``` + +**상태별 의미**: + +| 상태 | 의미 | POP 탭 | 전환 조건 | +|------|------|--------|----------| +| `waiting` | 앞공정 미완료, 접수 불가 | 대기 탭 | 앞공정 양품 발생 시 -> acceptable | +| `acceptable` | 접수 가능, 작업자 대기 | 접수가능 탭 | 작업자가 접수 시 -> in_progress (분할 행) | +| `in_progress` | 작업 진행 중 | 진행 탭 | 실적 전량 생산 시 -> completed (자동) | +| `completed` | 작업 완료 | 완료 탭 | 수동 확정 또는 자동 완료 | + +### 4.2 수량 계산 공식 + +``` +접수가능량 = 앞공정.양품합계 - 내공정.접수합계 + = (앞공정 SUM(good_qty + concession_qty)) - (내공정 분할행 SUM(input_qty)) + (1공정은 앞공정.양품합계 = work_instruction.qty) + +양품 = 생산수량 - 불량수량 + = production_qty - defect_qty + (서버에서 계산, 클라이언트 값은 참고만) + +특채(concession) = 불량 중 disposition='accept'인 항목 합계 + 양품에는 포함되지 않으나, 다음 공정 전달량에 합산 + +자동완료 조건: + - 분할 행: total_production_qty >= input_qty + - 마스터 행: 모든 분할 행 completed + 잔여 접수가능 <= 0 + - 작업지시: 마지막 seq_no의 모든 행 completed +``` + +### 4.3 접수(Accept) 상세 + +``` +[접수가능 카드 클릭] -> [수량 입력 모달] -> [접수 확인] + | + v + POST /api/pop/production/accept-process + body: { work_order_process_id: 마스터행ID, + accept_qty: 접수수량 } + | + v + [분할 행 INSERT] + - parent_process_id = 마스터행ID + - input_qty = 접수수량 + - status = 'in_progress' + - accepted_by = 로그인 사용자 + - 체크리스트 복사 + | + v + [POP 카드 갱신] + - 새 분할 카드가 "진행중" 탭에 표시 + - 잔여 접수가능량이 남으면 마스터 카드도 "접수가능"에 유지 +``` + +### 4.4 실적 저장(Save Result) 상세 + +``` +[진행중 카드 클릭] -> [상세 모달 열림] -> [실적 입력] + | + 생산수량 + 불량상세(코드/수량/처분) 입력 + | + v + POST /api/pop/production/save-result + body: { work_order_process_id: 분할행ID, + production_qty: 생산수량, + defect_detail: [ + { defect_code, defect_name, qty, disposition } + ] } + | + v + [서버 처리] + 1. 양품/불량/특채 서버 계산 + 2. 기존 수량에 누적 (total_production_qty += production_qty) + 3. 불량 상세 JSON 병합 + 4. disposition='rework' -> 재작업 카드 자동 생성 + 5. 양품 발생 -> 다음 공정 마스터 acceptable로 전환 + 6. 접수분 전량 생산 -> 분할 행 자동 completed + 7. 모든 분할 행 완료 + 잔여 0 -> 마스터 자동 completed + 8. 마지막 공정 전부 완료 -> work_instruction 완료 +``` + +**불량 처분(disposition) 3종**: + +| 처분 | 의미 | 수량 영향 | +|------|------|----------| +| `scrap` (폐기) | 폐기 처리 | 불량 집계에 포함, 양품에서 차감 | +| `rework` (재작업) | 같은 공정에서 재작업 | 재작업 카드 자동 생성, 양품에서 차감 | +| `accept` (특채) | 조건부 합격 | concession_qty에 기록, 다음 공정 전달량에 합산 | + +### 4.5 확정(Confirm Result) 상세 + +``` +수동 확정 = 접수분 전량 미생산이지만 강제로 완료 처리 + (예: 생산 중단, 일부만 완료 등) + +POST /api/pop/production/confirm-result +body: { work_order_process_id: 분할행ID } + +결과: +- status = 'completed' +- result_status = 'confirmed' +- 양품 있으면 다음 공정 활성화 +- 마스터/작업지시 캐스케이드 완료 판정 +``` + +--- + +## 5. MES 전용 API 엔드포인트 정리 + +**베이스 URL**: `/api/pop/production` +**인증**: 모든 엔드포인트에 JWT 토큰 필수 + +| 순서 | 메서드 | URL | 용도 | 호출 주체 | +|------|--------|-----|------|----------| +| 1 | POST | `/create-work-processes` | 작업지시에 공정 일괄 생성 | PC | +| 2 | POST | `/accept-process` | 공정 접수 (분할 행 생성) | POP | +| 3 | POST | `/cancel-accept` | 접수 취소 | POP | +| 4 | POST | `/save-result` | 실적 저장 (누적) | POP | +| 5 | POST | `/confirm-result` | 실적 확정 (수동 완료) | POP | +| 6 | GET | `/available-qty` | 접수가능량 조회 | POP | +| 7 | GET | `/result-history` | 차수별 실적 이력 | POP | +| 8 | GET | `/defect-types` | 불량 유형 목록 | POP | +| 9 | POST | `/timer` | 공정 타이머 (시작/정지/완료) | POP | +| 10 | POST | `/group-timer` | 그룹 타이머 (체크리스트별) | POP | + +--- + +## 6. PC 화면 현황 (COMPANY_7 탑씰) + +### 6.1 등록된 화면 목록 + +| 화면 ID | 이름 | 메인 테이블 | 용도 | +|---------|------|------------|------| +| 4155 | 작업지시 목록 | work_instruction | 목록 조회 | +| 4493 | 작업지시 등록화면 | work_instruction | 신규 등록 | +| 4156 | 수주 선택 | sales_order_detail | 모달 (수주 참조) | +| 4157 | 적용 확인 | work_instruction | 모달 (등록 확인) | + +### 6.2 메뉴 구조 + +``` +생산관리 (COMPANY_7) + ├── 생산옵션설정 (/screens/1606) + ├── 생산계획 (/screens/3985) + ├── 작업지시 (/production/work-instruction) <-- React 페이지 미구현 + ├── 공정정보관리 (/production/process-info) + ├── 생산실적 (하위 없음) + └── 생산리포트 (/admin/report/production) +``` + +### 6.3 현재 미구현 사항 (PC 개발 필요) + +1. **라우팅 버전 선택 UI**: 품목 선택 시 해당 품목의 라우팅 버전 목록을 표시하고 선택하는 기능 +2. **create-work-processes 호출 연동**: 작업지시 등록 시 자동으로 공정 생성 API를 호출하는 로직 +3. **work_instruction.routing 컬럼 활용**: 현재 비어있음. routing_version_id를 저장하면 추적 가능 +4. **작업지시 상태 관리**: 등록/수정/취소 워크플로우 + +--- + +## 7. 마스터 데이터 사전 등록 요건 + +> 작업지시 등록 전에 다음 마스터 데이터가 반드시 등록되어 있어야 한다. + +### 7.1 필수 사전 등록 순서 + +``` +1. process_mng (공정 마스터) 등록 + 예: P002=가공, P003=검사, P009=포장 + +2. item_info (품목 마스터) 등록 + 예: item_number=R_FREE3_002, item_name=원제_AK1000 + +3. item_routing_version (라우팅 버전) 등록 + item_code = item_info.item_number + is_default = true (기본 버전) + +4. item_routing_detail (공정 순서) 등록 + routing_version_id = 위에서 만든 버전 ID + seq_no = 1, process_code = P002 (1공정: 가공) + seq_no = 2, process_code = P003 (2공정: 검사) + seq_no = 3, process_code = P009 (3공정: 포장) + +5. (선택) process_work_item + detail (체크리스트 템플릿) + routing_detail_id = 위의 item_routing_detail.id +``` + +### 7.2 현재 COMPANY_7 데이터 현황 + +**등록된 공정 (15개)**: + +| 공정코드 | 공정명 | 유형 | +|---------|--------|------| +| P002 | 가공 | PT001 | +| P003 | 검사 | PT003 | +| P005 | 치수검사 | PT004 | +| P006 | 테스트 | PT001 | +| P007 | 인쇄 | PT006 | +| P008 | 조립 | PT002 | +| P009 | 포장 | PT004 | +| PRC-001 ~ PRC-006 | 검수/가공/조립/검사/포장 | 다양 | +| PROC-001 | 확인 | 세척 | + +**등록된 라우팅 버전 (24건)**: 다양한 품목에 대해 1~3개 공정 조합 + +--- + +## 8. 데이터 예시: 전체 흐름 시뮬레이션 + +### 시나리오: 품목 R_FREE3_002를 500개 생산 + +#### Step 1: 작업지시 등록 (PC) + +```sql +-- 자동 생성되는 행 +INSERT INTO work_instruction (work_instruction_no, item_id, qty, status) +VALUES ('WI-20260320-001', 'a4e492a0-...', '500', 'waiting'); +-- id = 'wi-new-001' (UUID 자동생성) +``` + +#### Step 2: 공정 생성 API 호출 (PC -> 서버) + +``` +POST /api/pop/production/create-work-processes +{ work_instruction_id: 'wi-new-001', + routing_version_id: '5cff0c1e-...' } +``` + +```sql +-- 서버가 자동 생성하는 행 (item_routing_detail 기반) +INSERT INTO work_order_process (wo_id, seq_no, process_code, process_name, status, plan_qty) +VALUES + ('wi-new-001', '2', 'P002', '가공', 'acceptable', '500'), -- 1공정: 접수가능 + ('wi-new-001', '30', 'P006', '테스트', 'waiting', '500'); -- 2공정: 대기 +``` + +#### Step 3: POP에서 작업자가 1공정 접수 (300개) + +```sql +-- accept-process가 생성하는 분할 행 +INSERT INTO work_order_process (wo_id, seq_no, process_code, status, input_qty, + parent_process_id, accepted_by) +VALUES ('wi-new-001', '2', 'P002', 'in_progress', '300', + '마스터행ID', '작업자A'); +``` + +#### Step 4: POP에서 실적 저장 (생산 300개, 불량 10개) + +```sql +-- save-result가 UPDATE하는 행 +UPDATE work_order_process +SET total_production_qty = '300', + good_qty = '290', -- 서버 계산: 300 - 10 + defect_qty = '10', + status = 'completed' -- 자동완료: 300 >= 300(input_qty) +WHERE id = '분할행ID'; + +-- 다음 공정 자동 활성화 +UPDATE work_order_process +SET status = 'acceptable' -- waiting -> acceptable +WHERE wo_id = 'wi-new-001' AND seq_no = '30' + AND parent_process_id IS NULL; +``` + +#### Step 5: 2공정도 접수 -> 실적 -> 완료하면 + +```sql +-- 작업지시 자동 완료 +UPDATE work_instruction +SET status = 'completed', + progress_status = 'completed', + completed_qty = '280' -- 마지막 공정 양품 합계 +WHERE id = 'wi-new-001'; +``` + +--- + +## 9. 주의사항 및 제약 + +### 9.1 필수 규칙 + +1. **create-work-processes는 1회만 호출 가능**: 같은 작업지시에 대해 2번 호출하면 409 에러 +2. **routing_version_id는 필수**: 라우팅 없이는 공정을 생성할 수 없음 +3. **1공정만 즉시 접수가능**: 나머지는 앞공정 양품 발생 후 자동 전환 +4. **수량은 모두 VARCHAR**: 정수 변환 시 parseInt 필수 +5. **멀티테넌시**: 모든 쿼리에 company_code 필터 필수 +6. **분할 행 구조**: 접수 시 마스터 행에서 분할 행을 INSERT하는 방식. 마스터 행에는 직접 실적 등록 불가 + +### 9.2 현재 미활용 컬럼 + +| 컬럼 | 테이블 | 상태 | +|------|--------|------| +| `routing` | work_instruction | 비어있음 (routing_version_id 저장 권장) | +| `equipment_id` | work_instruction | 등록 가능하나 POP 연동 미구현 | +| `item_id` | work_instruction | 일부 테스트 데이터에서 비어있음 | + +### 9.3 자동 완료 판정 주의 + +- 재작업 카드가 있으면 해당 카드가 완료될 때까지 마스터 행이 완료되지 않음 +- 특채(concession_qty)는 양품에 포함되지 않으나 다음 공정 전달량에는 합산됨 +- 초과 생산은 경고만 하고 차단하지 않음 (현장 유연성) diff --git a/docs/POP_화면_배포서버_마이그레이션_가이드.md b/docs/POP_화면_배포서버_마이그레이션_가이드.md new file mode 100644 index 00000000..8711c60e --- /dev/null +++ b/docs/POP_화면_배포서버_마이그레이션_가이드.md @@ -0,0 +1,603 @@ +# POP 화면 배포서버 마이그레이션 가이드 + +> **작성일**: 2026-03-23 +> **목적**: 로컬(탑씰 COMPANY_7) POP 화면 5종을 배포서버 COMPANY_21(테스트회사)로 복사 +> **대상 화면**: 4173, 4479, 4480, 4576, 4577 +> **주의**: DB 작업 전 반드시 백업 후 진행 + +--- + +## 0. 개념 정리 (먼저 읽기) + +### PopDeployModal이란? + +POP 관리 화면 내 내장된 **화면 배포 도구**입니다. + +- **접근**: POP 디자이너 > 카테고리 트리 > 그룹 우클릭 또는 배포 버튼 +- **하는 일**: + 1. 선택한 화면들을 다른 회사 계정으로 복사 (`screen_definitions` 새 행 생성) + 2. POP 레이아웃 JSON 복사 (`screen_layouts_pop`) + 3. **layout_json 내 화면 ID 참조 자동 리매핑** (screenId, cartScreenId, sourceScreenId, targetScreenId) + 4. 카테고리 그룹 구조 생성 (`screen_groups`, `screen_group_screens`) + 5. numberingRuleId 자동 제거 (회사별 고유값이므로) +- **제약**: 같은 서버 안에서만 동작 (로컬 → 배포 서버 간 복사 불가) +- **권장 사용 시점**: 배포 DB COMPANY_7에 화면이 먼저 세팅된 후, 같은 배포 서버 내 테스트 계정으로 복사할 때 + +### 마이그레이션 전체 흐름 + +``` +[로컬 DB / COMPANY_7] ──── SQL 직접 복사 ────→ [배포 DB / COMPANY_7] + │ + PopDeployModal + (배포서버 내) + │ + ↓ + [배포 DB / COMPANY_21] + (테스트 환경) +``` + +--- + +## 1. 현황 요약 + +### 1-1. 환경 정보 + +| 구분 | 로컬 DB | 배포 DB | +|------|---------|---------| +| Host | 39.117.244.52:11132 | 211.115.91.141:11134 | +| Database | plm | plm | +| 소스 회사 | COMPANY_7 (탑씰) | - | +| 1차 타겟 | - | COMPANY_7 (탑씰, SQL 직접 삽입) | +| 2차 타겟 | - | COMPANY_21 (테스트회사, PopDeployModal) | + +### 1-2. 복사 대상 화면 + +| 화면 ID | screen_code | screen_name | 역할 | +|---------|-------------|-------------|------| +| 4479 | COMPANY_7_179 | 홈 | POP 메인 홈 화면 | +| 4576 | COMPANY_7_194 | 입고메뉴 | 입고 카테고리 메뉴 | +| 4173 | COMPANY_7_169 | 구매입고 담기 | 구매입고 항목 선택/담기 | +| 4577 | COMPANY_7_195 | 구매입고 장바구니 | 장바구니 확인/입고 확정 | +| 4480 | COMPANY_7_180 | MES공정 | MES 생산실적 관리 | + +### 1-3. POP 카테고리 구조 (screen_groups) + +``` +탑씰 (id:3134, code:TOPSSEAL) +├── 홈 #4479 +├── 입고관리 (id:3216, code:INBOUND_MENU) +│ ├── 입고메뉴 #4576 +│ └── 구매입고 (id:3221, code:PURCHASE RECEIPT) +│ ├── 구매입고 담기 #4173 +│ └── 구매입고 장바구니 #4577 +└── 생산실적 (id:3220, code:PRODUCTION RESULTS) + └── MES공정 #4480 +``` + +### 1-4. 화면 간 상호참조 (layout_json 내부) + +| 출발 화면 | 참조 방식 | 대상 화면 | JSON 키 | +|----------|----------|----------|---------| +| 4479 홈 | navigate | 4480 MES공정 | `screenId: "4480"` | +| 4479 홈 | navigate | 4576 입고메뉴 | `screenId: "4576"` | +| 4576 입고메뉴 | navigate | 4173 구매입고 | `screenId: "4173"` | +| 4173 구매입고 | cart-save | 4577 장바구니 | `cartScreenId: "4577"` | +| 4577 장바구니 | source | 4173 구매입고 | `sourceScreenId: 4173` (숫자) | +| 4577 장바구니 | navigate | 4173 구매입고 | `targetScreenId: "4173"` | + +> PopDeployModal을 사용하면 이 참조들이 모두 자동 리매핑됩니다. + +--- + +## 2. 배포 DB 현황 점검 결과 + +### 2-1. 테이블 누락 상태 + +| 테이블 | 로컬 | 배포 | MES/기능 의존도 | +|--------|------|------|----------------| +| work_order_process | O (37컬럼) | **없음** | MES공정 화면 전체 동작 불가 | +| process_work_result | O (35컬럼) | **없음** | MES 체크리스트 기능 불가 | +| work_order_process_log | O (12컬럼+트리거) | **없음** | 공정 변경 이력 로깅 불가 | +| cart_items | O (16컬럼) | **없음** | 구매입고 장바구니 전체 불가 | + +### 2-2. 컬럼 누락 상태 + +| 테이블 | 누락 컬럼 | 영향 | +|--------|----------|------| +| work_instruction | `reason`, `completed_qty` | MES 완료수량 업데이트 실패 | + +### 2-3. COMPANY_21 (테스트회사) 현황 + +| 항목 | 상태 | +|------|------| +| 회사 존재 | O (active) | +| 기존 POP 레이아웃 | 없음 (깨끗한 상태) | +| 로그인 가능 계정 | **0개 (계정 없음!)** | +| 기존 화면 수 | 23개 (일반 ERP 화면들) | + +> **중요**: COMPANY_21에는 현재 등록된 사용자가 없습니다. +> 테스트 전에 사용자 계정을 먼저 생성해야 로그인 및 POP 테스트가 가능합니다. + +### 2-4. COMPANY_7 (탑씰) 배포 서버 현황 + +- 기존 POP 레이아웃: `screen_id 4114` (테스트용 1개만 존재) +- 로그인 계정: `topseal_admin`, `topseal_admin2`, `topseal_user`, `test1`, `test2` +- 5개 화면(4173, 4479, 4480, 4576, 4577) 모두 배포 DB에 없음 → 안전하게 삽입 가능 + +--- + +## 3. 누락 테이블/컬럼 추가 방법 + +### 3-1. Vexplor DDL 시스템으로 가능한 작업 + +Vexplor에는 **관리자 DDL 실행 시스템**이 내장되어 있습니다. + +- **접근**: 관리자 > 시스템관리 > 테이블관리 (`/admin/systemMng/tableMngList`) +- **가능한 작업**: + - 테이블 생성 (`POST /api/ddl/tables`) + - 컬럼 추가 (`POST /api/ddl/tables/:tableName/columns`) + - 생성 시 `table_type_columns` 메타데이터 자동 등록 +- **권한**: 슈퍼 어드민 계정 (`company_code = '*'`)만 사용 가능 +- **불가능한 것**: 트리거 함수 생성 (이건 psql 직접 실행 필요) + +### 3-2. 작업 분류 + +| 작업 | 방법 | 비고 | +|------|------|------| +| work_order_process 생성 | **psql 직접 실행** | 컬럼 37개 + 인덱스 7개, UI 입력보다 SQL이 효율적 | +| process_work_result 생성 | **psql 직접 실행** | 컬럼 35개 | +| cart_items 생성 | **psql 직접 실행** | 컬럼 16개 + 인덱스 5개 | +| work_order_process_log 생성 | **psql 직접 실행** | 트리거 함수 포함 필수 | +| work_instruction 컬럼 추가 | DDL UI 또는 psql | 컬럼 2개, 어느 방법이든 가능 | +| table_type_columns 메타데이터 | psql COPY 명령 | 로컬에서 추출 후 배포에 삽입 | + +> **결론**: DDL UI는 컬럼 추가(`work_instruction`) 정도에 활용하고, +> 테이블 생성은 모두 psql SQL 직접 실행이 현실적입니다. + +--- + +## 4. 실행 절차 (단계별) + +### STEP 0: 배포 DB 백업 + +```sql +-- 배포 DB에서 실행 +CREATE TABLE backup_20260323_screen_definitions AS + SELECT * FROM screen_definitions WHERE company_code = 'COMPANY_7'; +CREATE TABLE backup_20260323_screen_layouts_pop AS + SELECT * FROM screen_layouts_pop WHERE company_code = 'COMPANY_7'; +CREATE TABLE backup_20260323_screen_groups AS + SELECT * FROM screen_groups WHERE company_code = 'COMPANY_7'; +CREATE TABLE backup_20260323_screen_group_screens AS + SELECT * FROM screen_group_screens WHERE company_code = 'COMPANY_7'; +``` + +--- + +### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행) + +```bash +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm +``` + +#### 1-1. work_order_process + +```sql +CREATE TABLE work_order_process ( + id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text, + created_date timestamp without time zone DEFAULT now(), + updated_date timestamp without time zone DEFAULT now(), + writer character varying(255), + company_code character varying(255), + wo_id character varying(500), + seq_no character varying(255), + process_code character varying(255), + process_name character varying(255), + is_required character varying(255), + is_fixed_order character varying(255), + standard_time character varying(255), + status character varying(255), + accepted_by character varying(255), + accepted_at character varying(255), + started_at character varying(255), + completed_at character varying(255), + plan_qty character varying(255), + input_qty character varying(255), + good_qty character varying(255), + defect_qty character varying(255), + equipment_code character varying(255), + remark character varying(500), + paused_at character varying(500), + total_paused_time character varying(500) DEFAULT '0', + routing_detail_id character varying(500), + actual_work_time character varying(500) DEFAULT NULL::character varying, + completed_by character varying(500) DEFAULT NULL::character varying, + total_production_qty character varying(500), + defect_detail character varying(500), + result_note character varying(500), + result_status character varying(500) DEFAULT 'draft'::character varying, + attachments character varying(500), + parent_process_id character varying(500) DEFAULT NULL::character varying, + concession_qty character varying(500) DEFAULT '0'::character varying, + is_rework character varying(500) DEFAULT 'N'::character varying, + rework_source_id character varying(500) DEFAULT NULL::character varying, + CONSTRAINT work_order_process_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_wop_company ON work_order_process (company_code); +CREATE INDEX idx_wop_wo_id ON work_order_process (wo_id); +CREATE INDEX idx_wop_wo_id_seq_no ON work_order_process (wo_id, seq_no); +CREATE INDEX idx_wop_process ON work_order_process (company_code, process_code); +CREATE INDEX idx_wop_status ON work_order_process (company_code, process_code, status); +CREATE INDEX idx_wop_parent_process_id ON work_order_process (parent_process_id); +``` + +#### 1-2. process_work_result + +```sql +CREATE TABLE process_work_result ( + id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text, + created_date timestamp without time zone DEFAULT now(), + updated_date timestamp without time zone DEFAULT now(), + writer character varying(500) DEFAULT NULL::character varying, + company_code character varying(500), + work_order_process_id character varying(500), + source_work_item_id character varying(500), + source_detail_id character varying(500), + work_phase character varying(500), + item_title character varying(500), + item_sort_order character varying(500), + detail_content character varying(500), + detail_type character varying(500), + detail_sort_order character varying(500), + is_required character varying(500), + inspection_code character varying(500), + inspection_method character varying(500), + unit character varying(500), + lower_limit character varying(500), + upper_limit character varying(500), + input_type character varying(500), + lookup_target character varying(500), + display_fields character varying(500), + duration_minutes character varying(500), + status character varying(500), + result_value character varying(500), + is_passed character varying(500), + remark character varying(500), + recorded_by character varying(500), + recorded_at character varying(500), + started_at character varying(500) DEFAULT NULL::character varying, + group_started_at character varying(500) DEFAULT NULL::character varying, + group_paused_at character varying(500) DEFAULT NULL::character varying, + group_total_paused_time character varying(500) DEFAULT NULL::character varying, + group_completed_at character varying(500) DEFAULT NULL::character varying, + CONSTRAINT process_work_result_pkey PRIMARY KEY (id) +); +``` + +#### 1-3. work_order_process_log + 트리거 + +```sql +-- 로그 테이블 +CREATE TABLE work_order_process_log ( + log_id SERIAL PRIMARY KEY, + operation_type character varying(10) NOT NULL, + original_id character varying(100), + changed_column character varying(100), + old_value text, + new_value text, + changed_by character varying(50), + changed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + ip_address character varying(50), + user_agent text, + full_row_before jsonb, + full_row_after jsonb +); + +-- 트리거 함수 (로컬 DB에서 정확한 정의 먼저 추출) +-- 로컬에서: SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = 'work_order_process_log_trigger_func'; +-- 추출한 함수 정의를 아래에 붙여넣기 + +-- 트리거 등록 (함수 생성 후 실행) +CREATE TRIGGER work_order_process_audit_trigger + AFTER INSERT OR UPDATE OR DELETE ON work_order_process + FOR EACH ROW EXECUTE FUNCTION work_order_process_log_trigger_func(); +``` + +#### 1-4. cart_items + +```sql +CREATE TABLE cart_items ( + id character varying(255) NOT NULL DEFAULT (gen_random_uuid())::text, + created_date timestamp without time zone DEFAULT now(), + updated_date timestamp without time zone DEFAULT now(), + company_code character varying(20), + cart_type character varying(255), + screen_id character varying(255), + user_id character varying(255), + source_table character varying(255), + row_key text, + row_data text, + quantity character varying(255), + unit character varying(255), + package_unit character varying(255), + package_entries text, + status character varying(255), + memo text, + CONSTRAINT cart_items_pkey PRIMARY KEY (id) +); + +CREATE INDEX idx_cart_items_company ON cart_items (company_code); +CREATE INDEX idx_cart_items_screen_user ON cart_items (screen_id, user_id); +CREATE INDEX idx_cart_items_type ON cart_items (cart_type); +CREATE INDEX idx_cart_items_status ON cart_items (status); +``` + +--- + +### STEP 2: 기존 테이블 컬럼 추가 + +#### 방법 A: DDL UI (관리자 화면) + +1. 슈퍼 어드민 계정으로 배포서버 접속 +2. 관리자 > 시스템관리 > 테이블관리 +3. `work_instruction` 테이블 선택 +4. 컬럼 추가 버튼 → `reason` (varchar 500) 추가 +5. 컬럼 추가 버튼 → `completed_qty` (varchar 500, 기본값: '0') 추가 + +#### 방법 B: SQL 직접 + +```sql +ALTER TABLE work_instruction + ADD COLUMN IF NOT EXISTS reason character varying(500), + ADD COLUMN IF NOT EXISTS completed_qty character varying(500) DEFAULT '0'::character varying; +``` + +--- + +### STEP 3: table_type_columns 메타데이터 복사 (로컬 → 배포) + +```bash +# 로컬 DB에서 추출 +PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c " +COPY ( + SELECT * FROM table_type_columns + WHERE table_name IN ('work_order_process', 'cart_items', 'process_work_result') +) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv + +# 배포 DB에 삽입 (충돌 시 무시) +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +COPY table_type_columns FROM STDIN WITH CSV HEADER +ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv +``` + +--- + +### STEP 4: 화면 5종 → 배포 COMPANY_7 복사 (SQL) + +> 화면 ID 4173~4577은 배포 DB에 없으므로 동일 ID로 안전하게 삽입 가능 + +```bash +# 로컬에서 screen_definitions 추출 +PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c " +COPY ( + SELECT * FROM screen_definitions + WHERE screen_id IN (4173, 4479, 4480, 4576, 4577) +) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv + +# 배포에 삽입 +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +COPY screen_definitions FROM STDIN WITH CSV HEADER +ON CONFLICT DO NOTHING" < /tmp/screen_def.csv + + +# screen_layouts_pop 추출 (layout_id 제외) +PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c " +COPY ( + SELECT screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by + FROM screen_layouts_pop + WHERE screen_id IN (4173, 4479, 4480, 4576, 4577) +) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv + +# 배포에 삽입 +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) +FROM STDIN WITH CSV HEADER +ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv + + +# screen_groups 추출 (그룹 4개: 3134, 3216, 3220, 3221) +PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c " +COPY ( + SELECT * FROM screen_groups + WHERE id IN (3134, 3216, 3220, 3221) +) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv + +# 배포에 삽입 +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +COPY screen_groups FROM STDIN WITH CSV HEADER +ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv + + +# screen_group_screens 추출 +PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c " +COPY ( + SELECT * FROM screen_group_screens + WHERE screen_id IN (4173, 4479, 4480, 4576, 4577) +) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv + +# 배포에 삽입 +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +COPY screen_group_screens FROM STDIN WITH CSV HEADER +ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv + + +# 시퀀스 동기화 (배포 DB에서 실행) +SELECT setval('screen_definitions_screen_id_seq', + GREATEST((SELECT MAX(screen_id) FROM screen_definitions), + (SELECT last_value FROM screen_definitions_screen_id_seq))); + +SELECT setval('screen_layouts_pop_layout_id_seq', + GREATEST((SELECT MAX(layout_id) FROM screen_layouts_pop), + (SELECT last_value FROM screen_layouts_pop_layout_id_seq))); + +SELECT setval('screen_groups_id_seq', + GREATEST((SELECT MAX(id) FROM screen_groups), + (SELECT last_value FROM screen_groups_id_seq))); + +SELECT setval('screen_group_screens_id_seq', + GREATEST((SELECT MAX(id) FROM screen_group_screens), + (SELECT last_value FROM screen_group_screens_id_seq))); +``` + +--- + +### STEP 5: COMPANY_21 테스트 계정 사용자 생성 + +> COMPANY_21에 현재 등록된 사용자가 없습니다. 로그인하려면 계정이 필요합니다. + +배포서버 관리자 화면(슈퍼 어드민)에서 COMPANY_21 소속 사용자를 추가합니다: +- 관리자 > 회사관리 > COMPANY_21 > 사용자 추가 +- 또는 SQL: + +```sql +-- user_info 테이블에 테스트 사용자 추가 (기존 패턴 참고) +-- 실제 password 해시는 기존 계정 방식과 동일하게 처리 필요 +INSERT INTO user_info (user_id, user_name, company_code, user_type, status, password) +VALUES ('test21', '테스트계정', 'COMPANY_21', 'COMPANY_ADMIN', 'active', '-- 해시된 비밀번호 --'); +``` + +--- + +### STEP 6: PopDeployModal로 COMPANY_7 → COMPANY_21 복사 + +1. 배포서버에서 `topseal_admin` 계정으로 로그인 +2. POP 디자이너 > POP 관리 화면 > 카테고리 트리 접속 +3. "탑씰" 그룹에서 배포 버튼 클릭 +4. 대상 회사: **COMPANY_21 (테스트회사)** 선택 +5. 5개 화면 포함 여부 확인 후 배포 실행 +6. PopDeployModal이 COMPANY_21 전용 새 화면 ID 자동 부여 + 참조 자동 리매핑 + +--- + +### STEP 7: 검증 + +```sql +-- 배포 DB에서 실행 -- + +-- 7-1. 누락 테이블 생성 확인 +SELECT table_name FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ('work_order_process', 'cart_items', 'process_work_result', 'work_order_process_log'); +-- 예상: 4건 + +-- 7-2. work_instruction 컬럼 확인 +SELECT column_name FROM information_schema.columns +WHERE table_name = 'work_instruction' AND column_name IN ('reason', 'completed_qty'); +-- 예상: 2건 + +-- 7-3. COMPANY_7 화면 삽입 확인 +SELECT screen_id, screen_name, company_code FROM screen_definitions +WHERE screen_id IN (4173, 4479, 4480, 4576, 4577); +-- 예상: 5건 (COMPANY_7) + +-- 7-4. COMPANY_21 화면 복사 확인 (PopDeployModal 후) +SELECT sd.screen_id, sd.screen_name, sd.company_code, + CASE WHEN slp.layout_id IS NOT NULL THEN 'Y' ELSE 'N' END as has_layout +FROM screen_definitions sd +LEFT JOIN screen_layouts_pop slp ON sd.screen_id = slp.screen_id +WHERE sd.company_code = 'COMPANY_21' +AND sd.screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니'); +-- 예상: 5건 + has_layout = Y + +-- 7-5. 화면 간 참조 무결성 (COMPANY_21 기준 새 ID로 리매핑됐는지 확인) +SELECT slp.screen_id, + layout_data::text LIKE '%screenId%' as has_nav_ref, + layout_data::text LIKE '%cartScreenId%' as has_cart_ref +FROM screen_layouts_pop slp +JOIN screen_definitions sd ON slp.screen_id = sd.screen_id +WHERE sd.company_code = 'COMPANY_21' +AND sd.screen_name IN ('홈', '입고메뉴', '구매입고 담기', '구매입고 장바구니'); + +-- 7-6. 시퀀스 정합성 +SELECT 'screen_definitions' as tbl, + (SELECT MAX(screen_id) FROM screen_definitions) as max_id, + (SELECT last_value FROM screen_definitions_screen_id_seq) as seq_val, + CASE WHEN (SELECT last_value FROM screen_definitions_screen_id_seq) + >= (SELECT MAX(screen_id) FROM screen_definitions) + THEN 'OK' ELSE 'MISMATCH' END as status; +``` + +--- + +## 5. COMPANY_21 테스트 환경 수정 가능 여부 + +| 항목 | 수정 가능? | 방법 | +|------|----------|------| +| 회사명/정보 | O | 관리자 > 회사관리 | +| 사용자 추가/수정 | O | 관리자 > 사용자관리 | +| POP 화면 수정 | O | POP 디자이너에서 직접 편집 | +| 화면 삭제 후 재배포 | O | PopDeployModal 재실행 | +| 기존 ERP 화면 영향 없음 | O | POP 레이아웃 별도 테이블 관리 | + +> COMPANY_21은 테스트 전용 계정이므로 자유롭게 수정/삭제 가능합니다. +> 기존 23개 ERP 화면(구매관리, 영업관리 등)은 POP과 무관하므로 건드리지 않아도 됩니다. + +--- + +## 6. 요약: 실행 순서 + +| 순서 | 작업 | 방법 | 담당 | +|------|------|------|------| +| STEP 0 | 배포 DB 백업 | psql | DB 담당자 | +| STEP 1 | 누락 테이블 4개 생성 | psql SQL | DB 담당자 | +| STEP 2 | work_instruction 컬럼 추가 | DDL UI 또는 psql | DB 담당자 | +| STEP 3 | table_type_columns 메타데이터 복사 | psql COPY | DB 담당자 | +| STEP 4 | 화면 5종 COMPANY_7에 삽입 | psql COPY | DB 담당자 | +| STEP 5 | COMPANY_21 테스트 사용자 생성 | 관리자 UI 또는 SQL | 어드민 계정 | +| STEP 6 | PopDeployModal로 COMPANY_21 복사 | 배포서버 UI | 어드민 계정 | +| STEP 7 | 검증 쿼리 실행 | psql | DB 담당자 | +| STEP 8 | 브라우저 POP 화면 테스트 | 브라우저 | 테스터 | + +--- + +## 7. 롤백 방법 + +```sql +-- COMPANY_21 POP 화면 삭제 (PopDeployModal로 생성된 것) +DELETE FROM screen_group_screens +WHERE screen_id IN ( + SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21' + AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니') +); +DELETE FROM screen_layouts_pop +WHERE screen_id IN ( + SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21' + AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니') +); +DELETE FROM screen_definitions +WHERE company_code = 'COMPANY_21' +AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니'); + +-- COMPANY_7 화면 삭제 (SQL로 삽입한 것) +DELETE FROM screen_group_screens WHERE screen_id IN (4173, 4479, 4480, 4576, 4577); +DELETE FROM screen_layouts_pop WHERE screen_id IN (4173, 4479, 4480, 4576, 4577); +DELETE FROM screen_definitions WHERE screen_id IN (4173, 4479, 4480, 4576, 4577); +DELETE FROM screen_groups WHERE id IN (3134, 3216, 3220, 3221) + AND id NOT IN (SELECT id FROM backup_20260323_screen_groups); + +-- 생성한 테이블 제거 +DROP TABLE IF EXISTS work_order_process_log; +DROP TABLE IF EXISTS process_work_result; +DROP TABLE IF EXISTS work_order_process; +DROP TABLE IF EXISTS cart_items; + +-- 추가 컬럼 제거 +ALTER TABLE work_instruction DROP COLUMN IF EXISTS reason; +ALTER TABLE work_instruction DROP COLUMN IF EXISTS completed_qty; +``` + +--- + +*이 문서는 로컬 DB와 배포 DB를 읽기 전용으로 점검한 결과를 바탕으로 작성되었습니다.* +*실제 실행 전 반드시 배포 DB 백업을 완료하세요.* diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx index cc29697b..0688d6cb 100644 --- a/frontend/components/pop/viewer/PopViewerWithModals.tsx +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -231,11 +231,13 @@ export default function PopViewerWithModals({ if (!isTopModal || !closeOnEsc) e.preventDefault(); }} > - - - {definition.title} - - + {definition.title && ( + + + {definition.title} + + + )}
void; + /** 카드 행 데이터 */ + rowData: Record; + /** 품목 코드 (item_inspection_info 조회용) */ + itemCode?: string; + /** 품목명 */ + itemName?: string; + /** 참조 ID (검사 결과 저장 key) */ + referenceId?: string; + /** 참조 테이블 */ + referenceTable?: string; + /** 화면 ID */ + screenId?: string; + /** 검사 유형 필터 */ + inspectionType?: string; + /** 저장 완료 콜백 */ + onSaved?: (overallJudgment: "pass" | "fail") => void; +} + +// ===== 판정 배지 ===== + +function JudgmentBadge({ judgment }: { judgment: "pass" | "fail" | "" }) { + if (!judgment) return null; + return judgment === "pass" ? ( + 합격 + ) : ( + 불합격 + ); +} + +// ===== 메인 컴포넌트 ===== + +export function InspectionModal({ + open, + onOpenChange, + rowData, + itemCode, + itemName, + referenceId, + referenceTable, + screenId, + inspectionType, + onSaved, +}: InspectionModalProps) { + const [infoItems, setInfoItems] = useState([]); + const [resultItems, setResultItems] = useState([]); + const [overallJudgment, setOverallJudgment] = useState<"pass" | "fail" | "">(""); + const [memo, setMemo] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // 검사 항목 + 기존 결과 로드 + const loadData = useCallback(async () => { + if (!open) return; + setLoading(true); + setError(null); + + try { + // 검사 기준 조회 + const params = new URLSearchParams(); + if (itemCode) params.set("itemCode", itemCode); + if (inspectionType) params.set("inspectionType", inspectionType); + + const infoRes = await apiClient.get<{ success: boolean; data: InspectionInfoItem[] }>( + `/pop/inspection-result/info?${params.toString()}` + ); + const infoData = infoRes.data?.data || []; + setInfoItems(infoData); + + // 기존 결과 조회 + let existingMap: Record = {}; + if (referenceId && referenceTable) { + const resultParams = new URLSearchParams({ + referenceId, + referenceTable, + }); + if (screenId) resultParams.set("screenId", screenId); + + const resultRes = await apiClient.get<{ + success: boolean; + data: Array<{ + inspection_info_id: string; + measured_value: string; + judgment: string; + overall_judgment: string; + memo: string; + }>; + }>(`/pop/inspection-result?${resultParams.toString()}`); + + if (resultRes.data?.data && resultRes.data.data.length > 0) { + resultRes.data.data.forEach((r) => { + existingMap[r.inspection_info_id] = { + measuredValue: r.measured_value || "", + judgment: (r.judgment === "pass" || r.judgment === "fail") ? r.judgment : "", + }; + }); + const firstOverall = resultRes.data.data[0]?.overall_judgment; + if (firstOverall === "pass" || firstOverall === "fail") { + setOverallJudgment(firstOverall); + } + setMemo(resultRes.data.data[0]?.memo || ""); + } + } + + // 결과 항목 초기화 + const items: InspectionResultItem[] = infoData.map((info) => ({ + inspectionInfoId: info.id, + inspectionItemName: info.inspection_item_name, + inspectionStandard: info.inspection_standard, + passCriteria: info.pass_criteria, + isRequired: info.is_required, + measuredValue: existingMap[info.id]?.measuredValue || "", + judgment: existingMap[info.id]?.judgment || "", + })); + setResultItems(items); + } catch (err: any) { + setError(err?.message || "데이터 조회 실패"); + } finally { + setLoading(false); + } + }, [open, itemCode, inspectionType, referenceId, referenceTable, screenId]); + + useEffect(() => { + if (open) loadData(); + }, [open, loadData]); + + // 종합 판정 자동 계산 (필수 항목 모두 합격이면 합격) + useEffect(() => { + if (resultItems.length === 0) return; + const judgedItems = resultItems.filter((i) => i.judgment !== ""); + if (judgedItems.length === 0) { + setOverallJudgment(""); + return; + } + const hasAnyFail = resultItems.some((i) => i.isRequired === "Y" && i.judgment === "fail"); + if (hasAnyFail) { + setOverallJudgment("fail"); + return; + } + const requiredItems = resultItems.filter((i) => i.isRequired === "Y"); + const allRequiredJudged = requiredItems.every((i) => i.judgment !== ""); + if (allRequiredJudged && requiredItems.length > 0) { + setOverallJudgment("pass"); + } + }, [resultItems]); + + const updateItem = (index: number, partial: Partial) => { + setResultItems((prev) => { + const next = [...prev]; + next[index] = { ...next[index], ...partial }; + return next; + }); + }; + + const handleSave = async (isCompleted: boolean) => { + if (!overallJudgment && isCompleted) { + setError("종합 판정을 선택해주세요."); + return; + } + setSaving(true); + setError(null); + + try { + await apiClient.post("/pop/inspection-result", { + referenceTable: referenceTable || "", + referenceId: referenceId || "", + screenId: screenId || "", + itemCode: itemCode || "", + itemName: itemName || "", + inspectionType: inspectionType || "", + items: resultItems, + overallJudgment, + memo, + isCompleted, + }); + + if (isCompleted && onSaved) { + onSaved(overallJudgment as "pass" | "fail"); + } + onOpenChange(false); + } catch (err: any) { + setError(err?.message || "저장 실패"); + } finally { + setSaving(false); + } + }; + + // 진행률 + const judgedCount = resultItems.filter((i) => i.judgment !== "").length; + const total = resultItems.length; + const progress = total > 0 ? Math.round((judgedCount / total) * 100) : 0; + + return ( + + + + + + 검사 입력 + + {(itemCode || itemName) && ( +

+ {itemCode && {itemCode}} + {itemCode && itemName && " · "} + {itemName} +

+ )} +
+ + {/* 본문 */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+
+ ) : resultItems.length === 0 ? ( +
+ +

+ {itemCode + ? `"${itemCode}"에 등록된 검사 항목이 없습니다.` + : "검사 항목이 없습니다."} +

+
+ ) : ( +
+ {/* 진행률 표시 */} +
+ + {judgedCount}/{total} 항목 판정됨 + +
+
+
+
+ {progress}% +
+
+ + {/* 검사 항목 목록 */} +
+ {resultItems.map((item, index) => ( +
+ {/* 항목 헤더 */} +
+
+
+ {item.inspectionItemName} + {item.isRequired === "Y" && ( + + 필수 + + )} +
+ {item.inspectionStandard && ( +

+ 기준: {item.inspectionStandard} +

+ )} + {item.passCriteria && ( +

+ 합격: {item.passCriteria} +

+ )} +
+ +
+ + {/* 측정값 입력 */} +
+ + updateItem(index, { measuredValue: e.target.value })} + placeholder="측정값 입력" + className="mt-1 h-10 text-sm" + /> +
+ + {/* 합불 판정 버튼 */} +
+ + +
+
+ ))} +
+ + {/* 종합 판정 */} +
+ +
+ + +
+
+ + {/* 비고 */} +
+ +