2026-03-13 14:19:54 +09:00
|
|
|
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은 필수입니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 09:32:59 +09:00
|
|
|
if (!["start", "pause", "resume", "complete"].includes(action)) {
|
2026-03-13 14:19:54 +09:00
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
2026-03-17 09:32:59 +09:00
|
|
|
message:
|
|
|
|
|
"action은 start, pause, resume, complete 중 하나여야 합니다.",
|
2026-03-13 14:19:54 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-17 09:32:59 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-03-13 14:19:54 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 || "타이머 처리 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-17 09:32:59 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 그룹(작업항목)별 타이머 제어
|
|
|
|
|
* 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머
|
|
|
|
|
*/
|
|
|
|
|
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 || "그룹 타이머 처리 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|