feat: BLOCK MES-HARDEN Phase 0~3 + 공정 흐름 스트립 필/칩 UI 개편

MES 불량 처분 체계(disposition 3종)를 구현하고, 공정 카드의 흐름
스트립을 현재 공정 중심 필/칩 윈도우로 전면 재설계한다.
[Phase 0: 기반 안정화]
- confirmResult: SUM 버그 수정 + 마스터 캐스케이드 완료 판정
- checkAndCompleteWorkInstruction: 헬퍼 함수 추출
  (saveResult/confirmResult 양쪽에서 work_instruction 상태 갱신)
- saveResult: 초과 생산 에러 -> 경고 로그로 변경
[Phase 1: UI 정리]
- DISPOSITION_OPTIONS: 5종 -> 3종(폐기/재작업/특채)
- 카드 수동 완료 버튼: in_progress + 생산 있음 + 미완료 시 표시
  (__manualComplete -> confirmResult 호출)
[Phase 2: 양품 계산 서버화]
- concession_qty/is_rework/rework_source_id DB 컬럼 추가
- saveResult: defect_detail disposition별 서버 양품 계산
  (addGood = addProduction - addDefect, addConcession 분리)
- prevGoodQty 5곳: SUM(good_qty) + SUM(concession_qty) 통일
- 프론트 특채 표시: MesInProgressMetrics/MesCompletedMetrics
[Phase 3: 재작업 카드]
- saveResult: disposition=rework 시 동일 공정에 분할행 자동 INSERT
  (is_rework='Y', rework_source_id 연결, status='acceptable')
- 프론트: amber "재작업" 배지 + MesAcceptableMetrics 재작업 전용 UI
- 재작업 카드 접수가능 수량 버그 수정 (마스터 qty -> input_qty)
[공정 흐름 스트립 UI 개편]
- ProcessFlowStrip: 바 형태 -> 필/칩 5슬롯 윈도우
  (+N/이전/현재/다음/+N, 현재 공정 항상 중앙)
- 색상: 지나온=emerald(완료)/slate, 현재=primary,
  완료=emerald, 대기=muted, 남은=amber
This commit is contained in:
SeongHyun Kim
2026-03-18 16:38:22 +09:00
parent fba5390f5a
commit 9d164d08af
5 changed files with 371 additions and 144 deletions

View File

@@ -565,7 +565,8 @@ export const saveResult = async (
const statusCheck = await pool.query(
`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
wop.defect_qty, wop.concession_qty, wop.defect_detail,
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`,
[work_order_process_id, companyCode]
@@ -602,17 +603,23 @@ export const saveResult = async (
});
}
// 실적 누적이 접수량을 초과하지 않도록 검증
// 초과 생산 경고 (차단하지 않음 - 현장 유연성)
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})을 초과합니다. 추가 접수 후 등록해주세요.`,
logger.warn("[pop/production] 초과 생산 감지", {
work_order_process_id,
prevTotal, requestedQty, acceptedQty,
overAmount: (prevTotal + requestedQty) - acceptedQty,
});
}
// 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만)
const addProduction = parseInt(production_qty, 10) || 0;
let addDefect = 0;
let addConcession = 0;
let defectDetailStr: string | null = null;
if (defect_detail && Array.isArray(defect_detail)) {
const validated = defect_detail.map((item: DefectDetailItem) => ({
@@ -622,15 +629,23 @@ export const saveResult = async (
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;
for (const item of validated) {
const itemQty = parseInt(item.qty, 10) || 0;
addDefect += itemQty;
if (item.disposition === "accept") {
addConcession += itemQty;
}
}
} else {
addDefect = parseInt(defect_qty, 10) || 0;
}
const addGood = addProduction - addDefect;
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;
const newConcession = (parseInt(prev.concession_qty, 10) || 0) + addConcession;
// 기존 defect_detail에 이번 차수 상세를 병합
let mergedDefectDetail: string | null = null;
@@ -640,7 +655,6 @@ export const saveResult = async (
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(
@@ -662,6 +676,7 @@ export const saveResult = async (
SET total_production_qty = $3,
good_qty = $4,
defect_qty = $5,
concession_qty = $9,
defect_detail = COALESCE($6, defect_detail),
result_note = COALESCE($7, result_note),
result_status = 'draft',
@@ -669,7 +684,7 @@ export const saveResult = async (
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`,
RETURNING id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status`,
[
work_order_process_id,
companyCode,
@@ -679,6 +694,7 @@ export const saveResult = async (
mergedDefectDetail,
result_note || null,
userId,
String(newConcession),
]
);
@@ -692,7 +708,9 @@ export const saveResult = async (
// 현재 분할 행의 공정 정보 조회
const currentSeq = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty,
wop.parent_process_id,
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
@@ -700,6 +718,46 @@ export const saveResult = async (
[work_order_process_id, companyCode]
);
// 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때)
if (currentSeq.rowCount > 0 && defect_detail && Array.isArray(defect_detail)) {
let totalReworkQty = 0;
for (const item of defect_detail) {
if (item.disposition === "rework") {
totalReworkQty += parseInt(item.qty, 10) || 0;
}
}
if (totalReworkQty > 0) {
const proc = currentSeq.rows[0];
const masterId = proc.parent_process_id || work_order_process_id;
const reworkInsert = await pool.query(
`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, concession_qty, total_production_qty,
result_status, is_rework, rework_source_id,
parent_process_id, company_code, writer
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'acceptable', $10, '0', '0', '0', '0',
'draft', 'Y', $11,
$12, $13, $14
) RETURNING id`,
[
proc.wo_id, proc.seq_no, proc.process_code, proc.process_name,
proc.is_required, proc.is_fixed_order, proc.standard_time,
proc.equipment_code, proc.routing_detail_id,
String(totalReworkQty), work_order_process_id,
masterId, companyCode, userId,
]
);
logger.info("[pop/production] 재작업 카드 자동 생성", {
reworkId: reworkInsert.rows[0]?.id,
sourceId: work_order_process_id,
reworkQty: totalReworkQty,
});
}
}
// 다음 공정 활성화: 양품 발생 시 다음 seq_no의 원본(마스터) 행을 acceptable로
// waiting -> acceptable (최초 활성화)
// in_progress -> acceptable (앞공정 추가양품으로 접수가능량 복원)
@@ -753,7 +811,7 @@ export const saveResult = async (
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
`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`,
[wo_id, prevSeq, companyCode]
@@ -799,6 +857,10 @@ export const saveResult = async (
}
}
}
// 작업지시 전체 완료 판정
const { wo_id: woIdForWi } = currentSeq.rows[0];
await checkAndCompleteWorkInstruction(pool, woIdForWi, companyCode, userId);
}
logger.info("[pop/production] save-result 완료 (누적)", {
@@ -810,7 +872,7 @@ export const saveResult = async (
// 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음)
const latestData = await pool.query(
`SELECT id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status, input_qty
`SELECT id, total_production_qty, good_qty, defect_qty, concession_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]
);
@@ -828,6 +890,63 @@ export const saveResult = async (
}
};
/**
* 작업지시(work_instruction) 전체 완료 판정
* 마지막 공정의 모든 행이 completed이면 작업지시도 완료 처리
*/
const checkAndCompleteWorkInstruction = async (
pool: any,
woId: string,
companyCode: string,
userId: string
) => {
const maxSeqResult = await pool.query(
`SELECT MAX(seq_no::int) as max_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[woId, companyCode]
);
if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return;
const maxSeq = String(maxSeqResult.rows[0].max_seq);
const incompleteCheck = await pool.query(
`SELECT COUNT(*) as cnt
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND status != 'completed'`,
[woId, maxSeq, companyCode]
);
if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return;
const totalGoodResult = await pool.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`,
[woId, maxSeq, companyCode]
);
const completedQty = totalGoodResult.rows[0].total_good;
await pool.query(
`UPDATE work_instruction
SET status = 'completed',
progress_status = 'completed',
completed_qty = $3,
writer = $4,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'`,
[woId, companyCode, String(completedQty), userId]
);
logger.info("[pop/production] 작업지시 전체 완료", {
woId, completedQty, companyCode,
});
};
/**
* 실적 확정은 더 이상 단일 확정이 아님.
* 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임.
@@ -874,58 +993,18 @@ export const confirmResult = async (
});
}
// 잔여 접수가능량 계산하여 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 여부와 관계없이 completed 처리
// 자동 완료가 안 된 경우 관리자가 강제 완료할 때 사용
const newStatus = "completed";
const isCompleted = shouldComplete;
// 수동 확정: 무조건 completed 처리 (수동 완료 용도)
const result = await pool.query(
`UPDATE work_order_process
SET result_status = 'confirmed',
status = $4,
completed_at = CASE WHEN $5 THEN NOW()::text ELSE completed_at END,
completed_by = CASE WHEN $5 THEN $3 ELSE completed_by END,
status = 'completed',
completed_at = NOW()::text,
completed_by = $3,
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, isCompleted]
[work_order_process_id, companyCode, userId]
);
if (result.rowCount === 0) {
@@ -935,25 +1014,92 @@ export const confirmResult = async (
});
}
// 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]
);
// 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용)
const seqCheck = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.parent_process_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`,
[work_order_process_id, companyCode]
);
if (seqCheck.rowCount > 0) {
const { seq_no, wo_id, parent_process_id, instruction_qty } = seqCheck.rows[0];
const seqNum = parseInt(seq_no, 10);
const instrQty = parseInt(instruction_qty, 10) || 0;
// 다음 공정 활성화 (양품이 있으면)
const goodQty = parseInt(result.rows[0].good_qty, 10) || 0;
if (goodQty > 0) {
const nextSeq = String(seqNum + 1);
await pool.query(
`UPDATE work_order_process
SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END,
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL
AND status != 'completed'`,
[wo_id, nextSeq, companyCode]
);
}
// 마스터 자동완료 캐스케이드 (분할 행인 경우)
if (parent_process_id) {
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.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`,
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].total_good;
}
}
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;
if (incompleteCount === 0 && remainingAcceptable <= 0) {
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'`,
[parent_process_id, companyCode, userId]
);
logger.info("[pop/production] confirmResult: 마스터 자동 완료", {
masterId: parent_process_id, totalInput, prevGoodQty,
});
}
}
// 작업지시 전체 완료 판정
await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId);
}
logger.info("[pop/production] confirm-result 완료", {
companyCode,
work_order_process_id,
userId,
shouldComplete,
newStatus,
finalStatus: result.rows[0].status,
});
@@ -1105,12 +1251,12 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
);
const myInputQty = totalAccepted.rows[0].total_input;
// 앞공정 양품 합산
// 앞공정 양품+특채 합산
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
`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`,
[wo_id, prevSeq, companyCode]
@@ -1215,12 +1361,12 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
);
const currentTotalInput = totalAccepted.rows[0].total_input;
// 앞공정 양품 합산
// 앞공정 양품+특채 합산
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
`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`,
[row.wo_id, prevSeq, companyCode]