feat: BLOCK MES-REWORK - mes-process-card 전용 카드 + batch_done 워크플로우 + 실적 관리 강화
MES 카드를 CSS Grid 다중 셀 방식에서 Flexbox 기반 단일 전용 카드(mes-process-card)로 전환하고, batch_done 상태를 도입하여 부분 확정 후 추가접수 워크플로우를 구현한다. [mes-process-card 전용 카드] - CardCellType "mes-process-card" 신규: 상태별 좌측 보더+배경, 공정 흐름 스트립, 클릭 모달 - MesAcceptableMetrics / MesInProgressMetrics / MesCompletedMetrics 서브 컴포넌트 - 워크플로우 기반 activeBtn 결정 로직 (상태+잔여량 조합으로 버튼 1개만 표시) - 하드코딩 취소 버튼 제거, DB 설정 "접수취소" 라벨 인터셉트로 통합 [batch_done 워크플로우] - confirmResult API: 부분 확정 시 status = 'batch_done' (진행 탭 숨김 + 접수가능 탭 유지) - acceptProcess API: batch_done -> in_progress 복귀 (추가접수) - 카드 복제 로직에 batch_done 포함 (잔여량 있으면 접수가능 탭에 클론 카드) - status 매핑에 batch_done 추가 (semantic: active) [실적 관리 강화] - PopWorkDetailComponent: 실적 입력 UI 전면 구현 (차수별 등록, 누적 실적, 이력 표시) - 모든 실적 저장 시 process_completed 이벤트 발행 (카드 리스트 즉시 갱신) - 전량접수+전량생산 시 자동 완료 (status=completed, result_status=confirmed) [버그 수정] - 서브 필터 변경 시 __process_* 필드 미갱신 -> processFields 재주입 - cancelAccept SQL inconsistent types -> boolean 파라미터 분리 - 접수취소 라벨 매핑 누락 -> taskPreset 조건 확장
This commit is contained in:
@@ -3,6 +3,14 @@ import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
|
||||
// 불량 상세 항목 타입
|
||||
interface DefectDetailItem {
|
||||
defect_code: string;
|
||||
defect_name: string;
|
||||
qty: string;
|
||||
disposition: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* D-BE1: 작업지시 공정 일괄 생성
|
||||
* PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성.
|
||||
@@ -102,7 +110,7 @@ export const createWorkProcesses = async (
|
||||
rd.is_fixed_order,
|
||||
rd.standard_time,
|
||||
plan_qty || null,
|
||||
"waiting",
|
||||
parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting",
|
||||
rd.id,
|
||||
userId,
|
||||
]
|
||||
@@ -465,3 +473,823 @@ export const controlGroupTimer = async (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 불량 유형 목록 조회 (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(
|
||||
`SELECT status, result_status, total_production_qty, good_qty, defect_qty, input_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 prev = statusCheck.rows[0];
|
||||
|
||||
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: "공정을 찾을 수 없거나 권한이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 다음 공정 상태를 acceptable로 전환 (input_qty는 접수 버튼에서만 변경)
|
||||
const currentSeq = await pool.query(
|
||||
`SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_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]
|
||||
);
|
||||
|
||||
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
|
||||
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
|
||||
RETURNING id, process_name, status`,
|
||||
[wo_id, nextSeq, companyCode]
|
||||
);
|
||||
if (nextUpdate.rowCount > 0) {
|
||||
logger.info("[pop/production] 다음 공정 상태 전환", {
|
||||
nextProcess: nextUpdate.rows[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 완료 체크: 접수가능 잔여 0 + 접수한 수량 전부 완료 시 자동 completed
|
||||
if (currentSeq.rowCount > 0) {
|
||||
const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0];
|
||||
const seqNum = parseInt(seq_no, 10);
|
||||
const myInputQty = parseInt(current_input_qty, 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 allProduced = newTotal >= myInputQty && myInputQty > 0;
|
||||
|
||||
if (remainingAcceptable <= 0 && allProduced) {
|
||||
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]
|
||||
);
|
||||
logger.info("[pop/production] 자동 완료 처리", {
|
||||
work_order_process_id,
|
||||
remainingAcceptable,
|
||||
newTotal,
|
||||
myInputQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// shouldComplete = true: 전량 완료 -> completed
|
||||
// shouldComplete = false: 부분 확정 -> batch_done (진행 탭에서 숨김)
|
||||
const newStatus = shouldComplete ? "completed" : "batch_done";
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET result_status = 'confirmed',
|
||||
status = $4,
|
||||
completed_at = CASE WHEN $4 = 'completed' THEN NOW()::text ELSE completed_at END,
|
||||
completed_by = CASE WHEN $4 = 'completed' THEN $3 ELSE completed_by END,
|
||||
writer = $3,
|
||||
updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`,
|
||||
[work_order_process_id, companyCode, userId, newStatus]
|
||||
);
|
||||
|
||||
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(
|
||||
`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]
|
||||
);
|
||||
|
||||
if (current.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const { seq_no, wo_id, input_qty, instruction_qty } = current.rows[0];
|
||||
const myInputQty = parseInt(input_qty, 10) || 0;
|
||||
const instrQty = parseInt(instruction_qty, 10) || 0;
|
||||
const seqNum = parseInt(seq_no, 10);
|
||||
|
||||
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 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 이상이어야 합니다." });
|
||||
}
|
||||
|
||||
const current = await pool.query(
|
||||
`SELECT wop.seq_no, wop.wo_id, wop.input_qty, wop.status, wop.accepted_by,
|
||||
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: "공정을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
const { seq_no, wo_id, input_qty, status, instruction_qty } = current.rows[0];
|
||||
|
||||
if (status === "completed") {
|
||||
return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." });
|
||||
}
|
||||
if (status !== "acceptable" && status !== "in_progress") {
|
||||
return res.status(400).json({ success: false, message: `현재 상태(${status})에서는 접수할 수 없습니다.` });
|
||||
}
|
||||
|
||||
const myInputQty = parseInt(input_qty, 10) || 0;
|
||||
const instrQty = parseInt(instruction_qty, 10) || 0;
|
||||
const seqNum = parseInt(seq_no, 10);
|
||||
|
||||
// 앞공정 완료량 계산
|
||||
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 availableQty = prevGoodQty - myInputQty;
|
||||
if (qty > availableQty) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수: ${myInputQty})`,
|
||||
});
|
||||
}
|
||||
|
||||
const newInputQty = myInputQty + qty;
|
||||
const result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET input_qty = $3,
|
||||
status = CASE WHEN status IN ('acceptable', 'waiting', 'batch_done') THEN 'in_progress' ELSE status END,
|
||||
result_status = CASE WHEN result_status = 'confirmed' THEN 'draft' ELSE result_status END,
|
||||
accepted_by = $4,
|
||||
started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
|
||||
updated_date = NOW(),
|
||||
writer = $4
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id, input_qty, status, process_name, result_status`,
|
||||
[work_order_process_id, companyCode, String(newInputQty), userId]
|
||||
);
|
||||
|
||||
logger.info("[pop/production] accept-process 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
work_order_process_id,
|
||||
addedQty: qty,
|
||||
newInputQty,
|
||||
prevGoodQty,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
message: `${qty}개 접수 완료 (총 접수: ${newInputQty})`,
|
||||
});
|
||||
} 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(
|
||||
`SELECT id, status, input_qty, total_production_qty, result_status
|
||||
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];
|
||||
|
||||
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;
|
||||
|
||||
// 미소진 접수분 = input_qty - total_production_qty
|
||||
const unproducedQty = currentInputQty - totalProduced;
|
||||
|
||||
if (unproducedQty <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// input_qty를 total_production_qty로 되돌림 (실적 있는 분량만 유지)
|
||||
// 실적이 0이면 완전 초기화, 실적이 있으면 부분 취소
|
||||
const newInputQty = totalProduced;
|
||||
const newStatus = totalProduced > 0 ? "in_progress" : "acceptable";
|
||||
|
||||
const isFullCancel = newInputQty === 0;
|
||||
const result = await pool.query(
|
||||
`UPDATE work_order_process
|
||||
SET input_qty = $3,
|
||||
status = $4,
|
||||
accepted_by = CASE WHEN $6 THEN NULL ELSE accepted_by END,
|
||||
started_at = CASE WHEN $6 THEN NULL ELSE started_at END,
|
||||
updated_date = NOW(),
|
||||
writer = $5
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id, input_qty, status, process_name`,
|
||||
[work_order_process_id, companyCode, String(newInputQty), newStatus, userId, isFullCancel]
|
||||
);
|
||||
|
||||
logger.info("[pop/production] cancel-accept 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
work_order_process_id,
|
||||
previousInputQty: currentInputQty,
|
||||
newInputQty,
|
||||
cancelledQty: unproducedQty,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
message: `미소진 ${unproducedQty}개 접수가 취소되었습니다. (잔여 접수량: ${newInputQty})`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("[pop/production] cancel-accept 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "접수 취소 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,13 @@ import {
|
||||
createWorkProcesses,
|
||||
controlTimer,
|
||||
controlGroupTimer,
|
||||
getDefectTypes,
|
||||
saveResult,
|
||||
confirmResult,
|
||||
getResultHistory,
|
||||
getAvailableQty,
|
||||
acceptProcess,
|
||||
cancelAccept,
|
||||
} from "../controllers/popProductionController";
|
||||
|
||||
const router = Router();
|
||||
@@ -13,5 +20,12 @@ router.use(authenticateToken);
|
||||
router.post("/create-work-processes", createWorkProcesses);
|
||||
router.post("/timer", controlTimer);
|
||||
router.post("/group-timer", controlGroupTimer);
|
||||
router.get("/defect-types", getDefectTypes);
|
||||
router.post("/save-result", saveResult);
|
||||
router.post("/confirm-result", confirmResult);
|
||||
router.get("/result-history", getResultHistory);
|
||||
router.get("/available-qty", getAvailableQty);
|
||||
router.post("/accept-process", acceptProcess);
|
||||
router.post("/cancel-accept", cancelAccept);
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user