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"; import { onAcceptCancelled, onProcessAccept, onProcessCompleted, onProcessMove, onResultSaved, } from "../services/wipStockService"; /** * user_id → user_name(한글명) 조회 헬퍼 — wip_stock_history.manager_name 기록용. * 조회 실패 시 user_id 를 fallback 으로 반환. */ async function resolveUserName( exec: { query: (text: string, values?: any[]) => Promise }, userId: string, companyCode: string, ): Promise { try { const r = await exec.query( `SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`, [userId, companyCode], ); return r.rows[0]?.user_name || userId; } catch { return userId; } } // 불량 상세 항목 타입 interface DefectDetailItem { defect_code: string; defect_name: string; qty: string; disposition: string; } // 자동 마이그레이션: batch_id 컬럼 추가 (배치/로트 추적용) let _batchMigrationDone = false; async function ensureBatchIdColumn() { if (_batchMigrationDone) return; try { const pool = getPool(); await pool.query( "ALTER TABLE work_order_process ADD COLUMN IF NOT EXISTS batch_id VARCHAR(100)", ); _batchMigrationDone = true; } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } } /** * inventory_stock UPSERT 공통 함수 * PC의 receivingController와 동일한 SELECT→INSERT/UPDATE 패턴 사용. * (inventory_stock에 UNIQUE 제약조건이 없으므로 ON CONFLICT 사용 불가) */ interface InventoryHistoryOptions { userName: string; source: "auto_cascade" | "manual_inbound" | "quick_inbound"; woId?: string; workOrderProcessResultId?: string; referenceNumber?: string; } async function upsertInventoryStock( client: { query: (text: string, values?: any[]) => Promise }, companyCode: string, itemCode: string, warehouseCode: string, locationCode: string | null, qty: number, userId: string, historyOptions?: InventoryHistoryOptions, ): Promise { const whCode = warehouseCode || null; const locCode = locationCode || null; const qtyNum = Number(qty) || 0; const existing = await client.query( `SELECT id, current_qty FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(warehouse_code, '') = COALESCE($3, '') AND COALESCE(location_code, '') = COALESCE($4, '') LIMIT 1`, [companyCode, itemCode, whCode || "", locCode || ""], ); let stockId: string; let balanceAfter: number; if (existing.rows.length > 0) { stockId = existing.rows[0].id; const prev = parseFloat(existing.rows[0].current_qty || "0") || 0; balanceAfter = prev + qtyNum; await client.query( `UPDATE inventory_stock SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), last_in_date = NOW(), updated_date = NOW(), writer = $2 WHERE id = $3`, [qty, userId, stockId], ); } else { const inserted = await client.query( `INSERT INTO inventory_stock ( id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_in_date, created_date, updated_date, writer ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6) RETURNING id`, [companyCode, itemCode, whCode, locCode, String(qtyNum), userId], ); stockId = inserted.rows[0].id; balanceAfter = qtyNum; } if (historyOptions) { const remarkJson = JSON.stringify({ type: "process_inbound", source: historyOptions.source, wo_id: historyOptions.woId ?? null, work_order_process_result_id: historyOptions.workOrderProcessResultId ?? null, qty: qtyNum, }); await client.query( `INSERT INTO inventory_history ( id, company_code, stock_id, item_code, warehouse_code, location_code, transaction_type, transaction_date, quantity, balance_qty, reference_type, reference_id, reference_number, remark, writer, manager_id, manager_name, created_date ) VALUES ( gen_random_uuid()::text, $1, $2, $3, $4, $5, '입고', NOW(), $6, $7, $8, $9, $10, $11, $12, $12, $13, NOW() )`, [ companyCode, stockId, itemCode, whCode, locCode, String(qtyNum), String(balanceAfter), historyOptions.workOrderProcessResultId ? "work_order_process_result" : null, historyOptions.workOrderProcessResultId ?? null, historyOptions.referenceNumber ?? null, remarkJson, userId, historyOptions.userName, ], ); } } /** * 앞공정 양품 합계 조회 헬퍼 * - 첫 공정이면 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; } /** * 앞공정 평가 (스킵/차단/접수가능량 통합 판정) * * 정책: * - 비필수 공정(is_required != 'Y')이 진행 중(input_qty>0 AND result_status NOT IN ('confirmed','skipped'))이면 접수 차단 * - 비필수 공정 wop_result 0건은 자동 skip 대상 * - 비필수 공정이 모두 confirmed/skipped 이면 정상 흐름 * - prevGoodQty = currentSeq 미만 wop 중 wop_result 가 있는 wop 의 최대 seq 양품(good+concession). * 없고 미만 wop 에 필수가 있으면 0 (직전 필수 미처리 비정상 상태). * 미만 wop 이 모두 비필수 0건이면 work_instruction.qty (스킵 흐름). */ async function evaluatePrevProcesses( exec: Pool | PoolClient, woId: string, currentSeq: number, companyCode: string, ): Promise<{ canAccept: boolean; blockedReason: string | null; prevGoodQty: number; skipTargets: Array<{ wopId: string; processCode: string; processName: string; }>; }> { const sql = ` WITH wop_with_seq AS ( SELECT wop.id, CAST(wop.seq_no AS int) AS seq_int, wop.process_code, wop.process_name, COALESCE(wop.is_required, '') AS is_req FROM work_order_process wop WHERE wop.wo_id = $1::varchar AND wop.company_code = $2::varchar AND CAST(wop.seq_no AS int) < $3::int ), wr_agg AS ( SELECT wr.wop_id, COUNT(*) AS row_count, BOOL_OR( wr.result_status NOT IN ('confirmed','skipped') AND COALESCE(CAST(NULLIF(wr.input_qty, '') AS int), 0) > 0 ) AS has_inprogress, COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) + COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) AS sum_good FROM work_order_process_result wr JOIN wop_with_seq w ON w.id = wr.wop_id WHERE wr.company_code = $2 GROUP BY wr.wop_id ) SELECT w.id, w.seq_int, w.process_code, w.process_name, w.is_req, COALESCE(wa.row_count, 0) AS row_count, COALESCE(wa.has_inprogress, false) AS has_inprogress, COALESCE(wa.sum_good, 0) AS sum_good FROM wop_with_seq w LEFT JOIN wr_agg wa ON wa.wop_id = w.id ORDER BY w.seq_int DESC `; const result = await exec.query(sql, [woId, companyCode, currentSeq]); const rows = result.rows as Array<{ id: string; seq_int: number; process_code: string; process_name: string; is_req: string; row_count: string | number; has_inprogress: boolean; sum_good: string | number; }>; const fetchInstructionQty = async (): Promise => { const wi = await exec.query( `SELECT qty FROM work_instruction WHERE id = $1 AND company_code = $2`, [woId, companyCode], ); return parseInt(wi.rows[0]?.qty, 10) || 0; }; if (rows.length === 0) { return { canAccept: true, blockedReason: null, prevGoodQty: await fetchInstructionQty(), skipTargets: [], }; } for (const r of rows) { // 필수 공정 미처리 차단 (점프 방지) — 진행 중 차단은 제거(양품이 있으면 다음 공정에서 수령 가능) if (r.is_req === "Y" && Number(r.row_count) === 0) { return { canAccept: false, blockedReason: `필수 공정 ${r.process_name} 미처리`, prevGoodQty: 0, skipTargets: [], }; } } const skipTargets = rows .filter((r) => r.is_req !== "Y" && Number(r.row_count) === 0) .map((r) => ({ wopId: r.id, processCode: r.process_code, processName: r.process_name, })); const withRows = rows.filter((r) => Number(r.row_count) > 0); let prevGoodQty: number; if (withRows.length > 0) { prevGoodQty = parseInt(String(withRows[0].sum_good), 10) || 0; } else if (rows.some((r) => r.is_req === "Y")) { prevGoodQty = 0; } else { prevGoodQty = await fetchInstructionQty(); } return { canAccept: true, blockedReason: null, prevGoodQty, skipTargets, }; } /** * 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에 복사한다. * * 주의: 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, wopResultId: string, routingDetailId: string | null, companyCode: string, userId: string, options?: { workInstructionNo?: string; skipAStrategy?: boolean }, ): Promise { // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 // 단, 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 전략(마스터 wop의 체크리스트 복사)으로 fallthrough } // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 wop의 process_work_result 구조 복사 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, 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, inspection_code, inspection_method, unit, lower_limit, upper_limit, input_type, lookup_target, display_fields, duration_minutes, 'pending', $2 FROM process_work_result WHERE work_order_process_id = $3 AND company_code = $4 ORDER BY item_sort_order, detail_sort_order`, [wopResultId, userId, masterProcessId, companyCode], ); return result.rowCount ?? 0; } /** * 내부 헬퍼: 단일 작업지시에 대해 work_order_process(마스터) + process_work_result(체크리스트 스냅샷) 생성 */ async function generateWorkProcessesForInstruction( client: { query: (text: string, values?: any[]) => Promise }, workInstructionId: string, routingVersionId: string, planQty: string | null, companyCode: string, userId: string, batchId?: string | null, ): Promise<{ processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number; }>; total_checklists: number; } | null> { await client.query( `SELECT pg_advisory_xact_lock(hashtext($1))`, [`wop_sync:${companyCode}:${workInstructionId}:${batchId || ""}`], ); if (batchId) { const existCheck = await client.query( `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; } } else { 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; } } const routingDetails = await client.query( `SELECT rd.id, rd.seq_no, rd.process_code, COALESCE(pm.process_name, rd.process_code) as process_name, rd.is_required, rd.is_fixed_order, rd.standard_time FROM item_routing_detail rd LEFT JOIN process_mng pm ON pm.process_code = rd.process_code AND pm.company_code = rd.company_code WHERE rd.routing_version_id = $1 AND rd.company_code = $2 ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, [routingVersionId, companyCode], ); if (routingDetails.rows.length === 0) { 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; process_name: string; checklist_count: number; }> = []; let totalChecklists = 0; for (const rd of routingDetails.rows) { 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, batch_id, writer ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [ companyCode, workInstructionId, rd.seq_no, rd.process_code, rd.process_name, rd.is_required, rd.is_fixed_order, rd.standard_time, planQty || null, rd.id, batchId || null, userId, ], ); const wopId = wopResult.rows[0].id; const checklistCount = await copyChecklistToSplit( client, wopId, wopId, rd.id, companyCode, userId, { workInstructionNo }, ); totalChecklists += checklistCount; processes.push({ id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount, }); } return { processes, total_checklists: totalChecklists }; } /** * 작업지시 공정 일괄 생성 */ export const createWorkProcesses = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_instruction_id, item_code, routing_version_id, plan_qty } = req.body; if (!work_instruction_id || !routing_version_id) { return res.status(400).json({ success: false, message: "work_instruction_id와 routing_version_id는 필수입니다.", }); } await client.query("BEGIN"); const result = await generateWorkProcessesForInstruction( client, work_instruction_id, routing_version_id, plan_qty, companyCode, userId, item_code || null, ); if (!result) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.", }); } await client.query("COMMIT"); return res.json({ success: true, data: { processes: result.processes, total_processes: result.processes.length, total_checklists: result.total_checklists, }, }); } catch (error: any) { await client.query("ROLLBACK"); logger.error("[pop/production] create-work-processes 오류:", error); return res.status(500).json({ success: false, message: error.message || "공정 생성 중 오류가 발생했습니다.", }); } finally { client.release(); } }; /** * POP 온디맨드 Pull: 미동기화 작업지시 일괄 sync */ export const syncWorkInstructions = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; await ensureBatchIdColumn(); const unsyncedResult = await pool.query( `SELECT wi.id, wi.work_instruction_no, wi.routing AS header_routing, wi.qty AS header_qty, wi.item_id AS header_item_id FROM work_instruction wi WHERE wi.company_code = $1 AND ( (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 EXISTS ( SELECT 1 FROM work_instruction_detail wid WHERE wid.work_instruction_id = wi.id AND wid.routing_version_id IS NOT NULL AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0 AND NOT EXISTS ( SELECT 1 FROM work_order_process wop WHERE wop.wo_id = wi.id AND wop.company_code = $1 AND wop.batch_id = wid.item_number ) ) )`, [companyCode], ); const unsynced = unsyncedResult.rows; if (unsynced.length === 0) { return res.json({ success: true, data: { synced: 0, skipped: 0, errors: 0, details: [] }, }); } let synced = 0; let skipped = 0; let errors = 0; const details: Array<{ work_instruction_id: string; work_instruction_no: string; item_number?: string; status: "synced" | "skipped" | "error"; process_count?: number; error?: string; }> = []; for (const wi of unsynced) { const detailResult = await pool.query( `SELECT wid.item_number, wid.routing_version_id, wid.qty FROM work_instruction_detail wid WHERE wid.work_instruction_id = $1 AND wid.routing_version_id IS NOT NULL AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0 ORDER BY wid.created_date ASC`, [wi.id], ); const detailRows = detailResult.rows; if (detailRows.length === 0 && wi.header_routing) { const firstDetail = await pool.query( `SELECT routing_version_id, qty, item_number FROM work_instruction_detail WHERE work_instruction_id = $1 LIMIT 1`, [wi.id], ); const wid = firstDetail.rows[0]; if (wid) { await updateWorkInstructionHeader( pool, wi.id, companyCode, wid.routing_version_id, wid.qty, wid.item_number, ); } const client = await pool.connect(); try { await client.query("BEGIN"); const result = await generateWorkProcessesForInstruction( client, wi.id, wi.header_routing, wi.header_qty || null, companyCode, userId, ); if (!result) { await client.query("ROLLBACK"); skipped++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, status: "skipped", }); } else { await client.query("COMMIT"); synced++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, status: "synced", process_count: result.processes.length, }); } } catch (err: any) { await client.query("ROLLBACK"); errors++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, status: "error", error: err.message || "알 수 없는 오류", }); } finally { client.release(); } continue; } if (detailRows.length > 0) { const first = detailRows[0]; await updateWorkInstructionHeader( pool, wi.id, companyCode, first.routing_version_id, first.qty, first.item_number, ); } for (const detail of detailRows) { const client = await pool.connect(); try { await client.query("BEGIN"); const result = await generateWorkProcessesForInstruction( client, wi.id, detail.routing_version_id, detail.qty || null, companyCode, userId, detail.item_number, ); if (!result) { await client.query("ROLLBACK"); skipped++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, item_number: detail.item_number, status: "skipped", }); continue; } await client.query("COMMIT"); synced++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, item_number: detail.item_number, status: "synced", process_count: result.processes.length, }); } catch (err: any) { await client.query("ROLLBACK"); errors++; details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, item_number: detail.item_number, status: "error", error: err.message || "알 수 없는 오류", }); } finally { client.release(); } } } return res.json({ success: true, data: { synced, skipped, errors, details }, }); } catch (error: any) { logger.error("[pop/production] sync-work-instructions 오류:", error); return res.status(500).json({ success: false, message: error.message || "작업지시 동기화 중 오류가 발생했습니다.", }); } }; /** * 타이머 API — work_order_process_result 기준. * 요청 본문 work_order_process_id 는 work_order_process_result.id */ export const controlTimer = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, action } = req.body; if (!work_order_process_id || !action) { return res.status(400).json({ success: false, message: "work_order_process_id와 action은 필수입니다.", }); } if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, message: "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } let result; switch (action) { case "start": result = await pool.query( `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() WHERE id = $1 AND company_code = $2 RETURNING id, started_at, status`, [work_order_process_id, companyCode], ); break; case "pause": result = await pool.query( `UPDATE work_order_process_result SET paused_at = NOW()::text, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NULL RETURNING id, paused_at`, [work_order_process_id, companyCode], ); break; case "resume": result = await pool.query( `UPDATE work_order_process_result SET total_paused_time = ( COALESCE(total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int )::text, paused_at = NULL, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL RETURNING id, total_paused_time`, [work_order_process_id, companyCode], ); break; case "complete": { const { good_qty, defect_qty } = req.body; const groupSumResult = await pool.query( `SELECT COALESCE(SUM( 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 ), 0)::text AS total_work_seconds FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); const calculatedWorkTime = groupSumResult.rows[0]?.total_work_seconds || "0"; result = await pool.query( `UPDATE work_order_process_result SET status = 'completed', completed_at = NOW()::text, completed_by = $3, actual_work_time = $4, good_qty = COALESCE($5, good_qty), defect_qty = COALESCE($6, defect_qty), paused_at = NULL, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed' RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty, wop_id, equipment_code`, [ work_order_process_id, companyCode, userId, calculatedWorkTime, good_qty || null, defect_qty || null, ], ); break; } } if (!result || result.rowCount === 0) { return res.status(404).json({ success: false, message: "대상 접수 카드를 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", }); } // [WIP 적재 — 트리거 4] 타이머 완료(complete) 시 wip_stock status='completed' 전이 if (action === "complete" && result.rows[0]?.wop_id) { try { const row = result.rows[0]; const managerName = await resolveUserName(pool, userId, companyCode); await onProcessCompleted( pool, companyCode, row.wop_id, work_order_process_id, userId, row.equipment_code || null, managerName, "타이머 완료(상태 전이)", ); } catch (wipErr) { logger.error("[pop/production] WIP 적재 오류 (타이머 완료는 유지):", wipErr); } } return res.json({ success: true, data: result.rows[0], }); } catch (error: any) { logger.error("[pop/production] timer 오류:", error); return res.status(500).json({ success: false, message: error.message || "타이머 처리 중 오류가 발생했습니다.", }); } }; /** * 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, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { item_id, work_order_process_id, action, phase } = req.body; if (!action) { return res.status(400).json({ success: false, 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 가 필수입니다.", }); } if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, message: "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } // 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; switch (action) { case "start": result = await pool.query( `UPDATE process_work_result SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END, updated_date = NOW() WHERE ${whereClause} RETURNING id, group_started_at`, baseParams, ); 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], ); } break; case "pause": result = await pool.query( `UPDATE process_work_result SET group_paused_at = NOW()::text, updated_date = NOW() WHERE ${whereClause} AND group_paused_at IS NULL RETURNING id, group_paused_at`, baseParams, ); break; case "resume": result = await pool.query( `UPDATE process_work_result SET group_total_paused_time = ( COALESCE(group_total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int )::text, group_paused_at = NULL, updated_date = NOW() WHERE ${whereClause} AND group_paused_at IS NOT NULL RETURNING id, group_total_paused_time`, baseParams, ); break; case "complete": { result = await pool.query( `UPDATE process_work_result SET group_completed_at = NOW()::text, group_total_paused_time = CASE WHEN group_paused_at IS NOT NULL THEN ( COALESCE(group_total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int )::text ELSE group_total_paused_time END, group_paused_at = NULL, updated_date = NOW() WHERE ${whereClause} 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; } } if (!result || result.rowCount === 0) { return res.status(404).json({ success: false, message: "대상 항목을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", }); } return res.json({ success: true, data: result.rows[0], affectedRows: result.rowCount, }); } catch (error: any) { logger.error("[pop/production] group-timer 오류:", error); return res.status(500).json({ success: false, message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.", }); } }; /** * 불량 유형 목록 조회 * - defect_standard_mng 중 inspection_type 에 공정검사(CAT_MMEBA4LJ_UFJ9) 가 포함된 활성 행 반환 * - inspection_type 은 콤마 구분 다중값으로 저장됨 * - is_active 는 카테고리 코드(CAT_DA_01='사용')로 저장됨 */ const PROCESS_INSPECTION_CODE = "CAT_MMEBA4LJ_UFJ9"; const ACTIVE_CODE = "CAT_DA_01"; export const getDefectTypes = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; let query: string; let params: unknown[]; if (companyCode === "*") { query = ` SELECT id, defect_code, defect_name, defect_type, severity, company_code FROM defect_standard_mng WHERE is_active = $1 AND (',' || COALESCE(inspection_type, '') || ',') LIKE '%,' || $2 || ',%' ORDER BY defect_code`; params = [ACTIVE_CODE, PROCESS_INSPECTION_CODE]; } else { query = ` SELECT id, defect_code, defect_name, defect_type, severity, company_code FROM defect_standard_mng WHERE is_active = $1 AND company_code = $2 AND (',' || COALESCE(inspection_type, '') || ',') LIKE '%,' || $3 || ',%' ORDER BY defect_code`; params = [ACTIVE_CODE, companyCode, PROCESS_INSPECTION_CODE]; } const result = await pool.query(query, params); return res.json({ success: true, data: result.rows, }); } catch (error: any) { logger.error("[pop/production] defect-types 오류:", error); return res.status(500).json({ success: false, message: error.message || "불량 유형 조회 중 오류가 발생했습니다.", }); } }; /** * 실적 저장 (누적 방식) — 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 { await client.query("BEGIN"); // 초과 생산 경고 (차단하지 않음) const prevTotal = parseInt(prev.total_production_qty, 10) || 0; const acceptedQty = parseInt(prev.input_qty, 10) || 0; const requestedQty = parseInt(production_qty, 10) || 0; if (acceptedQty > 0 && prevTotal + requestedQty > acceptedQty) { logger.warn("[pop/production] 초과 생산 감지", { work_order_process_id, prevTotal, requestedQty, acceptedQty, }); } const addProduction = parseInt(production_qty, 10) || 0; let addDefect = 0; let addConcession = 0; let defectDetailStr: string | null = null; if (defect_detail && Array.isArray(defect_detail)) { const validated = defect_detail.map((item: DefectDetailItem) => ({ defect_code: item.defect_code || "", defect_name: item.defect_name || "", qty: item.qty || "0", disposition: item.disposition || "scrap", })); defectDetailStr = JSON.stringify(validated); for (const item of validated) { const itemQty = parseInt(item.qty, 10) || 0; addDefect += itemQty; if (item.disposition === "accept") { addConcession += itemQty; } } } else { addDefect = parseInt(defect_qty, 10) || 0; } const addGood = addProduction - addDefect; const newTotal = (parseInt(prev.total_production_qty, 10) || 0) + addProduction; const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood; const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect; const newConcession = (parseInt(prev.concession_qty, 10) || 0) + addConcession; // defect_detail 병합 let mergedDefectDetail: string | null = null; if (defectDetailStr) { let existingEntries: DefectDetailItem[] = []; try { existingEntries = prev.defect_detail ? JSON.parse(prev.defect_detail) : []; } catch { /* ignore */ } const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr); const merged = [...existingEntries]; for (const ne of newEntries) { const existing = merged.find( (e) => e.defect_code === ne.defect_code && e.disposition === ne.disposition, ); if (existing) { existing.qty = String( (parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0), ); } else { merged.push(ne); } } mergedDefectDetail = JSON.stringify(merged); } const result = await client.query( `UPDATE work_order_process_result SET total_production_qty = $3, good_qty = $4, defect_qty = $5, concession_qty = $9, defect_detail = COALESCE($6, defect_detail), result_note = COALESCE($7, result_note), result_status = 'draft', status = CASE WHEN status IN ('acceptable', 'waiting') THEN 'in_progress' ELSE status END, writer = $8, updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status`, [ work_order_process_id, companyCode, String(newTotal), String(newGood), String(newDefect), mergedDefectDetail, result_note || null, userId, String(newConcession), ], ); if (result.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "접수 카드를 찾을 수 없거나 권한이 없습니다.", }); } // 현재 접수 카드 공정 정보 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, wr.equipment_code, wop.routing_detail_id, wi.qty as instruction_qty 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 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) > 0 && defect_detail && Array.isArray(defect_detail) ) { let totalReworkQty = 0; let targetProcessCode: string | null = null; for (const item of defect_detail) { if (item.disposition === "rework") { totalReworkQty += parseInt(item.qty, 10) || 0; if (item.target_process_code) targetProcessCode = item.target_process_code; } } if (totalReworkQty > 0) { const proc = currentSeq.rows[0]; let reworkMasterId: string = proc.wop_id; if (targetProcessCode) { const targetProc = await client.query( `SELECT id FROM work_order_process WHERE wo_id = $1 AND process_code = $2 AND company_code = $3 LIMIT 1`, [proc.wo_id, targetProcessCode, companyCode], ); 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::varchar, (SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1::varchar AND company_code = $2::varchar), $2::varchar, 'acceptable', 'draft', $3::varchar, '0', '0', '0', '0', 'Y', $4::varchar, $5::varchar, 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; } } if (reworkId) { await copyChecklistToSplit( client, reworkMasterId, reworkId, null, companyCode, userId, ); } } } // 접수 카드 자동완료 판정 + 작업지시 완료 캐스케이드 if ((currentSeq.rowCount ?? 0) > 0) { const { wo_id: csWoId, current_input_qty: csInputQty, } = currentSeq.rows[0]; const csMyInput = parseInt(csInputQty, 10) || 0; if (newTotal >= csMyInput && csMyInput > 0) { 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], ); } await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId); } // [WIP 적재 — 트리거 2] 실적 저장분(양품/불량 증분)을 wip_stock 에 반영 // SAVEPOINT 로 감싼다: 공유 트랜잭션(client)에서 WIP 쿼리가 throw 하면 // PG 가 트랜잭션 전체를 aborted 로 만들어 이후 COMMIT 이 ROLLBACK 처리된다. // SAVEPOINT 까지만 되돌려 트랜잭션을 건강하게 살려 본작업(실적저장)을 보존한다. if ((currentSeq.rowCount ?? 0) > 0) { await client.query("SAVEPOINT wip_save_result"); try { const cs = currentSeq.rows[0]; const managerName = await resolveUserName(client, userId, companyCode); await onResultSaved( client, companyCode, cs.wop_id, work_order_process_id, addGood, addDefect, userId, cs.equipment_code || null, managerName, ); await client.query("RELEASE SAVEPOINT wip_save_result"); } catch (wipErr) { // WIP 부분쓰기만 되돌리고 본작업 트랜잭션은 살린다 (데이터는 후속 백필로 보정 가능) await client .query("ROLLBACK TO SAVEPOINT wip_save_result") .catch(() => {}); logger.error("[pop/production] WIP 적재 오류 (실적 저장은 유지):", wipErr); } } 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]; 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) 전체 완료 판정 * 신 구조: 마지막 seq 의 각 마스터 wop 에 대해 그 wop 의 wop_result 들이 * 모두 result_status='confirmed' 이고 SUM(good+concession) >= wi.qty 이면 완료 처리. */ const checkAndCompleteWorkInstruction = async ( pool: any, woId: string, companyCode: string, userId: string, ) => { // "마지막 필수 seq" 기준: 후행에 비필수 공정만 있어도 작업지시 완료 판정 가능 const maxSeqResult = await pool.query( `SELECT MAX(seq_no::int) as max_seq FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND COALESCE(is_required, '') = 'Y'`, [woId, companyCode], ); if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return; const maxSeq = String(maxSeqResult.rows[0].max_seq); // 마지막 필수 seq 의 마스터 wop 중 confirmed/skipped 가 하나라도 없는 게 있으면 미완료 const incompleteCheck = await pool.query( `SELECT wop.id FROM work_order_process wop WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 AND COALESCE(wop.is_required, '') = 'Y' 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 IN ('confirmed','skipped') ) LIMIT 1`, [woId, maxSeq, companyCode], ); if ((incompleteCheck.rowCount ?? 0) > 0) return; const totalGoodResult = await pool.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`, [woId, maxSeq, companyCode], ); const completedQty = totalGoodResult.rows[0].total_good; const updateResult = await pool.query( `UPDATE work_instruction SET status = 'completed', progress_status = 'completed', completed_qty = $3, writer = $4, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed' RETURNING id, item_id`, [woId, companyCode, String(completedQty), userId], ); // 작업지시 완료 시점에 후행 비필수 wop 들에 자동 skipped row INSERT (wop_result 0건만) if ((updateResult.rowCount ?? 0) > 0) { const trailingNonReqs = await pool.query( `SELECT wop.id FROM work_order_process wop WHERE wop.wo_id = $1::varchar AND wop.company_code = $2::varchar AND CAST(wop.seq_no AS int) > $3::int AND COALESCE(wop.is_required, '') <> 'Y' AND NOT EXISTS ( SELECT 1 FROM work_order_process_result wr WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code )`, [woId, companyCode, parseInt(maxSeq, 10)], ); for (const row of trailingNonReqs.rows) { try { await pool.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, completed_at, completed_by, 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::varchar AND company_code = $2::varchar), $2::varchar, 'completed', 'skipped', '0', '0', '0', '0', '0', NOW()::text, $3::varchar, $3::varchar, NOW(), NOW() )`, [row.id, companyCode, userId], ); } catch (err: any) { if (err?.code !== "23505") throw err; } } } if ((updateResult.rowCount ?? 0) > 0 && completedQty > 0) { try { const itemId = updateResult.rows[0].item_id; const itemResult = await pool.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [itemId, companyCode], ); if (itemResult.rowCount === 0) return; const itemCode = itemResult.rows[0].item_number; // 담당자 한글명 조회 — inventory_history manager_name 기록용 // (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name) let autoUserName = userId; try { const mgrRes = await pool.query( `SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`, [userId, companyCode], ); if (mgrRes.rows[0]?.user_name) autoUserName = mgrRes.rows[0].user_name; } catch { /* user_info 조회 실패 시 userId fallback 유지 */ } 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 LIMIT 1`, [woId, maxSeq, companyCode], ); let warehouseCode: string | null = null; let locationCode: string | null = null; if ( warehouseResult.rowCount && warehouseResult.rows[0].target_warehouse_id ) { warehouseCode = warehouseResult.rows[0].target_warehouse_id; locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; } else { // 완제품 창고 fallback: 1개 이상이면 첫 번째(warehouse_code 오름차순) 자동 입고 const finishedWh = await pool.query( `SELECT warehouse_code FROM warehouse_info WHERE company_code = $1 AND warehouse_type IN ( SELECT value_code FROM category_values WHERE company_code = $1 AND table_name = 'warehouse_info' AND column_name = 'warehouse_type' AND value_label = '완제품' ) ORDER BY warehouse_code`, [companyCode], ); if ((finishedWh.rowCount ?? 0) >= 1) { warehouseCode = finishedWh.rows[0].warehouse_code; locationCode = warehouseCode; } } if (!warehouseCode) { // 자동 입고 skip (사용자가 inventoryInbound 로 명시 입고) return; } await upsertInventoryStock( pool, companyCode, itemCode, warehouseCode, locationCode, completedQty, userId, { userName: autoUserName, source: "auto_cascade", woId, }, ); } catch (inventoryError: any) { logger.error( "[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError, ); } } }; /** * 실적 확정 — work_order_process_result UPDATE. * work_order_process_id 는 work_order_process_result.id */ export const confirmResult = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id } = req.body; if (!work_order_process_id) { return res.status(400).json({ success: false, message: "work_order_process_id는 필수입니다.", }); } const statusCheck = await pool.query( `SELECT status, result_status, total_production_qty FROM work_order_process_result WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); if (statusCheck.rowCount === 0) { return res.status(404).json({ success: false, message: "접수 카드를 찾을 수 없습니다.", }); } const currentProcess = statusCheck.rows[0]; if ( !currentProcess.total_production_qty || parseInt(currentProcess.total_production_qty, 10) <= 0 ) { return res.status(400).json({ success: false, message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.", }); } const result = await pool.query( `UPDATE work_order_process_result SET result_status = 'confirmed', status = 'completed', completed_at = NOW()::text, completed_by = $3, writer = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id, equipment_code`, [work_order_process_id, companyCode, userId], ); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "접수 카드를 찾을 수 없습니다.", }); } // [WIP 적재 — 트리거 3] 실적 확정 시 wip_stock status='completed' 전이 try { const managerName = await resolveUserName(pool, userId, companyCode); await onProcessCompleted( pool, companyCode, result.rows[0].wop_id, work_order_process_id, userId, result.rows[0].equipment_code || null, managerName, "실적 확정(상태 전이)", ); } catch (wipErr) { logger.error("[pop/production] WIP 적재 오류 (실적 확정은 유지):", wipErr); } // 작업지시 완료 캐스케이드 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 ((wopLookup.rowCount ?? 0) > 0) { await checkAndCompleteWorkInstruction( pool, wopLookup.rows[0].wo_id, companyCode, userId, ); } return res.json({ success: true, data: result.rows[0], }); } catch (error: any) { logger.error("[pop/production] confirm-result 오류:", error); return res.status(500).json({ success: false, message: error.message || "실적 확정 중 오류가 발생했습니다.", }); } }; /** * 실적 이력 조회 — work_order_process_result 기준 (wop_result.id) */ export const getResultHistory = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const rawWopId = req.query.work_order_process_id; const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; if (!work_order_process_id) { return res.status(400).json({ success: false, message: "work_order_process_id는 필수입니다.", }); } const ownerCheck = await pool.query( `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: "접수 카드를 찾을 수 없습니다." }); } const historyResult = await pool.query( `WITH grouped AS ( SELECT changed_at, MAX(changed_by) as changed_by, MAX(CASE WHEN changed_column = 'total_production_qty' THEN old_value END) as total_old, MAX(CASE WHEN changed_column = 'total_production_qty' THEN new_value END) as total_new, MAX(CASE WHEN changed_column = 'good_qty' THEN old_value END) as good_old, MAX(CASE WHEN changed_column = 'good_qty' THEN new_value END) as good_new, MAX(CASE WHEN changed_column = 'defect_qty' THEN old_value END) as defect_old, MAX(CASE WHEN changed_column = 'defect_qty' THEN new_value END) as defect_new FROM work_order_process_log WHERE original_id = $1 AND changed_column IN ('total_production_qty', 'good_qty', 'defect_qty') AND new_value IS NOT NULL GROUP BY changed_at ) SELECT * FROM grouped WHERE total_new IS NOT NULL AND (COALESCE(total_new::int, 0) - COALESCE(total_old::int, 0)) > 0 ORDER BY changed_at ASC`, [work_order_process_id], ); const batches = historyResult.rows.map((row: any, idx: number) => { const batchQty = (parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0); const batchGood = (parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0); const batchDefect = (parseInt(row.defect_new, 10) || 0) - (parseInt(row.defect_old, 10) || 0); return { seq: idx + 1, batch_qty: batchQty, batch_good: batchGood, batch_defect: batchDefect, accumulated_total: parseInt(row.total_new, 10) || 0, changed_at: row.changed_at, changed_by: row.changed_by, }; }); return res.json({ success: true, data: batches, }); } catch (error: any) { logger.error("[pop/production] result-history 오류:", error); return res.status(500).json({ success: false, message: error.message || "이력 조회 중 오류가 발생했습니다.", }); } }; /** * 앞공정 완료량 + 접수가능량 조회 (신 구조) * work_order_process_id 는 마스터 work_order_process.id */ export const getAvailableQty = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const rawWopId = req.query.work_order_process_id; const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; if (!work_order_process_id) { return res.status(400).json({ success: false, message: "work_order_process_id가 필요합니다.", }); } const current = await pool.query( `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 WHERE wop.id = $1 AND wop.company_code = $2`, [work_order_process_id, companyCode], ); if (current.rowCount === 0) { return res .status(404) .json({ success: false, message: "공정을 찾을 수 없습니다." }); } const { seq_no, wo_id, instruction_qty } = current.rows[0]; const instrQty = parseInt(instruction_qty, 10) || 0; const seqNum = parseInt(seq_no, 10); // 기접수합계 (같은 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 myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; // accept-process 와 정책 일치: 비필수 0건 자동 skip 흐름을 모달 max 에도 반영 // (getPrevProcessGoodQty 는 단순 직전 seq 만 봐서 비필수 wop 가 끼면 0 으로 잘못 잡힘) const prevEval = await evaluatePrevProcesses(pool, wo_id, seqNum, companyCode); const prevGoodQty = prevEval.prevGoodQty; const availableQty = Math.max(0, prevGoodQty - myInputQty); // 앞공정 리워크 미소진 let reworkAvailableQty = 0; if (seqNum > 1) { const prevSeq = String(seqNum - 1); const reworkSplits = await pool.query( `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], ); 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(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; reworkAvailableQty += Math.max(0, srcGood - consumed); } } return res.json({ success: true, data: { prevGoodQty, myInputQty, availableQty, instructionQty: instrQty, reworkAvailableQty, }, }); } catch (error: any) { logger.error("[pop/production] available-qty 오류:", error); return res.status(500).json({ success: false, message: error.message || "접수가능량 조회 중 오류가 발생했습니다.", }); } }; /** * 공정 접수 — 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, res: Response, ) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, accept_qty } = req.body; if (!work_order_process_id || !accept_qty) { return res.status(400).json({ success: false, message: "work_order_process_id와 accept_qty가 필요합니다.", }); } const qty = parseInt(accept_qty, 10); if (qty <= 0) { return res .status(400) .json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); } await client.query("BEGIN"); // 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.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, 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 WHERE wop.id = $1 AND wop.company_code = $2 FOR UPDATE OF wop`, [work_order_process_id, companyCode], ); if (current.rowCount === 0) { await client.query("ROLLBACK"); return res .status(404) .json({ success: false, message: "공정을 찾을 수 없습니다." }); } const row = current.rows[0]; const masterId: string = row.id; const instrQty = parseInt(row.instruction_qty, 10) || 0; const seqNum = parseInt(row.seq_no, 10); // 완료 판정: 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"); return res .status(400) .json({ success: false, message: "이미 완료된 공정입니다." }); } // 기접수합계 (리워크 제외) 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 currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; // 앞공정 평가 (스킵/차단/접수가능량 통합) const evalResult = await evaluatePrevProcesses(client, row.wo_id, seqNum, companyCode); if (!evalResult.canAccept) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: evalResult.blockedReason || "앞공정이 진행 중이라 접수할 수 없습니다.", }); } const prevGoodQty = evalResult.prevGoodQty; const availableQty = prevGoodQty - currentTotalInput; // 리워크 접수: 자동 생성된 리워크 카드(status='acceptable', is_rework='Y') 의 input_qty 기준으로 검증. // 일반 가용량(availableQty) 검증은 우회 — 리워크 물량은 앞공정 양품 풀과 별개. const reqReworkSourceId = typeof req.body.rework_source_id === "string" && req.body.rework_source_id.trim() ? req.body.rework_source_id.trim() : null; if (reqReworkSourceId) { const autoCardRes = await client.query( `SELECT COALESCE(CAST(NULLIF(input_qty, '') AS int), 0) AS planned FROM work_order_process_result WHERE wop_id = $1 AND company_code = $2 AND rework_source_id = $3 AND is_rework IN ('Y','true','1') AND status = 'acceptable' LIMIT 1`, [masterId, companyCode, reqReworkSourceId], ); if ((autoCardRes.rowCount ?? 0) === 0) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: "리워크 카드를 찾을 수 없습니다.", }); } const reworkPlanned = parseInt(autoCardRes.rows[0].planned, 10) || 0; const acceptedRes = 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 accepted FROM work_order_process_result WHERE wop_id = $1 AND company_code = $2 AND rework_source_id = $3 AND is_rework IN ('Y','true','1') AND status <> 'acceptable'`, [masterId, companyCode, reqReworkSourceId], ); const reworkAccepted = parseInt(acceptedRes.rows[0].accepted, 10) || 0; const reworkAvail = Math.max(0, reworkPlanned - reworkAccepted); if (qty > reworkAvail) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: `리워크 접수가능량(${reworkAvail})을 초과합니다. (예정: ${reworkPlanned}, 기접수: ${reworkAccepted})`, }); } } else if (qty > availableQty) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, }); } // 건너뛴 비필수 공정 자동 skip 마크 (같은 트랜잭션) for (const tgt of evalResult.skipTargets) { try { 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, completed_at, completed_by, 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::varchar AND company_code = $2::varchar), $2::varchar, 'completed', 'skipped', '0', '0', '0', '0', '0', NOW()::text, $3::varchar, $3::varchar, NOW(), NOW() )`, [tgt.wopId, companyCode, userId], ); } catch (err: any) { // 23505: 동시성으로 이미 누가 skipped INSERT 함 — 무시 if (err?.code !== "23505") throw err; } } // 리워크 정보 결정 let splitIsRework: string | null = null; let splitReworkSourceId: string | null = null; if (req.body.rework_source_id) { splitIsRework = "Y"; splitReworkSourceId = req.body.rework_source_id; } else if (seqNum > 1) { const prevSeq = String(seqNum - 1); const prevReworkSplits = await client.query( `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], ); 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(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; } break; } } } // 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 }, ); // [WIP 적재 — 트리거 1·6] 공정 접수 시 wip_stock UPSERT + 직전 공정 이동 반영. // 공유 트랜잭션(client) 이므로 WIP 쿼리가 throw 하면 트랜잭션 전체가 aborted 되어 // 이후 COMMIT 이 ROLLBACK 처리된다 → SAVEPOINT 로 감싸 본작업(접수)을 보존한다. // 트리거1·6 을 각각 별도 SAVEPOINT 로 감싸 트리거6 실패가 트리거1 적재를 되돌리지 않게 한다. const wipManagerName = await resolveUserName(client, userId, companyCode); // 트리거 1: 이번 공정 접수분 적재 (input_qty 누적, status='in_progress') await client.query("SAVEPOINT wip_accept"); try { await onProcessAccept( client, companyCode, masterId, inserted.id, qty, userId, req.body.equipment_code || null, wipManagerName, ); await client.query("RELEASE SAVEPOINT wip_accept"); } catch (wipErr) { await client.query("ROLLBACK TO SAVEPOINT wip_accept").catch(() => {}); logger.error("[pop/production] WIP 적재 오류 (공정 접수는 유지):", wipErr); } // 트리거 6: 후속 공정(seq>최소) 접수면 직전 공정 wip_stock 행에 이동 반영 if (seqNum > 1) { await client.query("SAVEPOINT wip_move"); try { const prevWopRes = await client.query( `SELECT id FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND NULLIF(regexp_replace(COALESCE(seq_no, ''), '[^0-9-]', '', 'g'), '')::int < $3 ORDER BY NULLIF(regexp_replace(COALESCE(seq_no, ''), '[^0-9-]', '', 'g'), '')::int DESC LIMIT 1`, [row.wo_id, companyCode, seqNum], ); if ((prevWopRes.rowCount ?? 0) > 0) { await onProcessMove( client, companyCode, prevWopRes.rows[0].id, masterId, inserted.id, qty, userId, req.body.equipment_code || null, wipManagerName, ); } await client.query("RELEASE SAVEPOINT wip_move"); } catch (wipErr) { await client.query("ROLLBACK TO SAVEPOINT wip_move").catch(() => {}); logger.error("[pop/production] WIP 이동 적재 오류 (공정 접수는 유지):", wipErr); } } await client.query("COMMIT"); logger.info("[pop/production] accept-process 접수 완료", { companyCode, userId, masterId, wopResultId: inserted.id, seq: inserted.seq, acceptedQty: qty, checklistCount, }); const acceptData: any = { ...inserted, process_name: row.process_name, }; if (splitReworkSourceId) { acceptData.rework_source_id = splitReworkSourceId; acceptData.is_rework = splitIsRework; } return res.json({ success: true, data: acceptData, message: `${qty}개 접수 완료`, }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] accept-process 오류:", error); return res.status(500).json({ success: false, message: error.message || "접수 중 오류가 발생했습니다.", }); } finally { client.release(); } }; /** * 접수 취소 — work_order_process_result 기준 * work_order_process_id 는 wop_result.id */ export const cancelAccept = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id } = req.body; if (!work_order_process_id) { return res.status(400).json({ success: false, message: "work_order_process_id는 필수입니다.", }); } const current = await pool.query( `SELECT wr.id, wr.status, wr.input_qty, wr.total_production_qty, wr.result_status, wr.wop_id, wr.good_qty, wr.concession_qty, wr.equipment_code, 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: "접수 카드를 찾을 수 없습니다." }); } const proc = current.rows[0]; if (proc.status !== "in_progress") { return res.status(400).json({ success: false, message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`, }); } const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0; const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0; const unproducedQty = currentInputQty - totalProduced; if (unproducedQty <= 0) { return res.status(400).json({ success: false, message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", }); } const cancelledQty = unproducedQty; const client = await pool.connect(); try { await client.query("BEGIN"); if (totalProduced === 0) { // 자기 wop_result 가 acceptProcess 시점에 같이 INSERT 한 자동 skipped row 들도 함께 DELETE // (같은 트랜잭션이라 created_date 동일) await client.query( `DELETE FROM work_order_process_result wr USING work_order_process wop WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code AND wop.wo_id = $1::varchar AND wr.company_code = $2::varchar AND wr.result_status = 'skipped' AND wr.id <> $3::varchar AND wr.created_date = ( SELECT created_date FROM work_order_process_result WHERE id = $3::varchar AND company_code = $2::varchar )`, [proc.wo_id, companyCode, work_order_process_id], ); 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_result WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); } else { await client.query( `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 WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode, String(totalProduced), userId], ); } // 재고 원복 if (proc.target_warehouse_id) { const inboundQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); if (inboundQty > 0) { 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) > 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) > 0) { const itemCode = itemResult.rows[0].item_number; const locCode = proc.target_location_code || proc.target_warehouse_id; await client.query( `UPDATE inventory_stock SET current_qty = GREATEST((COALESCE(current_qty::numeric, 0) - $4::numeric), 0)::text, updated_date = NOW(), writer = $5 WHERE company_code = $1 AND item_code = $2 AND warehouse_code = $3 AND location_code = $6`, [ companyCode, itemCode, proc.target_warehouse_id, String(inboundQty), userId, locCode, ], ); } } } } // [WIP 적재 — 트리거 5] 접수 취소 시 wip_stock 미소진 접수분 롤백. // 공유 트랜잭션(client) 이므로 WIP 쿼리 throw 시 트랜잭션 전체가 aborted 되어 // 이후 COMMIT 이 ROLLBACK 처리된다 → SAVEPOINT 로 감싸 본작업(접수 취소)을 보존한다. await client.query("SAVEPOINT wip_cancel"); try { const managerName = await resolveUserName(client, userId, companyCode); await onAcceptCancelled( client, companyCode, proc.wop_id, work_order_process_id, cancelledQty, userId, totalProduced === 0, // 실적 0 → result 행 전체 삭제(fullRemove) proc.equipment_code || null, managerName, ); await client.query("RELEASE SAVEPOINT wip_cancel"); } catch (wipErr) { await client .query("ROLLBACK TO SAVEPOINT wip_cancel") .catch(() => {}); logger.error( "[pop/production] WIP 적재 오류 (접수 취소는 유지):", wipErr, ); } await client.query("COMMIT"); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } return res.json({ success: true, data: { id: work_order_process_id, process_name: proc.process_name }, message: `미소진 ${cancelledQty}개 접수가 취소되었습니다.`, }); } catch (error: any) { logger.error("[pop/production] cancel-accept 오류:", error); return res.status(500).json({ success: false, message: error.message || "접수 취소 중 오류가 발생했습니다.", }); } }; /** * 창고 목록 조회 (POP 생산용) */ export const getWarehouses = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const result = await pool.query( `SELECT id, warehouse_code, warehouse_name, warehouse_type FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, [companyCode], ); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("[pop/production] 창고 목록 조회 실패:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 특정 창고의 위치(로케이션) 목록 조회 */ export const getWarehouseLocations = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { warehouseId } = req.params; if (!warehouseId) { return res .status(400) .json({ success: false, message: "warehouseId는 필수입니다." }); } const whInfo = await pool.query( `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, [warehouseId, companyCode], ); if (whInfo.rowCount === 0) { return res.json({ success: true, data: [] }); } const warehouseCode = whInfo.rows[0].warehouse_code; const result = await pool.query( `SELECT id, location_code, location_name FROM warehouse_location WHERE warehouse_code = $1 AND company_code = $2 ORDER BY location_name`, [warehouseCode, companyCode], ); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("[pop/production] 창고 위치 조회 실패:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 마지막 공정 여부 확인 — 마스터 wop 기준 (seq_no MAX) */ export const isLastProcess = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { processId } = req.params; if (!processId) { return res.json({ success: true, data: { isLast: false } }); } const process = await pool.query( `SELECT wo_id, seq_no, target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, [processId, companyCode], ); if (process.rowCount === 0) { return res.json({ success: true, data: { isLast: false } }); } const { wo_id, seq_no, target_warehouse_id, target_location_code } = process.rows[0]; // "마지막 공정" = 자기 이후에 필수 공정이 하나도 없음 (후행 비필수만 있어도 isLast=true) 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 COALESCE(is_required, '') = 'Y' LIMIT 1`, [wo_id, companyCode, seq_no], ); return res.json({ success: true, data: { isLast: next.rowCount === 0, woId: wo_id, seqNo: seq_no, targetWarehouseId: target_warehouse_id || null, targetLocationCode: target_location_code || null, }, }); } catch (error: any) { logger.error("[pop/production] 마지막 공정 확인 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 공정의 목표 창고/위치 업데이트 (마스터 wop 기준) */ export const updateTargetWarehouse = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, target_warehouse_id, target_location_code } = req.body; if (!work_order_process_id || !target_warehouse_id) { return res.status(400).json({ success: false, message: "work_order_process_id와 target_warehouse_id는 필수입니다.", }); } 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, ], ); return res.json({ success: true, data: { target_warehouse_id, target_location_code }, }); } catch (error: any) { logger.error("[pop/production] 목표 창고 업데이트 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 독립 재고 입고 — work_order_process_result 기준. * body.work_order_process_id 는 wop_result.id */ export const inventoryInbound = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, warehouse_code, location_code } = req.body; if (!work_order_process_id || !warehouse_code) { return res.status(400).json({ success: false, message: "work_order_process_id와 warehouse_code는 필수입니다.", }); } await client.query("BEGIN"); const procResult = await client.query( `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], ); if (procResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "해당 접수 카드를 찾을 수 없습니다.", }); } const proc = procResult.rows[0]; if (proc.target_warehouse_id) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 재고 입고가 완료된 공정입니다.", data: { existing_warehouse: proc.target_warehouse_id }, }); } const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); if (goodQty <= 0) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: "양품 수량이 0이므로 재고 입고할 수 없습니다.", }); } 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) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "작업지시를 찾을 수 없습니다.", }); } const itemId = wiResult.rows[0].item_id; const itemResult = await client.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [itemId, companyCode], ); if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 정보를 찾을 수 없습니다.", }); } const itemCode = itemResult.rows[0].item_number; const effectiveLocationCode = location_code || null; await upsertInventoryStock( client, companyCode, itemCode, warehouse_code, effectiveLocationCode, goodQty, userId, { userName: req.user!.userName || userId, source: "manual_inbound", woId: proc.wo_id, workOrderProcessResultId: work_order_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], ); // 자기 wop 이 "마지막 필수 공정" 이면 후행 비필수 자동 skipped INSERT const trailingRequired = await client.query( `SELECT 1 FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND COALESCE(is_required, '') = 'Y' LIMIT 1`, [proc.wo_id, companyCode, proc.seq_no], ); if ((trailingRequired.rowCount ?? 0) === 0) { const trailingNonReqs = await client.query( `SELECT wop.id FROM work_order_process wop WHERE wop.wo_id = $1::varchar AND wop.company_code = $2::varchar AND CAST(wop.seq_no AS int) > $3::int AND COALESCE(wop.is_required, '') <> 'Y' AND NOT EXISTS ( SELECT 1 FROM work_order_process_result wr WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code )`, [proc.wo_id, companyCode, parseInt(proc.seq_no, 10)], ); for (const row of trailingNonReqs.rows) { try { 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, completed_at, completed_by, 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::varchar AND company_code = $2::varchar), $2::varchar, 'completed', 'skipped', '0', '0', '0', '0', '0', NOW()::text, $3::varchar, $3::varchar, NOW(), NOW() )`, [row.id, companyCode, userId], ); } catch (err: any) { if (err?.code !== "23505") throw err; } } } if (proc.is_rework === "Y") { await client.query( `UPDATE work_order_process_result SET is_rework = NULL, updated_date = NOW() WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode], ); } await client.query("COMMIT"); return res.json({ success: true, message: "재고 입고가 완료되었습니다.", data: { item_code: itemCode, warehouse_code, location_code: effectiveLocationCode, qty: goodQty, }, }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] 독립 재고 입고 오류:", error); return res.status(500).json({ success: false, message: error.message }); } finally { client.release(); } }; /** * 간이 재고 입고 (공정 접수 없이 바로 입고) */ export const quickInventoryInbound = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { item_id, qty, warehouse_code, location_code, remark } = req.body; if (!item_id || !qty || !warehouse_code) { return res.status(400).json({ success: false, message: "item_id, qty, warehouse_code는 필수입니다.", }); } const parsedQty = parseInt(String(qty), 10); if (isNaN(parsedQty) || parsedQty <= 0) { return res.status(400).json({ success: false, message: "수량은 1 이상의 정수여야 합니다.", }); } await client.query("BEGIN"); const itemResult = await client.query( `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, [item_id, companyCode], ); if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 정보를 찾을 수 없습니다.", }); } const item = itemResult.rows[0]; const itemCode = item.item_number; const effectiveLocationCode = location_code || null; await upsertInventoryStock( client, companyCode, itemCode, warehouse_code, effectiveLocationCode, parsedQty, userId, { userName: req.user!.userName || userId, source: "quick_inbound", }, ); const seqResult = await client.query( `SELECT COALESCE(MAX( CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) ELSE 0 END ), 0) + 1 AS next_seq FROM inbound_mng WHERE company_code = $1`, [companyCode], ); const nextSeq = seqResult.rows[0].next_seq; const year = new Date().getFullYear(); const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`; await client.query( `INSERT INTO inbound_mng ( id, company_code, inbound_number, inbound_type, inbound_date, item_number, item_name, spec, material, unit, inbound_qty, warehouse_code, location_code, inbound_status, memo, remark, created_date, updated_date, writer, created_by, updated_by ) VALUES ( gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE, $3, $4, $5, $6, $7, $8, $9, $10, '완료', $11, $12, NOW(), NOW(), $13, $13, $13 )`, [ companyCode, inboundNumber, item.item_number, item.item_name, item.size, item.material, item.unit, parsedQty, warehouse_code, effectiveLocationCode, remark || "POP 간이입고", remark || null, userId, ], ); await client.query("COMMIT"); return res.json({ success: true, message: "간이 재고 입고가 완료되었습니다.", data: { inbound_number: inboundNumber, item_code: itemCode, item_name: item.item_name, warehouse_code, location_code: effectiveLocationCode, qty: parsedQty, }, }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] 간이 재고 입고 오류:", error); return res.status(500).json({ success: false, message: error.message }); } finally { client.release(); } }; /** * 재작업 이력 조회 — 신 구조: 접수 카드(wop_result) 기반 */ export const getReworkHistory = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const woId = (req.query.wo_id as string) || req.params.woId; if (!woId) { return res .status(400) .json({ success: false, message: "wo_id는 필수입니다." }); } const result = await pool.query( `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], ); const rows = result.rows; 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), ); const sources = rows.filter( (r) => reworkSourceIds.has(r.id) || (parseInt(r.defect_qty || "0", 10) > 0 && r.is_rework !== "Y"), ); 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); } chains.push({ source: src, reworks: chain, totalReworkCount: chain.length, }); } return res.json({ success: true, data: { wo_id: woId, total_rework_count: rows.filter( (r) => r.is_rework === "Y" || r.is_rework === "1", ).length, chains, all_records: rows, }, }); } catch (error: any) { logger.error("[pop/production] rework-history 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 공정별 BOM 자재 목록 + 소요량 계산 * processId = 마스터 work_order_process.id */ export const getBomMaterials = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { processId } = req.params; if (!processId) { return res .status(400) .json({ success: false, message: "processId 필수" }); } const procResult = await pool.query( `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`, [processId, companyCode], ); if (procResult.rowCount === 0) { return res.json({ success: true, data: { materials: [], processQty: 0 }, }); } const proc = procResult.rows[0]; const processQty = parseInt( String( proc.total_input && parseInt(proc.total_input, 10) > 0 ? proc.total_input : proc.plan_qty || proc.instruction_qty || "0", ), 10, ); const itemResult = await pool.query( `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, [proc.item_id, companyCode], ); if (itemResult.rowCount === 0) { return res.json({ success: true, data: { materials: [], processQty } }); } const itemCode = itemResult.rows[0].item_number; 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, 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 WHERE (b.item_code = $1 OR b.item_id = $2) AND b.company_code = $3 ORDER BY bd.seq_no ASC`, [itemCode, proc.item_id, companyCode], ); const baseQty = parseFloat(bomResult.rows[0]?.base_qty || "1") || 1; // 투입량: 마스터 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 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`, [companyCode, processId], ); const inputMap = new Map(); for (const row of inputResult.rows) { inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0); } const materials = bomResult.rows.map((bd: Record) => { const bomQty = parseFloat(String(bd.quantity || "0")) || 0; const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0; const requiredQty = Math.ceil( (processQty / baseQty) * bomQty * (1 + lossRate / 100), ); const childItemCode = String(bd.child_item_code || ""); return { id: bd.id, child_item_id: bd.child_item_id, child_item_code: childItemCode, child_item_name: bd.child_item_name || "", bom_qty: bomQty, unit: bd.unit || bd.item_unit || "", process_type: bd.process_type || "", loss_rate: lossRate, required_qty: requiredQty, input_qty: inputMap.get(childItemCode) || 0, }; }); return res.json({ success: true, data: { materials, processQty, baseQty, itemCode, itemName: itemResult.rows[0].item_name, }, }); } catch (error: any) { logger.error("[pop/production] bom-materials 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 자재 투입 기록 저장 * work_order_process_id 는 wop_result.id (접수 카드) 기준으로 저장. */ export const saveMaterialInput = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, inputs } = req.body; if (!work_order_process_id || !inputs || !Array.isArray(inputs)) { return res.status(400).json({ success: false, message: "work_order_process_id, inputs[] 필수", }); } await client.query("BEGIN"); const results = []; for (const input of inputs) { const { child_item_id, child_item_code, child_item_name, input_qty, unit, bom_detail_id, required_qty, warehouse_code, location_code, } = input; const effectiveItemId = child_item_id || input.item_id || input.item_code || child_item_code; const effectiveItemCode = child_item_code || input.item_code || child_item_id; const effectiveItemName = child_item_name || input.item_name || ""; const effectiveQty = input_qty || input.qty || input.quantity; if (!effectiveItemId || !effectiveQty) continue; const parsedQty = parseFloat(String(effectiveQty)); if (isNaN(parsedQty) || parsedQty <= 0) continue; const insertResult = await client.query( `INSERT INTO process_work_result ( id, company_code, work_order_process_id, detail_type, detail_content, item_title, result_value, unit, is_passed, status, remark, recorded_by, recorded_at, writer ) VALUES ( gen_random_uuid()::text, $1, $2, 'material_input', $3, $4, $5, $6, 'Y', 'completed', $7, $8, NOW()::text, $8 ) RETURNING id`, [ companyCode, work_order_process_id, effectiveItemCode || effectiveItemId, effectiveItemName, String(parsedQty), unit || "", JSON.stringify({ bom_detail_id, required_qty: required_qty || 0, warehouse_code, location_code, }), userId, ], ); let effectiveWh = warehouse_code; let effectiveLoc = location_code; if (!effectiveWh) { const autoStock = await client.query( `SELECT warehouse_code, location_code FROM inventory_stock WHERE company_code = $1 AND item_code = $2 AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0 ORDER BY last_in_date DESC NULLS LAST LIMIT 1`, [companyCode, effectiveItemCode], ); if (autoStock.rows.length > 0) { effectiveWh = autoStock.rows[0].warehouse_code; effectiveLoc = autoStock.rows[0].location_code || effectiveWh; } } if (effectiveWh) { const locCode = effectiveLoc || effectiveWh; await client.query( `UPDATE inventory_stock SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text, updated_date = NOW(), writer = $5 WHERE company_code = $1 AND item_code = $2 AND warehouse_code = $3 AND location_code = $6`, [ companyCode, effectiveItemCode, effectiveWh, String(parsedQty), userId, locCode, ], ); } results.push({ id: insertResult.rows[0].id, child_item_code: effectiveItemCode, input_qty: parsedQty, }); } await client.query("COMMIT"); return res.json({ success: true, message: `${results.length}건 자재 투입 완료`, data: results, }); } catch (error: any) { await client.query("ROLLBACK").catch(() => {}); logger.error("[pop/production] material-input 오류:", error); return res.status(500).json({ success: false, message: error.message }); } finally { client.release(); } }; /** * 자재 투입 현황 조회 * processId 는 wop_result.id (접수 카드) 기준 */ export const getMaterialInputs = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { processId } = req.params; if (!processId) { return res .status(400) .json({ success: false, message: "processId 필수" }); } const result = await pool.query( `SELECT id, detail_content as item_code, item_title as item_name, result_value as input_qty, unit, remark, recorded_by, recorded_at FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' ORDER BY recorded_at ASC`, [processId, companyCode], ); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("[pop/production] material-inputs 조회 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 체크리스트 조회 * processId 는 wop_result.id (접수 카드) 기준 */ export const getChecklistItems = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { processId } = req.params; if (!processId) { return res .status(400) .json({ success: false, message: "processId 필수" }); } const result = await pool.query( `SELECT pwr.id, pwr.company_code, pwr.work_order_process_id, pwr.source_work_item_id, pwr.source_detail_id, pwr.work_phase, pwr.item_title, pwr.item_sort_order, pwr.detail_content, pwr.detail_type, pwr.detail_sort_order, pwr.is_required, pwr.inspection_code, pwr.inspection_method, pwr.unit, pwr.lower_limit, pwr.upper_limit, pwr.input_type, pwr.lookup_target, pwr.display_fields, pwr.duration_minutes, pwr.status, pwr.result_value, pwr.is_passed, pwr.remark, pwr.recorded_by, pwr.recorded_at, pwr.started_at, pwr.group_started_at, pwr.group_paused_at, pwr.group_total_paused_time, pwr.group_completed_at, ist.judgment_criteria FROM process_work_result pwr LEFT JOIN inspection_standard ist ON pwr.inspection_code = ist.inspection_code AND pwr.company_code = ist.company_code WHERE pwr.work_order_process_id = $1 AND pwr.company_code = $2 ORDER BY COALESCE(NULLIF(pwr.item_sort_order, '')::int, 0), COALESCE(NULLIF(pwr.detail_sort_order, '')::int, 0)`, [processId, companyCode], ); return res.json({ success: true, data: result.rows }); } catch (error: any) { logger.error("[pop/production] checklist-items 조회 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /* ================================================================== */ /* 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(); try { const companyCode = req.user!.companyCode; await ensureBatchIdColumn(); await ensureProcessListIndexes(); // 마스터 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 ), 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_OR(result_status = 'skipped') AS has_skipped, 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_raw AS ( SELECT wop.id AS wop_id, wop.wo_id, wop.company_code, ( SELECT COALESCE(SUM(CAST(NULLIF(wr2.good_qty, '') AS numeric)), 0) + COALESCE(SUM(CAST(NULLIF(wr2.concession_qty, '') AS numeric)), 0) FROM work_order_process_result wr2 WHERE wr2.company_code = wop.company_code AND wr2.wop_id = ( SELECT wop2.id FROM work_order_process wop2 WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND EXISTS ( SELECT 1 FROM work_order_process_result wr3 WHERE wr3.wop_id = wop2.id AND wr3.company_code = wop2.company_code ) ORDER BY CAST(wop2.seq_no AS int) DESC LIMIT 1 ) ) AS prev_good_qty_raw, EXISTS ( SELECT 1 FROM work_order_process wop2 WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND COALESCE(wop2.is_required, '') = 'Y' ) AS has_prior_required, EXISTS ( SELECT 1 FROM work_order_process wop2 JOIN work_order_process_result wr2 ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) ) AS has_prior_wr, EXISTS ( SELECT 1 FROM work_order_process wop2 JOIN work_order_process_result wr2 ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND COALESCE(wop2.is_required, '') <> 'Y' AND wr2.result_status NOT IN ('confirmed','skipped') AND COALESCE(CAST(NULLIF(wr2.input_qty, '') AS numeric), 0) > 0 ) AS has_prior_blocking, EXISTS ( SELECT 1 FROM work_order_process wop2 WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) AND COALESCE(wop2.is_required, '') = 'Y' AND NOT EXISTS ( SELECT 1 FROM work_order_process_result wr2 WHERE wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code ) ) AS has_unfinished_required_prior FROM work_order_process wop WHERE wop.company_code = $1 ), prev_good AS ( SELECT pgr.wop_id, pgr.has_prior_blocking, CASE WHEN pgr.has_unfinished_required_prior THEN 0 WHEN pgr.has_prior_wr THEN COALESCE(pgr.prev_good_qty_raw, 0) WHEN pgr.has_prior_required THEN 0 ELSE COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) END AS prev_good_qty FROM prev_good_raw pgr LEFT JOIN work_instruction wi ON wi.id = pgr.wo_id AND wi.company_code = pgr.company_code ), 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, 'defect_detail', defect_detail, 'result_note', result_note, '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 COALESCE(pg.has_prior_blocking, false) THEN 'waiting' -- 자기 wop 이 자동 skip 처리됨 (confirmed 없음) → completed WHEN wa.has_skipped AND NOT wa.has_confirmed AND wa.all_completed THEN 'completed' -- 추가 접수가능량 > 0 → acceptable (master 카드: 분할 접수 권유. 진행중 row 는 split 카드로 별도 표시) WHEN GREATEST(0, COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0)) > 0 THEN 'acceptable' -- 잔량 0 + 자기 row 진행중 → in_progress WHEN wa.accept_count > 0 AND NOT wa.all_completed THEN 'in_progress' -- 잔량 0 + 자기 row 전부 confirmed → completed WHEN wa.has_confirmed AND wa.all_completed THEN 'completed' -- wop_result 0건 + prev_good_qty 0 → waiting ELSE 'waiting' END AS status, CASE WHEN wa.has_confirmed THEN 'confirmed' WHEN wa.has_skipped THEN 'skipped' ELSE 'draft' END AS result_status, COALESCE(pg.prev_good_qty, 0) AS prev_good_qty, COALESCE(wa.sum_input_norework, 0) AS my_input_qty, GREATEST(0, COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0)) 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 prev_good pg ON pg.wop_id = wop.id LEFT JOIN accepted_results ar ON ar.wop_id = wop.id ORDER BY wop.wo_id, CAST(wop.seq_no AS int) `; 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 }); } }; /** * 단일 접수 카드 조회 (신규 엔드포인트) * GET /api/pop/production/result/:id * :id = work_order_process_result.id */ export const getProcessResult = async ( req: AuthenticatedRequest, res: Response, ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { id } = req.params; if (!id) { return res .status(400) .json({ success: false, message: "id는 필수입니다." }); } 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 (result.rowCount === 0) { return res .status(404) .json({ success: false, message: "접수 카드를 찾을 수 없습니다." }); } return res.json({ success: true, data: result.rows[0] }); } catch (error: any) { logger.error("[pop/production] get-process-result 오류:", error); return res.status(500).json({ success: false, message: error.message }); } };