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:
parent
fba5390f5a
commit
9d164d08af
|
|
@ -182,3 +182,4 @@ scripts/browser-test-*.js
|
|||
# 개인 작업 문서
|
||||
popdocs/
|
||||
.cursor/rules/popdocs-safety.mdc
|
||||
.cursor/rules/overtime-registration.mdc
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -977,6 +977,9 @@ export function PopCardListV2Component({
|
|||
__process_total_production_qty: splitRow.total_production_qty,
|
||||
__process_good_qty: splitRow.good_qty,
|
||||
__process_defect_qty: splitRow.defect_qty,
|
||||
__process_concession_qty: splitRow.concession_qty,
|
||||
__process_is_rework: splitRow.is_rework,
|
||||
__process_rework_source_id: splitRow.rework_source_id,
|
||||
__process_result_status: splitRow.result_status,
|
||||
__availableQty: current?.rawData?.__availableQty ?? 0,
|
||||
__prevGoodQty: current?.rawData?.__prevGoodQty ?? 0,
|
||||
|
|
@ -1748,6 +1751,26 @@ function CardV2({
|
|||
const cfg = buttonConfig as Record<string, unknown> | undefined;
|
||||
const processId = cfg?.__processId as string | number | undefined;
|
||||
|
||||
// 수동 완료 처리
|
||||
if (taskPreset === "__manualComplete" && processId) {
|
||||
if (!window.confirm("이 공정을 수동으로 완료 처리하시겠습니까? 현재 생산량으로 확정됩니다.")) return;
|
||||
try {
|
||||
const result = await apiClient.post("/pop/production/confirm-result", {
|
||||
work_order_process_id: processId,
|
||||
});
|
||||
if (result.data?.success) {
|
||||
toast.success("공정이 완료 처리되었습니다.");
|
||||
onRefresh?.();
|
||||
} else {
|
||||
toast.error(result.data?.message || "완료 처리에 실패했습니다.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = (err as any)?.response?.data?.message;
|
||||
toast.error(errMsg || "완료 처리 중 오류가 발생했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 접수 취소 처리 (__cancelAccept 또는 "접수취소" 라벨 버튼)
|
||||
if ((taskPreset === "__cancelAccept" || taskPreset === "접수취소") && processId) {
|
||||
if (!window.confirm("접수를 취소하시겠습니까? 실적이 없는 경우에만 가능합니다.")) return;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||
Loader2, CheckCircle2, CircleDot, Clock,
|
||||
Loader2, CheckCircle2, CircleDot, Clock, Check, ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -1002,6 +1002,8 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
|
||||
const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0;
|
||||
const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0;
|
||||
const concessionQty = parseInt(String(row.__process_concession_qty ?? row.concession_qty ?? "0"), 10) || 0;
|
||||
const isRework = String(row.__process_is_rework ?? row.is_rework ?? "N") === "Y";
|
||||
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
|
||||
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instrQty)), 10) || 0;
|
||||
const resultStatus = String(row.__process_result_status ?? "");
|
||||
|
|
@ -1027,6 +1029,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match");
|
||||
|
||||
let activeBtn: ActionButtonDef | undefined;
|
||||
let showManualComplete = false;
|
||||
const isFullyProduced = inputQty > 0 && totalProd >= inputQty;
|
||||
if (isClone) {
|
||||
activeBtn = acceptBtn;
|
||||
|
|
@ -1035,6 +1038,8 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
} else if (rawStatus === "in_progress") {
|
||||
if (isFullyProduced) {
|
||||
if (availableQty > 0) activeBtn = acceptBtn;
|
||||
} else if (totalProd > 0) {
|
||||
showManualComplete = true;
|
||||
} else {
|
||||
activeBtn = cancelBtn;
|
||||
}
|
||||
|
|
@ -1065,12 +1070,19 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="ml-2 shrink-0 rounded px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ backgroundColor: st.color, color: "#fff" }}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||
{isRework && (
|
||||
<span className="rounded bg-amber-500 px-1.5 py-0.5 text-[9px] font-bold text-white">
|
||||
재작업
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="rounded px-2 py-0.5 text-[10px] font-bold"
|
||||
style={{ backgroundColor: st.color, color: "#fff" }}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 수량 메트릭 (상태별) ── */}
|
||||
|
|
@ -1083,6 +1095,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
inputQty={inputQty}
|
||||
isFirstProcess={isFirstProcess}
|
||||
isClone={isClone}
|
||||
isRework={isRework}
|
||||
/>
|
||||
)}
|
||||
{rawStatus === "in_progress" && (
|
||||
|
|
@ -1091,6 +1104,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
totalProd={totalProd}
|
||||
goodQty={goodQty}
|
||||
defectQty={defectQty}
|
||||
concessionQty={concessionQty}
|
||||
remainingQty={remainingQty}
|
||||
progressPct={progressPct}
|
||||
availableQty={availableQty}
|
||||
|
|
@ -1103,6 +1117,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
instrQty={instrQty}
|
||||
goodQty={goodQty}
|
||||
defectQty={defectQty}
|
||||
concessionQty={concessionQty}
|
||||
yieldRate={yieldRate}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1148,6 +1163,19 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
{activeBtn.label}
|
||||
</Button>
|
||||
)}
|
||||
{showManualComplete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-[11px] font-semibold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onActionButtonClick?.("__manualComplete", row, { __processId: processId });
|
||||
}}
|
||||
>
|
||||
수동 완료
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1228,73 +1256,100 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
|
|||
);
|
||||
}
|
||||
|
||||
// ── 공정 흐름 스트립 (카드 내 표시) ──
|
||||
// ── 공정 흐름 스트립 (5슬롯: 지나온 + 이전 + 현재 + 다음 + 남은) ──
|
||||
function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
|
||||
steps: TimelineProcessStep[]; currentIdx: number; instrQty: number;
|
||||
}) {
|
||||
const maxShow = 5;
|
||||
const showAll = steps.length <= maxShow;
|
||||
const visible = showAll ? steps : steps.slice(0, maxShow);
|
||||
const hiddenCount = steps.length - visible.length;
|
||||
const prevStep = currentIdx > 0 ? steps[currentIdx - 1] : null;
|
||||
const currStep = steps[currentIdx];
|
||||
const nextStep = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null;
|
||||
|
||||
const hiddenBefore = currentIdx > 1 ? currentIdx - 1 : 0;
|
||||
const hiddenAfter = currentIdx < steps.length - 2 ? steps.length - currentIdx - 2 : 0;
|
||||
|
||||
const allBeforeDone = hiddenBefore > 0 && steps.slice(0, currentIdx - 1).every(s => {
|
||||
const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending";
|
||||
return sem === "done";
|
||||
});
|
||||
|
||||
const renderChip = (step: TimelineProcessStep, isCurrent: boolean) => {
|
||||
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap",
|
||||
isCurrent
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: sem === "done"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{sem === "done" && !isCurrent && <Check className="h-2.5 w-2.5" />}
|
||||
{step.seqNo} {step.processName}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-0.5">
|
||||
{visible.map((step, idx) => {
|
||||
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
|
||||
const isCurr = idx === currentIdx;
|
||||
const sInput = step.inputQty || 0;
|
||||
const sProd = step.totalProductionQty || 0;
|
||||
const sGood = step.goodQty || 0;
|
||||
const pct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0);
|
||||
<div className="flex items-center gap-1">
|
||||
{hiddenBefore > 0 && (
|
||||
<>
|
||||
<span className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-bold tabular-nums",
|
||||
allBeforeDone
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-slate-100 text-slate-500",
|
||||
)}>
|
||||
+{hiddenBefore}
|
||||
</span>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
|
||||
</>
|
||||
)}
|
||||
|
||||
let barColor = "#e2e8f0";
|
||||
if (sem === "done") barColor = "#10b981";
|
||||
else if (sem === "active") barColor = "#3b82f6";
|
||||
{prevStep && (
|
||||
<>
|
||||
{renderChip(prevStep, false)}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
|
||||
</>
|
||||
)}
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.seqNo}>
|
||||
<div className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center gap-0.5",
|
||||
isCurr && "relative",
|
||||
)}>
|
||||
{isCurr && (
|
||||
<div className="absolute -top-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"max-w-full truncate text-[9px] leading-tight",
|
||||
isCurr ? "font-bold text-primary" : sem === "done" ? "font-medium text-emerald-700" : "text-muted-foreground",
|
||||
)}>
|
||||
{step.processName}
|
||||
</span>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full" style={{ backgroundColor: `${barColor}30` }}>
|
||||
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: barColor }} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[8px] tabular-nums",
|
||||
sem === "done" ? "text-emerald-600" : sem === "active" ? "text-primary" : "text-muted-foreground/60",
|
||||
)}>
|
||||
{sem === "pending" && sInput === 0 ? "-" : `${sGood}/${sInput || instrQty}`}
|
||||
</span>
|
||||
</div>
|
||||
{idx < visible.length - 1 && (
|
||||
<div className="mb-2 h-px w-1.5 shrink-0 bg-border" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<div className="flex h-full shrink-0 items-center">
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-bold text-muted-foreground">+{hiddenCount}</span>
|
||||
</div>
|
||||
{currStep && renderChip(currStep, true)}
|
||||
|
||||
{nextStep && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
|
||||
{renderChip(nextStep, false)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hiddenAfter > 0 && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
|
||||
<span className="inline-flex shrink-0 items-center rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold tabular-nums text-amber-600">
|
||||
+{hiddenAfter}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 접수가능 메트릭 ──
|
||||
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone }: {
|
||||
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean;
|
||||
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone, isRework }: {
|
||||
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; isRework?: boolean;
|
||||
}) {
|
||||
if (isRework) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-amber-600 font-medium">불량 재작업 대상</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-md py-2" style={{ backgroundColor: "rgba(245,158,11,0.08)" }}>
|
||||
<span className="text-[11px] text-muted-foreground">재작업 수량 </span>
|
||||
<span className="text-lg font-extrabold tabular-nums text-amber-600">{inputQty.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty);
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -1316,8 +1371,8 @@ function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, i
|
|||
}
|
||||
|
||||
// ── 진행중 메트릭 ──
|
||||
function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: {
|
||||
inputQty: number; totalProd: number; goodQty: number; defectQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
|
||||
function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, concessionQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: {
|
||||
inputQty: number; totalProd: number; goodQty: number; defectQty: number; concessionQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -1329,11 +1384,12 @@ function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remaini
|
|||
</div>
|
||||
<span className="text-[11px] font-extrabold tabular-nums" style={{ color: statusColor }}>{progressPct}%</span>
|
||||
</div>
|
||||
{/* 수량 4칸 */}
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{/* 수량 메트릭 */}
|
||||
<div className={cn("grid gap-1", concessionQty > 0 ? "grid-cols-5" : "grid-cols-4")}>
|
||||
<MesMetricBox label="완료" value={totalProd} color="#3b82f6" />
|
||||
<MesMetricBox label="양품" value={goodQty} color="#10b981" />
|
||||
<MesMetricBox label="불량" value={defectQty} color="#ef4444" dimZero />
|
||||
{concessionQty > 0 && <MesMetricBox label="특채" value={concessionQty} color="#8b5cf6" />}
|
||||
<MesMetricBox label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
|
||||
</div>
|
||||
{availableQty > 0 && (
|
||||
|
|
@ -1346,14 +1402,17 @@ function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remaini
|
|||
}
|
||||
|
||||
// ── 완료 메트릭 ──
|
||||
function MesCompletedMetrics({ instrQty, goodQty, defectQty, yieldRate }: {
|
||||
instrQty: number; goodQty: number; defectQty: number; yieldRate: number;
|
||||
function MesCompletedMetrics({ instrQty, goodQty, defectQty, concessionQty, yieldRate }: {
|
||||
instrQty: number; goodQty: number; defectQty: number; concessionQty: number; yieldRate: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3 text-[10px]">
|
||||
<span className="text-muted-foreground">지시 <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
|
||||
<span className="text-muted-foreground">최종양품 <b className="text-emerald-600">{goodQty.toLocaleString()}</b></span>
|
||||
{concessionQty > 0 && (
|
||||
<span className="text-muted-foreground">특채 <b className="text-violet-600">{concessionQty.toLocaleString()}</b></span>
|
||||
)}
|
||||
<span className="ml-auto text-muted-foreground">수율 <b style={{ color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444" }}>{yieldRate}%</b></span>
|
||||
</div>
|
||||
{defectQty > 0 && (
|
||||
|
|
|
|||
|
|
@ -1060,8 +1060,6 @@ export function PopWorkDetailComponent({
|
|||
const DISPOSITION_OPTIONS = [
|
||||
{ value: "scrap", label: "폐기" },
|
||||
{ value: "rework", label: "재작업" },
|
||||
{ value: "downgrade", label: "등급하향" },
|
||||
{ value: "return", label: "반품" },
|
||||
{ value: "accept", label: "특채" },
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue