From 9ed9915c98b009ef4ab11d909d4bfd4aaa8c2ac9 Mon Sep 17 00:00:00 2001 From: kmh Date: Mon, 27 Apr 2026 16:49:56 +0900 Subject: [PATCH] refactor(pop): keep mhkim WIP popProductionController over gbpark merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gbpark-node(ee8f274f) 머지 시 popProductionController.ts 충돌. 작업 중이던 mhkim 워킹트리 변경분(앞공정 양품 헬퍼/work_instruction 헤더 헬퍼/wi_* 커스텀 템플릿 우선 처리/CTE 기반 getProcessList 등)을 통째로 채택. --- .../controllers/popProductionController.ts | 2308 +++++++---------- 1 file changed, 889 insertions(+), 1419 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index a95e55db..5ebe9e45 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -1,4 +1,5 @@ import type { Response } from "express"; +import type { Pool, PoolClient } from "pg"; import { getPool } from "../database/db"; import type { AuthenticatedRequest } from "../middleware/authMiddleware"; import logger from "../utils/logger"; @@ -74,57 +75,190 @@ async function upsertInventoryStock( } } +/** + * 앞공정 양품 합계 조회 헬퍼 + * - 첫 공정이면 null 반환 (호출자가 instruction_qty 사용) + * - 앞공정이 있으면 SUM(good_qty + concession_qty) 반환 + */ +async function getPrevProcessGoodQty( + exec: Pool | PoolClient, + woId: string, + seqNum: number, + companyCode: string, +): Promise { + // 1. 첫 공정 여부 판정 + const minSeqCheck = await exec.query( + `SELECT MIN(CAST(seq_no AS int)) as min_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [woId, companyCode], + ); + const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; + if (seqNum <= minSeq) { + return null; // 첫 공정 + } + + // 2. 앞 seq 조회 + const prevProcessSeq = await exec.query( + `SELECT MAX(CAST(seq_no AS int)) as prev_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND CAST(seq_no AS int) < $3`, + [woId, companyCode, seqNum], + ); + const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; + if (actualPrevSeq == null) { + return null; + } + + // 3. 앞공정 양품 SUM + const prevAgg = await exec.query( + `SELECT COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) + + COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) as total_good + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND wr.result_status IN ('draft','confirmed')`, + [woId, String(actualPrevSeq), companyCode], + ); + return parseInt(prevAgg.rows[0].total_good, 10) || 0; +} + +/** + * work_instruction 헤더 필드를 COALESCE 방식으로 업데이트하는 공통 헬퍼 + * syncWorkInstructions 내 header_routing 단일 경로와 detailRows 다중 경로에서 공용 + */ +async function updateWorkInstructionHeader( + exec: Pool | PoolClient, + wiId: string, + companyCode: string, + routing: string | null, + qty: string | number | null, + itemNumber: string | null, +): Promise { + await exec.query( + `UPDATE work_instruction SET + routing = COALESCE(routing, $2), + qty = COALESCE(NULLIF(qty, ''), $3), + item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), + updated_date = NOW() + WHERE id = $1 AND company_code = $5 + AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, + [wiId, routing, qty != null ? String(qty) : null, itemNumber, companyCode], + ); +} + /** * 체크리스트 복사 공통 함수 - * 분할 행/재작업 카드 생성 시 마스터의 체크리스트를 새 행에 복사한다. + * 접수 카드(work_order_process_result) 생성 시 마스터 공정의 체크리스트 템플릿을 + * 새 접수 row에 복사한다. * - * 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 결과에서 복사 + * 주의: process_work_result.work_order_process_id 컬럼은 + * - 마스터 체크리스트(routing 스냅샷): work_order_process.id 저장 (createWorkProcesses에서 1회) + * - 접수 카드 체크리스트: work_order_process_result.id 저장 (acceptProcess 호출 시) + * 컬럼명은 DB 스키마상 변경 금지. + * + * 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 체크리스트에서 복사 + * + * options: + * - workInstructionNo: 주어지고 wi_process_work_item 에 (wiNo, routingDetailId) row 가 있으면 + * 원본(process_work_item) 대신 wi_* 템플릿에서 복사 (작업지시별 커스텀 우선) + * - skipAStrategy: true 면 A 전략(템플릿 복사)을 건너뛰고 바로 B 전략(마스터 스냅샷 복사)으로 진입 + * (접수 시 마스터 스냅샷 일관성 보장용) */ export async function copyChecklistToSplit( client: { query: (text: string, values?: any[]) => Promise }, masterProcessId: string, - newProcessId: string, + wopResultId: string, routingDetailId: string | null, companyCode: string, userId: string, + options?: { workInstructionNo?: string; skipAStrategy?: boolean }, ): Promise { // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 - if (routingDetailId) { - const result = await client.query( - `INSERT INTO process_work_result ( - id, company_code, work_order_process_id, - source_work_item_id, source_detail_id, - work_phase, item_title, item_sort_order, - detail_content, detail_type, detail_sort_order, is_required, - inspection_code, inspection_method, unit, lower_limit, upper_limit, - input_type, lookup_target, display_fields, duration_minutes, - status, writer - ) - SELECT - gen_random_uuid()::text, pwi.company_code, $1, - pwi.id, pwd.id, - pwi.work_phase, pwi.title, pwi.sort_order::text, - pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, - pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, - pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, - 'pending', $2 - FROM process_work_item pwi - JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id - AND pwd.company_code = pwi.company_code - WHERE pwi.routing_detail_id = $3 - AND pwi.company_code = $4 - ORDER BY pwi.sort_order, pwd.sort_order`, - [newProcessId, userId, routingDetailId, companyCode], - ); - const countA = result.rowCount ?? 0; + // 단, options.skipAStrategy === true 이면 A 전략 전체를 건너뛰고 B 로 진입 + if (routingDetailId && options?.skipAStrategy !== true) { + const wiNo = options?.workInstructionNo; + let wiExists = false; + if (wiNo) { + const wiCheck = await client.query( + `SELECT 1 FROM wi_process_work_item + WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 + LIMIT 1`, + [wiNo, routingDetailId, companyCode], + ); + wiExists = (wiCheck.rowCount ?? 0) > 0; + } + + let countA = 0; + if (wiNo && wiExists) { + // A-1. wi_* (작업지시 커스텀 템플릿) 에서 복사 + const result = await client.query( + `INSERT INTO process_work_result ( + id, company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + gen_random_uuid()::text, wi.company_code, $1, + wi.id, wid.id, + wi.work_phase, wi.title, wi.sort_order::text, + wid.content, wid.detail_type, wid.sort_order::text, wid.is_required, + wid.inspection_code, wid.inspection_method, wid.unit, wid.lower_limit, wid.upper_limit, + wid.input_type, wid.lookup_target, wid.display_fields, wid.duration_minutes::text, + 'pending', $2 + FROM wi_process_work_item wi + JOIN wi_process_work_item_detail wid + ON wid.wi_work_item_id = wi.id AND wid.company_code = wi.company_code + WHERE wi.work_instruction_no = $5 + AND wi.routing_detail_id = $3 + AND wi.company_code = $4 + ORDER BY wi.sort_order::int, wid.sort_order::int`, + [wopResultId, userId, routingDetailId, companyCode, wiNo], + ); + countA = result.rowCount ?? 0; + } else { + // A-2. 원본 템플릿(process_work_item) 에서 복사 (기존 동작) + const result = await client.query( + `INSERT INTO process_work_result ( + id, company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + gen_random_uuid()::text, pwi.company_code, $1, + pwi.id, pwd.id, + pwi.work_phase, pwi.title, pwi.sort_order::text, + pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, + pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, + pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + 'pending', $2 + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + ORDER BY pwi.sort_order, pwd.sort_order`, + [wopResultId, userId, routingDetailId, companyCode], + ); + countA = result.rowCount ?? 0; + } if (countA > 0) return countA; - // A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough + // A 전략에서 0건이면 B 전략(마스터 wop의 체크리스트 복사)으로 fallthrough } - // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) + // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 wop의 process_work_result 구조 복사 const result = await client.query( `INSERT INTO process_work_result ( - company_code, work_order_process_id, + id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -133,7 +267,7 @@ export async function copyChecklistToSplit( status, writer ) SELECT - company_code, $1, + gen_random_uuid()::text, company_code, $1, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -144,16 +278,13 @@ export async function copyChecklistToSplit( WHERE work_order_process_id = $3 AND company_code = $4 ORDER BY item_sort_order, detail_sort_order`, - [newProcessId, userId, masterProcessId, companyCode], + [wopResultId, userId, masterProcessId, companyCode], ); return result.rowCount ?? 0; } /** - * 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성 - * createWorkProcesses와 syncWorkInstructions 양쪽에서 재사용한다. - * - * @returns 생성된 공정 목록 + 체크리스트 총 수. 이미 존재하면 null 반환. + * 내부 헬퍼: 단일 작업지시에 대해 work_order_process(마스터) + process_work_result(체크리스트 스냅샷) 생성 */ async function generateWorkProcessesForInstruction( client: { query: (text: string, values?: any[]) => Promise }, @@ -172,33 +303,32 @@ async function generateWorkProcessesForInstruction( }>; total_checklists: number; } | null> { - // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리) - // ※ TASK:ERP-011 스키마 분리 반영 — batch_id는 실적 테이블(work_order_process_result)로 이동 + await client.query( + `SELECT pg_advisory_xact_lock(hashtext($1))`, + [`wop_sync:${companyCode}:${workInstructionId}:${batchId || ""}`], + ); + if (batchId) { - // 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip - // 기준정보(work_order_process) ⟷ 실적(work_order_process_result) JOIN으로 확인 const existCheck = await client.query( - `SELECT COUNT(*) as cnt FROM work_order_process p - JOIN work_order_process_result r ON r.wop_id = p.id - WHERE p.wo_id = $1 AND p.company_code = $2 AND r.batch_id = $3`, + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 + AND (batch_id = $3 OR batch_id IS NULL)`, [workInstructionId, companyCode, batchId], ); if (parseInt(existCheck.rows[0].cnt, 10) > 0) { - return null; // 이미 존재 + return null; } } else { - // 기존 동작: batch_id 없으면 wo_id 전체로 체크 (기준정보 테이블만 조회) const existCheck = await client.query( `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND company_code = $2`, [workInstructionId, companyCode], ); if (parseInt(existCheck.rows[0].cnt, 10) > 0) { - return null; // 이미 존재 + return null; } } - // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) const routingDetails = await client.query( `SELECT rd.id, rd.seq_no, rd.process_code, COALESCE(pm.process_name, rd.process_code) as process_name, @@ -212,9 +342,16 @@ async function generateWorkProcessesForInstruction( ); if (routingDetails.rows.length === 0) { - return null; // 공정 없음 + return null; } + // 작업지시번호 1회 조회 — copyChecklistToSplit 에 전달하여 wi_* 커스텀 템플릿 우선 적용 + const wiRow = await client.query( + `SELECT work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, + [workInstructionId, companyCode], + ); + const workInstructionNo = wiRow.rows[0]?.work_instruction_no as string | undefined; + const processes: Array<{ id: string; seq_no: string; @@ -224,14 +361,12 @@ async function generateWorkProcessesForInstruction( let totalChecklists = 0; for (const rd of routingDetails.rows) { - // 2-A. work_order_process INSERT — 기준정보(TASK:ERP-011 분리 후) - // status/batch_id는 실적 테이블로 이동했으므로 여기에서는 제외 const wopResult = await client.query( `INSERT INTO work_order_process ( id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, - routing_detail_id, writer - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + routing_detail_id, batch_id, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [ companyCode, @@ -244,26 +379,12 @@ async function generateWorkProcessesForInstruction( rd.standard_time, planQty || null, rd.id, + batchId || null, userId, ], ); const wopId = wopResult.rows[0].id; - // 2-B. work_order_process_result INSERT — 최초 실적 레코드 (seq=1) - // 동일 client로 실행 → 트랜잭션 보호 유지 - // id는 wopId와 동일하게 부여 (초기 이관 정책 및 copyChecklistToSplit 호환 목적) - const initialStatus = - parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" - ? "acceptable" - : "waiting"; - await client.query( - `INSERT INTO work_order_process_result ( - id, wop_id, seq, company_code, status, batch_id, writer - ) VALUES ($1, $2, 1, $3, $4, $5, $6)`, - [wopId, wopId, companyCode, initialStatus, batchId || null, userId], - ); - - // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) const checklistCount = await copyChecklistToSplit( client, wopId, @@ -271,6 +392,7 @@ async function generateWorkProcessesForInstruction( rd.id, companyCode, userId, + { workInstructionNo }, ); totalChecklists += checklistCount; @@ -286,8 +408,7 @@ async function generateWorkProcessesForInstruction( } /** - * D-BE1: 작업지시 공정 일괄 생성 - * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. + * 작업지시 공정 일괄 생성 */ export const createWorkProcesses = async ( req: AuthenticatedRequest, @@ -310,15 +431,6 @@ export const createWorkProcesses = async ( }); } - logger.info("[pop/production] create-work-processes 요청", { - companyCode, - userId, - work_instruction_id, - item_code, - routing_version_id, - plan_qty, - }); - await client.query("BEGIN"); const result = await generateWorkProcessesForInstruction( @@ -328,6 +440,7 @@ export const createWorkProcesses = async ( plan_qty, companyCode, userId, + item_code || null, ); if (!result) { @@ -340,13 +453,6 @@ export const createWorkProcesses = async ( await client.query("COMMIT"); - logger.info("[pop/production] create-work-processes 완료", { - companyCode, - work_instruction_id, - total_processes: result.processes.length, - total_checklists: result.total_checklists, - }); - return res.json({ success: true, data: { @@ -369,8 +475,6 @@ export const createWorkProcesses = async ( /** * POP 온디맨드 Pull: 미동기화 작업지시 일괄 sync - * routing이 있지만 work_order_process가 없는 작업지시를 찾아 공정을 자동 생성한다. - * 각 건별 개별 try-catch로 하나 실패해도 나머지 진행. */ export const syncWorkInstructions = async ( req: AuthenticatedRequest, @@ -382,14 +486,8 @@ export const syncWorkInstructions = async ( const companyCode = req.user!.companyCode; const userId = req.user!.userId; - logger.info("[pop/production] sync-work-instructions 요청", { - companyCode, - userId, - }); + await ensureBatchIdColumn(); - // 미동기화 작업지시 조회 — 다중 품목(detail) 지원 - // 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환) - // 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목 const unsyncedResult = await pool.query( `SELECT wi.id, wi.work_instruction_no, wi.routing AS header_routing, @@ -398,13 +496,11 @@ export const syncWorkInstructions = async ( FROM work_instruction wi WHERE wi.company_code = $1 AND ( - -- header routing이 있는데 공정이 아예 없는 경우 (wi.routing IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM work_order_process wop WHERE wop.wo_id = wi.id AND wop.company_code = $1 )) OR - -- detail에 routing이 있는 경우 (다중 품목 지원) EXISTS ( SELECT 1 FROM work_instruction_detail wid WHERE wid.work_instruction_id = wi.id @@ -442,7 +538,6 @@ export const syncWorkInstructions = async ( }> = []; for (const wi of unsynced) { - // detail 목록 조회: routing_version_id가 있고 qty > 0인 것 const detailResult = await pool.query( `SELECT wid.item_number, wid.routing_version_id, wid.qty FROM work_instruction_detail wid @@ -456,8 +551,6 @@ export const syncWorkInstructions = async ( const detailRows = detailResult.rows; if (detailRows.length === 0 && wi.header_routing) { - // detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목) - // header에 routing/qty/item_id 자동 보정 const firstDetail = await pool.query( `SELECT routing_version_id, qty, item_number FROM work_instruction_detail @@ -467,15 +560,13 @@ export const syncWorkInstructions = async ( ); const wid = firstDetail.rows[0]; if (wid) { - await pool.query( - `UPDATE work_instruction SET - routing = COALESCE(routing, $2), - qty = COALESCE(NULLIF(qty, ''), $3), - item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), - updated_date = NOW() - WHERE id = $1 AND company_code = $5 - AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, - [wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode], + await updateWorkInstructionHeader( + pool, + wi.id, + companyCode, + wid.routing_version_id, + wid.qty, + wid.item_number, ); } @@ -507,10 +598,6 @@ export const syncWorkInstructions = async ( status: "synced", process_count: result.processes.length, }); - logger.info("[pop/production] sync: 공정 생성 완료 (header routing)", { - work_instruction_no: wi.work_instruction_no, - process_count: result.processes.length, - }); } } catch (err: any) { await client.query("ROLLBACK"); @@ -521,29 +608,21 @@ export const syncWorkInstructions = async ( status: "error", error: err.message || "알 수 없는 오류", }); - logger.error("[pop/production] sync: header routing 오류", { - work_instruction_no: wi.work_instruction_no, - error: err.message, - }); } finally { client.release(); } continue; } - // 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number) - // header routing/item_id도 첫 번째 detail 기준 보정 if (detailRows.length > 0) { const first = detailRows[0]; - await pool.query( - `UPDATE work_instruction SET - routing = COALESCE(routing, $2), - qty = COALESCE(NULLIF(qty, ''), $3), - item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)), - updated_date = NOW() - WHERE id = $1 AND company_code = $5 - AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, - [wi.id, first.routing_version_id, first.qty, first.item_number, companyCode], + await updateWorkInstructionHeader( + pool, + wi.id, + companyCode, + first.routing_version_id, + first.qty, + first.item_number, ); } @@ -559,7 +638,7 @@ export const syncWorkInstructions = async ( detail.qty || null, companyCode, userId, - detail.item_number, // batch_id = item_number + detail.item_number, ); if (!result) { @@ -583,12 +662,6 @@ export const syncWorkInstructions = async ( status: "synced", process_count: result.processes.length, }); - - logger.info("[pop/production] sync: 다중품목 공정 생성 완료", { - work_instruction_no: wi.work_instruction_no, - item_number: detail.item_number, - process_count: result.processes.length, - }); } catch (err: any) { await client.query("ROLLBACK"); errors++; @@ -599,24 +672,12 @@ export const syncWorkInstructions = async ( status: "error", error: err.message || "알 수 없는 오류", }); - logger.error("[pop/production] sync: 다중품목 개별 오류", { - work_instruction_no: wi.work_instruction_no, - item_number: detail.item_number, - error: err.message, - }); } finally { client.release(); } } } - logger.info("[pop/production] sync-work-instructions 완료", { - companyCode, - synced, - skipped, - errors, - }); - return res.json({ success: true, data: { synced, skipped, errors, details }, @@ -631,7 +692,8 @@ export const syncWorkInstructions = async ( }; /** - * D-BE2: 타이머 API (시작/일시정지/재시작) + * 타이머 API — work_order_process_result 기준. + * 요청 본문 work_order_process_id 는 work_order_process_result.id */ export const controlTimer = async ( req: AuthenticatedRequest, @@ -659,20 +721,12 @@ export const controlTimer = async ( }); } - logger.info("[pop/production] timer 요청", { - companyCode, - userId, - work_order_process_id, - action, - }); - let result; switch (action) { case "start": - // 최초 1회만 설정, 이미 있으면 무시 result = await pool.query( - `UPDATE work_order_process + `UPDATE work_order_process_result SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, updated_date = NOW() @@ -684,7 +738,7 @@ export const controlTimer = async ( case "pause": result = await pool.query( - `UPDATE work_order_process + `UPDATE work_order_process_result SET paused_at = NOW()::text, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NULL @@ -694,9 +748,8 @@ export const controlTimer = async ( break; case "resume": - // 일시정지 시간 누적 후 paused_at 초기화 result = await pool.query( - `UPDATE work_order_process + `UPDATE work_order_process_result SET total_paused_time = ( COALESCE(total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int @@ -727,7 +780,7 @@ export const controlTimer = async ( groupSumResult.rows[0]?.total_work_seconds || "0"; result = await pool.query( - `UPDATE work_order_process + `UPDATE work_order_process_result SET status = 'completed', completed_at = NOW()::text, completed_by = $3, @@ -755,16 +808,10 @@ export const controlTimer = async ( if (!result || result.rowCount === 0) { return res.status(404).json({ success: false, - message: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + message: "대상 접수 카드를 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", }); } - logger.info("[pop/production] timer 완료", { - action, - work_order_process_id, - result: result.rows[0], - }); - return res.json({ success: true, data: result.rows[0], @@ -779,8 +826,15 @@ export const controlTimer = async ( }; /** - * 그룹(작업항목)별 타이머 제어 - * 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머 + * Phase/그룹 타이머 제어 + * process_work_result.group_* 컬럼을 phase 단위로 공유. + * complete 액션에서 wop_result.actual_work_time 합산 갱신. + * + * Body: + * - phase (optional): 있으면 해당 phase 내 모든 row 를 동일하게 업데이트 + * - item_id (optional): phase 가 없을 때만 단일 row 대상 (backward-compat) + * - work_order_process_id (required with phase, optional with item_id) + * - action: start / pause / resume / complete */ export const controlGroupTimer = async ( req: AuthenticatedRequest, @@ -790,13 +844,24 @@ export const controlGroupTimer = async ( try { const companyCode = req.user!.companyCode; - const { work_order_process_id, source_work_item_id, action } = req.body; + const { item_id, work_order_process_id, action, phase } = req.body; - if (!work_order_process_id || !source_work_item_id || !action) { + if (!action) { return res.status(400).json({ success: false, - message: - "work_order_process_id, source_work_item_id, action은 필수입니다.", + message: "action은 필수입니다.", + }); + } + if (!phase && !item_id) { + return res.status(400).json({ + success: false, + message: "phase 또는 item_id 중 하나는 필수입니다.", + }); + } + if (phase && !work_order_process_id) { + return res.status(400).json({ + success: false, + message: "phase 모드에서는 work_order_process_id 가 필수입니다.", }); } @@ -807,19 +872,13 @@ export const controlGroupTimer = async ( }); } - logger.info("[pop/production] group-timer 요청", { - companyCode, - work_order_process_id, - source_work_item_id, - action, - }); - - const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; - const baseParams = [ - work_order_process_id, - source_work_item_id, - companyCode, - ]; + // phase 모드: phase 내 모든 row 에 동일값 반영 / fallback: item_id 단일 row + const whereClause = phase + ? `work_order_process_id = $1 AND work_phase = $2 AND company_code = $3` + : `id = $1 AND company_code = $2`; + const baseParams: unknown[] = phase + ? [work_order_process_id, phase, companyCode] + : [item_id, companyCode]; let result; @@ -833,12 +892,14 @@ export const controlGroupTimer = async ( RETURNING id, group_started_at`, baseParams, ); - await pool.query( - `UPDATE work_order_process + if (work_order_process_id) { + await pool.query( + `UPDATE work_order_process_result SET started_at = NOW()::text, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND started_at IS NULL`, - [work_order_process_id, companyCode], - ); + [work_order_process_id, companyCode], + ); + } break; case "pause": @@ -884,6 +945,33 @@ export const controlGroupTimer = async ( RETURNING id, group_started_at, group_completed_at, group_total_paused_time`, baseParams, ); + + // 접수 카드(wop_result) 의 actual_work_time 합산 갱신 + // phase 내 모든 row 가 동일값을 가지므로 phase 별 MAX 로 대표값 추출 후 SUM + if (work_order_process_id) { + await pool.query( + `UPDATE work_order_process_result wr + SET actual_work_time = sub.total_work_seconds::text, + updated_date = NOW() + FROM ( + SELECT COALESCE(SUM(phase_seconds), 0) AS total_work_seconds + FROM ( + SELECT work_phase, + MAX( + CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN + EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int + - COALESCE(group_total_paused_time::int, 0) + ELSE 0 END + ) AS phase_seconds + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 + GROUP BY work_phase + ) p + ) sub + WHERE wr.id = $1 AND wr.company_code = $2`, + [work_order_process_id, companyCode], + ); + } break; } } @@ -891,16 +979,10 @@ export const controlGroupTimer = async ( if (!result || result.rowCount === 0) { return res.status(404).json({ success: false, - message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + message: "대상 항목을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", }); } - logger.info("[pop/production] group-timer 완료", { - action, - source_work_item_id, - affectedRows: result.rowCount, - }); - return res.json({ success: true, data: result.rows[0], @@ -948,11 +1030,6 @@ export const getDefectTypes = async ( const result = await pool.query(query, params); - logger.info("[pop/production] defect-types 조회", { - companyCode, - count: result.rowCount, - }); - return res.json({ success: true, data: result.rows, @@ -967,82 +1044,70 @@ export const getDefectTypes = async ( }; /** - * 실적 저장 (누적 방식) - * 이번 차수 생산수량을 기존 누적치에 더한다. - * result_status는 'draft' 유지 (확정 전까지 계속 추가 등록 가능) + * 실적 저장 (누적 방식) — work_order_process_result UPDATE + * work_order_process_id 는 work_order_process_result.id */ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { const pool = getPool(); + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { + work_order_process_id, + production_qty, + good_qty, + defect_qty, + defect_detail, + result_note, + } = req.body; + + // validation: BEGIN 이전에 처리 + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + if (!production_qty || parseInt(production_qty, 10) <= 0) { + return res.status(400).json({ + success: false, + message: "생산수량을 입력해주세요.", + }); + } + + const statusCheck = await pool.query( + `SELECT wr.status, wr.result_status, wr.total_production_qty, wr.good_qty, + wr.defect_qty, wr.concession_qty, wr.defect_detail, + wr.input_qty, wr.wop_id, wop.wo_id, wop.seq_no + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wr.id = $1 AND wr.company_code = $2`, + [work_order_process_id, companyCode], + ); + + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "접수 카드를 찾을 수 없습니다.", + }); + } + + const prev = statusCheck.rows[0]; + + if (prev.result_status === "confirmed") { + return res.status(403).json({ + success: false, + message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", + }); + } + + const client = await pool.connect(); try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + await client.query("BEGIN"); - const { - work_order_process_id, - production_qty, - good_qty, - defect_qty, - defect_detail, - result_note, - } = req.body; - - if (!work_order_process_id) { - return res.status(400).json({ - success: false, - message: "work_order_process_id는 필수입니다.", - }); - } - - if (!production_qty || parseInt(production_qty, 10) <= 0) { - return res.status(400).json({ - success: false, - message: "생산수량을 입력해주세요.", - }); - } - - const statusCheck = await pool.query( - `SELECT wop.status, wop.result_status, wop.total_production_qty, wop.good_qty, - wop.defect_qty, wop.concession_qty, wop.defect_detail, - wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no - FROM work_order_process wop - WHERE wop.id = $1 AND wop.company_code = $2`, - [work_order_process_id, companyCode], - ); - - if (statusCheck.rowCount === 0) { - return res.status(404).json({ - success: false, - message: "공정을 찾을 수 없습니다.", - }); - } - - const prev = statusCheck.rows[0]; - - // 마스터 행에 직접 실적 등록 방지 (분할 행이 존재하는 경우) - if (!prev.parent_process_id) { - const splitCheck = await pool.query( - `SELECT COUNT(*) as cnt FROM work_order_process - WHERE parent_process_id = $1 AND company_code = $2`, - [work_order_process_id, companyCode], - ); - if (parseInt(splitCheck.rows[0].cnt, 10) > 0) { - return res.status(400).json({ - success: false, - message: - "원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.", - }); - } - } - - if (prev.result_status === "confirmed") { - return res.status(403).json({ - success: false, - message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", - }); - } - - // 초과 생산 경고 (차단하지 않음 - 현장 유연성) + // 초과 생산 경고 (차단하지 않음) const prevTotal = parseInt(prev.total_production_qty, 10) || 0; const acceptedQty = parseInt(prev.input_qty, 10) || 0; const requestedQty = parseInt(production_qty, 10) || 0; @@ -1052,11 +1117,9 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { prevTotal, requestedQty, acceptedQty, - overAmount: prevTotal + requestedQty - acceptedQty, }); } - // 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만) const addProduction = parseInt(production_qty, 10) || 0; let addDefect = 0; let addConcession = 0; @@ -1090,7 +1153,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { const newConcession = (parseInt(prev.concession_qty, 10) || 0) + addConcession; - // 기존 defect_detail에 이번 차수 상세를 병합 + // defect_detail 병합 let mergedDefectDetail: string | null = null; if (defectDetailStr) { let existingEntries: DefectDetailItem[] = []; @@ -1099,7 +1162,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { ? JSON.parse(prev.defect_detail) : []; } catch { - /* 파싱 실패 시 빈 배열 */ + /* ignore */ } const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr); const merged = [...existingEntries]; @@ -1120,8 +1183,8 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { mergedDefectDetail = JSON.stringify(merged); } - const result = await pool.query( - `UPDATE work_order_process + const result = await client.query( + `UPDATE work_order_process_result SET total_production_qty = $3, good_qty = $4, defect_qty = $5, @@ -1148,55 +1211,32 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { ); if (result.rowCount === 0) { + await client.query("ROLLBACK"); return res.status(404).json({ success: false, - message: "공정을 찾을 수 없거나 권한이 없습니다.", + message: "접수 카드를 찾을 수 없거나 권한이 없습니다.", }); } - // === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 === - if (prev.parent_process_id) { - await pool.query( - `UPDATE work_order_process - SET good_qty = sub.sum_good, - defect_qty = sub.sum_defect, - total_production_qty = sub.sum_total, - concession_qty = sub.sum_concession, - updated_date = NOW() - FROM ( - SELECT - COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, - COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, - COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, - COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession - FROM work_order_process - WHERE parent_process_id = $1 AND company_code = $2 - ) sub - WHERE id = $1 AND company_code = $2`, - [prev.parent_process_id, companyCode], - ); - logger.info("[pop/production] master 합산 업데이트", { - masterId: prev.parent_process_id, - splitId: work_order_process_id, - }); - } - - // 현재 분할 행의 공정 정보 조회 - const currentSeq = await pool.query( - `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, - wop.parent_process_id, wop.process_code, wop.process_name, + // 현재 접수 카드 공정 정보 + const currentSeq = await client.query( + `SELECT wr.id as wr_id, wr.wop_id, wr.input_qty as current_input_qty, + wop.seq_no, wop.wo_id, + wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, - wop.equipment_code, wop.routing_detail_id, + wr.equipment_code, wop.routing_detail_id, wi.qty as instruction_qty - FROM work_order_process wop + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code - WHERE wop.id = $1 AND wop.company_code = $2`, + WHERE wr.id = $1 AND wr.company_code = $2`, [work_order_process_id, companyCode], ); // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) + // 신 구조: work_order_process_result 에 새 row INSERT (is_rework='Y', seq=MAX+1, UNIQUE 충돌 시 1회 재시도) if ( - currentSeq.rowCount > 0 && + (currentSeq.rowCount ?? 0) > 0 && defect_detail && Array.isArray(defect_detail) ) { @@ -1211,260 +1251,118 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => { } if (totalReworkQty > 0) { const proc = currentSeq.rows[0]; - const masterId = proc.parent_process_id || work_order_process_id; - - // 재작업 대상 공정 결정 - let reworkSeqNo = proc.seq_no; - let reworkProcessCode = proc.process_code; - let reworkProcessName = proc.process_name; - let reworkRoutingDetailId = proc.routing_detail_id; - let reworkMasterId = masterId; - - // target_process_code가 지정되면 해당 공정 정보를 조회 + let reworkMasterId: string = proc.wop_id; if (targetProcessCode) { - const targetProc = await pool.query( - `SELECT id, seq_no, process_code, process_name, routing_detail_id - FROM work_order_process + const targetProc = await client.query( + `SELECT id FROM work_order_process WHERE wo_id = $1 AND process_code = $2 AND company_code = $3 - AND parent_process_id IS NULL LIMIT 1`, [proc.wo_id, targetProcessCode, companyCode], ); - if (targetProc.rowCount > 0) { - const tp = targetProc.rows[0]; - reworkSeqNo = tp.seq_no; - reworkProcessCode = tp.process_code; - reworkProcessName = tp.process_name; - reworkRoutingDetailId = tp.routing_detail_id; - reworkMasterId = tp.id; // 지정 공정의 마스터 ID + if ((targetProc.rowCount ?? 0) > 0) { + reworkMasterId = targetProc.rows[0].id; + } + } + + let reworkId: string | null = null; + for (let attempt = 0; attempt < 2 && !reworkId; attempt++) { + try { + const reworkInsert = await client.query( + `INSERT INTO work_order_process_result ( + id, wop_id, seq, company_code, + status, result_status, + input_qty, good_qty, defect_qty, concession_qty, total_production_qty, + is_rework, rework_source_id, + writer, created_date, updated_date + ) VALUES ( + gen_random_uuid()::text, $1, + (SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1 AND company_code = $2), + $2, + 'acceptable', 'draft', + $3, '0', '0', '0', '0', + 'Y', $4, + $5, NOW(), NOW() + ) RETURNING id`, + [ + reworkMasterId, + companyCode, + String(totalReworkQty), + work_order_process_id, + userId, + ], + ); + reworkId = reworkInsert.rows[0]?.id; + } catch (err: any) { + if (err?.code !== "23505" || attempt >= 1) throw err; } } - const reworkInsert = await pool.query( - `INSERT INTO work_order_process ( - id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, - standard_time, equipment_code, routing_detail_id, - status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty, - result_status, is_rework, rework_source_id, - parent_process_id, company_code, writer - ) VALUES ( - gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, - 'acceptable', $10, '0', '0', '0', '0', - 'draft', 'Y', $11, - $12, $13, $14 - ) RETURNING id`, - [ - proc.wo_id, - reworkSeqNo, - reworkProcessCode, - reworkProcessName, - proc.is_required, - proc.is_fixed_order, - proc.standard_time, - proc.equipment_code, - reworkRoutingDetailId, - String(totalReworkQty), - work_order_process_id, - reworkMasterId, - companyCode, - userId, - ], - ); - // 재작업 카드에 체크리스트 복사 - const reworkId = reworkInsert.rows[0]?.id; if (reworkId) { - const reworkChecklistCount = await copyChecklistToSplit( - pool, + await copyChecklistToSplit( + client, reworkMasterId, reworkId, - reworkRoutingDetailId, + null, companyCode, userId, ); - logger.info("[pop/production] 재작업 카드 자동 생성", { - reworkId, - sourceId: work_order_process_id, - reworkQty: totalReworkQty, - targetProcess: targetProcessCode || "(같은 공정)", - reworkSeqNo, - checklistCount: reworkChecklistCount, - }); } } } - // 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행) - if (currentSeq.rowCount > 0) { + // 접수 카드 자동완료 판정 + 작업지시 완료 캐스케이드 + if ((currentSeq.rowCount ?? 0) > 0) { const { - seq_no: csSeq, wo_id: csWoId, current_input_qty: csInputQty, - instruction_qty: csInstrQty, - parent_process_id: csParentId, } = currentSeq.rows[0]; const csMyInput = parseInt(csInputQty, 10) || 0; if (newTotal >= csMyInput && csMyInput > 0) { - await pool.query( - `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', - completed_at = NOW()::text, completed_by = $3, updated_date = NOW() + await client.query( + `UPDATE work_order_process_result + SET status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [work_order_process_id, companyCode, userId], ); - - // 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed - const csSeqNum = parseInt(csSeq, 10); - let csPrevGood = parseInt(csInstrQty, 10) || 0; - if (csSeqNum > 1) { - const prev = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg - FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [csWoId, String(csSeqNum - 1), companyCode], - ); - if (prev.rowCount > 0) - csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; - } - const sibCheck = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic - FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, - [csWoId, csSeq, companyCode], - ); - const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0; - const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0; - if ( - csIncomplete === 0 && - csPrevGood - csTotalInput <= 0 && - csParentId - ) { - await pool.query( - `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', - completed_at = NOW()::text, completed_by = $3, updated_date = NOW() - WHERE id = $1 AND company_code = $2 AND status != 'completed'`, - [csParentId, companyCode, userId], - ); - } } - await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId); + await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId); } - // 다음 공정 활성화 (다중공정 대응) - // is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화 - if (addGood > 0 && currentSeq.rowCount > 0) { - const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0]; - const seqNum = parseInt(seq_no, 10); - - let shouldActivateNext = true; - - if (is_fixed_order === "Y") { - // 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인 - // (병렬 그룹 = 같은 seq_no를 공유하는 공정들) - const groupCheck = await pool.query( - `SELECT id, seq_no, status, - COALESCE(good_qty::int, 0) + COALESCE(concession_qty::int, 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 - AND parent_process_id IS NULL - AND seq_no = $3 - ORDER BY CAST(seq_no AS int)`, - [wo_id, companyCode, seq_no], - ); - - // 같은 seq의 미완료 공정 확인 (병렬 그룹 내) - const incomplete = groupCheck.rows.filter( - (r: Record) => - String(r.status) !== "completed" && - parseInt(String(r.total_good), 10) <= 0, - ); - shouldActivateNext = incomplete.length === 0; - - if (!shouldActivateNext) { - logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", { - groupSize: groupCheck.rows.length, - incomplete: incomplete.length, - }); - } - } - - if (shouldActivateNext) { - // 다음 seq 활성화 (seq_no 비순차 대응: seqNum+1이 아니라 "현재보다 큰 가장 작은 seq_no") - const nextSeqQuery = await pool.query( - `SELECT MIN(CAST(seq_no AS int)) as next_seq - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL - AND CAST(seq_no AS int) > $3`, - [wo_id, companyCode, seqNum], - ); - const actualNextSeq = nextSeqQuery.rows[0]?.next_seq; - if (actualNextSeq != null) { - const nextUpdate = await pool.query( - `UPDATE work_order_process - SET status = 'acceptable', - updated_date = NOW() - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - RETURNING id, process_name, status`, - [wo_id, String(actualNextSeq), companyCode], - ); - if (nextUpdate.rowCount > 0) { - logger.info("[pop/production] 다음 공정 상태 전환", { - nextProcess: nextUpdate.rows[0], - }); - } - } - } - } - - // (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨) - - logger.info("[pop/production] save-result 완료 (누적)", { - companyCode, - work_order_process_id, - added: { - production_qty: addProduction, - good_qty: addGood, - defect_qty: addDefect, - }, - accumulated: { total: newTotal, good: newGood, defect: newDefect }, - }); - - // 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음) - const latestData = await pool.query( - `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty - FROM work_order_process WHERE id = $1 AND company_code = $2`, + const latestData = await client.query( + `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, + result_note, result_status, status, input_qty, + is_rework, rework_source_id + FROM work_order_process_result WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); - // 리워크 정보도 응답에 포함 (프론트에서 다음 공정 접수 시 전달 가능) + await client.query("COMMIT"); + const responseData = latestData.rows[0] || result.rows[0]; - if (responseData) { - const reworkInfo = await pool.query( - `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, - [work_order_process_id], - ); - if (reworkInfo.rows[0]?.rework_source_id) { - responseData.rework_source_id = reworkInfo.rows[0].rework_source_id; - responseData.is_rework = reworkInfo.rows[0].is_rework; - } - } return res.json({ success: true, data: responseData, }); } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] save-result 오류:", error); return res.status(500).json({ success: false, message: error.message || "실적 저장 중 오류가 발생했습니다.", }); + } finally { + client.release(); } }; /** * 작업지시(work_instruction) 전체 완료 판정 - * 마지막 공정의 모든 행이 completed이면 작업지시도 완료 처리 + * 신 구조: 마지막 seq 의 각 마스터 wop 에 대해 그 wop 의 wop_result 들이 + * 모두 result_status='confirmed' 이고 SUM(good+concession) >= wi.qty 이면 완료 처리. */ const checkAndCompleteWorkInstruction = async ( pool: any, @@ -1483,21 +1381,27 @@ const checkAndCompleteWorkInstruction = async ( const maxSeq = String(maxSeqResult.rows[0].max_seq); + // 마지막 seq 의 마스터 wop 중 confirmed 가 하나라도 없는 게 있으면 미완료 const incompleteCheck = await pool.query( - `SELECT COUNT(*) as cnt - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND status != 'completed'`, + `SELECT wop.id + FROM work_order_process wop + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND NOT EXISTS ( + SELECT 1 FROM work_order_process_result wr + WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code + AND wr.result_status = 'confirmed' + ) + LIMIT 1`, [woId, maxSeq, companyCode], ); - - if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return; + if ((incompleteCheck.rowCount ?? 0) > 0) return; const totalGoodResult = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, + `SELECT COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) + + COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) as total_good + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3`, [woId, maxSeq, companyCode], ); @@ -1516,37 +1420,21 @@ const checkAndCompleteWorkInstruction = async ( [woId, companyCode, String(completedQty), userId], ); - logger.info("[pop/production] 작업지시 전체 완료", { - woId, - completedQty, - companyCode, - }); - - // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT - if (updateResult.rowCount > 0 && completedQty > 0) { + if ((updateResult.rowCount ?? 0) > 0 && completedQty > 0) { try { const itemId = updateResult.rows[0].item_id; - // item_info에서 item_number 조회 const itemResult = await pool.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [itemId, companyCode], ); - if (itemResult.rowCount === 0) { - logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { - itemId, - companyCode, - }); - return; - } + if (itemResult.rowCount === 0) return; const itemCode = itemResult.rows[0].item_number; - // 마지막 공정의 창고 설정 조회 (마스터 행에서) const warehouseResult = await pool.query( `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL LIMIT 1`, [woId, maxSeq, companyCode], ); @@ -1555,9 +1443,6 @@ const checkAndCompleteWorkInstruction = async ( warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id ) { - logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { - woId, - }); return; } @@ -1565,7 +1450,6 @@ const checkAndCompleteWorkInstruction = async ( const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; - // inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) await upsertInventoryStock( pool, companyCode, @@ -1575,17 +1459,7 @@ const checkAndCompleteWorkInstruction = async ( completedQty, userId, ); - - logger.info("[pop/production] 생산완료→재고 입고 완료", { - woId, - itemCode, - warehouseCode, - locationCode, - qty: completedQty, - companyCode, - }); } catch (inventoryError: any) { - // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) logger.error( "[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError, @@ -1595,8 +1469,8 @@ const checkAndCompleteWorkInstruction = async ( }; /** - * 실적 확정은 더 이상 단일 확정이 아님. - * 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임. + * 실적 확정 — work_order_process_result UPDATE. + * work_order_process_id 는 work_order_process_result.id */ export const confirmResult = async ( req: AuthenticatedRequest, @@ -1618,7 +1492,8 @@ export const confirmResult = async ( } const statusCheck = await pool.query( - `SELECT status, result_status, total_production_qty FROM work_order_process + `SELECT status, result_status, total_production_qty + FROM work_order_process_result WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); @@ -1626,7 +1501,7 @@ export const confirmResult = async ( if (statusCheck.rowCount === 0) { return res.status(404).json({ success: false, - message: "공정을 찾을 수 없습니다.", + message: "접수 카드를 찾을 수 없습니다.", }); } @@ -1642,9 +1517,8 @@ export const confirmResult = async ( }); } - // 수동 확정: 무조건 completed 처리 (수동 완료 용도) const result = await pool.query( - `UPDATE work_order_process + `UPDATE work_order_process_result SET result_status = 'confirmed', status = 'completed', completed_at = NOW()::text, @@ -1652,133 +1526,31 @@ export const confirmResult = async ( writer = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 - RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`, + RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`, [work_order_process_id, companyCode, userId], ); if (result.rowCount === 0) { return res.status(404).json({ success: false, - message: "공정을 찾을 수 없습니다.", + message: "접수 카드를 찾을 수 없습니다.", }); } - // 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용) - const seqCheck = await pool.query( - `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, - wi.qty as instruction_qty - FROM work_order_process wop - JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code - WHERE wop.id = $1 AND wop.company_code = $2`, - [work_order_process_id, companyCode], + // 작업지시 완료 캐스케이드 + const wopLookup = await pool.query( + `SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [result.rows[0].wop_id, companyCode], ); - - if (seqCheck.rowCount > 0) { - const { seq_no, wo_id, parent_process_id, instruction_qty } = - seqCheck.rows[0]; - const seqNum = parseInt(seq_no, 10); - const instrQty = parseInt(instruction_qty, 10) || 0; - - // 다음 공정 활성화 (양품이 있으면) - const goodQty = parseInt(result.rows[0].good_qty, 10) || 0; - if (goodQty > 0) { - const nextSeq = String(seqNum + 1); - await pool.query( - `UPDATE work_order_process - SET status = 'acceptable', - updated_date = NOW() - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL`, - [wo_id, nextSeq, companyCode], - ); - } - - // === BUG-2 FIX: confirmResult에서도 master 합산 === - if (parent_process_id) { - await pool.query( - `UPDATE work_order_process - SET good_qty = sub.sum_good, - defect_qty = sub.sum_defect, - total_production_qty = sub.sum_total, - concession_qty = sub.sum_concession, - updated_date = NOW() - FROM ( - SELECT - COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, - COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, - COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, - COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession - FROM work_order_process - WHERE parent_process_id = $1 AND company_code = $2 - ) sub - WHERE id = $1 AND company_code = $2`, - [parent_process_id, companyCode], - ); - } - - // 마스터 자동완료 캐스케이드 (분할 행인 경우) - if (parent_process_id) { - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, prevSeq, companyCode], - ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } - - const siblingCheck = await pool.query( - `SELECT - COALESCE(SUM(input_qty::int), 0) as total_input, - COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, seq_no, companyCode], - ); - - const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; - const incompleteCount = - parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; - const remainingAcceptable = prevGoodQty - totalInput; - - if (incompleteCount === 0 && remainingAcceptable <= 0) { - await pool.query( - `UPDATE work_order_process - SET status = 'completed', - result_status = 'confirmed', - completed_at = NOW()::text, - completed_by = $3, - updated_date = NOW() - WHERE id = $1 AND company_code = $2 - AND status != 'completed'`, - [parent_process_id, companyCode, userId], - ); - logger.info("[pop/production] confirmResult: 마스터 자동 완료", { - masterId: parent_process_id, - totalInput, - prevGoodQty, - }); - } - } - - // 작업지시 전체 완료 판정 - await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId); + if ((wopLookup.rowCount ?? 0) > 0) { + await checkAndCompleteWorkInstruction( + pool, + wopLookup.rows[0].wo_id, + companyCode, + userId, + ); } - logger.info("[pop/production] confirm-result 완료", { - companyCode, - work_order_process_id, - userId, - finalStatus: result.rows[0].status, - }); - return res.json({ success: true, data: result.rows[0], @@ -1793,8 +1565,7 @@ export const confirmResult = async ( }; /** - * 실적 이력 조회 (work_order_process_log에서 차수별 추출) - * total_production_qty 변경 이력 = 각 차수의 등록 기록 + * 실적 이력 조회 — work_order_process_result 기준 (wop_result.id) */ export const getResultHistory = async ( req: AuthenticatedRequest, @@ -1816,19 +1587,16 @@ export const getResultHistory = async ( }); } - // 소유권 확인 const ownerCheck = await pool.query( - `SELECT id FROM work_order_process WHERE id = $1 AND company_code = $2`, + `SELECT id FROM work_order_process_result WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); if (ownerCheck.rowCount === 0) { return res .status(404) - .json({ success: false, message: "공정을 찾을 수 없습니다." }); + .json({ success: false, message: "접수 카드를 찾을 수 없습니다." }); } - // 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출 - // total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점 const historyResult = await pool.query( `WITH grouped AS ( SELECT @@ -1873,11 +1641,6 @@ export const getResultHistory = async ( }; }); - logger.info("[pop/production] result-history 조회", { - work_order_process_id, - batchCount: batches.length, - }); - return res.json({ success: true, data: batches, @@ -1892,9 +1655,8 @@ export const getResultHistory = async ( }; /** - * 앞공정 완료량 + 접수가능량 조회 - * GET /api/pop/production/available-qty?work_order_process_id=xxx - * 반환: { prevGoodQty, myInputQty, availableQty, instructionQty } + * 앞공정 완료량 + 접수가능량 조회 (신 구조) + * work_order_process_id 는 마스터 work_order_process.id */ export const getAvailableQty = async ( req: AuthenticatedRequest, @@ -1916,7 +1678,7 @@ export const getAvailableQty = async ( } const current = await pool.query( - `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, + `SELECT wop.id, wop.seq_no, wop.wo_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code @@ -1934,108 +1696,49 @@ export const getAvailableQty = async ( const instrQty = parseInt(instruction_qty, 10) || 0; const seqNum = parseInt(seq_no, 10); - // 재작업 카드 여부 확인 - const reworkCheck = await pool.query( - `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, - [work_order_process_id], + // 기접수합계 (같은 wop_id, 리워크 제외) + const totalAccepted = await pool.query( + `SELECT COALESCE(SUM(GREATEST( + CAST(COALESCE(NULLIF(input_qty, ''), '0') AS int), + CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS int) + )), 0) as total_input + FROM work_order_process_result + WHERE wop_id = $1 AND company_code = $2 + AND (is_rework IS NULL OR is_rework NOT IN ('Y','true','1'))`, + [work_order_process_id, companyCode], ); - const isRework = reworkCheck.rows[0]?.is_rework === "Y"; + const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - let myInputQty: number; - let prevGoodQty: number; - let availableQty: number; + const prevGoodResult = await getPrevProcessGoodQty(pool, wo_id, seqNum, companyCode); + const prevGoodQty = prevGoodResult !== null ? prevGoodResult : instrQty; - if (isRework) { - // 재작업 카드: 자체 input_qty가 접수 가능 수량 - const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0; - myInputQty = 0; - prevGoodQty = reworkInput; - availableQty = reworkInput; - } else { - // 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외) - const totalAccepted = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL - AND (is_rework IS NULL OR is_rework != 'Y')`, - [wo_id, seq_no, companyCode], - ); - myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + const availableQty = Math.max(0, prevGoodQty - myInputQty); - prevGoodQty = instrQty; - // 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단 - // (라우팅 seq_no가 1, 2, 3이 아니라 10, 20, 30 같은 비순차여도 정상 동작) - const minSeqCheck = await pool.query( - `SELECT MIN(CAST(seq_no AS int)) as min_seq - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`, - [wo_id, companyCode], - ); - const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; - const isFirstProcess = seqNum <= minSeq; - if (!isFirstProcess) { - // 이전 공정 찾기 (seq_no가 더 작은 가장 가까운 공정) - const prevProcessSeq = await pool.query( - `SELECT MAX(CAST(seq_no AS int)) as prev_seq - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL - AND CAST(seq_no AS int) < $3`, - [wo_id, companyCode, seqNum], - ); - const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; - if (actualPrevSeq != null) { - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, String(actualPrevSeq), companyCode], - ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } - } - availableQty = Math.max(0, prevGoodQty - myInputQty); - } - - logger.info("[pop/production] available-qty 조회", { - work_order_process_id, - prevGoodQty, - myInputQty, - availableQty, - instructionQty: instrQty, - }); - - // 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용) - // rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산 + // 앞공정 리워크 미소진 let reworkAvailableQty = 0; - if (!isRework && seqNum > 1) { + if (seqNum > 1) { const prevSeq = String(seqNum - 1); - // 앞공정의 리워크 완료 SPLIT들 (rework_source_id별) const reworkSplits = await pool.query( - `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL - AND is_rework = 'Y' - AND status = 'completed' - AND good_qty::int > 0 - GROUP BY rework_source_id`, + `SELECT wr.rework_source_id, COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) as rg + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND wr.is_rework IN ('Y','true','1') + AND wr.status = 'completed' + AND CAST(NULLIF(wr.good_qty, '') AS int) > 0 + GROUP BY wr.rework_source_id`, [wo_id, prevSeq, companyCode], ); - // 현재 공정에서 각 rework_source_id별로 소비된 수량 for (const rs of reworkSplits.rows) { const srcId = rs.rework_source_id; const srcGood = parseInt(rs.rg, 10) || 0; const consumedResult = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as consumed - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL - AND is_rework = 'Y' - AND rework_source_id = $4`, + `SELECT COALESCE(SUM(CAST(NULLIF(wr.input_qty, '') AS int)), 0) as consumed + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND wr.is_rework IN ('Y','true','1') + AND wr.rework_source_id = $4`, [wo_id, seq_no, companyCode, srcId], ); const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; @@ -2050,7 +1753,7 @@ export const getAvailableQty = async ( myInputQty, availableQty, instructionQty: instrQty, - reworkAvailableQty, // 리워크 물량 포함 수량 + reworkAvailableQty, }, }); } catch (error: any) { @@ -2063,12 +1766,9 @@ export const getAvailableQty = async ( }; /** - * 공정 접수 (수량 지정) - * POST /api/pop/production/accept-process - * body: { work_order_process_id, accept_qty } - * - 접수 상한 = 앞공정.good_qty - 내.input_qty (첫 공정은 지시수량 - input_qty) - * - 추가 접수 가능 (in_progress 상태에서도) - * - status: acceptable/waiting -> in_progress (또는 이미 in_progress면 유지) + * 공정 접수 — work_order_process_result INSERT (신 구조) + * body: { work_order_process_id, accept_qty, batch_id?, rework_source_id?, equipment_code? } + * work_order_process_id 는 항상 마스터 work_order_process.id */ export const acceptProcess = async ( req: AuthenticatedRequest, @@ -2082,7 +1782,6 @@ export const acceptProcess = async ( const { work_order_process_id, accept_qty } = req.body; if (!work_order_process_id || !accept_qty) { - client.release(); return res.status(400).json({ success: false, message: "work_order_process_id와 accept_qty가 필요합니다.", @@ -2091,7 +1790,6 @@ export const acceptProcess = async ( const qty = parseInt(accept_qty, 10); if (qty <= 0) { - client.release(); return res .status(400) .json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); @@ -2099,11 +1797,27 @@ export const acceptProcess = async ( await client.query("BEGIN"); - // 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지) + // wi_* 편집(syncMasterChecklistFromWi) 과 직렬화하여 마스터 스냅샷 불변 계약 보장 + const woRow = await client.query( + `SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode], + ); + if (woRow.rowCount === 0) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + await client.query( + `SELECT pg_advisory_xact_lock(hashtext($1))`, + [`wi_snapshot:${companyCode}:${woRow.rows[0].wo_id}`], + ); + + // 마스터 wop FOR UPDATE (동시 접수 race 방지) const current = await client.query( - `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, - wop.standard_time, wop.equipment_code, wop.routing_detail_id, + wop.standard_time, wop.routing_detail_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code @@ -2114,272 +1828,185 @@ export const acceptProcess = async ( if (current.rowCount === 0) { await client.query("ROLLBACK"); - client.release(); return res .status(404) .json({ success: false, message: "공정을 찾을 수 없습니다." }); } const row = current.rows[0]; - const masterId = row.parent_process_id || row.id; + const masterId: string = row.id; + const instrQty = parseInt(row.instruction_qty, 10) || 0; + const seqNum = parseInt(row.seq_no, 10); - if (row.status === "completed") { + // 완료 판정: confirmed 존재 + SUM(good+concession) >= instrQty → 거부 + const completedCheck = await client.query( + `SELECT + EXISTS ( + SELECT 1 FROM work_order_process_result + WHERE wop_id = $1 AND company_code = $2 AND result_status = 'confirmed' + ) AS has_confirmed, + COALESCE(SUM(CAST(NULLIF(good_qty, '') AS int)), 0) + + COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS int)), 0) AS total_good + FROM work_order_process_result + WHERE wop_id = $1 AND company_code = $2`, + [masterId, companyCode], + ); + const hasConfirmed = completedCheck.rows[0].has_confirmed === true; + const totalGood = parseInt(completedCheck.rows[0].total_good, 10) || 0; + if (hasConfirmed && instrQty > 0 && totalGood >= instrQty) { await client.query("ROLLBACK"); - client.release(); return res .status(400) .json({ success: false, message: "이미 완료된 공정입니다." }); } - if (row.status !== "acceptable") { - await client.query("ROLLBACK"); - client.release(); - return res.status(400).json({ - success: false, - message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.`, - }); - } - const instrQty = parseInt(row.instruction_qty, 10) || 0; - const seqNum = parseInt(row.seq_no, 10); - - // 재작업 카드 여부 확인 - const isReworkCard = await client.query( - `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, - [work_order_process_id], + // 기접수합계 (리워크 제외) + const totalAccepted = await client.query( + `SELECT COALESCE(SUM(GREATEST( + CAST(COALESCE(NULLIF(input_qty, ''), '0') AS int), + CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS int) + )), 0) as total_input + FROM work_order_process_result + WHERE wop_id = $1 AND company_code = $2 + AND (is_rework IS NULL OR is_rework != 'Y')`, + [masterId, companyCode], ); - const isRework = isReworkCard.rows[0]?.is_rework === "Y"; - const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0; + const currentTotalInput = + parseInt(totalAccepted.rows[0].total_input, 10) || 0; - let prevGoodQty: number; - let currentTotalInput: number; - let availableQty: number; + // 앞공정 양품 계산 + const prevGoodResult = await getPrevProcessGoodQty(client, row.wo_id, seqNum, companyCode); + const prevGoodQty = prevGoodResult !== null ? prevGoodResult : instrQty; - if (isRework) { - // 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관) - prevGoodQty = reworkInputQty; - currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음 - availableQty = reworkInputQty; - } else { - // 일반 카드: 앞공정 양품 - 기접수합계 - const totalAccepted = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL - AND (is_rework IS NULL OR is_rework != 'Y')`, - [row.wo_id, row.seq_no, companyCode], - ); - currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - - prevGoodQty = instrQty; - // 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단 - const minSeqCheck = await client.query( - `SELECT MIN(CAST(seq_no AS int)) as min_seq - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`, - [row.wo_id, companyCode], - ); - const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; - const isFirstProcess = seqNum <= minSeq; - if (!isFirstProcess) { - // 이전 공정 = 이 공정보다 작은 seq_no 중 가장 큰 값 - const prevProcessSeq = await client.query( - `SELECT MAX(CAST(seq_no AS int)) as prev_seq - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL - AND CAST(seq_no AS int) < $3`, - [row.wo_id, companyCode, seqNum], - ); - const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; - const prevSeq = - actualPrevSeq != null ? String(actualPrevSeq) : String(seqNum - 1); - const prevProcess = await client.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [row.wo_id, prevSeq, companyCode], - ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } - availableQty = prevGoodQty - currentTotalInput; - } + const availableQty = prevGoodQty - currentTotalInput; if (qty > availableQty) { await client.query("ROLLBACK"); - client.release(); return res.status(400).json({ success: false, message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, }); } - // batch_id: 컬럼이 있으면 포함, 없으면 제외 - const batchId = - req.body.batch_id || - `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - const hasBatchCol = _batchMigrationDone; - - // 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지 + // 리워크 정보 결정 let splitIsRework: string | null = null; let splitReworkSourceId: string | null = null; - if (isRework) { - // 케이스 1: 리워크 카드에서 직접 접수 - const parentReworkInfo = await client.query( - `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, - [work_order_process_id], - ); - splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null; - splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null; - } else if (req.body.rework_source_id) { - // 케이스 2: 프론트에서 리워크 추적 정보 전달 + if (req.body.rework_source_id) { splitIsRework = "Y"; splitReworkSourceId = req.body.rework_source_id; } else if (seqNum > 1) { - // 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인 const prevSeq = String(seqNum - 1); - // rework_source_id별로 개별 추적 const prevReworkSplits = await client.query( - `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL - AND is_rework = 'Y' - AND status = 'completed' - AND good_qty::int > 0 - GROUP BY rework_source_id`, + `SELECT wr.rework_source_id, + COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) as rework_good + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND wr.is_rework = 'Y' + AND wr.status = 'completed' + AND CAST(NULLIF(wr.good_qty, '') AS int) > 0 + GROUP BY wr.rework_source_id`, [row.wo_id, prevSeq, companyCode], ); - - // 각 rework_source별로 미소진 수량 확인 for (const rs of prevReworkSplits.rows) { const srcId = rs.rework_source_id; const srcGood = parseInt(rs.rework_good, 10) || 0; const consumedResult = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as consumed - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL - AND is_rework = 'Y' - AND rework_source_id = $4`, + `SELECT COALESCE(SUM(CAST(NULLIF(wr.input_qty, '') AS int)), 0) as consumed + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND wr.is_rework = 'Y' + AND wr.rework_source_id = $4`, [row.wo_id, row.seq_no, companyCode, srcId], ); const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; const remaining = srcGood - consumed; - if (remaining > 0 && qty <= remaining) { - // 합류 판정: 일반 물량이 있으면 합류(마크 없음), 없으면 마크 부착 const normalAvailable = availableQty - remaining; if (normalAvailable <= 0) { - // 일반 물량 없음 → 합류 불가 → 리워크 마크 splitIsRework = "Y"; splitReworkSourceId = srcId; } - // normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null) break; } } } - // 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함) - const reworkCols = splitIsRework ? ", is_rework, rework_source_id" : ""; - const reworkVals = splitIsRework - ? `, $${hasBatchCol ? 15 : 14}, $${hasBatchCol ? 16 : 15}` - : ""; - const reworkParams = splitIsRework - ? [splitIsRework, splitReworkSourceId] - : []; - - const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, - standard_time, equipment_code, routing_detail_id, - status, input_qty, good_qty, defect_qty, total_production_qty, - result_status, accepted_by, accepted_at, started_at, - parent_process_id, company_code, writer${hasBatchCol ? ", batch_id" : ""}${reworkCols}`; - const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, - 'in_progress', $10, '0', '0', '0', - 'draft', $11, NOW()::text, NOW()::text, - $12, $13, $11${hasBatchCol ? ", $14" : ""}${reworkVals}`; - const insertParams = [ - row.wo_id, - row.seq_no, - row.process_code, - row.process_name, - row.is_required, - row.is_fixed_order, - row.standard_time, - row.equipment_code, - row.routing_detail_id, - String(qty), - userId, - masterId, - companyCode, - ...(hasBatchCol ? [batchId] : []), - ...reworkParams, - ]; - - const result = await client.query( - `INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals}) - RETURNING id, input_qty, status, process_name, result_status, accepted_by`, - insertParams, - ); - - // 분할 행에 체크리스트 복사 - const splitId = result.rows[0].id; - const checklistCount = await copyChecklistToSplit( - client, - masterId, - splitId, - row.routing_detail_id, - companyCode, - userId, - ); - - // 마스터 행의 input_qty를 분할 합계로 갱신 (리워크 접수 시에는 마스터 input_qty 변경 안 함) - let newTotalInput = currentTotalInput + qty; - if (!isRework) { - await client.query( - `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() - WHERE id = $1 AND company_code = $2`, - [masterId, companyCode, String(newTotalInput)], - ); - } else { - newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지 - // 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경 - // (다른 리워크 카드에 영향 없도록 id 정확히 지정) - const reworkAlreadyAccepted = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total - FROM work_order_process - WHERE parent_process_id = $1 AND company_code = $2`, - [work_order_process_id, companyCode], - ); - const totalReworkAccepted = - (parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty; - if (totalReworkAccepted >= reworkInputQty) { - await client.query( - `UPDATE work_order_process SET status = 'completed', updated_date = NOW() - WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode], + // work_order_process_result INSERT (seq=MAX+1, UNIQUE 충돌 시 1회 재시도) + let inserted: any = null; + for (let attempt = 0; attempt < 2 && !inserted; attempt++) { + try { + const result = await client.query( + `INSERT INTO work_order_process_result ( + id, wop_id, seq, company_code, + status, result_status, + accepted_by, accepted_at, started_at, + input_qty, good_qty, defect_qty, concession_qty, total_production_qty, + equipment_code, batch_id, + is_rework, rework_source_id, + writer, created_date, updated_date + ) VALUES ( + gen_random_uuid()::text, $1::varchar, + (SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1 AND company_code = $2), + $2, + 'in_progress', 'draft', + $3, NOW()::text, NOW()::text, + $4, '0', '0', '0', '0', + $5, $6, + $7, $8, + $3, NOW(), NOW() + ) RETURNING id, seq, input_qty, status, result_status, accepted_by`, + [ + masterId, + companyCode, + userId, + String(qty), + req.body.equipment_code || null, + req.body.batch_id || null, + splitIsRework, + splitReworkSourceId, + ], ); + inserted = result.rows[0]; + } catch (err: any) { + if (err?.code !== "23505" || attempt >= 1) { + await client.query("ROLLBACK"); + logger.error("[pop/production] accept-process INSERT 오류:", err); + return res.status(500).json({ success: false, message: err.message }); + } } } + // 체크리스트 복사: masterId → wop_result.id + // 접수는 항상 마스터 스냅샷(B 전략)에서만 복사 — 첫 접수 이후 일관성 보장 + const checklistCount = await copyChecklistToSplit( + client, + masterId, + inserted.id, + row.routing_detail_id, + companyCode, + userId, + { skipAStrategy: true }, + ); + await client.query("COMMIT"); - logger.info("[pop/production] accept-process 분할 접수 완료", { + logger.info("[pop/production] accept-process 접수 완료", { companyCode, userId, masterId, - splitId, + wopResultId: inserted.id, + seq: inserted.seq, acceptedQty: qty, - totalAccepted: newTotalInput, - prevGoodQty, checklistCount, }); - const acceptData = result.rows[0] || {}; + const acceptData: any = { + ...inserted, + process_name: row.process_name, + }; if (splitReworkSourceId) { acceptData.rework_source_id = splitReworkSourceId; acceptData.is_rework = splitIsRework; @@ -2388,7 +2015,7 @@ export const acceptProcess = async ( return res.json({ success: true, data: acceptData, - message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, + message: `${qty}개 접수 완료`, }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); @@ -2403,8 +2030,8 @@ export const acceptProcess = async ( }; /** - * 접수 취소: input_qty를 0으로 리셋하고 status를 acceptable로 되돌림 - * 조건: 아직 실적(total_production_qty)이 없어야 함 + * 접수 취소 — work_order_process_result 기준 + * work_order_process_id 는 wop_result.id */ export const cancelAccept = async ( req: AuthenticatedRequest, @@ -2425,31 +2052,24 @@ export const cancelAccept = async ( } const current = await pool.query( - `SELECT id, status, input_qty, total_production_qty, result_status, - parent_process_id, wo_id, seq_no, process_name, - target_warehouse_id, target_location_code, good_qty, concession_qty - FROM work_order_process - WHERE id = $1 AND company_code = $2`, + `SELECT wr.id, wr.status, wr.input_qty, wr.total_production_qty, wr.result_status, + wr.wop_id, wr.good_qty, wr.concession_qty, + wop.wo_id, wop.seq_no, wop.process_name, + wop.target_warehouse_id, wop.target_location_code + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wr.id = $1 AND wr.company_code = $2`, [work_order_process_id, companyCode], ); if (current.rowCount === 0) { return res .status(404) - .json({ success: false, message: "공정을 찾을 수 없습니다." }); + .json({ success: false, message: "접수 카드를 찾을 수 없습니다." }); } const proc = current.rows[0]; - // 분할 행만 취소 가능 (원본 행은 취소 대상이 아님) - if (!proc.parent_process_id) { - return res.status(400).json({ - success: false, - message: - "원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.", - }); - } - if (proc.status !== "in_progress") { return res.status(400).json({ success: false, @@ -2476,19 +2096,17 @@ export const cancelAccept = async ( await client.query("BEGIN"); if (totalProduced === 0) { - // 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제 await client.query( `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); await client.query( - `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, + `DELETE FROM work_order_process_result WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); } else { - // 실적이 있으면 input_qty를 실적 수량으로 축소 + completed await client.query( - `UPDATE work_order_process + `UPDATE work_order_process_result SET input_qty = $3, status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $4, updated_date = NOW(), writer = $4 @@ -2497,23 +2115,22 @@ export const cancelAccept = async ( ); } - // 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감 + // 재고 원복 if (proc.target_warehouse_id) { const inboundQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); if (inboundQty > 0) { - // work_instruction에서 item_id 조회 const wiResult = await client.query( `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode], ); - if (wiResult.rowCount > 0) { + if ((wiResult.rowCount ?? 0) > 0) { const itemResult = await client.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [wiResult.rows[0].item_id, companyCode], ); - if (itemResult.rowCount > 0) { + if ((itemResult.rowCount ?? 0) > 0) { const itemCode = itemResult.rows[0].item_number; const locCode = proc.target_location_code || proc.target_warehouse_id; @@ -2537,24 +2154,6 @@ export const cancelAccept = async ( } } - // 마스터 행의 input_qty를 분할 합계로 재계산 - const remainingSplits = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE parent_process_id = $1 AND company_code = $2`, - [proc.parent_process_id, companyCode], - ); - const newMasterInput = - parseInt(remainingSplits.rows[0].total_input, 10) || 0; - - // 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지 - await client.query( - `UPDATE work_order_process - SET status = 'acceptable', input_qty = $3, updated_date = NOW() - WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, - [proc.parent_process_id, companyCode, String(newMasterInput)], - ); - await client.query("COMMIT"); } catch (txErr) { await client.query("ROLLBACK"); @@ -2563,17 +2162,6 @@ export const cancelAccept = async ( client.release(); } - logger.info("[pop/production] cancel-accept 완료 (분할 행)", { - companyCode, - userId, - work_order_process_id, - masterId: proc.parent_process_id, - previousInputQty: currentInputQty, - totalProduced, - cancelledQty, - action: totalProduced === 0 ? "DELETE" : "SHRINK", - }); - return res.json({ success: true, data: { id: work_order_process_id, process_name: proc.process_name }, @@ -2614,7 +2202,6 @@ export const getWarehouses = async ( /** * 특정 창고의 위치(로케이션) 목록 조회 - * warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭 */ export const getWarehouseLocations = async ( req: AuthenticatedRequest, @@ -2630,7 +2217,6 @@ export const getWarehouseLocations = async ( .json({ success: false, message: "warehouseId는 필수입니다." }); } - // warehouse_info.id → warehouse_code 변환 const whInfo = await pool.query( `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, [warehouseId, companyCode], @@ -2655,8 +2241,7 @@ export const getWarehouseLocations = async ( }; /** - * 마지막 공정 여부 확인 - * 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막 + * 마지막 공정 여부 확인 — 마스터 wop 기준 (seq_no MAX) */ export const isLastProcess = async ( req: AuthenticatedRequest, @@ -2670,9 +2255,8 @@ export const isLastProcess = async ( return res.json({ success: true, data: { isLast: false } }); } - // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) const process = await pool.query( - `SELECT wo_id, seq_no, parent_process_id + `SELECT wo_id, seq_no, target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, [processId, companyCode], @@ -2681,35 +2265,14 @@ export const isLastProcess = async ( return res.json({ success: true, data: { isLast: false } }); } - const { wo_id, seq_no, parent_process_id } = process.rows[0]; - - // 분할 행이면 마스터의 seq_no 기준으로 판단 - let effectiveSeqNo = seq_no; - if (parent_process_id) { - const master = await pool.query( - `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, - [parent_process_id, companyCode], - ); - if (master.rowCount > 0) { - effectiveSeqNo = master.rows[0].seq_no; - } - } + const { wo_id, seq_no, target_warehouse_id, target_location_code } = process.rows[0]; const next = await pool.query( `SELECT id FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) - AND parent_process_id IS NULL LIMIT 1`, - [wo_id, companyCode, effectiveSeqNo], - ); - - // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) - const warehouseInfo = await pool.query( - `SELECT target_warehouse_id, target_location_code - FROM work_order_process - WHERE id = $1 AND company_code = $2`, - [processId, companyCode], + [wo_id, companyCode, seq_no], ); return res.json({ @@ -2717,9 +2280,9 @@ export const isLastProcess = async ( data: { isLast: next.rowCount === 0, woId: wo_id, - seqNo: effectiveSeqNo, - targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, - targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, + seqNo: seq_no, + targetWarehouseId: target_warehouse_id || null, + targetLocationCode: target_location_code || null, }, }); } catch (error: any) { @@ -2729,9 +2292,7 @@ export const isLastProcess = async ( }; /** - * 공정의 목표 창고/위치 업데이트 - * 마지막 공정 완료 전 또는 완료 후 창고를 지정한다. - * 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다. + * 공정의 목표 창고/위치 업데이트 (마스터 wop 기준) */ export const updateTargetWarehouse = async ( req: AuthenticatedRequest, @@ -2751,44 +2312,22 @@ export const updateTargetWarehouse = async ( }); } - // 분할 행이면 마스터 행도 함께 업데이트 - const procInfo = await pool.query( - `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, - [work_order_process_id, companyCode], + await pool.query( + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [ + work_order_process_id, + companyCode, + target_warehouse_id, + target_location_code || null, + userId, + ], ); - const idsToUpdate = [work_order_process_id]; - if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { - idsToUpdate.push(procInfo.rows[0].parent_process_id); - } - - for (const id of idsToUpdate) { - await pool.query( - `UPDATE work_order_process - SET target_warehouse_id = $3, - target_location_code = $4, - writer = $5, - updated_date = NOW() - WHERE id = $1 AND company_code = $2`, - [ - id, - companyCode, - target_warehouse_id, - target_location_code || null, - userId, - ], - ); - } - - logger.info("[pop/production] 목표 창고 업데이트", { - companyCode, - userId, - work_order_process_id, - target_warehouse_id, - target_location_code, - updatedIds: idsToUpdate, - }); - return res.json({ success: true, data: { target_warehouse_id, target_location_code }, @@ -2800,10 +2339,8 @@ export const updateTargetWarehouse = async ( }; /** - * 독립 재고 입고 API - * 창고 저장 + inventory_stock UPSERT를 한 번에 수행한다. - * 실적(save-result) 완료 후 나중에 창고를 선택해도 재고가 들어가도록 분리. - * 이중 입고 방지: target_warehouse_id가 이미 설정된 경우 "이미 입고됨" 반환. + * 독립 재고 입고 — work_order_process_result 기준. + * body.work_order_process_id 는 wop_result.id */ export const inventoryInbound = async ( req: AuthenticatedRequest, @@ -2826,11 +2363,12 @@ export const inventoryInbound = async ( await client.query("BEGIN"); - // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 const procResult = await client.query( - `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework - FROM work_order_process - WHERE id = $1 AND company_code = $2`, + `SELECT wr.wop_id, wr.good_qty, wr.concession_qty, wr.is_rework, + wop.wo_id, wop.seq_no, wop.target_warehouse_id + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wr.id = $1 AND wr.company_code = $2`, [work_order_process_id, companyCode], ); @@ -2838,13 +2376,12 @@ export const inventoryInbound = async ( await client.query("ROLLBACK"); return res.status(404).json({ success: false, - message: "해당 공정을 찾을 수 없습니다.", + message: "해당 접수 카드를 찾을 수 없습니다.", }); } const proc = procResult.rows[0]; - // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 if (proc.target_warehouse_id) { await client.query("ROLLBACK"); return res.status(409).json({ @@ -2866,7 +2403,6 @@ export const inventoryInbound = async ( }); } - // 2. work_instruction에서 item_id 조회 const wiResult = await client.query( `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode], @@ -2882,7 +2418,6 @@ export const inventoryInbound = async ( const itemId = wiResult.rows[0].item_id; - // 3. item_info에서 item_number 조회 const itemResult = await client.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [itemId, companyCode], @@ -2899,7 +2434,6 @@ export const inventoryInbound = async ( const itemCode = itemResult.rows[0].item_number; const effectiveLocationCode = location_code || null; - // 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) await upsertInventoryStock( client, companyCode, @@ -2910,28 +2444,20 @@ export const inventoryInbound = async ( userId, ); - // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) - const idsToUpdate = [work_order_process_id]; - if (proc.parent_process_id) { - idsToUpdate.push(proc.parent_process_id); - } + // 마스터 wop 에 target_warehouse_id 저장 + await client.query( + `UPDATE work_order_process + SET target_warehouse_id = $3, + target_location_code = $4, + writer = $5, + updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [proc.wop_id, companyCode, warehouse_code, location_code || null, userId], + ); - for (const id of idsToUpdate) { - await client.query( - `UPDATE work_order_process - SET target_warehouse_id = $3, - target_location_code = $4, - writer = $5, - updated_date = NOW() - WHERE id = $1 AND company_code = $2`, - [id, companyCode, warehouse_code, location_code || null, userId], - ); - } - - // 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존) if (proc.is_rework === "Y") { await client.query( - `UPDATE work_order_process SET is_rework = NULL, updated_date = NOW() + `UPDATE work_order_process_result SET is_rework = NULL, updated_date = NOW() WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); @@ -2939,17 +2465,6 @@ export const inventoryInbound = async ( await client.query("COMMIT"); - logger.info("[pop/production] 독립 재고 입고 완료", { - companyCode, - userId, - work_order_process_id, - itemCode, - warehouse_code, - location_code: effectiveLocationCode, - qty: goodQty, - reworkCleared: proc.is_rework === "Y", - }); - return res.json({ success: true, message: "재고 입고가 완료되었습니다.", @@ -2971,7 +2486,6 @@ export const inventoryInbound = async ( /** * 간이 재고 입고 (공정 접수 없이 바로 입고) - * 품목 + 수량 + 창고만으로 inventory_stock UPSERT + inbound_mng 이력 기록 */ export const quickInventoryInbound = async ( req: AuthenticatedRequest, @@ -2985,7 +2499,6 @@ export const quickInventoryInbound = async ( const userId = req.user!.userId; const { item_id, qty, warehouse_code, location_code, remark } = req.body; - // 필수 파라미터 검증 if (!item_id || !qty || !warehouse_code) { return res.status(400).json({ success: false, @@ -3003,7 +2516,6 @@ export const quickInventoryInbound = async ( await client.query("BEGIN"); - // 1. item_info에서 item_number, item_name 조회 const itemResult = await client.query( `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, @@ -3022,7 +2534,6 @@ export const quickInventoryInbound = async ( const itemCode = item.item_number; const effectiveLocationCode = location_code || null; - // 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) await upsertInventoryStock( client, companyCode, @@ -3033,7 +2544,6 @@ export const quickInventoryInbound = async ( userId, ); - // 3. inbound_mng에 간이입고 이력 기록 const seqResult = await client.query( `SELECT COALESCE(MAX( CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' @@ -3080,17 +2590,6 @@ export const quickInventoryInbound = async ( await client.query("COMMIT"); - logger.info("[pop/production] 간이 재고 입고 완료", { - companyCode, - userId, - item_id, - itemCode, - warehouse_code, - location_code: effectiveLocationCode, - qty: parsedQty, - inboundNumber, - }); - return res.json({ success: true, message: "간이 재고 입고가 완료되었습니다.", @@ -3113,9 +2612,7 @@ export const quickInventoryInbound = async ( }; /** - * 재작업 이력 조회 - * 작업지시(wo_id) 기준으로 모든 재작업 체인을 반환한다. - * 원본 → 재작업1 → 재작업2 → ... 순서로 체인 추적. + * 재작업 이력 조회 — 신 구조: 접수 카드(wop_result) 기반 */ export const getReworkHistory = async ( req: AuthenticatedRequest, @@ -3132,30 +2629,26 @@ export const getReworkHistory = async ( } const result = await pool.query( - `SELECT id, seq_no, process_code, process_name, status, - input_qty, good_qty, defect_qty, concession_qty, - is_rework, rework_source_id, parent_process_id, - accepted_by, accepted_at, started_at, completed_at, - created_date - FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 - AND (is_rework = 'Y' OR is_rework = '1' OR defect_qty::int > 0 OR parent_process_id IS NOT NULL) - ORDER BY created_date ASC`, + `SELECT wr.id, wop.seq_no, wop.process_code, wop.process_name, + wr.status, wr.input_qty, wr.good_qty, wr.defect_qty, wr.concession_qty, + wr.is_rework, wr.rework_source_id, wr.wop_id, + wr.accepted_by, wr.accepted_at, wr.started_at, wr.completed_at, + wr.created_date + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wop.wo_id = $1 AND wr.company_code = $2 + AND (wr.is_rework = 'Y' OR wr.is_rework = '1' OR CAST(NULLIF(wr.defect_qty, '') AS int) > 0) + ORDER BY wr.created_date ASC`, [woId, companyCode], ); - // 체인 구성: rework_source_id를 따라 트리 구조 const rows = result.rows; - const byId: Record = {}; - for (const r of rows) byId[r.id] = r; - const chains: Array<{ source: (typeof rows)[0]; reworks: typeof rows; totalReworkCount: number; }> = []; - // 원본 행(불량 발생한 것) 찾기 const reworkSourceIds = new Set( rows.filter((r) => r.rework_source_id).map((r) => r.rework_source_id), ); @@ -3168,14 +2661,12 @@ export const getReworkHistory = async ( for (const src of sources) { const chain: typeof rows = []; const visited = new Set(); - // 이 소스에서 시작하는 재작업 체인 추적 const queue = rows.filter((r) => r.rework_source_id === src.id); while (queue.length > 0) { const item = queue.shift()!; if (visited.has(item.id)) continue; visited.add(item.id); chain.push(item); - // 이 재작업에서 또 재작업이 나온 것 찾기 const next = rows.filter((r) => r.rework_source_id === item.id); queue.push(...next); } @@ -3205,7 +2696,7 @@ export const getReworkHistory = async ( /** * 공정별 BOM 자재 목록 + 소요량 계산 - * work_order_process_id → item_code → bom + bom_detail 조회 + * processId = 마스터 work_order_process.id */ export const getBomMaterials = async ( req: AuthenticatedRequest, @@ -3221,10 +2712,12 @@ export const getBomMaterials = async ( .json({ success: false, message: "processId 필수" }); } - // 1. work_order_process → work_instruction → item_code, plan_qty const procResult = await pool.query( - `SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty, - wi.item_id, wi.qty as instruction_qty + `SELECT wop.wo_id, wop.process_code, wop.plan_qty, + wi.item_id, wi.qty as instruction_qty, + (SELECT COALESCE(SUM(CAST(NULLIF(input_qty, '') AS int)), 0) + FROM work_order_process_result + WHERE wop_id = wop.id AND company_code = wop.company_code) as total_input FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, @@ -3238,11 +2731,14 @@ export const getBomMaterials = async ( } const proc = procResult.rows[0]; const processQty = parseInt( - proc.input_qty || proc.plan_qty || proc.instruction_qty || "0", + String( + proc.total_input && parseInt(proc.total_input, 10) > 0 + ? proc.total_input + : proc.plan_qty || proc.instruction_qty || "0", + ), 10, ); - // 2. item_info → item_code (item_number) const itemResult = await pool.query( `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, [proc.item_id, companyCode], @@ -3252,10 +2748,10 @@ export const getBomMaterials = async ( } const itemCode = itemResult.rows[0].item_number; - // 3. BOM 조회 const bomResult = await pool.query( `SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate, - i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit + i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit, + b.base_qty FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info i ON bd.child_item_id = i.id AND i.company_code = b.company_code @@ -3264,21 +2760,23 @@ export const getBomMaterials = async ( [itemCode, proc.item_id, companyCode], ); - // 4. 소요량 계산 - const bomBase = await pool.query( - `SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`, - [itemCode, proc.item_id, companyCode], - ); - const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; + const baseQty = parseFloat(bomResult.rows[0]?.base_qty || "1") || 1; - // 기존 투입량 조회 (item_code별 합산 — detail_content에 item_code 저장됨) + // 투입량: 마스터 wop.id 직결 + 접수 카드들(wop_result.id) 의 material_input 전부 합산 const inputResult = await pool.query( `SELECT detail_content as item_code, SUM(CAST(NULLIF(result_value, '') AS numeric)) as total_input FROM process_work_result - WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' + WHERE company_code = $1 + AND detail_type = 'material_input' AND result_value IS NOT NULL AND result_value != '' + AND ( + work_order_process_id = $2 + OR work_order_process_id IN ( + SELECT id FROM work_order_process_result WHERE wop_id = $2 AND company_code = $1 + ) + ) GROUP BY detail_content`, - [processId, companyCode], + [companyCode, processId], ); const inputMap = new Map(); for (const row of inputResult.rows) { @@ -3324,7 +2822,7 @@ export const getBomMaterials = async ( /** * 자재 투입 기록 저장 - * BOM 기준과 다른 수량도 허용 (유동 투입) + * work_order_process_id 는 wop_result.id (접수 카드) 기준으로 저장. */ export const saveMaterialInput = async ( req: AuthenticatedRequest, @@ -3359,7 +2857,6 @@ export const saveMaterialInput = async ( warehouse_code, location_code, } = input; - // item_code/qty 등 대안 필드명도 허용 const effectiveItemId = child_item_id || input.item_id || input.item_code || child_item_code; const effectiveItemCode = @@ -3372,7 +2869,6 @@ export const saveMaterialInput = async ( const parsedQty = parseFloat(String(effectiveQty)); if (isNaN(parsedQty) || parsedQty <= 0) continue; - // 투입 기록 INSERT (process_work_result에 material_input 타입으로) const insertResult = await client.query( `INSERT INTO process_work_result ( id, company_code, work_order_process_id, @@ -3402,7 +2898,6 @@ export const saveMaterialInput = async ( ], ); - // 재고 차감: warehouse_code 있으면 그 창고, 없으면 자동으로 재고가 있는 창고 탐색 let effectiveWh = warehouse_code; let effectiveLoc = location_code; if (!effectiveWh) { @@ -3462,6 +2957,7 @@ export const saveMaterialInput = async ( /** * 자재 투입 현황 조회 + * processId 는 wop_result.id (접수 카드) 기준 */ export const getMaterialInputs = async ( req: AuthenticatedRequest, @@ -3495,15 +2991,8 @@ export const getMaterialInputs = async ( }; /** - * 체크리스트 조회 (judgment_criteria 조인 포함) - * - * process_work_result를 조회하면서 inspection_standard.judgment_criteria를 - * LEFT JOIN으로 같이 반환한다. - * - * UI는 프론트의 resolveInputType()에서 - * 1순위: judgment_criteria (CAT_JC_01~04) - * 2순위: detail_type 폴백 - * 으로 입력 UI를 결정한다. + * 체크리스트 조회 + * processId 는 wop_result.id (접수 카드) 기준 */ export const getChecklistItems = async ( req: AuthenticatedRequest, @@ -3573,188 +3062,160 @@ export const getChecklistItems = async ( } }; -/** - * 작업공정 목록 조회 (POP 공정실행 화면) - * GET /api/pop/production/processes - * - * 응답: WorkOrderProcessRaw[] - * - work_order_process (기준정보) + work_order_process_result (실적 배열) - * - plan_qty는 work_instruction_detail.qty에서 가져옴 - */ +/* ================================================================== */ +/* getProcessList — 신 구조 (work_order_process + work_order_process_result) */ +/* ================================================================== */ + +let _processListIndexesEnsured = false; +async function ensureProcessListIndexes() { + if (_processListIndexesEnsured) return; + try { + const pool = getPool(); + await pool.query( + "CREATE INDEX IF NOT EXISTS idx_wop_wo_seq ON work_order_process (wo_id, seq_no)", + ); + _processListIndexesEnsured = true; + } catch { + /* ignore */ + } +} + export const getProcessList = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); - const __debugTag = `[POP-DEBUG][getProcessList][${req.user!.userId}@${req.user!.companyCode}][${new Date().toISOString()}]`; - console.log(`${__debugTag} ▶ ENTER`, { - query: req.query, - headers_user_agent: req.headers["user-agent"], - }); try { const companyCode = req.user!.companyCode; + await ensureBatchIdColumn(); + await ensureProcessListIndexes(); - // 1) 작업공정 기준 목록 - const wopRes = await pool.query( - `SELECT - p.id, p.wo_id, p.seq_no, p.process_code, p.process_name, - p.parent_process_id, p.routing_detail_id, p.batch_id, - p.target_warehouse_id, p.target_location_code, p.use_default_warehouse, - p.standard_time, p.is_required, p.is_fixed_order, p.remark, - p.created_date, - COALESCE(NULLIF(p.plan_qty,''), NULL)::numeric AS plan_qty_base, - wid.qty AS wi_detail_qty - FROM work_order_process p - LEFT JOIN work_instruction_detail wid ON wid.id = p.wo_id - WHERE p.company_code = $1 - ORDER BY p.created_date DESC, p.seq_no ASC`, - [companyCode], - ); - const wops = wopRes.rows; - console.log(`${__debugTag} 1) wop SQL → rows:`, { - total: wops.length, - by_process: wops.reduce((m: Record, w: any) => { m[w.process_code] = (m[w.process_code] || 0) + 1; return m; }, {}), - master_count: wops.filter((w: any) => !w.parent_process_id).length, - split_count: wops.filter((w: any) => w.parent_process_id).length, - }); + // 마스터 wop 행 + wop_result 집계 + 앞공정 양품 + 접수 카드 배열 + const sql = ` +WITH wop AS ( + SELECT w.*, COALESCE(wi.qty, '0') AS instruction_qty + FROM work_order_process w + LEFT JOIN work_instruction wi + ON w.wo_id = wi.id AND w.company_code = wi.company_code + WHERE w.company_code = $1 +), +first_seq AS ( + SELECT wo_id, MIN(CAST(seq_no AS int)) AS min_seq + FROM work_order_process + WHERE company_code = $1 + GROUP BY wo_id +), +wr_agg AS ( + SELECT wop_id, + COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0) AS sum_good, + COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0) AS sum_defect, + COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0) AS sum_concession, + COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0) AS sum_total, + COALESCE(SUM(CAST(NULLIF(input_qty, '') AS numeric)), 0) AS sum_input, + COALESCE(SUM(GREATEST( + CAST(COALESCE(NULLIF(input_qty, ''), '0') AS numeric), + CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS numeric) + )) FILTER (WHERE is_rework IS NULL OR is_rework NOT IN ('Y','true','1')), 0) AS sum_input_norework, + BOOL_OR(result_status = 'confirmed') AS has_confirmed, + BOOL_AND(status = 'completed') AS all_completed, + COUNT(*) AS accept_count + FROM work_order_process_result + WHERE company_code = $1 + GROUP BY wop_id +), +prev_good AS ( + SELECT wop.wo_id, + (CAST(wop.seq_no AS int) + 1) AS target_seq, + COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS numeric)), 0) + + COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS numeric)), 0) AS prev_good_qty + FROM work_order_process wop + LEFT JOIN work_order_process_result wr + ON wr.wop_id = wop.id AND wr.company_code = wop.company_code + WHERE wop.company_code = $1 + GROUP BY wop.wo_id, wop.seq_no +), +accepted_results AS ( + SELECT wop_id, + JSON_AGG( + JSON_BUILD_OBJECT( + 'id', id, + 'seq', seq, + 'status', status, + 'result_status', result_status, + 'input_qty', input_qty, + 'good_qty', good_qty, + 'defect_qty', defect_qty, + 'concession_qty', concession_qty, + 'total_production_qty', total_production_qty, + 'is_rework', is_rework, + 'rework_source_id', rework_source_id, + 'accepted_by', accepted_by, + 'accepted_at', accepted_at, + 'started_at', started_at, + 'completed_at', completed_at, + 'equipment_code', equipment_code, + 'batch_id', batch_id + ) ORDER BY seq + ) AS accepted_results + FROM work_order_process_result + WHERE company_code = $1 + GROUP BY wop_id +) +SELECT + wop.*, + COALESCE(wa.sum_good, 0) AS good_qty, + COALESCE(wa.sum_defect, 0) AS defect_qty, + COALESCE(wa.sum_concession, 0) AS concession_qty, + COALESCE(wa.sum_total, 0) AS total_production_qty, + COALESCE(wa.sum_input, 0) AS input_qty, + CASE + WHEN wa.has_confirmed AND wa.all_completed THEN 'completed' + WHEN wa.accept_count > 0 AND ( + CASE + WHEN CAST(wop.seq_no AS int) <= COALESCE(fs.min_seq, 1) + THEN GREATEST(0, + COALESCE(CAST(NULLIF(wop.instruction_qty, '') AS numeric), 0) + - COALESCE(wa.sum_input_norework, 0) + ) + ELSE GREATEST(0, + COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0) + ) + END + ) <= 0 THEN 'in_progress' + WHEN CAST(wop.seq_no AS int) > COALESCE(fs.min_seq, 1) + AND COALESCE(pg.prev_good_qty, 0) = 0 THEN 'waiting' + ELSE 'acceptable' + END AS status, + CASE WHEN wa.has_confirmed THEN 'confirmed' ELSE 'draft' END AS result_status, + CASE + WHEN CAST(wop.seq_no AS int) <= COALESCE(fs.min_seq, 1) + THEN COALESCE(CAST(NULLIF(wop.instruction_qty, '') AS numeric), 0) + ELSE COALESCE(pg.prev_good_qty, 0) + END AS prev_good_qty, + COALESCE(wa.sum_input_norework, 0) AS my_input_qty, + CASE + WHEN CAST(wop.seq_no AS int) <= COALESCE(fs.min_seq, 1) + THEN GREATEST(0, + COALESCE(CAST(NULLIF(wop.instruction_qty, '') AS numeric), 0) + - COALESCE(wa.sum_input_norework, 0) + ) + ELSE GREATEST(0, + COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0) + ) + END AS available_qty, + COALESCE(ar.accepted_results, '[]'::json) AS accepted_results +FROM wop +LEFT JOIN wr_agg wa ON wa.wop_id = wop.id +LEFT JOIN first_seq fs ON fs.wo_id = wop.wo_id +LEFT JOIN prev_good pg + ON pg.wo_id = wop.wo_id + AND pg.target_seq = CAST(wop.seq_no AS int) +LEFT JOIN accepted_results ar ON ar.wop_id = wop.id +ORDER BY wop.wo_id, CAST(wop.seq_no AS int) +`; - if (wops.length === 0) { - console.log(`${__debugTag} ◀ EXIT (no wop)`); - return res.json({ success: true, data: [] }); - } - - // 2) 실적(wop_result) 조회 — accepted_results 배열로 그룹핑 - const wopIds = wops.map((w: any) => w.id); - const resRes = await pool.query( - `SELECT - id, wop_id, seq, status, result_status, - input_qty, good_qty, defect_qty, concession_qty, total_production_qty, - is_rework, rework_source_id, accepted_by, accepted_at, - started_at, completed_at, equipment_code, batch_id - FROM work_order_process_result - WHERE wop_id = ANY($1::text[]) - ORDER BY wop_id, seq ASC`, - [wopIds], - ); - console.log(`${__debugTag} 2) wopr SQL → rows:`, { - total: resRes.rows.length, - by_status: resRes.rows.reduce((m: Record, r: any) => { m[r.status] = (m[r.status] || 0) + 1; return m; }, {}), - rework_count: resRes.rows.filter((r: any) => r.is_rework === "Y").length, - }); - - const resultsByWop = new Map(); - for (const r of resRes.rows) { - const arr = resultsByWop.get(r.wop_id) || []; - arr.push(r); - resultsByWop.set(r.wop_id, arr); - } - const __wopWithoutResult = wops.filter((w: any) => !resultsByWop.has(w.id)); - console.log(`${__debugTag} 2-1) wop ↔ wopr 매핑:`, { - wop_with_result: wops.length - __wopWithoutResult.length, - wop_WITHOUT_result: __wopWithoutResult.length, - missing_sample: __wopWithoutResult.slice(0, 5).map((w: any) => ({ id: w.id, process_code: w.process_code, seq_no: w.seq_no })), - }); - - // 3) 대표 상태/수량은 첫 실적 기준으로 매핑 (프론트는 accepted_results도 별도 순회) - const data = wops.map((w: any) => { - const results = resultsByWop.get(w.id) || []; - const first = results[0] || {}; - return { - id: w.id, - wo_id: w.wo_id, - seq_no: w.seq_no, - process_code: w.process_code, - process_name: w.process_name, - status: first.status || "waiting", - result_status: first.result_status || null, - plan_qty: w.plan_qty_base ?? w.wi_detail_qty ?? null, - input_qty: first.input_qty ?? null, - good_qty: first.good_qty ?? null, - defect_qty: first.defect_qty ?? null, - concession_qty: first.concession_qty ?? null, - total_production_qty: first.total_production_qty ?? null, - parent_process_id: w.parent_process_id ?? null, - is_rework: first.is_rework ?? null, - rework_source_id: first.rework_source_id ?? null, - started_at: first.started_at ?? null, - completed_at: first.completed_at ?? null, - accepted_by: first.accepted_by ?? null, - accepted_at: first.accepted_at ?? null, - created_date: w.created_date ?? null, - batch_id: first.batch_id ?? w.batch_id ?? null, - equipment_code: first.equipment_code ?? null, - // Phase C 계산 필드는 별도 엔드포인트(available-qty 등)에서 제공 - available_qty: null, - prev_good_qty: null, - my_input_qty: null, - rework_available_qty: null, - split_no: null, - split_total: null, - batch_count: results.length, - batch_list: null, - batch_index: null, - accepted_results: results.map((r: any) => ({ - id: r.id, - seq: r.seq, - status: r.status, - result_status: r.result_status, - input_qty: r.input_qty, - good_qty: r.good_qty, - defect_qty: r.defect_qty, - concession_qty: r.concession_qty, - total_production_qty: r.total_production_qty, - is_rework: r.is_rework, - rework_source_id: r.rework_source_id, - accepted_by: r.accepted_by, - accepted_at: r.accepted_at, - started_at: r.started_at, - completed_at: r.completed_at, - equipment_code: r.equipment_code, - batch_id: r.batch_id, - })), - }; - }); - - // 3) 응답 데이터 분포 로그 - const masterFirstDist: Record = {}; - const p001WoprDist: Record = {}; - const reworkDist: Record = {}; - let totalWopr = 0; - const p001Cards: any[] = []; - for (const d of data) { - masterFirstDist[d.status] = (masterFirstDist[d.status] || 0) + 1; - if (d.process_code === "P001") { - p001Cards.push({ - id: d.id, - seq_no: d.seq_no, - parent: d.parent_process_id, - master_status: d.status, - wopr_count: d.accepted_results.length, - wopr_statuses: d.accepted_results.map((r: any) => `${r.seq}:${r.status}${r.is_rework === "Y" ? "(R)" : ""}`), - }); - } - for (const ar of d.accepted_results) { - totalWopr++; - if (d.process_code === "P001") { - p001WoprDist[ar.status] = (p001WoprDist[ar.status] || 0) + 1; - } - if (ar.is_rework === "Y") { - reworkDist[`${d.process_code}:${ar.status}`] = (reworkDist[`${d.process_code}:${ar.status}`] || 0) + 1; - } - } - } - console.log(`${__debugTag} 3) 응답 빌드 완료:`, { - data_length: data.length, - wopr_total_in_response: totalWopr, - master_first_status_dist: masterFirstDist, - p001_wopr_status_dist: p001WoprDist, - rework_dist: reworkDist, - }); - console.log(`${__debugTag} 3-1) P001 카드 상세 (${p001Cards.length}개):`); - console.table(p001Cards); - console.log(`${__debugTag} ◀ EXIT`); - - return res.json({ success: true, data }); + const result = await pool.query(sql, [companyCode]); + return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("[pop/production] processes 조회 오류:", error); return res.status(500).json({ success: false, message: error.message }); @@ -3762,9 +3223,9 @@ export const getProcessList = async ( }; /** - * 단일 작업공정 결과 조회 + * 단일 접수 카드 조회 (신규 엔드포인트) * GET /api/pop/production/result/:id - * :id = work_order_process_result.id (wop_result row 식별자) + * :id = work_order_process_result.id */ export const getProcessResult = async ( req: AuthenticatedRequest, @@ -3775,27 +3236,36 @@ export const getProcessResult = async ( const companyCode = req.user!.companyCode; const { id } = req.params; if (!id) { - return res.status(400).json({ success: false, message: "id는 필수입니다." }); + return res + .status(400) + .json({ success: false, message: "id는 필수입니다." }); } - const r = await pool.query( - `SELECT r.*, p.process_code, p.process_name, p.wo_id, p.seq_no - FROM work_order_process_result r - JOIN work_order_process p ON p.id = r.wop_id - WHERE r.id = $1 AND r.company_code = $2 - LIMIT 1`, + const result = await pool.query( + `SELECT wr.*, + wop.process_code, + wop.process_name, + wop.seq_no, + wop.wo_id, + wop.routing_detail_id, + wop.plan_qty, + wop.target_warehouse_id, + wop.target_location_code + FROM work_order_process_result wr + JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code + WHERE wr.id = $1 AND wr.company_code = $2`, [id, companyCode], ); - if (r.rowCount === 0) { + if (result.rowCount === 0) { return res .status(404) - .json({ success: false, message: "결과를 찾을 수 없습니다." }); + .json({ success: false, message: "접수 카드를 찾을 수 없습니다." }); } - return res.json({ success: true, data: r.rows[0] }); + return res.json({ success: true, data: result.rows[0] }); } catch (error: any) { - logger.error("[pop/production] result 조회 오류:", error); + logger.error("[pop/production] get-process-result 오류:", error); return res.status(500).json({ success: false, message: error.message }); } };