feat: BLOCK DETAIL Phase 2 - 생산 공정 관리 백엔드 API 파이프라인

작업지시 생성 시 공정+체크리스트 일괄 생성과 공정별 타이머 제어를
위한 백엔드 API 파이프라인을 구축한다.
[신규] popProductionController.ts
- createWorkProcesses: POST /api/pop/production/create-work-processes
  - item_routing_detail + process_mng JOIN으로 공정 목록 조회
  - work_order_process INSERT (공정별)
  - process_work_result INSERT SELECT (마스터 스냅샷 복사)
  - 중복 호출 방지 (409 Conflict)
  - 1 트랜잭션 처리
- controlTimer: POST /api/pop/production/timer
  - start: started_at 설정 + status waiting->in_progress (멱등)
  - pause: paused_at 설정
  - resume: total_paused_time 누적 + paused_at 초기화
[신규] popProductionRoutes.ts
- authenticateToken 미들웨어 전역 적용
- 2개 POST 엔드포인트 등록
[수정] app.ts
- popProductionRoutes import + /api/pop/production 라우트 등록
This commit is contained in:
SeongHyun Kim 2026-03-13 14:19:54 +09:00
parent a2c532c7c7
commit c067c37390
3 changed files with 308 additions and 0 deletions

View File

@ -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);

View File

@ -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 || "타이머 처리 중 오류가 발생했습니다.",
});
}
};

View File

@ -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;