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:
@@ -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,
|
||||
|
||||
@@ -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 */}
|
||||
{/* ============================================================ */}
|
||||
|
||||
Reference in New Issue
Block a user