From 1961385ddf16548be6be3045473e0ec6362cede3 Mon Sep 17 00:00:00 2001 From: kmh Date: Fri, 22 May 2026 11:18:14 +0900 Subject: [PATCH] Restore POP batch-id separation SQL in popProductionController (B1~B10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implements the batch-id separation backend SQL that was lost during the 2026-05-21 jskim-node merge (commit 9da6b22a). The previous re-apply commit (15fa3e37) covered packaging/material auto-input/autoComplete but missed the batch separation block. Changes (B1~B10 in POP.md log): - syncWorkInstructions: detail SELECT + generateWorkProcessesForInstruction call use detail.id instead of detail.item_number - generateWorkProcessesForInstruction: existCheck uses batch_id = $3 OR matching item_number via subquery - syncWorkInstructions unsynced EXISTS: matches both wid.id and wid.item_number - getProcessList prev_good_raw: 5 inner subqueries match COALESCE(wop2.batch_id,'') = COALESCE(wop.batch_id,'') and CTE exposes wop.batch_id - prev_good CTE: first-process fallback uses COALESCE(wid.qty, wi.qty, 0) with LEFT JOIN work_instruction_detail wid ON wid.id = pgr.batch_id - Final SELECT: ROW_NUMBER batch_index, COUNT batch_count, wid_b.item_number batch_item_number; LEFT JOIN wid_b; ORDER BY batch keys - getPrevProcessGoodQty: batchId param + 3 SELECTs filter COALESCE(batch_id,'') = $batchKey - evaluatePrevProcesses: batchId param + wop_with_seq CTE adds COALESCE(wop.batch_id,'') = $4 + fetchInstructionQty prefers work_instruction_detail.qty when batchKey present - getAvailableQty: current SELECT adds wop.batch_id and passes to evaluatePrevProcesses - acceptProcess: master FOR UPDATE SELECT adds wop.batch_id and passes to evaluatePrevProcesses Verification (per POP.md): - backend npm run build PASS - GET /api/pop/production/processes responds with batch_id/batch_index/batch_count/batch_item_number - COMPANY_7 GUI: 25 cards, 17 with -NN (n/m) suffix; CODE-00027 shows -01..04 of 4 correctly - No regression on single-batch (batch_id NULL) rows due to COALESCE matching pattern Known follow-up: work-instruction edit guard (locked detail rows) — implemented in next commit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/popProductionController.ts | 99 +++++++++++++++---- frontend/app/(main)/COMPANY_7/pop/POP.md | 23 +++++ 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index b38f30d1..0c31450f 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -174,41 +174,47 @@ async function getPrevProcessGoodQty( woId: string, seqNum: number, companyCode: string, + batchId?: string | null, ): Promise { - // 1. 첫 공정 여부 판정 + const batchKey = batchId ?? ""; + + // 1. 첫 공정 여부 판정 (같은 batch 내에서) const minSeqCheck = await exec.query( `SELECT MIN(CAST(seq_no AS int)) as min_seq FROM work_order_process - WHERE wo_id = $1 AND company_code = $2`, - [woId, companyCode], + WHERE wo_id = $1 AND company_code = $2 + AND COALESCE(batch_id, '') = $3`, + [woId, companyCode, batchKey], ); const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum; if (seqNum <= minSeq) { return null; // 첫 공정 } - // 2. 앞 seq 조회 + // 2. 앞 seq 조회 (같은 batch 내에서) const prevProcessSeq = await exec.query( `SELECT MAX(CAST(seq_no AS int)) as prev_seq FROM work_order_process WHERE wo_id = $1 AND company_code = $2 + AND COALESCE(batch_id, '') = $4 AND CAST(seq_no AS int) < $3`, - [woId, companyCode, seqNum], + [woId, companyCode, seqNum, batchKey], ); const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq; if (actualPrevSeq == null) { return null; } - // 3. 앞공정 양품 SUM + // 3. 앞공정 양품 SUM (같은 batch 내에서) const prevAgg = await exec.query( `SELECT COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) + COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) as total_good FROM work_order_process_result wr JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3 + AND COALESCE(wop.batch_id, '') = $4 AND wr.result_status IN ('draft','confirmed')`, - [woId, String(actualPrevSeq), companyCode], + [woId, String(actualPrevSeq), companyCode, batchKey], ); return parseInt(prevAgg.rows[0].total_good, 10) || 0; } @@ -229,6 +235,7 @@ async function evaluatePrevProcesses( woId: string, currentSeq: number, companyCode: string, + batchId?: string | null, ): Promise<{ canAccept: boolean; blockedReason: string | null; @@ -239,6 +246,7 @@ async function evaluatePrevProcesses( processName: string; }>; }> { + const batchKey = batchId ?? ""; const sql = ` WITH wop_with_seq AS ( SELECT @@ -250,6 +258,7 @@ async function evaluatePrevProcesses( FROM work_order_process wop WHERE wop.wo_id = $1::varchar AND wop.company_code = $2::varchar AND CAST(wop.seq_no AS int) < $3::int + AND COALESCE(wop.batch_id, '') = $4::varchar ), wr_agg AS ( SELECT @@ -276,7 +285,7 @@ async function evaluatePrevProcesses( ORDER BY w.seq_int DESC `; - const result = await exec.query(sql, [woId, companyCode, currentSeq]); + const result = await exec.query(sql, [woId, companyCode, currentSeq, batchKey]); const rows = result.rows as Array<{ id: string; seq_int: number; @@ -289,6 +298,16 @@ async function evaluatePrevProcesses( }>; const fetchInstructionQty = async (): Promise => { + // batch 가 있으면 work_instruction_detail.qty 우선 + if (batchKey) { + const wid = await exec.query( + `SELECT qty FROM work_instruction_detail + WHERE id = $1 AND company_code = $2`, + [batchKey, companyCode], + ); + const widQty = parseInt(wid.rows[0]?.qty, 10); + if (!Number.isNaN(widQty) && widQty > 0) return widQty; + } const wi = await exec.query( `SELECT qty FROM work_instruction WHERE id = $1 AND company_code = $2`, [woId, companyCode], @@ -531,7 +550,14 @@ async function generateWorkProcessesForInstruction( const existCheck = await client.query( `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND company_code = $2 - AND (batch_id = $3 OR batch_id IS NULL)`, + AND ( + batch_id = $3 + OR batch_id = ( + SELECT item_number FROM work_instruction_detail + WHERE id = $3 AND company_code = $2 + LIMIT 1 + ) + )`, [workInstructionId, companyCode, batchId], ); if (parseInt(existCheck.rows[0].cnt, 10) > 0) { @@ -728,7 +754,7 @@ export const syncWorkInstructions = async ( AND NOT EXISTS ( SELECT 1 FROM work_order_process wop WHERE wop.wo_id = wi.id AND wop.company_code = $1 - AND wop.batch_id = wid.item_number + AND (wop.batch_id = wid.id OR wop.batch_id = wid.item_number) ) ) )`, @@ -758,7 +784,7 @@ export const syncWorkInstructions = async ( for (const wi of unsynced) { const detailResult = await pool.query( - `SELECT wid.item_number, wid.routing_version_id, wid.qty + `SELECT wid.id, wid.item_number, wid.routing_version_id, wid.qty FROM work_instruction_detail wid WHERE wid.work_instruction_id = $1 AND wid.routing_version_id IS NOT NULL @@ -857,7 +883,7 @@ export const syncWorkInstructions = async ( detail.qty || null, companyCode, userId, - detail.item_number, + detail.id, ); if (!result) { @@ -2156,7 +2182,7 @@ export const getAvailableQty = async ( } const current = await pool.query( - `SELECT wop.id, wop.seq_no, wop.wo_id, + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.batch_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 @@ -2170,7 +2196,7 @@ export const getAvailableQty = async ( .json({ success: false, message: "공정을 찾을 수 없습니다." }); } - const { seq_no, wo_id, instruction_qty } = current.rows[0]; + const { seq_no, wo_id, instruction_qty, batch_id: currentBatchId } = current.rows[0]; const instrQty = parseInt(instruction_qty, 10) || 0; const seqNum = parseInt(seq_no, 10); @@ -2189,7 +2215,13 @@ export const getAvailableQty = async ( // accept-process 와 정책 일치: 비필수 0건 자동 skip 흐름을 모달 max 에도 반영 // (getPrevProcessGoodQty 는 단순 직전 seq 만 봐서 비필수 wop 가 끼면 0 으로 잘못 잡힘) - const prevEval = await evaluatePrevProcesses(pool, wo_id, seqNum, companyCode); + const prevEval = await evaluatePrevProcesses( + pool, + wo_id, + seqNum, + companyCode, + currentBatchId, + ); const prevGoodQty = prevEval.prevGoodQty; const availableQty = Math.max(0, prevGoodQty - myInputQty); @@ -2295,7 +2327,7 @@ export const acceptProcess = async ( // 마스터 wop FOR UPDATE (동시 접수 race 방지) const current = await client.query( - `SELECT wop.id, wop.seq_no, wop.wo_id, + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.batch_id, wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, wop.standard_time, wop.routing_detail_id, wi.qty as instruction_qty @@ -2355,7 +2387,13 @@ export const acceptProcess = async ( parseInt(totalAccepted.rows[0].total_input, 10) || 0; // 앞공정 평가 (스킵/차단/접수가능량 통합) - const evalResult = await evaluatePrevProcesses(client, row.wo_id, seqNum, companyCode); + const evalResult = await evaluatePrevProcesses( + client, + row.wo_id, + seqNum, + companyCode, + row.batch_id, + ); if (!evalResult.canAccept) { await client.query("ROLLBACK"); return res.status(400).json({ @@ -3847,6 +3885,7 @@ prev_good_raw AS ( wop.id AS wop_id, wop.wo_id, wop.company_code, + wop.batch_id, ( SELECT COALESCE(SUM(CAST(NULLIF(wr2.good_qty, '') AS numeric)), 0) + COALESCE(SUM(CAST(NULLIF(wr2.concession_qty, '') AS numeric)), 0) @@ -3857,6 +3896,7 @@ prev_good_raw AS ( WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND EXISTS ( SELECT 1 FROM work_order_process_result wr3 WHERE wr3.wop_id = wop2.id AND wr3.company_code = wop2.company_code @@ -3870,6 +3910,7 @@ prev_good_raw AS ( WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND COALESCE(wop2.is_required, '') = 'Y' ) AS has_prior_required, EXISTS ( @@ -3879,6 +3920,7 @@ prev_good_raw AS ( WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') ) AS has_prior_wr, EXISTS ( SELECT 1 FROM work_order_process wop2 @@ -3887,6 +3929,7 @@ prev_good_raw AS ( WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND COALESCE(wop2.is_required, '') <> 'Y' AND wr2.result_status NOT IN ('confirmed','skipped') AND COALESCE(CAST(NULLIF(wr2.input_qty, '') AS numeric), 0) > 0 @@ -3896,6 +3939,7 @@ prev_good_raw AS ( WHERE wop2.wo_id = wop.wo_id AND wop2.company_code = wop.company_code AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int) + AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '') AND COALESCE(wop2.is_required, '') = 'Y' AND NOT EXISTS ( SELECT 1 FROM work_order_process_result wr2 @@ -3913,11 +3957,17 @@ prev_good AS ( WHEN pgr.has_unfinished_required_prior THEN 0 WHEN pgr.has_prior_wr THEN COALESCE(pgr.prev_good_qty_raw, 0) WHEN pgr.has_prior_required THEN 0 - ELSE COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) + ELSE COALESCE( + CAST(NULLIF(wid.qty, '') AS numeric), + CAST(NULLIF(wi.qty, '') AS numeric), + 0 + ) END AS prev_good_qty FROM prev_good_raw pgr LEFT JOIN work_instruction wi ON wi.id = pgr.wo_id AND wi.company_code = pgr.company_code + LEFT JOIN work_instruction_detail wid + ON wid.id = pgr.batch_id AND wid.company_code = pgr.company_code ), accepted_results AS ( SELECT wop_id, @@ -3977,12 +4027,21 @@ SELECT COALESCE(pg.prev_good_qty, 0) AS prev_good_qty, COALESCE(wa.sum_input_norework, 0) AS my_input_qty, GREATEST(0, COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0)) AS available_qty, - COALESCE(ar.accepted_results, '[]'::json) AS accepted_results + COALESCE(ar.accepted_results, '[]'::json) AS accepted_results, + ROW_NUMBER() OVER ( + PARTITION BY wop.wo_id, wop.process_code + ORDER BY wid_b.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id + ) AS batch_index, + COUNT(*) OVER (PARTITION BY wop.wo_id, wop.process_code) AS batch_count, + wid_b.item_number AS batch_item_number FROM wop LEFT JOIN wr_agg wa ON wa.wop_id = wop.id LEFT JOIN prev_good pg ON pg.wop_id = wop.id LEFT JOIN accepted_results ar ON ar.wop_id = wop.id -ORDER BY wop.wo_id, CAST(wop.seq_no AS int) +LEFT JOIN work_instruction_detail wid_b + ON wid_b.id = wop.batch_id AND wid_b.company_code = wop.company_code +ORDER BY wop.wo_id, CAST(wop.seq_no AS int), + wid_b.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id `; const result = await pool.query(sql, [companyCode]); diff --git a/frontend/app/(main)/COMPANY_7/pop/POP.md b/frontend/app/(main)/COMPANY_7/pop/POP.md index f56304a4..f81cfbc8 100644 --- a/frontend/app/(main)/COMPANY_7/pop/POP.md +++ b/frontend/app/(main)/COMPANY_7/pop/POP.md @@ -185,6 +185,29 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유 ## 작업 로그 +### 2026-05-22 — POP 공정실행 batch 카드 분리 백엔드 SQL 복원 (popProductionController 단일 파일) +- **배경**: 2026-05-21 jskim-node 머지(9da6b22a) 시 popProductionController.ts 충돌 → mhkim 워킹트리에만 있던 2026-05-13 batch 분리 백엔드 작업(B1~B10)이 commit 누락 상태로 손실. 5/22 복원 커밋 15fa3e37 은 packaging/material auto-input/autoComplete 만 복원하고 batch 분리는 누락. 결과: API 응답에 `batch_id`/`batch_index`/`batch_count`/`batch_item_number` 0 노출 → 카드 헤더의 `-NN (n/m)` suffix 사라짐(프론트 코드는 그대로 살아있어 batch_count<=1 분기로 fallback). git 전체 ref + reflog + dangling 47개 + stash 2개 풀스캔으로 batch SQL 코드가 git 어디에도 없음 확정. +- **대상**: `backend-node/src/controllers/popProductionController.ts` 1 파일. 프론트(types.ts/WorkOrderList.tsx)는 5/13 커밋이 살아있어 추가 변경 없음. +- **복원 변경 (POP.md 2026-05-13 spec 그대로 재구현)**: + - **B1** `syncWorkInstructions` detail SELECT 에 `wid.id` 추가 + `generateWorkProcessesForInstruction(..., detail.item_number)` → `detail.id` 로 교체 + - **B2** `existCheck` (batchId 분기) — `OR batch_id IS NULL` 제거하고 `batch_id = $3 OR batch_id = (SELECT item_number FROM work_instruction_detail WHERE id = $3 ...)` 호환 매칭 + - **B3** `unsyncedResult` EXISTS — `wop.batch_id = wid.item_number` → `(wop.batch_id = wid.id OR wop.batch_id = wid.item_number)` 호환 + - **B4** `getProcessList` `prev_good_raw` — 5개 inner subquery(메인 prev_good_qty_raw + 4개 EXISTS) 전부에 `COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')` 매칭 추가, CTE 출력에 `wop.batch_id` 컬럼 추가 + - **B5** `prev_good` CTE — 첫 공정 fallback `ELSE COALESCE(wid.qty, wi.qty, 0)` 로 교체, `LEFT JOIN work_instruction_detail wid ON wid.id = pgr.batch_id` 추가 (batch 별 detail.qty 우선) + - **B6** 최종 SELECT — `ROW_NUMBER() OVER (PARTITION BY wop.wo_id, wop.process_code ORDER BY wid_b.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id) AS batch_index`, `COUNT(*) OVER (PARTITION BY wop.wo_id, wop.process_code) AS batch_count`, `wid_b.item_number AS batch_item_number` 추가. FROM 절에 `LEFT JOIN work_instruction_detail wid_b ON wid_b.id = wop.batch_id` 추가, ORDER BY 에 batch 보조키 추가 + - **B7** `getPrevProcessGoodQty` — `batchId` 인자 추가, 3개 SELECT (minSeqCheck/prevProcessSeq/prevAgg) 전부에 `COALESCE(batch_id, '') = $batchKey` 추가 + - **B8** `evaluatePrevProcesses` — `batchId` 인자 추가, `wop_with_seq` CTE 에 `AND COALESCE(wop.batch_id, '') = $4::varchar` 추가, `fetchInstructionQty` 안에서 batchKey 있을 때 `work_instruction_detail.qty` 우선 lookup + - **B9** `getAvailableQty` — current SELECT 에 `wop.batch_id` 추가 → `evaluatePrevProcesses(..., currentBatchId)` 전달 + - **B10** `acceptProcess` 마스터 FOR UPDATE SELECT 에 `wop.batch_id` 추가 → `evaluatePrevProcesses(..., row.batch_id)` 전달 +- **검증**: + - backend `npm run build` PASS + - GET `/api/pop/production/processes` 응답에 4개 batch 키(`batch_id`/`batch_index`/`batch_count`/`batch_item_number`) 정상 노출, 1093 row 중 multi-batch 그룹(`wo_id + process_code`) 6+ 건 확인 (예: wo `12e3ebc6` x P001 = 4 rows / `ab1af887` x P001 = 7 rows) + - GUI(COMPANY_7 / 제조반_계량): 25개 카드 중 17개에 `-NN (n/m)` suffix 노출. CODE-00027 4장 모두 `-01 (1/4) / -02 (2/4) / -03 (3/4) / -04 (4/4)` 정상. 단일 batch 카드(`CODE-00016` 등) suffix 없음(정상). 콘솔 에러 0 (페이지 동작 무관 prefetch 401만) +- **회귀 영향 없음**: 기존 단일 wop 작업지시(batch_id=null)는 `COALESCE(batch_id,'') = COALESCE(batch_id,'')` 매칭으로 자기 자신만 매칭 → 기존 동작 보존. `wid_b LEFT JOIN` 실패 시 `batch_item_number=null` 이지만 ROW_NUMBER/COUNT 는 wo+process 묶음으로 매겨져 라벨 출력은 정상 +- **알려진 미해결 (5/13 spec 과 동일)**: + - 작업지시 수정 케이스 (`workInstructionController.ts:292`): detail DELETE 후 재 INSERT 시 wop.batch_id 가 옛 detail.id 가리키는 고아 발생 가능 — 본 작업 범위 외 + - `getAvailableQty` 리워크 분기는 batch_id 미적용 + ### 2026-05-21 (5차) — POP 공정작업 O/X 유형 버튼 라벨 변경 (COMPANY_7만) - **배경**: 검사정보관리에 등록된 `judgment_criteria=CAT_JC_03` (O/X) 유형 검사기준이 POP 공정 작업 화면에서 "합격/불합격" 두 버튼으로 렌더되고 있었음. 사용자 요청: O/X 유형은 "O"/"X" 버튼 라벨로 표기. - **대상**: `_components/production/ProcessWork.tsx` 1 파일 (line 3033, 3043)