fix: 공정실행 워크플로우 대칭성 버그 4건 수정

1. cancelAccept: 마스터 input_qty 재계산 (취소 시 수량 복원)
2. cancelAccept: 체크리스트(process_work_result) 같이 삭제
3. cancelAccept: 트랜잭션 적용 (BEGIN/COMMIT/ROLLBACK)
4. acceptProcess: 트랜잭션+FOR UPDATE 추가 (동시 접수 초과 방지)
5. ProcessWork: 접수취소 버튼 UI 추가 (실적 없을 때만 표시)
This commit is contained in:
SeongHyun Kim
2026-04-03 00:07:03 +09:00
parent 35ecd4221e
commit 2a2c244d4a
2 changed files with 109 additions and 37 deletions

View File

@@ -1559,12 +1559,14 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
*/
export const acceptProcess = 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_order_process_id, accept_qty } = req.body;
if (!work_order_process_id || !accept_qty) {
client.release();
return res.status(400).json({
success: false,
message: "work_order_process_id와 accept_qty가 필요합니다.",
@@ -1573,54 +1575,63 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
const qty = parseInt(accept_qty, 10);
if (qty <= 0) {
client.release();
return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." });
}
// 원본(마스터) 행 조회 - parent_process_id가 NULL인 행 또는 직접 지정된 행
const current = await pool.query(
await client.query("BEGIN");
// 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지)
const current = await client.query(
`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,
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`,
WHERE wop.id = $1 AND wop.company_code = $2
FOR UPDATE OF wop`,
[work_order_process_id, companyCode]
);
if (current.rowCount === 0) {
await client.query("ROLLBACK");
client.release();
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const row = current.rows[0];
// 접수 대상은 원본(마스터) 행이어야 함
const masterId = row.parent_process_id || row.id;
if (row.status === "completed") {
await client.query("ROLLBACK");
client.release();
return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." });
}
if (row.status !== "acceptable") {
return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다. 접수가능 상태의 카드에서 접수해주세요.` });
await client.query("ROLLBACK");
client.release();
return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.` });
}
const instrQty = parseInt(row.instruction_qty, 10) || 0;
const seqNum = parseInt(row.seq_no, 10);
// 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산
const totalAccepted = await pool.query(
// 같은 공정의 모든 분할 행 접수량 합산 (트랜잭션 내부 — 정확한 값)
const totalAccepted = await client.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;
const currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
// 앞공정 양품+특채 합산
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
const prevProcess = await client.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
@@ -1633,6 +1644,8 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
const availableQty = prevGoodQty - currentTotalInput;
if (qty > availableQty) {
await client.query("ROLLBACK");
client.release();
return res.status(400).json({
success: false,
message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`,
@@ -1640,7 +1653,7 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
}
// 분할 행 INSERT (원본 행에서 공정 정보 복사)
const result = await pool.query(
const result = await client.query(
`INSERT INTO work_order_process (
id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
@@ -1661,22 +1674,22 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
]
);
// 분할 행에 체크리스트 복사 (마스터의 routing_detail_id 또는 마스터의 기존 체크리스트에서)
// 분할 행에 체크리스트 복사
const splitId = result.rows[0].id;
const checklistCount = await copyChecklistToSplit(
pool, masterId, splitId, row.routing_detail_id, companyCode, userId
client, masterId, splitId, row.routing_detail_id, companyCode, userId
);
// 마스터는 항상 acceptable 유지 (completed 전환은 saveResult에서 처리)
// 마스터 행의 input_qty를 분할 합계로 갱신
const newTotalInput = currentTotalInput + qty;
// 마스터 행의 input_qty를 분할 합계로 갱신 (프론트에서 접수가능 수량 계산용)
await pool.query(
await client.query(
`UPDATE work_order_process SET input_qty = $3, updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[masterId, companyCode, String(newTotalInput)]
);
await client.query("COMMIT");
logger.info("[pop/production] accept-process 분할 접수 완료", {
companyCode, userId, masterId,
splitId,
@@ -1692,11 +1705,14 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`,
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] accept-process 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
};
@@ -1763,32 +1779,57 @@ export const cancelAccept = async (
}
let cancelledQty = unproducedQty;
const client = await pool.connect();
if (totalProduced === 0) {
// 실적이 없으면 분할 행 완전 삭제
await pool.query(
`DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
try {
await client.query("BEGIN");
if (totalProduced === 0) {
// 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제
await client.query(
`DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
await client.query(
`DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
} else {
// 실적이 있으면 input_qty를 실적 수량으로 축소 + completed
await client.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]
);
}
// 마스터 행의 input_qty를 분할 합계로 재계산
const remainingSplits = await client.query(
`SELECT COALESCE(SUM(input_qty::int), 0) as total_input
FROM work_order_process
WHERE parent_process_id = $1 AND company_code = $2`,
[proc.parent_process_id, companyCode]
);
} else {
// 실적이 있으면 input_qty를 실적 수량으로 축소 + 접수분 전량 생산이므로 completed
await pool.query(
const newMasterInput = parseInt(remainingSplits.rows[0].total_input, 10) || 0;
// 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지
await client.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]
SET status = 'acceptable', input_qty = $3, updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[proc.parent_process_id, companyCode, String(newMasterInput)]
);
}
// 원본(마스터) 행을 다시 acceptable로 복원 (잔여 접수 가능하도록)
await pool.query(
`UPDATE work_order_process
SET status = 'acceptable', updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[proc.parent_process_id, companyCode]
);
await client.query("COMMIT");
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
logger.info("[pop/production] cancel-accept 완료 (분할 행)", {
companyCode, userId, work_order_process_id,

View File

@@ -921,6 +921,37 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
</div>
</div>
{/* ============================================================ */}
{/* Cancel Accept Button (실적 없을 때만) */}
{/* ============================================================ */}
{process?.parent_process_id && process.status === "in_progress" && totalProduced === 0 && !isCompleted && (
<div className="shrink-0 bg-red-50 border-b border-red-100 px-4 py-2 flex items-center justify-between">
<span className="text-sm text-red-600"> </span>
<button
onClick={async () => {
if (!confirm("접수를 취소하시겠습니까? 체크리스트도 함께 삭제됩니다.")) return;
try {
const res = await apiClient.post("/pop/production/cancel-accept", {
work_order_process_id: processId,
});
if (res.data?.success) {
alert(res.data.message || "접수 취소 완료");
router.back();
} else {
alert(res.data?.message || "취소 실패");
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || "취소 중 오류");
}
}}
className="px-4 py-2 rounded-lg text-sm font-bold text-white bg-red-500 hover:bg-red-600 active:scale-95 transition-all"
>
</button>
</div>
)}
{/* ============================================================ */}
{/* KPI Stats Row */}
{/* ============================================================ */}