import { Response } from "express"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; // 불량 상세 항목 타입 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 사용 불가) */ async function upsertInventoryStock( client: { query: (text: string, values?: any[]) => Promise }, companyCode: string, itemCode: string, warehouseCode: string, locationCode: string | null, qty: number, userId: string ): Promise { const whCode = warehouseCode || null; const locCode = locationCode || null; const existing = await client.query( `SELECT id 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 || ''] ); if (existing.rows.length > 0) { 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, existing.rows[0].id] ); } else { 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)`, [companyCode, itemCode, whCode, locCode, String(qty), userId] ); } } /** * 체크리스트 복사 공통 함수 * 분할 행/재작업 카드 생성 시 마스터의 체크리스트를 새 행에 복사한다. * * 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 결과에서 복사 */ async function copyChecklistToSplit( client: { query: (text: string, values?: any[]) => Promise }, masterProcessId: string, newProcessId: string, routingDetailId: string | null, companyCode: string, userId: string ): Promise { // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 if (routingDetailId) { const result = await client.query( `INSERT INTO process_work_result ( id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, inspection_code, inspection_method, unit, lower_limit, upper_limit, input_type, lookup_target, display_fields, duration_minutes, status, writer ) SELECT gen_random_uuid()::text, pwi.company_code, $1, pwi.id, pwd.id, pwi.work_phase, pwi.title, pwi.sort_order::text, pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, 'pending', $2 FROM process_work_item pwi JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id AND pwd.company_code = pwi.company_code WHERE pwi.routing_detail_id = $3 AND pwi.company_code = $4 ORDER BY pwi.sort_order, pwd.sort_order`, [newProcessId, userId, routingDetailId, companyCode] ); const countA = result.rowCount ?? 0; if (countA > 0) return countA; // A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough } // B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) const result = await client.query( `INSERT INTO process_work_result ( company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, inspection_code, inspection_method, unit, lower_limit, upper_limit, input_type, lookup_target, display_fields, duration_minutes, status, writer ) SELECT 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`, [newProcessId, userId, masterProcessId, companyCode] ); return result.rowCount ?? 0; } /** * 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성 * createWorkProcesses와 syncWorkInstructions 양쪽에서 재사용한다. * * @returns 생성된 공정 목록 + 체크리스트 총 수. 이미 존재하면 null 반환. */ async function generateWorkProcessesForInstruction( client: { query: (text: string, values?: any[]) => Promise }, workInstructionId: string, routingVersionId: string, planQty: string | null, companyCode: string, userId: string ): Promise<{ processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>; total_checklists: number; } | null> { // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 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; // 이미 존재 } // 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명) const routingDetails = await client.query( `SELECT rd.id, rd.seq_no, rd.process_code, COALESCE(pm.process_name, rd.process_code) as process_name, rd.is_required, rd.is_fixed_order, rd.standard_time FROM item_routing_detail rd LEFT JOIN process_mng pm ON pm.process_code = rd.process_code AND pm.company_code = rd.company_code WHERE rd.routing_version_id = $1 AND rd.company_code = $2 ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, [routingVersionId, companyCode] ); if (routingDetails.rows.length === 0) { return null; // 공정 없음 } const processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number; }> = []; let totalChecklists = 0; for (const rd of routingDetails.rows) { // 2. work_order_process INSERT const wopResult = await client.query( `INSERT INTO work_order_process ( id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, status, routing_detail_id, writer ) VALUES (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, parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" ? "acceptable" : "waiting", rd.id, userId, ] ); const wopId = wopResult.rows[0].id; // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) const checklistCount = await copyChecklistToSplit( client, wopId, wopId, rd.id, companyCode, userId ); totalChecklists += checklistCount; processes.push({ id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount, }); } return { processes, total_checklists: totalChecklists }; } /** * D-BE1: 작업지시 공정 일괄 생성 * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. */ export const createWorkProcesses = async ( req: AuthenticatedRequest, res: Response ) => { const pool = getPool(); const client = await pool.connect(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_instruction_id, item_code, routing_version_id, plan_qty } = req.body; if (!work_instruction_id || !routing_version_id) { return res.status(400).json({ success: false, message: "work_instruction_id와 routing_version_id는 필수입니다.", }); } logger.info("[pop/production] create-work-processes 요청", { companyCode, userId, work_instruction_id, item_code, routing_version_id, plan_qty, }); await client.query("BEGIN"); const result = await generateWorkProcessesForInstruction( client, work_instruction_id, routing_version_id, plan_qty, companyCode, userId ); if (!result) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.", }); } await client.query("COMMIT"); logger.info("[pop/production] create-work-processes 완료", { companyCode, work_instruction_id, total_processes: result.processes.length, total_checklists: result.total_checklists, }); return res.json({ success: true, data: { 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 * routing이 있지만 work_order_process가 없는 작업지시를 찾아 공정을 자동 생성한다. * 각 건별 개별 try-catch로 하나 실패해도 나머지 진행. */ export const syncWorkInstructions = async ( req: AuthenticatedRequest, res: Response ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; logger.info("[pop/production] sync-work-instructions 요청", { companyCode, userId, }); // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 const unsyncedResult = await pool.query( `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty 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 )`, [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; status: "synced" | "skipped" | "error"; process_count?: number; error?: string; }> = []; for (const wi of unsynced) { const client = await pool.connect(); try { await client.query("BEGIN"); const result = await generateWorkProcessesForInstruction( client, wi.id, wi.routing, wi.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", }); continue; } 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, }); logger.info("[pop/production] sync: 공정 생성 완료", { work_instruction_no: wi.work_instruction_no, 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 || "알 수 없는 오류", }); logger.error("[pop/production] sync: 개별 오류", { work_instruction_no: wi.work_instruction_no, error: err.message, }); } finally { client.release(); } } logger.info("[pop/production] sync-work-instructions 완료", { companyCode, synced, skipped, errors, }); return res.json({ success: true, data: { synced, skipped, errors, details }, }); } catch (error: any) { logger.error("[pop/production] sync-work-instructions 오류:", error); return res.status(500).json({ success: false, message: error.message || "작업지시 동기화 중 오류가 발생했습니다.", }); } }; /** * D-BE2: 타이머 API (시작/일시정지/재시작) */ export const controlTimer = async ( req: AuthenticatedRequest, res: Response ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { work_order_process_id, action } = req.body; if (!work_order_process_id || !action) { return res.status(400).json({ success: false, message: "work_order_process_id와 action은 필수입니다.", }); } if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, message: "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } logger.info("[pop/production] timer 요청", { companyCode, userId, work_order_process_id, action, }); let result; switch (action) { case "start": // 최초 1회만 설정, 이미 있으면 무시 result = await pool.query( `UPDATE work_order_process SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END, updated_date = NOW() WHERE id = $1 AND company_code = $2 RETURNING id, started_at, status`, [work_order_process_id, companyCode] ); break; case "pause": result = await pool.query( `UPDATE work_order_process SET paused_at = NOW()::text, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NULL RETURNING id, paused_at`, [work_order_process_id, companyCode] ); break; case "resume": // 일시정지 시간 누적 후 paused_at 초기화 result = await pool.query( `UPDATE work_order_process SET total_paused_time = ( COALESCE(total_paused_time::int, 0) + EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int )::text, paused_at = NULL, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL RETURNING id, total_paused_time`, [work_order_process_id, companyCode] ); break; 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 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`, [ 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: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", }); } logger.info("[pop/production] timer 완료", { action, work_order_process_id, result: result.rows[0], }); return res.json({ success: true, data: result.rows[0], }); } catch (error: any) { logger.error("[pop/production] timer 오류:", error); return res.status(500).json({ success: false, message: error.message || "타이머 처리 중 오류가 발생했습니다.", }); } }; /** * 그룹(작업항목)별 타이머 제어 * 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머 */ export const controlGroupTimer = async ( req: AuthenticatedRequest, res: Response ) => { const pool = getPool(); try { const companyCode = req.user!.companyCode; const { work_order_process_id, source_work_item_id, action } = req.body; if (!work_order_process_id || !source_work_item_id || !action) { return res.status(400).json({ success: false, message: "work_order_process_id, source_work_item_id, action은 필수입니다.", }); } if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, message: "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } logger.info("[pop/production] group-timer 요청", { companyCode, work_order_process_id, source_work_item_id, action, }); const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; const baseParams = [work_order_process_id, source_work_item_id, companyCode]; 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 ); await pool.query( `UPDATE work_order_process 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 ); break; } } if (!result || result.rowCount === 0) { return res.status(404).json({ success: false, message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", }); } logger.info("[pop/production] group-timer 완료", { action, source_work_item_id, affectedRows: result.rowCount, }); 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) */ 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 = 'Y' ORDER BY defect_code`; params = []; } else { query = ` SELECT id, defect_code, defect_name, defect_type, severity, company_code FROM defect_standard_mng WHERE is_active = 'Y' AND company_code = $1 ORDER BY defect_code`; params = [companyCode]; } const result = await pool.query(query, params); logger.info("[pop/production] defect-types 조회", { companyCode, count: result.rowCount, }); return res.json({ success: true, data: result.rows, }); } catch (error: any) { logger.error("[pop/production] defect-types 오류:", error); return res.status(500).json({ success: false, message: error.message || "불량 유형 조회 중 오류가 발생했습니다.", }); } }; /** * 실적 저장 (누적 방식) * 이번 차수 생산수량을 기존 누적치에 더한다. * result_status는 'draft' 유지 (확정 전까지 계속 추가 등록 가능) */ export const saveResult = async ( req: AuthenticatedRequest, res: Response ) => { const pool = getPool(); try { 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; if (!work_order_process_id) { return res.status(400).json({ success: false, message: "work_order_process_id는 필수입니다.", }); } if (!production_qty || parseInt(production_qty, 10) <= 0) { return res.status(400).json({ success: false, message: "생산수량을 입력해주세요.", }); } const statusCheck = await pool.query( `SELECT wop.status, wop.result_status, wop.total_production_qty, wop.good_qty, wop.defect_qty, wop.concession_qty, wop.defect_detail, wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no FROM work_order_process wop WHERE wop.id = $1 AND wop.company_code = $2`, [work_order_process_id, companyCode] ); if (statusCheck.rowCount === 0) { return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다.", }); } const prev = statusCheck.rows[0]; // 마스터 행에 직접 실적 등록 방지 (분할 행이 존재하는 경우) if (!prev.parent_process_id) { const splitCheck = await pool.query( `SELECT COUNT(*) as cnt FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); if (parseInt(splitCheck.rows[0].cnt, 10) > 0) { return res.status(400).json({ success: false, message: "원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.", }); } } if (prev.result_status === "confirmed") { return res.status(403).json({ success: false, message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", }); } // 초과 생산 경고 (차단하지 않음 - 현장 유연성) const prevTotal = parseInt(prev.total_production_qty, 10) || 0; const acceptedQty = parseInt(prev.input_qty, 10) || 0; const requestedQty = parseInt(production_qty, 10) || 0; if (acceptedQty > 0 && (prevTotal + requestedQty) > acceptedQty) { logger.warn("[pop/production] 초과 생산 감지", { work_order_process_id, prevTotal, requestedQty, acceptedQty, overAmount: (prevTotal + requestedQty) - acceptedQty, }); } // 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만) 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 { /* 파싱 실패 시 빈 배열 */ } 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 pool.query( `UPDATE work_order_process 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) { return res.status(404).json({ success: false, message: "공정을 찾을 수 없거나 권한이 없습니다.", }); } // === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 === if (prev.parent_process_id) { await pool.query( `UPDATE work_order_process SET good_qty = sub.sum_good, defect_qty = sub.sum_defect, total_production_qty = sub.sum_total, concession_qty = sub.sum_concession, updated_date = NOW() FROM ( SELECT COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2 ) sub WHERE id = $1 AND company_code = $2`, [prev.parent_process_id, companyCode] ); logger.info("[pop/production] master 합산 업데이트", { masterId: prev.parent_process_id, splitId: work_order_process_id, }); } // 현재 분할 행의 공정 정보 조회 const currentSeq = await pool.query( `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, wop.parent_process_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.equipment_code, 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`, [work_order_process_id, companyCode] ); // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) if (currentSeq.rowCount > 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]; const masterId = proc.parent_process_id || work_order_process_id; // 재작업 대상 공정 결정 let reworkSeqNo = proc.seq_no; let reworkProcessCode = proc.process_code; let reworkProcessName = proc.process_name; let reworkRoutingDetailId = proc.routing_detail_id; let reworkMasterId = masterId; // target_process_code가 지정되면 해당 공정 정보를 조회 if (targetProcessCode) { const targetProc = await pool.query( `SELECT id, seq_no, process_code, process_name, routing_detail_id FROM work_order_process WHERE wo_id = $1 AND process_code = $2 AND company_code = $3 AND parent_process_id IS NULL LIMIT 1`, [proc.wo_id, targetProcessCode, companyCode] ); if (targetProc.rowCount > 0) { const tp = targetProc.rows[0]; reworkSeqNo = tp.seq_no; reworkProcessCode = tp.process_code; reworkProcessName = tp.process_name; reworkRoutingDetailId = tp.routing_detail_id; reworkMasterId = tp.id; // 지정 공정의 마스터 ID } } const reworkInsert = await pool.query( `INSERT INTO work_order_process ( id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty, result_status, is_rework, rework_source_id, parent_process_id, company_code, writer ) VALUES ( gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'acceptable', $10, '0', '0', '0', '0', 'draft', 'Y', $11, $12, $13, $14 ) RETURNING id`, [ proc.wo_id, reworkSeqNo, reworkProcessCode, reworkProcessName, proc.is_required, proc.is_fixed_order, proc.standard_time, proc.equipment_code, reworkRoutingDetailId, String(totalReworkQty), work_order_process_id, reworkMasterId, companyCode, userId, ] ); // 재작업 카드에 체크리스트 복사 const reworkId = reworkInsert.rows[0]?.id; if (reworkId) { const reworkChecklistCount = await copyChecklistToSplit( pool, reworkMasterId, reworkId, reworkRoutingDetailId, companyCode, userId ); logger.info("[pop/production] 재작업 카드 자동 생성", { reworkId, sourceId: work_order_process_id, reworkQty: totalReworkQty, targetProcess: targetProcessCode || "(같은 공정)", reworkSeqNo, checklistCount: reworkChecklistCount, }); } } } // 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행) if (currentSeq.rowCount > 0) { const { seq_no: csSeq, wo_id: csWoId, current_input_qty: csInputQty, instruction_qty: csInstrQty, parent_process_id: csParentId } = currentSeq.rows[0]; const csMyInput = parseInt(csInputQty, 10) || 0; if (newTotal >= csMyInput && csMyInput > 0) { await pool.query( `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [work_order_process_id, companyCode, userId] ); // 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed const csSeqNum = parseInt(csSeq, 10); let csPrevGood = parseInt(csInstrQty, 10) || 0; if (csSeqNum > 1) { const prev = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [csWoId, String(csSeqNum - 1), companyCode] ); if (prev.rowCount > 0) csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; } const sibCheck = await pool.query( `SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [csWoId, csSeq, companyCode] ); const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0; const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0; if (csIncomplete === 0 && csPrevGood - csTotalInput <= 0 && csParentId) { await pool.query( `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [csParentId, companyCode, userId] ); } } await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId); } // 다음 공정 활성화 (다중공정 대응) // is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화 if (addGood > 0 && currentSeq.rowCount > 0) { const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0]; const seqNum = parseInt(seq_no, 10); let shouldActivateNext = true; if (is_fixed_order === "Y") { // 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인 // (병렬 그룹 = 같은 seq_no를 공유하는 공정들) const groupCheck = await pool.query( `SELECT id, seq_no, status, COALESCE(good_qty::int, 0) + COALESCE(concession_qty::int, 0) as total_good FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL AND seq_no = $3 ORDER BY CAST(seq_no AS int)`, [wo_id, companyCode, seq_no] ); // 같은 seq의 미완료 공정 확인 (병렬 그룹 내) const incomplete = groupCheck.rows.filter((r: Record) => String(r.status) !== "completed" && parseInt(String(r.total_good), 10) <= 0 ); shouldActivateNext = incomplete.length === 0; if (!shouldActivateNext) { logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", { groupSize: groupCheck.rows.length, incomplete: incomplete.length, }); } } if (shouldActivateNext) { // 다음 seq 활성화 (completed도 재활성화 — 새 양품이 들어오면 추가 접수 가능) const nextSeq = String(seqNum + 1); const nextUpdate = await pool.query( `UPDATE work_order_process SET status = 'acceptable', updated_date = NOW() WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NULL RETURNING id, process_name, status`, [wo_id, nextSeq, companyCode] ); if (nextUpdate.rowCount > 0) { logger.info("[pop/production] 다음 공정 상태 전환", { nextProcess: nextUpdate.rows[0], }); } } } // (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨) logger.info("[pop/production] save-result 완료 (누적)", { companyCode, work_order_process_id, added: { production_qty: addProduction, good_qty: addGood, defect_qty: addDefect }, accumulated: { total: newTotal, good: newGood, defect: newDefect }, }); // 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음) const latestData = await pool.query( `SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); return res.json({ success: true, data: latestData.rows[0] || result.rows[0], }); } catch (error: any) { logger.error("[pop/production] save-result 오류:", error); return res.status(500).json({ success: false, message: error.message || "실적 저장 중 오류가 발생했습니다.", }); } }; /** * 작업지시(work_instruction) 전체 완료 판정 * 마지막 공정의 모든 행이 completed이면 작업지시도 완료 처리 */ const checkAndCompleteWorkInstruction = async ( pool: any, woId: string, companyCode: string, userId: string ) => { 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`, [woId, companyCode] ); if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return; const maxSeq = String(maxSeqResult.rows[0].max_seq); const incompleteCheck = await pool.query( `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND status != 'completed'`, [woId, maxSeq, companyCode] ); if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return; const totalGoodResult = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [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] ); logger.info("[pop/production] 작업지시 전체 완료", { woId, completedQty, companyCode, }); // 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT if (updateResult.rowCount > 0 && completedQty > 0) { try { const itemId = updateResult.rows[0].item_id; // item_info에서 item_number 조회 const itemResult = await pool.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [itemId, companyCode] ); if (itemResult.rowCount === 0) { logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", { itemId, companyCode }); return; } const itemCode = itemResult.rows[0].item_number; // 마지막 공정의 창고 설정 조회 (마스터 행에서) const warehouseResult = await pool.query( `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NULL LIMIT 1`, [woId, maxSeq, companyCode] ); if (warehouseResult.rowCount === 0 || !warehouseResult.rows[0].target_warehouse_id) { logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", { woId }); return; } const warehouseCode = warehouseResult.rows[0].target_warehouse_id; const locationCode = warehouseResult.rows[0].target_location_code || warehouseCode; // inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) await upsertInventoryStock(pool, companyCode, itemCode, warehouseCode, locationCode, completedQty, userId); logger.info("[pop/production] 생산완료→재고 입고 완료", { woId, itemCode, warehouseCode, locationCode, qty: completedQty, companyCode, }); } catch (inventoryError: any) { // 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능) logger.error("[pop/production] 재고입고 오류 (공정 완료는 유지):", inventoryError); } } }; /** * 실적 확정은 더 이상 단일 확정이 아님. * 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임. */ 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 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: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.", }); } // 수동 확정: 무조건 completed 처리 (수동 완료 용도) const result = await pool.query( `UPDATE work_order_process 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`, [work_order_process_id, companyCode, userId] ); if (result.rowCount === 0) { return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다.", }); } // 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용) const seqCheck = await pool.query( `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, [work_order_process_id, companyCode] ); if (seqCheck.rowCount > 0) { const { seq_no, wo_id, parent_process_id, instruction_qty } = seqCheck.rows[0]; const seqNum = parseInt(seq_no, 10); const instrQty = parseInt(instruction_qty, 10) || 0; // 다음 공정 활성화 (양품이 있으면) const goodQty = parseInt(result.rows[0].good_qty, 10) || 0; if (goodQty > 0) { const nextSeq = String(seqNum + 1); await pool.query( `UPDATE work_order_process SET status = 'acceptable', updated_date = NOW() WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NULL`, [wo_id, nextSeq, companyCode] ); } // === BUG-2 FIX: confirmResult에서도 master 합산 === if (parent_process_id) { await pool.query( `UPDATE work_order_process SET good_qty = sub.sum_good, defect_qty = sub.sum_defect, total_production_qty = sub.sum_total, concession_qty = sub.sum_concession, updated_date = NOW() FROM ( SELECT COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good, COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect, COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total, COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2 ) sub WHERE id = $1 AND company_code = $2`, [parent_process_id, companyCode] ); } // 마스터 자동완료 캐스케이드 (분할 행인 경우) if (parent_process_id) { let prevGoodQty = instrQty; if (seqNum > 1) { const prevSeq = String(seqNum - 1); const prevProcess = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } const siblingCheck = await pool.query( `SELECT COALESCE(SUM(input_qty::int), 0) as total_input, COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [wo_id, seq_no, companyCode] ); const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; const remainingAcceptable = prevGoodQty - totalInput; if (incompleteCount === 0 && remainingAcceptable <= 0) { await pool.query( `UPDATE work_order_process SET status = 'completed', result_status = 'confirmed', completed_at = NOW()::text, completed_by = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND status != 'completed'`, [parent_process_id, companyCode, userId] ); logger.info("[pop/production] confirmResult: 마스터 자동 완료", { masterId: parent_process_id, totalInput, prevGoodQty, }); } } // 작업지시 전체 완료 판정 await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId); } logger.info("[pop/production] confirm-result 완료", { companyCode, work_order_process_id, userId, finalStatus: result.rows[0].status, }); return res.json({ success: true, data: result.rows[0], }); } catch (error: any) { logger.error("[pop/production] confirm-result 오류:", error); return res.status(500).json({ success: false, message: error.message || "실적 확정 중 오류가 발생했습니다.", }); } }; /** * 실적 이력 조회 (work_order_process_log에서 차수별 추출) * total_production_qty 변경 이력 = 각 차수의 등록 기록 */ 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 WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); if (ownerCheck.rowCount === 0) { return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); } // 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출 // total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점 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, }; }); logger.info("[pop/production] result-history 조회", { work_order_process_id, batchCount: batches.length, }); 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 || "이력 조회 중 오류가 발생했습니다.", }); } }; /** * 앞공정 완료량 + 접수가능량 조회 * GET /api/pop/production/available-qty?work_order_process_id=xxx * 반환: { prevGoodQty, myInputQty, availableQty, instructionQty } */ 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.seq_no, wop.wo_id, wop.parent_process_id, wi.qty as instruction_qty FROM work_order_process wop JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code WHERE wop.id = $1 AND wop.company_code = $2`, [work_order_process_id, companyCode] ); 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); // 재작업 카드 여부 확인 const reworkCheck = await pool.query( `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, [work_order_process_id] ); const isRework = reworkCheck.rows[0]?.is_rework === "Y"; let myInputQty: number; let prevGoodQty: number; let availableQty: number; if (isRework) { // 재작업 카드: 자체 input_qty가 접수 가능 수량 const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0; myInputQty = 0; prevGoodQty = reworkInput; availableQty = reworkInput; } else { // 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외) const totalAccepted = await pool.query( `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL AND (is_rework IS NULL OR is_rework != 'Y')`, [wo_id, seq_no, companyCode] ); myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; prevGoodQty = instrQty; if (seqNum > 1) { const prevSeq = String(seqNum - 1); const prevProcess = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } availableQty = Math.max(0, prevGoodQty - myInputQty); } logger.info("[pop/production] available-qty 조회", { work_order_process_id, prevGoodQty, myInputQty, availableQty, instructionQty: instrQty, }); return res.json({ success: true, data: { prevGoodQty, myInputQty, availableQty, instructionQty: instrQty, }, }); } catch (error: any) { logger.error("[pop/production] available-qty 오류:", error); return res.status(500).json({ success: false, message: error.message || "접수가능량 조회 중 오류가 발생했습니다.", }); } }; /** * 공정 접수 (수량 지정) * POST /api/pop/production/accept-process * body: { work_order_process_id, accept_qty } * - 접수 상한 = 앞공정.good_qty - 내.input_qty (첫 공정은 지시수량 - input_qty) * - 추가 접수 가능 (in_progress 상태에서도) * - status: acceptable/waiting -> in_progress (또는 이미 in_progress면 유지) */ 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) { client.release(); return res.status(400).json({ success: false, message: "work_order_process_id와 accept_qty가 필요합니다.", }); } const qty = parseInt(accept_qty, 10); if (qty <= 0) { client.release(); return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); } await client.query("BEGIN"); // 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지) const current = await client.query( `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.equipment_code, 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"); client.release(); return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); } const row = current.rows[0]; const masterId = row.parent_process_id || row.id; if (row.status === "completed") { await client.query("ROLLBACK"); client.release(); return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); } if (row.status !== "acceptable") { await client.query("ROLLBACK"); client.release(); return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.` }); } const instrQty = parseInt(row.instruction_qty, 10) || 0; const seqNum = parseInt(row.seq_no, 10); // 재작업 카드 여부 확인 const isReworkCard = await client.query( `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, [work_order_process_id] ); const isRework = isReworkCard.rows[0]?.is_rework === "Y"; const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0; let prevGoodQty: number; let currentTotalInput: number; let availableQty: number; if (isRework) { // 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관) prevGoodQty = reworkInputQty; currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음 availableQty = reworkInputQty; } else { // 일반 카드: 앞공정 양품 - 기접수합계 const totalAccepted = await client.query( `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL AND (is_rework IS NULL OR is_rework != 'Y')`, [row.wo_id, row.seq_no, companyCode] ); currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; prevGoodQty = instrQty; if (seqNum > 1) { const prevSeq = String(seqNum - 1); const prevProcess = await client.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`, [row.wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; } } availableQty = prevGoodQty - currentTotalInput; } if (qty > availableQty) { await client.query("ROLLBACK"); client.release(); return res.status(400).json({ success: false, message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, }); } // batch_id: 컬럼이 있으면 포함, 없으면 제외 const batchId = req.body.batch_id || `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const hasBatchCol = _batchMigrationDone; // 분할 행 INSERT (batch_id는 컬럼 존재 시에만) const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, total_production_qty, result_status, accepted_by, accepted_at, started_at, parent_process_id, company_code, writer${hasBatchCol ? ", batch_id" : ""}`; const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'in_progress', $10, '0', '0', '0', 'draft', $11, NOW()::text, NOW()::text, $12, $13, $11${hasBatchCol ? ", $14" : ""}`; const insertParams = [ row.wo_id, row.seq_no, row.process_code, row.process_name, row.is_required, row.is_fixed_order, row.standard_time, row.equipment_code, row.routing_detail_id, String(qty), userId, masterId, companyCode, ...(hasBatchCol ? [batchId] : []), ]; const result = await client.query( `INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals}) RETURNING id, input_qty, status, process_name, result_status, accepted_by`, insertParams ); // 분할 행에 체크리스트 복사 const splitId = result.rows[0].id; const checklistCount = await copyChecklistToSplit( client, masterId, splitId, row.routing_detail_id, companyCode, userId ); // 마스터 행의 input_qty를 분할 합계로 갱신 const newTotalInput = currentTotalInput + qty; await client.query( `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2`, [masterId, companyCode, String(newTotalInput)] ); await client.query("COMMIT"); logger.info("[pop/production] accept-process 분할 접수 완료", { companyCode, userId, masterId, splitId, acceptedQty: qty, totalAccepted: newTotalInput, prevGoodQty, checklistCount, }); return res.json({ success: true, data: result.rows[0], message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, }); } 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(); } }; /** * 접수 취소: input_qty를 0으로 리셋하고 status를 acceptable로 되돌림 * 조건: 아직 실적(total_production_qty)이 없어야 함 */ 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 id, status, input_qty, total_production_qty, result_status, parent_process_id, wo_id, seq_no, process_name, target_warehouse_id, target_location_code, good_qty, concession_qty FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); if (current.rowCount === 0) { return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); } const proc = current.rows[0]; // 분할 행만 취소 가능 (원본 행은 취소 대상이 아님) if (!proc.parent_process_id) { return res.status(400).json({ success: false, message: "원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.", }); } if (proc.status !== "in_progress") { return res.status(400).json({ success: false, 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: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", }); } let cancelledQty = unproducedQty; const client = await pool.connect(); try { await client.query("BEGIN"); if (totalProduced === 0) { // 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제 await client.query( `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); await client.query( `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); } else { // 실적이 있으면 input_qty를 실적 수량으로 축소 + completed await client.query( `UPDATE work_order_process 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] ); } // 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감 if (proc.target_warehouse_id) { const inboundQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); if (inboundQty > 0) { // work_instruction에서 item_id 조회 const wiResult = await client.query( `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode] ); if (wiResult.rowCount > 0) { 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) { 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] ); } } } } // 마스터 행의 input_qty를 분할 합계로 재계산 const remainingSplits = await client.query( `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process WHERE parent_process_id = $1 AND company_code = $2`, [proc.parent_process_id, companyCode] ); const newMasterInput = parseInt(remainingSplits.rows[0].total_input, 10) || 0; // 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지 await client.query( `UPDATE work_order_process SET status = 'acceptable', input_qty = $3, updated_date = NOW() WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, [proc.parent_process_id, companyCode, String(newMasterInput)] ); await client.query("COMMIT"); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } finally { client.release(); } logger.info("[pop/production] cancel-accept 완료 (분할 행)", { companyCode, userId, work_order_process_id, masterId: proc.parent_process_id, previousInputQty: currentInputQty, totalProduced, cancelledQty, action: totalProduced === 0 ? "DELETE" : "SHRINK", }); return res.json({ success: true, data: { id: work_order_process_id, process_name: proc.process_name }, 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 }); } }; /** * 특정 창고의 위치(로케이션) 목록 조회 * warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭 */ 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는 필수입니다." }); } // warehouse_info.id → warehouse_code 변환 const whInfo = await pool.query( `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, [warehouseId, companyCode] ); 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 }); } }; /** * 마지막 공정 여부 확인 * 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막 */ 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 } }); } // 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준) const process = await pool.query( `SELECT wo_id, seq_no, parent_process_id 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, parent_process_id } = process.rows[0]; // 분할 행이면 마스터의 seq_no 기준으로 판단 let effectiveSeqNo = seq_no; if (parent_process_id) { const master = await pool.query( `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, [parent_process_id, companyCode] ); if (master.rowCount > 0) { effectiveSeqNo = master.rows[0].seq_no; } } const next = await pool.query( `SELECT id FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND parent_process_id IS NULL LIMIT 1`, [wo_id, companyCode, effectiveSeqNo] ); // 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용) const warehouseInfo = await pool.query( `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, [processId, companyCode] ); return res.json({ success: true, data: { isLast: next.rowCount === 0, woId: wo_id, seqNo: effectiveSeqNo, targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, }, }); } catch (error: any) { logger.error("[pop/production] 마지막 공정 확인 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 공정의 목표 창고/위치 업데이트 * 마지막 공정 완료 전 또는 완료 후 창고를 지정한다. * 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다. */ 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는 필수입니다.", }); } // 분할 행이면 마스터 행도 함께 업데이트 const procInfo = await pool.query( `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] ); const idsToUpdate = [work_order_process_id]; if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) { idsToUpdate.push(procInfo.rows[0].parent_process_id); } for (const id of idsToUpdate) { await pool.query( `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, [id, companyCode, target_warehouse_id, target_location_code || null, userId] ); } logger.info("[pop/production] 목표 창고 업데이트", { companyCode, userId, work_order_process_id, target_warehouse_id, target_location_code, updatedIds: idsToUpdate, }); return res.json({ success: true, data: { target_warehouse_id, target_location_code } }); } catch (error: any) { logger.error("[pop/production] 목표 창고 업데이트 오류:", error); return res.status(500).json({ success: false, message: error.message }); } }; /** * 독립 재고 입고 API * 창고 저장 + inventory_stock UPSERT를 한 번에 수행한다. * 실적(save-result) 완료 후 나중에 창고를 선택해도 재고가 들어가도록 분리. * 이중 입고 방지: target_warehouse_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"); // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 const procResult = await client.query( `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no FROM work_order_process WHERE id = $1 AND 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]; // 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부 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이므로 재고 입고할 수 없습니다.", }); } // 2. work_instruction에서 item_id 조회 const wiResult = await client.query( `SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode] ); if (wiResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "작업지시를 찾을 수 없습니다.", }); } const itemId = wiResult.rows[0].item_id; // 3. item_info에서 item_number 조회 const itemResult = await client.query( `SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [itemId, companyCode] ); 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; // 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, goodQty, userId); // 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행) const idsToUpdate = [work_order_process_id]; if (proc.parent_process_id) { idsToUpdate.push(proc.parent_process_id); } for (const id of idsToUpdate) { await client.query( `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, [id, companyCode, warehouse_code, location_code || null, userId] ); } await client.query("COMMIT"); logger.info("[pop/production] 독립 재고 입고 완료", { companyCode, userId, work_order_process_id, itemCode, warehouse_code, location_code: effectiveLocationCode, qty: goodQty, }); 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(); } }; /** * 간이 재고 입고 (공정 접수 없이 바로 입고) * 품목 + 수량 + 창고만으로 inventory_stock UPSERT + inbound_mng 이력 기록 */ 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"); // 1. item_info에서 item_number, item_name 조회 const itemResult = await client.query( `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, [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; // 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴) await upsertInventoryStock(client, companyCode, itemCode, warehouse_code, effectiveLocationCode, parsedQty, userId); // 3. inbound_mng에 간이입고 이력 기록 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"); logger.info("[pop/production] 간이 재고 입고 완료", { companyCode, userId, item_id, itemCode, warehouse_code, location_code: effectiveLocationCode, qty: parsedQty, inboundNumber, }); 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(); } }; /** * 재작업 이력 조회 * 작업지시(wo_id) 기준으로 모든 재작업 체인을 반환한다. * 원본 → 재작업1 → 재작업2 → ... 순서로 체인 추적. */ 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 id, seq_no, process_code, process_name, status, input_qty, good_qty, defect_qty, concession_qty, is_rework, rework_source_id, parent_process_id, accepted_by, accepted_at, started_at, completed_at, created_date FROM work_order_process WHERE wo_id = $1 AND company_code = $2 AND (is_rework = 'Y' OR is_rework = '1' OR defect_qty::int > 0 OR parent_process_id IS NOT NULL) ORDER BY created_date ASC`, [woId, companyCode] ); // 체인 구성: rework_source_id를 따라 트리 구조 const rows = result.rows; const byId: Record = {}; for (const r of rows) byId[r.id] = r; const chains: Array<{ source: typeof rows[0]; reworks: typeof rows; totalReworkCount: number; }> = []; // 원본 행(불량 발생한 것) 찾기 const reworkSourceIds = new Set(rows.filter(r => r.rework_source_id).map(r => r.rework_source_id)); 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 자재 목록 + 소요량 계산 * work_order_process_id → item_code → bom + bom_detail 조회 */ 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 필수" }); } // 1. work_order_process → work_instruction → item_code, plan_qty const procResult = await pool.query( `SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty, wi.item_id, wi.qty as instruction_qty 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(proc.input_qty || proc.plan_qty || proc.instruction_qty || "0", 10); // 2. item_info → item_code (item_number) const itemResult = await pool.query( `SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`, [proc.item_id, companyCode] ); if (itemResult.rowCount === 0) { return res.json({ success: true, data: { materials: [], processQty } }); } const itemCode = itemResult.rows[0].item_number; // 3. BOM 조회 const bomResult = await pool.query( `SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate, i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit 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] ); // 4. 소요량 계산 const bomBase = await pool.query( `SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`, [itemCode, proc.item_id, companyCode] ); const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; const 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)); return { id: bd.id, child_item_id: bd.child_item_id, child_item_code: bd.child_item_code || "", 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: 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 }); } }; /** * 자재 투입 기록 저장 * BOM 기준과 다른 수량도 허용 (유동 투입) */ 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; // item_code/qty 등 대안 필드명도 허용 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; // 투입 기록 INSERT (process_work_result에 material_input 타입으로) 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, ] ); // 재고 차감 (warehouse_code가 있을 때만) if (warehouse_code) { const locCode = location_code || warehouse_code; 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, warehouse_code, 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(); } }; /** * 자재 투입 현황 조회 */ 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 }); } };