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";
|
|
|
|
|
|
2026-03-17 21:36:43 +09:00
|
|
|
// 불량 상세 항목 타입
|
|
|
|
|
interface DefectDetailItem {
|
|
|
|
|
defect_code: string;
|
|
|
|
|
defect_name: string;
|
|
|
|
|
qty: string;
|
|
|
|
|
disposition: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 14:19:54 +09:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-03-17 21:36:43 +09:00
|
|
|
parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting",
|
2026-03-13 14:19:54 +09:00
|
|
|
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 || "그룹 타이머 처리 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-17 21:36:43 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 불량 유형 목록 조회 (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(
|
2026-03-18 13:57:14 +09:00
|
|
|
`SELECT wop.status, wop.result_status, wop.total_production_qty, wop.good_qty,
|
|
|
|
|
wop.defect_qty, 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`,
|
2026-03-17 21:36:43 +09:00
|
|
|
[work_order_process_id, companyCode]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (statusCheck.rowCount === 0) {
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "공정을 찾을 수 없습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prev = statusCheck.rows[0];
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 마스터 행에 직접 실적 등록 방지 (분할 행이 존재하는 경우)
|
|
|
|
|
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: "원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 21:36:43 +09:00
|
|
|
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) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: `실적 누적(${prevTotal + requestedQty})이 접수량(${acceptedQty})을 초과합니다. 추가 접수 후 등록해주세요.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addProduction = parseInt(production_qty, 10) || 0;
|
|
|
|
|
const addGood = parseInt(good_qty, 10) || 0;
|
|
|
|
|
const addDefect = parseInt(defect_qty, 10) || 0;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// 기존 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,
|
|
|
|
|
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, defect_detail, result_note, result_status, status`,
|
|
|
|
|
[
|
|
|
|
|
work_order_process_id,
|
|
|
|
|
companyCode,
|
|
|
|
|
String(newTotal),
|
|
|
|
|
String(newGood),
|
|
|
|
|
String(newDefect),
|
|
|
|
|
mergedDefectDetail,
|
|
|
|
|
result_note || null,
|
|
|
|
|
userId,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result.rowCount === 0) {
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "공정을 찾을 수 없거나 권한이 없습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 현재 분할 행의 공정 정보 조회
|
2026-03-17 21:36:43 +09:00
|
|
|
const currentSeq = await pool.query(
|
|
|
|
|
`SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty,
|
2026-03-18 13:57:14 +09:00
|
|
|
wop.parent_process_id,
|
2026-03-17 21:36:43 +09:00
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 다음 공정 활성화: 양품 발생 시 다음 seq_no의 원본(마스터) 행을 acceptable로
|
|
|
|
|
// waiting -> acceptable (최초 활성화)
|
|
|
|
|
// in_progress -> acceptable (앞공정 추가양품으로 접수가능량 복원)
|
2026-03-17 21:36:43 +09:00
|
|
|
if (addGood > 0 && currentSeq.rowCount > 0) {
|
|
|
|
|
const { seq_no, wo_id } = currentSeq.rows[0];
|
|
|
|
|
const nextSeq = String(parseInt(seq_no, 10) + 1);
|
|
|
|
|
const nextUpdate = await pool.query(
|
|
|
|
|
`UPDATE work_order_process
|
2026-03-18 13:57:14 +09:00
|
|
|
SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END,
|
2026-03-17 21:36:43 +09:00
|
|
|
updated_date = NOW()
|
|
|
|
|
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
|
2026-03-18 13:57:14 +09:00
|
|
|
AND parent_process_id IS NULL
|
|
|
|
|
AND status != 'completed'
|
2026-03-17 21:36:43 +09:00
|
|
|
RETURNING id, process_name, status`,
|
|
|
|
|
[wo_id, nextSeq, companyCode]
|
|
|
|
|
);
|
|
|
|
|
if (nextUpdate.rowCount > 0) {
|
|
|
|
|
logger.info("[pop/production] 다음 공정 상태 전환", {
|
|
|
|
|
nextProcess: nextUpdate.rows[0],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 개별 분할 행 자동완료: 이 분할 행의 접수분 전량 생산 시 completed
|
2026-03-17 21:36:43 +09:00
|
|
|
if (currentSeq.rowCount > 0) {
|
|
|
|
|
const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0];
|
|
|
|
|
const myInputQty = parseInt(current_input_qty, 10) || 0;
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
if (newTotal >= myInputQty && myInputQty > 0) {
|
2026-03-17 21:36:43 +09:00
|
|
|
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]
|
|
|
|
|
);
|
2026-03-18 13:57:14 +09:00
|
|
|
logger.info("[pop/production] 분할 행 자동 완료", {
|
|
|
|
|
work_order_process_id, newTotal, myInputQty,
|
2026-03-17 21:36:43 +09:00
|
|
|
});
|
2026-03-18 13:57:14 +09:00
|
|
|
|
|
|
|
|
// 같은 공정의 모든 분할 행이 completed인지 체크 -> 원본도 completed로
|
|
|
|
|
const seqNum = parseInt(seq_no, 10);
|
|
|
|
|
const instrQty = parseInt(instruction_qty, 10) || 0;
|
|
|
|
|
|
|
|
|
|
// 앞공정 양품 합산 (접수가능 잔여 계산용)
|
|
|
|
|
let prevGoodQty = instrQty;
|
|
|
|
|
if (seqNum > 1) {
|
|
|
|
|
const prevSeq = String(seqNum - 1);
|
|
|
|
|
const prevProcess = await pool.query(
|
|
|
|
|
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
|
|
|
|
|
FROM work_order_process
|
|
|
|
|
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
|
|
|
|
|
[wo_id, prevSeq, companyCode]
|
|
|
|
|
);
|
|
|
|
|
if (prevProcess.rowCount > 0) {
|
|
|
|
|
prevGoodQty = prevProcess.rows[0].total_good;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 같은 seq_no의 모든 분할 행 접수량 합산 + 미완료 행 카운트
|
|
|
|
|
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 = siblingCheck.rows[0].total_input;
|
|
|
|
|
const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10);
|
|
|
|
|
const remainingAcceptable = prevGoodQty - totalInput;
|
|
|
|
|
|
|
|
|
|
// 모든 분할 행 완료 + 잔여 접수가능 0 -> 원본(마스터)도 completed
|
|
|
|
|
if (incompleteCount === 0 && remainingAcceptable <= 0) {
|
|
|
|
|
const masterId = currentSeq.rows[0].parent_process_id;
|
|
|
|
|
if (masterId) {
|
|
|
|
|
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'`,
|
|
|
|
|
[masterId, companyCode, userId]
|
|
|
|
|
);
|
|
|
|
|
logger.info("[pop/production] 원본(마스터) 공정 자동 완료", {
|
|
|
|
|
masterId, totalInput, prevGoodQty,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 21:36:43 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, 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 || "실적 저장 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 실적 확정은 더 이상 단일 확정이 아님.
|
|
|
|
|
* 마지막 등록 확인 용도로 유지. 실적은 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 seqCheck = await pool.query(
|
|
|
|
|
`SELECT wop.seq_no, wop.wo_id, wop.input_qty,
|
|
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let shouldComplete = false;
|
|
|
|
|
if (seqCheck.rowCount > 0) {
|
|
|
|
|
const { seq_no, wo_id, input_qty: currentInputQty, instruction_qty } = seqCheck.rows[0];
|
|
|
|
|
const seqNum = parseInt(seq_no, 10);
|
|
|
|
|
const myInputQty = parseInt(currentInputQty, 10) || 0;
|
|
|
|
|
const instrQty = parseInt(instruction_qty, 10) || 0;
|
|
|
|
|
|
|
|
|
|
let prevGoodQty = instrQty;
|
|
|
|
|
if (seqNum > 1) {
|
|
|
|
|
const prevSeq = String(seqNum - 1);
|
|
|
|
|
const prevProcess = await pool.query(
|
|
|
|
|
`SELECT COALESCE(good_qty::int, 0) as good_qty
|
|
|
|
|
FROM work_order_process
|
|
|
|
|
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
|
|
|
|
|
[wo_id, prevSeq, companyCode]
|
|
|
|
|
);
|
|
|
|
|
if (prevProcess.rowCount > 0) {
|
|
|
|
|
prevGoodQty = prevProcess.rows[0].good_qty;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const remainingAcceptable = prevGoodQty - myInputQty;
|
|
|
|
|
const totalProduced = parseInt(currentProcess.total_production_qty, 10) || 0;
|
|
|
|
|
shouldComplete = remainingAcceptable <= 0 && totalProduced >= myInputQty && myInputQty > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 수동 확정: shouldComplete 여부와 관계없이 completed 처리
|
|
|
|
|
// 자동 완료가 안 된 경우 관리자가 강제 완료할 때 사용
|
|
|
|
|
const newStatus = "completed";
|
2026-03-17 21:36:43 +09:00
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
const isCompleted = shouldComplete;
|
2026-03-17 21:36:43 +09:00
|
|
|
const result = await pool.query(
|
|
|
|
|
`UPDATE work_order_process
|
|
|
|
|
SET result_status = 'confirmed',
|
|
|
|
|
status = $4,
|
2026-03-18 13:57:14 +09:00
|
|
|
completed_at = CASE WHEN $5 THEN NOW()::text ELSE completed_at END,
|
|
|
|
|
completed_by = CASE WHEN $5 THEN $3 ELSE completed_by END,
|
2026-03-17 21:36:43 +09:00
|
|
|
writer = $3,
|
|
|
|
|
updated_date = NOW()
|
|
|
|
|
WHERE id = $1 AND company_code = $2
|
|
|
|
|
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`,
|
2026-03-18 13:57:14 +09:00
|
|
|
[work_order_process_id, companyCode, userId, newStatus, isCompleted]
|
2026-03-17 21:36:43 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result.rowCount === 0) {
|
|
|
|
|
return res.status(404).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "공정을 찾을 수 없습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// completed로 전환된 경우에만 다음 공정 활성화
|
|
|
|
|
if (shouldComplete && seqCheck.rowCount > 0) {
|
|
|
|
|
const { seq_no, wo_id } = seqCheck.rows[0];
|
|
|
|
|
const nextSeq = String(parseInt(seq_no, 10) + 1);
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE work_order_process
|
|
|
|
|
SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END,
|
|
|
|
|
updated_date = NOW()
|
|
|
|
|
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
|
|
|
|
|
[wo_id, nextSeq, companyCode]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("[pop/production] confirm-result 완료", {
|
|
|
|
|
companyCode,
|
|
|
|
|
work_order_process_id,
|
|
|
|
|
userId,
|
|
|
|
|
shouldComplete,
|
|
|
|
|
newStatus,
|
|
|
|
|
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 { work_order_process_id } = req.query;
|
|
|
|
|
|
|
|
|
|
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 { work_order_process_id } = req.query;
|
|
|
|
|
|
|
|
|
|
if (!work_order_process_id) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "work_order_process_id가 필요합니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = await pool.query(
|
2026-03-18 13:57:14 +09:00
|
|
|
`SELECT wop.seq_no, wop.wo_id, wop.parent_process_id,
|
2026-03-17 21:36:43 +09:00
|
|
|
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: "공정을 찾을 수 없습니다." });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
const { seq_no, wo_id, instruction_qty } = current.rows[0];
|
2026-03-17 21:36:43 +09:00
|
|
|
const instrQty = parseInt(instruction_qty, 10) || 0;
|
|
|
|
|
const seqNum = parseInt(seq_no, 10);
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산
|
|
|
|
|
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`,
|
|
|
|
|
[wo_id, seq_no, companyCode]
|
|
|
|
|
);
|
|
|
|
|
const myInputQty = totalAccepted.rows[0].total_input;
|
|
|
|
|
|
|
|
|
|
// 앞공정 양품 합산
|
|
|
|
|
let prevGoodQty = instrQty;
|
2026-03-17 21:36:43 +09:00
|
|
|
if (seqNum > 1) {
|
|
|
|
|
const prevSeq = String(seqNum - 1);
|
|
|
|
|
const prevProcess = await pool.query(
|
2026-03-18 13:57:14 +09:00
|
|
|
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
|
2026-03-17 21:36:43 +09:00
|
|
|
FROM work_order_process
|
|
|
|
|
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
|
|
|
|
|
[wo_id, prevSeq, companyCode]
|
|
|
|
|
);
|
|
|
|
|
if (prevProcess.rowCount > 0) {
|
2026-03-18 13:57:14 +09:00
|
|
|
prevGoodQty = prevProcess.rows[0].total_good;
|
2026-03-17 21:36:43 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const 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();
|
|
|
|
|
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) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "work_order_process_id와 accept_qty가 필요합니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const qty = parseInt(accept_qty, 10);
|
|
|
|
|
if (qty <= 0) {
|
|
|
|
|
return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 원본(마스터) 행 조회 - parent_process_id가 NULL인 행 또는 직접 지정된 행
|
2026-03-17 21:36:43 +09:00
|
|
|
const current = await pool.query(
|
2026-03-18 13:57:14 +09:00
|
|
|
`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,
|
2026-03-17 21:36:43 +09:00
|
|
|
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: "공정을 찾을 수 없습니다." });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
const row = current.rows[0];
|
|
|
|
|
// 접수 대상은 원본(마스터) 행이어야 함
|
|
|
|
|
const masterId = row.parent_process_id || row.id;
|
2026-03-17 21:36:43 +09:00
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
if (row.status === "completed") {
|
2026-03-17 21:36:43 +09:00
|
|
|
return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." });
|
|
|
|
|
}
|
2026-03-18 13:57:14 +09:00
|
|
|
if (row.status !== "acceptable") {
|
|
|
|
|
return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다. 접수가능 상태의 카드에서 접수해주세요.` });
|
2026-03-17 21:36:43 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
const instrQty = parseInt(row.instruction_qty, 10) || 0;
|
|
|
|
|
const seqNum = parseInt(row.seq_no, 10);
|
2026-03-17 21:36:43 +09:00
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산
|
|
|
|
|
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`,
|
|
|
|
|
[row.wo_id, row.seq_no, companyCode]
|
|
|
|
|
);
|
|
|
|
|
const currentTotalInput = totalAccepted.rows[0].total_input;
|
|
|
|
|
|
|
|
|
|
// 앞공정 양품 합산
|
2026-03-17 21:36:43 +09:00
|
|
|
let prevGoodQty = instrQty;
|
|
|
|
|
if (seqNum > 1) {
|
|
|
|
|
const prevSeq = String(seqNum - 1);
|
|
|
|
|
const prevProcess = await pool.query(
|
2026-03-18 13:57:14 +09:00
|
|
|
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
|
2026-03-17 21:36:43 +09:00
|
|
|
FROM work_order_process
|
|
|
|
|
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
|
2026-03-18 13:57:14 +09:00
|
|
|
[row.wo_id, prevSeq, companyCode]
|
2026-03-17 21:36:43 +09:00
|
|
|
);
|
|
|
|
|
if (prevProcess.rowCount > 0) {
|
2026-03-18 13:57:14 +09:00
|
|
|
prevGoodQty = prevProcess.rows[0].total_good;
|
2026-03-17 21:36:43 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
const availableQty = prevGoodQty - currentTotalInput;
|
2026-03-17 21:36:43 +09:00
|
|
|
if (qty > availableQty) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
2026-03-18 13:57:14 +09:00
|
|
|
message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`,
|
2026-03-17 21:36:43 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 분할 행 INSERT (원본 행에서 공정 정보 복사)
|
2026-03-17 21:36:43 +09:00
|
|
|
const result = await pool.query(
|
2026-03-18 13:57:14 +09:00
|
|
|
`INSERT INTO work_order_process (
|
|
|
|
|
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
|
|
|
|
|
) VALUES (
|
|
|
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
|
|
|
'in_progress', $10, '0', '0', '0',
|
|
|
|
|
'draft', $11, NOW()::text, NOW()::text,
|
|
|
|
|
$12, $13, $11
|
|
|
|
|
) RETURNING id, input_qty, status, process_name, result_status, accepted_by`,
|
|
|
|
|
[
|
|
|
|
|
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,
|
|
|
|
|
]
|
2026-03-17 21:36:43 +09:00
|
|
|
);
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 마스터는 항상 acceptable 유지 (completed 전환은 saveResult에서 처리)
|
|
|
|
|
// availableQty=0이면 프론트에서 접수 버튼이 숨겨지므로 상태 변경 불필요
|
|
|
|
|
const newTotalInput = currentTotalInput + qty;
|
|
|
|
|
|
|
|
|
|
logger.info("[pop/production] accept-process 분할 접수 완료", {
|
|
|
|
|
companyCode, userId, masterId,
|
|
|
|
|
splitId: result.rows[0].id,
|
|
|
|
|
acceptedQty: qty,
|
|
|
|
|
totalAccepted: newTotalInput,
|
2026-03-17 21:36:43 +09:00
|
|
|
prevGoodQty,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: result.rows[0],
|
2026-03-18 13:57:14 +09:00
|
|
|
message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`,
|
2026-03-17 21:36:43 +09:00
|
|
|
});
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
logger.error("[pop/production] accept-process 오류:", error);
|
|
|
|
|
return res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: error.message || "접수 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 접수 취소: 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(
|
2026-03-18 13:57:14 +09:00
|
|
|
`SELECT id, status, input_qty, total_production_qty, result_status,
|
|
|
|
|
parent_process_id, wo_id, seq_no
|
2026-03-17 21:36:43 +09:00
|
|
|
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];
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
// 분할 행만 취소 가능 (원본 행은 취소 대상이 아님)
|
|
|
|
|
if (!proc.parent_process_id) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: "원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 21:36:43 +09:00
|
|
|
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: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
let cancelledQty = unproducedQty;
|
2026-03-17 21:36:43 +09:00
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
if (totalProduced === 0) {
|
|
|
|
|
// 실적이 없으면 분할 행 완전 삭제
|
|
|
|
|
await pool.query(
|
|
|
|
|
`DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`,
|
|
|
|
|
[work_order_process_id, companyCode]
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// 실적이 있으면 input_qty를 실적 수량으로 축소 + 접수분 전량 생산이므로 completed
|
|
|
|
|
await pool.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]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 원본(마스터) 행을 다시 acceptable로 복원 (잔여 접수 가능하도록)
|
|
|
|
|
await pool.query(
|
2026-03-17 21:36:43 +09:00
|
|
|
`UPDATE work_order_process
|
2026-03-18 13:57:14 +09:00
|
|
|
SET status = 'acceptable', updated_date = NOW()
|
|
|
|
|
WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
|
|
|
|
|
[proc.parent_process_id, companyCode]
|
2026-03-17 21:36:43 +09:00
|
|
|
);
|
|
|
|
|
|
2026-03-18 13:57:14 +09:00
|
|
|
logger.info("[pop/production] cancel-accept 완료 (분할 행)", {
|
|
|
|
|
companyCode, userId, work_order_process_id,
|
|
|
|
|
masterId: proc.parent_process_id,
|
2026-03-17 21:36:43 +09:00
|
|
|
previousInputQty: currentInputQty,
|
2026-03-18 13:57:14 +09:00
|
|
|
totalProduced,
|
|
|
|
|
cancelledQty,
|
|
|
|
|
action: totalProduced === 0 ? "DELETE" : "SHRINK",
|
2026-03-17 21:36:43 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return res.json({
|
|
|
|
|
success: true,
|
2026-03-18 13:57:14 +09:00
|
|
|
data: { id: work_order_process_id, process_name: proc.process_name },
|
|
|
|
|
message: `미소진 ${cancelledQty}개 접수가 취소되었습니다.`,
|
2026-03-17 21:36:43 +09:00
|
|
|
});
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
logger.error("[pop/production] cancel-accept 오류:", error);
|
|
|
|
|
return res.status(500).json({
|
|
|
|
|
success: false,
|
|
|
|
|
message: error.message || "접수 취소 중 오류가 발생했습니다.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|