From 15029dc58d4413856d4678095ec2c6701c00e7b0 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 6 Apr 2026 22:27:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=EC=9B=8C=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20+=20=ED=95=A9?= =?UTF-8?q?=EB=A5=98=20=ED=8C=90=EC=A0=95=20+=20=EB=B2=84=EA=B7=B8=2012?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리워크 추적: - 다음 공정 접수 시 리워크 자동 감지 (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 (집계 ✅ 흐름 ✅ 재고 ✅ 마크 ✅) --- .../controllers/popProductionController.ts | 119 +++++++++++++++--- .../hardcoded/production/DefectTypeModal.tsx | 4 +- .../hardcoded/production/WorkOrderList.tsx | 86 +++++++++---- 3 files changed, 168 insertions(+), 41 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 0965e257..61d81716 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -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({ diff --git a/frontend/components/pop/hardcoded/production/DefectTypeModal.tsx b/frontend/components/pop/hardcoded/production/DefectTypeModal.tsx index b4c58699..991ce8ff 100644 --- a/frontend/components/pop/hardcoded/production/DefectTypeModal.tsx +++ b/frontend/components/pop/hardcoded/production/DefectTypeModal.tsx @@ -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({

불량 수량 (최대 {max})

-

{padValue}

+

{padValue || "0"}

{["1","2","3","4","5","6","7","8","9"].map((k) => ( diff --git a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx index 3cdfb7cf..97d7dc27 100644 --- a/frontend/components/pop/hardcoded/production/WorkOrderList.tsx +++ b/frontend/components/pop/hardcoded/production/WorkOrderList.tsx @@ -486,30 +486,42 @@ function AcceptableCardBody({ planQty, prevGoodQty, availableQty, + reworkAvailableQty, }: { planQty: number; prevGoodQty: number | null; availableQty: number; + reworkAvailableQty?: number; }) { return ( -
-
-
지시수량
-
{planQty.toLocaleString()}
-
-
-
전공정양품
-
- {prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"} + <> +
+
+
지시수량
+
{planQty.toLocaleString()}
+
+
+
전공정양품
+
+ {prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"} +
+
+
+
접수가능
+
+ {availableQty.toLocaleString()} +
-
-
접수가능
-
- {availableQty.toLocaleString()} -
-
-
+ {reworkAvailableQty && reworkAvailableQty > 0 ? ( + reworkAvailableQty >= availableQty ? null : ( +
+ 리워크 + 재작업 물량 {reworkAvailableQty}개 포함 +
+ ) + ) : 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() {
- {isRework && ( + {showReworkBadge && ( 🔄 리워크 @@ -1400,6 +1439,7 @@ export function WorkOrderList() { planQty={planQty} prevGoodQty={prevInfo.prevGoodQty} availableQty={availableQty} + reworkAvailableQty={prevInfo.reworkAvailableQty} /> ) : proc.status === "in_progress" ? (