From 9d164d08afd51915290eebaced790b16b4ec4d95 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 18 Mar 2026 16:38:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BLOCK=20MES-HARDEN=20Phase=200~3=20+=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=ED=9D=90=EB=A6=84=20=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=BD=20=ED=95=84/=EC=B9=A9=20UI=20=EA=B0=9C=ED=8E=B8=20MES?= =?UTF-8?q?=20=EB=B6=88=EB=9F=89=20=EC=B2=98=EB=B6=84=20=EC=B2=B4=EA=B3=84?= =?UTF-8?q?(disposition=203=EC=A2=85)=EB=A5=BC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=98=EA=B3=A0,=20=EA=B3=B5=EC=A0=95=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=9D=98=20=ED=9D=90=EB=A6=84=20=EC=8A=A4=ED=8A=B8=EB=A6=BD?= =?UTF-8?q?=EC=9D=84=20=ED=98=84=EC=9E=AC=20=EA=B3=B5=EC=A0=95=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=20=ED=95=84/=EC=B9=A9=20=EC=9C=88=EB=8F=84=EC=9A=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20=EC=9E=AC=EC=84=A4=EA=B3=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[Phase=200:=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94]=20-=20confirmResult:=20SUM=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20+=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=BA=90=EC=8A=A4=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=99=84=EB=A3=8C=20=ED=8C=90=EC=A0=95=20-=20check?= =?UTF-8?q?AndCompleteWorkInstruction:=20=ED=97=AC=ED=8D=BC=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EC=B6=9C=20=20=20(saveResult/confirmResul?= =?UTF-8?q?t=20=EC=96=91=EC=AA=BD=EC=97=90=EC=84=9C=20work=5Finstruction?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B0=B1=EC=8B=A0)=20-=20saveResult:?= =?UTF-8?q?=20=EC=B4=88=EA=B3=BC=20=EC=83=9D=EC=82=B0=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20->=20=EA=B2=BD=EA=B3=A0=20=EB=A1=9C=EA=B7=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20[Phase=201:=20UI=20=EC=A0=95=EB=A6=AC]=20-?= =?UTF-8?q?=20DISPOSITION=5FOPTIONS:=205=EC=A2=85=20->=203=EC=A2=85(?= =?UTF-8?q?=ED=8F=90=EA=B8=B0/=EC=9E=AC=EC=9E=91=EC=97=85/=ED=8A=B9?= =?UTF-8?q?=EC=B1=84)=20-=20=EC=B9=B4=EB=93=9C=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B2=84=ED=8A=BC:=20in=5Fprogress=20+=20?= =?UTF-8?q?=EC=83=9D=EC=82=B0=20=EC=9E=88=EC=9D=8C=20+=20=EB=AF=B8?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=ED=91=9C=EC=8B=9C=20=20=20(?= =?UTF-8?q?=5F=5FmanualComplete=20->=20confirmResult=20=ED=98=B8=EC=B6=9C)?= =?UTF-8?q?=20[Phase=202:=20=EC=96=91=ED=92=88=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=ED=99=94]=20-=20concession=5Fqty/is=5Frework?= =?UTF-8?q?/rework=5Fsource=5Fid=20DB=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20saveResult:=20defect=5Fdetail=20disposition?= =?UTF-8?q?=EB=B3=84=20=EC=84=9C=EB=B2=84=20=EC=96=91=ED=92=88=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=20=20(addGood=20=3D=20addProduction=20-=20addDefec?= =?UTF-8?q?t,=20addConcession=20=EB=B6=84=EB=A6=AC)=20-=20prevGoodQty=205?= =?UTF-8?q?=EA=B3=B3:=20SUM(good=5Fqty)=20+=20SUM(concession=5Fqty)=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20-=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=ED=8A=B9=EC=B1=84=20=ED=91=9C=EC=8B=9C:=20MesInProgressMetrics?= =?UTF-8?q?/MesCompletedMetrics=20[Phase=203:=20=EC=9E=AC=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=B9=B4=EB=93=9C]=20-=20saveResult:=20disposition?= =?UTF-8?q?=3Drework=20=EC=8B=9C=20=EB=8F=99=EC=9D=BC=20=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=EB=B6=84=ED=95=A0=ED=96=89=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?INSERT=20=20=20(is=5Frework=3D'Y',=20rework=5Fsource=5Fid=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0,=20status=3D'acceptable')=20-=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8:=20amber=20"=EC=9E=AC=EC=9E=91=EC=97=85"=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20+=20MesAcceptableMetrics=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A0=84=EC=9A=A9=20UI=20-=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=B9=B4=EB=93=9C=20=EC=A0=91=EC=88=98?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=88=98=EB=9F=89=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(=EB=A7=88=EC=8A=A4=ED=84=B0=20qty=20->=20?= =?UTF-8?q?input=5Fqty)=20[=EA=B3=B5=EC=A0=95=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=BD=20UI=20=EA=B0=9C=ED=8E=B8]=20-=20Pr?= =?UTF-8?q?ocessFlowStrip:=20=EB=B0=94=20=ED=98=95=ED=83=9C=20->=20?= =?UTF-8?q?=ED=95=84/=EC=B9=A9=205=EC=8A=AC=EB=A1=AF=20=EC=9C=88=EB=8F=84?= =?UTF-8?q?=EC=9A=B0=20=20=20(+N/=EC=9D=B4=EC=A0=84/=ED=98=84=EC=9E=AC/?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C/+N,=20=ED=98=84=EC=9E=AC=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=ED=95=AD=EC=83=81=20=EC=A4=91=EC=95=99)=20-=20?= =?UTF-8?q?=EC=83=89=EC=83=81:=20=EC=A7=80=EB=82=98=EC=98=A8=3Demerald(?= =?UTF-8?q?=EC=99=84=EB=A3=8C)/slate,=20=ED=98=84=EC=9E=AC=3Dprimary,=20?= =?UTF-8?q?=20=20=EC=99=84=EB=A3=8C=3Demerald,=20=EB=8C=80=EA=B8=B0=3Dmute?= =?UTF-8?q?d,=20=EB=82=A8=EC=9D=80=3Damber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../controllers/popProductionController.ts | 298 +++++++++++++----- .../PopCardListV2Component.tsx | 23 ++ .../pop-card-list-v2/cell-renderers.tsx | 191 +++++++---- .../PopWorkDetailComponent.tsx | 2 - 5 files changed, 371 insertions(+), 144 deletions(-) diff --git a/.gitignore b/.gitignore index 552d1265..ac7c9e27 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,4 @@ scripts/browser-test-*.js # 개인 작업 문서 popdocs/ .cursor/rules/popdocs-safety.mdc +.cursor/rules/overtime-registration.mdc diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index ca65062a..0192aa35 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -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] diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 5f5593d8..f3349cbb 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -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 | 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; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 25a9d8b4..da8889cb 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -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 )} - - {st.label} - +
+ {isRework && ( + + 재작업 + + )} + + {st.label} + +
{/* ── 수량 메트릭 (상태별) ── */} @@ -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} )} + {showManualComplete && ( + + )} @@ -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 ( + + {sem === "done" && !isCurrent && } + {step.seqNo} {step.processName} + + ); + }; return ( -
- {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); +
+ {hiddenBefore > 0 && ( + <> + + +{hiddenBefore} + + + + )} - let barColor = "#e2e8f0"; - if (sem === "done") barColor = "#10b981"; - else if (sem === "active") barColor = "#3b82f6"; + {prevStep && ( + <> + {renderChip(prevStep, false)} + + + )} - return ( - -
- {isCurr && ( -
- )} - - {step.processName} - -
-
-
- - {sem === "pending" && sInput === 0 ? "-" : `${sGood}/${sInput || instrQty}`} - -
- {idx < visible.length - 1 && ( -
- )} - - ); - })} - {hiddenCount > 0 && ( -
- +{hiddenCount} -
+ {currStep && renderChip(currStep, true)} + + {nextStep && ( + <> + + {renderChip(nextStep, false)} + + )} + + {hiddenAfter > 0 && ( + <> + + + +{hiddenAfter} + + )}
); } // ── 접수가능 메트릭 ── -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 ( +
+
+ 불량 재작업 대상 +
+
+ 재작업 수량  + {inputQty.toLocaleString()} +
+
+ ); + } const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty); return (
@@ -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 (
@@ -1329,11 +1384,12 @@ function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remaini
{progressPct}%
- {/* 수량 4칸 */} -
+ {/* 수량 메트릭 */} +
0 ? "grid-cols-5" : "grid-cols-4")}> + {concessionQty > 0 && } 0 ? "#f59e0b" : "#10b981"} />
{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 (
지시 {instrQty.toLocaleString()} 최종양품 {goodQty.toLocaleString()} + {concessionQty > 0 && ( + 특채 {concessionQty.toLocaleString()} + )} 수율 = 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444" }}>{yieldRate}%
{defectQty > 0 && ( diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 5eb1f9d9..116ac3eb 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -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: "특채" }, ];