diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index f45a88cd..6b86a333 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -124,6 +124,7 @@ import entitySearchRoutes, { import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 +import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머) import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -259,6 +260,7 @@ app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/pop", popActionRoutes); // POP 액션 실행 +app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts new file mode 100644 index 00000000..d575b07a --- /dev/null +++ b/backend-node/src/controllers/popProductionController.ts @@ -0,0 +1,291 @@ +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"].includes(action)) { + return res.status(400).json({ + success: false, + message: "action은 start, pause, resume 중 하나여야 합니다.", + }); + } + + 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; + } + + 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 || "타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts new file mode 100644 index 00000000..f20d470d --- /dev/null +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + createWorkProcesses, + controlTimer, +} from "../controllers/popProductionController"; + +const router = Router(); + +router.use(authenticateToken); + +router.post("/create-work-processes", createWorkProcesses); +router.post("/timer", controlTimer); + +export default router;