feat: 리워크 추적 시스템 + 합류 판정 + 버그 12건 수정
리워크 추적: - 다음 공정 접수 시 리워크 자동 감지 (rework_source_id별 개별 추적) - 합류 판정: 일반 물량 있으면 마크 해제(합류), 없으면 마크 유지 - 창고 입고 시 마크 자동 해제 (is_rework=NULL, 이력 보존) - 접수가능 카드: 합류 불가 시 리워크 배지 표시 버그 수정: - 접수가능 이중 집계 (master+SPLIT 합산 → SPLIT만) - completed master 재활성화 - 리워크 접수 불가 (자체 input 기반으로 변경) - master input_qty 덮어쓰기 방지 - 리워크 카드A 접수 시 카드B 사라짐 (개별 전량 판정) - 진행중/완료 탭 마스터 행 제외 - 리워크 진행중 카드 중복 (마스터 숨김, SPLIT만 표시) - 리워크 SPLIT에 is_rework 전달 - 재작업 회차 이중 카운트 (마스터만 카운트) - reworkAvailable이 available 초과 (clamp 처리) - 불량 수량 키패드 기본값 빈값 - 불량 처리 공정선택 UI 연결 테스트 검증: 전체 시나리오 PASS (집계 ✅ 흐름 ✅ 재고 ✅ 마크 ✅)
This commit is contained in:
@@ -1696,6 +1696,41 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
|
||||
instructionQty: instrQty,
|
||||
});
|
||||
|
||||
// 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용)
|
||||
// rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산
|
||||
let reworkAvailableQty = 0;
|
||||
if (!isRework && seqNum > 1) {
|
||||
const prevSeq = String(seqNum - 1);
|
||||
// 앞공정의 리워크 완료 SPLIT들 (rework_source_id별)
|
||||
const reworkSplits = await pool.query(
|
||||
`SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg
|
||||
FROM work_order_process
|
||||
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
|
||||
AND parent_process_id IS NOT NULL
|
||||
AND is_rework = 'Y'
|
||||
AND status = 'completed'
|
||||
AND good_qty::int > 0
|
||||
GROUP BY rework_source_id`,
|
||||
[wo_id, prevSeq, companyCode]
|
||||
);
|
||||
// 현재 공정에서 각 rework_source_id별로 소비된 수량
|
||||
for (const rs of reworkSplits.rows) {
|
||||
const srcId = rs.rework_source_id;
|
||||
const srcGood = parseInt(rs.rg, 10) || 0;
|
||||
const consumedResult = await pool.query(
|
||||
`SELECT COALESCE(SUM(input_qty::int), 0) as consumed
|
||||
FROM work_order_process
|
||||
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
|
||||
AND parent_process_id IS NOT NULL
|
||||
AND is_rework = 'Y'
|
||||
AND rework_source_id = $4`,
|
||||
[wo_id, seq_no, companyCode, srcId]
|
||||
);
|
||||
const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0;
|
||||
reworkAvailableQty += Math.max(0, srcGood - consumed);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1703,6 +1738,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
|
||||
myInputQty,
|
||||
availableQty,
|
||||
instructionQty: instrQty,
|
||||
reworkAvailableQty, // 리워크 물량 포함 수량
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
@@ -1841,32 +1877,65 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
|
||||
const batchId = req.body.batch_id || `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const hasBatchCol = _batchMigrationDone;
|
||||
|
||||
// 리워크 정보 전달: 리워크 카드 접수 또는 프론트에서 전달한 경우
|
||||
// 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지
|
||||
let splitIsRework: string | null = null;
|
||||
let splitReworkSourceId: string | null = null;
|
||||
|
||||
if (isRework) {
|
||||
// 리워크 카드에서 직접 접수
|
||||
// 케이스 1: 리워크 카드에서 직접 접수
|
||||
const parentReworkInfo = await client.query(
|
||||
`SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, [work_order_process_id]
|
||||
);
|
||||
splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null;
|
||||
splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null;
|
||||
} else if (req.body.rework_source_id) {
|
||||
// 프론트에서 리워크 추적 정보 전달 (다음 공정 접수 시)
|
||||
// 원본 불량 공정의 seq_no를 조회하여 현재 공정과 비교
|
||||
const originProc = await client.query(
|
||||
`SELECT seq_no FROM work_order_process WHERE id = $1`, [req.body.rework_source_id]
|
||||
// 케이스 2: 프론트에서 리워크 추적 정보 전달
|
||||
splitIsRework = "Y";
|
||||
splitReworkSourceId = req.body.rework_source_id;
|
||||
} else if (seqNum > 1) {
|
||||
// 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인
|
||||
const prevSeq = String(seqNum - 1);
|
||||
// rework_source_id별로 개별 추적
|
||||
const prevReworkSplits = await client.query(
|
||||
`SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good
|
||||
FROM work_order_process
|
||||
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
|
||||
AND parent_process_id IS NOT NULL
|
||||
AND is_rework = 'Y'
|
||||
AND status = 'completed'
|
||||
AND good_qty::int > 0
|
||||
GROUP BY rework_source_id`,
|
||||
[row.wo_id, prevSeq, companyCode]
|
||||
);
|
||||
const originSeq = parseInt(originProc.rows[0]?.seq_no, 10) || 0;
|
||||
const currentSeqNum = parseInt(row.seq_no, 10);
|
||||
|
||||
if (currentSeqNum < originSeq) {
|
||||
// 아직 원본 공정에 도달 안 함 → 리워크 마크 유지
|
||||
splitIsRework = "Y";
|
||||
splitReworkSourceId = req.body.rework_source_id;
|
||||
// 각 rework_source별로 미소진 수량 확인
|
||||
for (const rs of prevReworkSplits.rows) {
|
||||
const srcId = rs.rework_source_id;
|
||||
const srcGood = parseInt(rs.rework_good, 10) || 0;
|
||||
const consumedResult = await client.query(
|
||||
`SELECT COALESCE(SUM(input_qty::int), 0) as consumed
|
||||
FROM work_order_process
|
||||
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
|
||||
AND parent_process_id IS NOT NULL
|
||||
AND is_rework = 'Y'
|
||||
AND rework_source_id = $4`,
|
||||
[row.wo_id, row.seq_no, companyCode, srcId]
|
||||
);
|
||||
const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0;
|
||||
const remaining = srcGood - consumed;
|
||||
|
||||
if (remaining > 0 && qty <= remaining) {
|
||||
// 합류 판정: 일반 물량이 있으면 합류(마크 없음), 없으면 마크 부착
|
||||
const normalAvailable = availableQty - remaining;
|
||||
if (normalAvailable <= 0) {
|
||||
// 일반 물량 없음 → 합류 불가 → 리워크 마크
|
||||
splitIsRework = "Y";
|
||||
splitReworkSourceId = srcId;
|
||||
}
|
||||
// normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null)
|
||||
break;
|
||||
}
|
||||
}
|
||||
// currentSeqNum >= originSeq → 원본 공정 도달 → 마크 해제 (splitIsRework = null)
|
||||
}
|
||||
|
||||
// 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함)
|
||||
@@ -1914,8 +1983,16 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
|
||||
);
|
||||
} else {
|
||||
newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지
|
||||
// 리워크 카드: 전량 접수 시 status를 completed로 변경 (추가 접수 방지 + 탭에서 숨김)
|
||||
if (qty >= reworkInputQty) {
|
||||
// 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경
|
||||
// (다른 리워크 카드에 영향 없도록 id 정확히 지정)
|
||||
const reworkAlreadyAccepted = await client.query(
|
||||
`SELECT COALESCE(SUM(input_qty::int), 0) as total
|
||||
FROM work_order_process
|
||||
WHERE parent_process_id = $1 AND company_code = $2`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
const totalReworkAccepted = (parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty;
|
||||
if (totalReworkAccepted >= reworkInputQty) {
|
||||
await client.query(
|
||||
`UPDATE work_order_process SET status = 'completed', updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2`,
|
||||
@@ -2352,7 +2429,7 @@ export const inventoryInbound = async (
|
||||
|
||||
// 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회
|
||||
const procResult = await client.query(
|
||||
`SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no
|
||||
`SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework
|
||||
FROM work_order_process
|
||||
WHERE id = $1 AND company_code = $2`,
|
||||
[work_order_process_id, companyCode]
|
||||
@@ -2442,12 +2519,22 @@ export const inventoryInbound = async (
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존)
|
||||
if (proc.is_rework === "Y") {
|
||||
await client.query(
|
||||
`UPDATE work_order_process SET is_rework = NULL, updated_date = NOW()
|
||||
WHERE id = $1 AND company_code = $2`,
|
||||
[work_order_process_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/production] 독립 재고 입고 완료", {
|
||||
companyCode, userId, work_order_process_id,
|
||||
itemCode, warehouse_code, location_code: effectiveLocationCode,
|
||||
qty: goodQty,
|
||||
reworkCleared: proc.is_rework === "Y",
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
||||
@@ -49,7 +49,7 @@ function QtyInput({
|
||||
const [padValue, setPadValue] = useState(String(value));
|
||||
|
||||
const handlePadOpen = () => {
|
||||
setPadValue(String(value));
|
||||
setPadValue("");
|
||||
setPadOpen(true);
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ function QtyInput({
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[280px] z-10">
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-gray-500">불량 수량 (최대 {max})</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1" style={{ fontVariantNumeric: "tabular-nums" }}>{padValue}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1" style={{ fontVariantNumeric: "tabular-nums" }}>{padValue || "0"}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{["1","2","3","4","5","6","7","8","9"].map((k) => (
|
||||
|
||||
@@ -486,30 +486,42 @@ function AcceptableCardBody({
|
||||
planQty,
|
||||
prevGoodQty,
|
||||
availableQty,
|
||||
reworkAvailableQty,
|
||||
}: {
|
||||
planQty: number;
|
||||
prevGoodQty: number | null;
|
||||
availableQty: number;
|
||||
reworkAvailableQty?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="text-center bg-gray-50 rounded-xl py-4">
|
||||
<div className="text-sm text-gray-400">지시수량</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-center bg-emerald-50 rounded-xl py-4">
|
||||
<div className="text-sm text-emerald-500">전공정양품</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-600">
|
||||
{prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"}
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="text-center bg-gray-50 rounded-xl py-4">
|
||||
<div className="text-sm text-gray-400">지시수량</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-center bg-emerald-50 rounded-xl py-4">
|
||||
<div className="text-sm text-emerald-500">전공정양품</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-600">
|
||||
{prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center bg-blue-50 rounded-xl py-4">
|
||||
<div className="text-sm text-blue-500">접수가능</div>
|
||||
<div className="font-extrabold text-blue-600" style={{ fontSize: 28 }}>
|
||||
{availableQty.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center bg-blue-50 rounded-xl py-4">
|
||||
<div className="text-sm text-blue-500">접수가능</div>
|
||||
<div className="font-extrabold text-blue-600" style={{ fontSize: 28 }}>
|
||||
{availableQty.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{reworkAvailableQty && reworkAvailableQty > 0 ? (
|
||||
reworkAvailableQty >= availableQty ? null : (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<span className="text-xs font-bold text-white bg-amber-500 px-2 py-0.5 rounded-full">리워크</span>
|
||||
<span className="text-xs text-amber-700 font-medium">재작업 물량 {reworkAvailableQty}개 포함</span>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1104,10 +1116,27 @@ export function WorkOrderList() {
|
||||
const prevGood = parseInt(prev.good_qty || "0", 10);
|
||||
const prevPlan = parseInt(prev.plan_qty || "0", 10);
|
||||
const prevPct = prevPlan > 0 ? Math.round((prevGood / prevPlan) * 100) : 0;
|
||||
// 앞공정에서 리워크로 완료된 양품 수량
|
||||
const prevSeqNo = prev.seq_no;
|
||||
const reworkGoodFromPrev = allProcesses
|
||||
.filter((p) => p.wo_id === proc.wo_id && p.seq_no === prevSeqNo && p.parent_process_id && p.status === "completed" && (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"))
|
||||
.reduce((sum, p) => sum + (parseInt(p.good_qty || "0", 10)), 0);
|
||||
// 현재 공정에서 이미 리워크로 접수된 수량
|
||||
const reworkConsumedHere = allProcesses
|
||||
.filter((p) => p.wo_id === proc.wo_id && p.seq_no === proc.seq_no && p.parent_process_id && (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"))
|
||||
.reduce((sum, p) => sum + (parseInt(p.input_qty || "0", 10)), 0);
|
||||
const reworkAvailableQty = Math.max(0, reworkGoodFromPrev - reworkConsumedHere);
|
||||
|
||||
// 접수가능 수량을 초과하지 않도록 제한
|
||||
const inputQtyNum = parseInt(proc.input_qty || "0", 10);
|
||||
const actualAvailable = Math.max(0, prevGood - inputQtyNum);
|
||||
const clampedReworkAvailable = Math.min(reworkAvailableQty, actualAvailable);
|
||||
|
||||
return {
|
||||
prevGoodQty: prevGood,
|
||||
prevProcessName: prev.process_name || prev.process_code,
|
||||
prevProgressPct: prev.status === "in_progress" ? prevPct : prev.status === "completed" ? 100 : null,
|
||||
reworkAvailableQty: clampedReworkAvailable,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1295,25 +1324,35 @@ export function WorkOrderList() {
|
||||
// Split order label
|
||||
const splitInfo = splitOrderMap[proc.id];
|
||||
|
||||
// 합류 불가 리워크 감지: 접수가능 물량이 전부 리워크일 때
|
||||
const reworkQtyAvail = prevInfo.reworkAvailableQty || 0;
|
||||
const normalAvail = availableQty - reworkQtyAvail;
|
||||
const isReworkOnly = !isRework && proc.status === "acceptable" && reworkQtyAvail > 0 && normalAvail <= 0 && availableQty > 0;
|
||||
|
||||
// 리워크 표시 여부 (실제 리워크 카드 OR 합류불가 리워크)
|
||||
const showReworkBadge = isRework || isReworkOnly;
|
||||
|
||||
// Rework info: origin process + rework round
|
||||
let reworkRound = 1;
|
||||
let originProcessName = proc.process_name || proc.process_code;
|
||||
let originProcessCode = proc.process_code;
|
||||
let originDefectQty = defectQty;
|
||||
if (isRework) {
|
||||
// Count how many rework processes exist for this wo_id with same process_code
|
||||
const sameWoReworks = allProcesses.filter(
|
||||
// 리워크 마스터 카드만 카운트 (SPLIT 제외 — parent_process_id 없는 것만)
|
||||
const reworkMasters = allProcesses.filter(
|
||||
(p) =>
|
||||
p.wo_id === proc.wo_id &&
|
||||
!p.parent_process_id &&
|
||||
(p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1")
|
||||
);
|
||||
// Find this process's position among reworks (by created_date or id)
|
||||
const sortedReworks = [...sameWoReworks].sort((a, b) => {
|
||||
const sortedReworks = [...reworkMasters].sort((a, b) => {
|
||||
const da = a.created_date ? new Date(a.created_date).getTime() : 0;
|
||||
const db = b.created_date ? new Date(b.created_date).getTime() : 0;
|
||||
return da - db || a.id.localeCompare(b.id);
|
||||
});
|
||||
const myIdx = sortedReworks.findIndex((r) => r.id === proc.id);
|
||||
// 현재 카드가 SPLIT이면 parent(마스터)의 위치로, 마스터면 직접 위치
|
||||
const masterId = proc.parent_process_id || proc.id;
|
||||
const myIdx = sortedReworks.findIndex((r) => r.id === masterId);
|
||||
reworkRound = myIdx >= 0 ? myIdx + 1 : 1;
|
||||
|
||||
// Find origin (source) process
|
||||
@@ -1332,7 +1371,7 @@ export function WorkOrderList() {
|
||||
<div
|
||||
className={`bg-white rounded-2xl border-l-4 ${borderLeft} border border-gray-100 shadow-sm overflow-hidden flex flex-col ${
|
||||
proc.status === "waiting" ? "opacity-75" : ""
|
||||
} ${isRework ? "border-2 border-orange-200" : ""} ${
|
||||
} ${showReworkBadge ? "border-2 border-orange-200" : ""} ${
|
||||
proc.status === "in_progress" ? "cursor-pointer active:scale-[0.99] transition-transform" : ""
|
||||
}`}
|
||||
style={{ height: "100%" }}
|
||||
@@ -1342,7 +1381,7 @@ export function WorkOrderList() {
|
||||
{/* Header: Work instruction number + status badge */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isRework && (
|
||||
{showReworkBadge && (
|
||||
<span className="bg-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded shrink-0">
|
||||
🔄 리워크
|
||||
</span>
|
||||
@@ -1400,6 +1439,7 @@ export function WorkOrderList() {
|
||||
planQty={planQty}
|
||||
prevGoodQty={prevInfo.prevGoodQty}
|
||||
availableQty={availableQty}
|
||||
reworkAvailableQty={prevInfo.reworkAvailableQty}
|
||||
/>
|
||||
) : proc.status === "in_progress" ? (
|
||||
<InProgressCardBody
|
||||
|
||||
Reference in New Issue
Block a user