import { Response } from "express"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; /** * 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 existCheck = await client.query( `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND company_code = $2`, [work_instruction_id, companyCode] ); if (parseInt(existCheck.rows[0].cnt, 10) > 0) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 공정이 생성된 작업지시입니다.", }); } // 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`, [routing_version_id, companyCode] ); if (routingDetails.rows.length === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "라우팅 버전에 등록된 공정이 없습니다.", }); } 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 ( 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [ companyCode, work_instruction_id, rd.seq_no, rd.process_code, rd.process_name, rd.is_required, rd.is_fixed_order, rd.standard_time, plan_qty || null, "waiting", rd.id, userId, ] ); const wopId = wopResult.rows[0].id; // 3. process_work_result INSERT (스냅샷 복사) // process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사 const snapshotResult = 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 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`, [wopId, userId, rd.id, companyCode] ); const checklistCount = snapshotResult.rowCount ?? 0; totalChecklists += checklistCount; processes.push({ id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount, }); logger.info("[pop/production] 공정 생성 완료", { wopId, processName: rd.process_name, checklistCount, }); } await client.query("COMMIT"); logger.info("[pop/production] create-work-processes 완료", { companyCode, work_instruction_id, total_processes: processes.length, total_checklists: totalChecklists, }); return res.json({ success: true, data: { processes, total_processes: processes.length, total_checklists: totalChecklists, }, }); } 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(); } }; /** * 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 || "그룹 타이머 처리 중 오류가 발생했습니다.", }); } };