From 471704445bd4d21b593471f5e50e0d2caf7e3478 Mon Sep 17 00:00:00 2001 From: kmh Date: Fri, 22 May 2026 11:18:30 +0900 Subject: [PATCH] Add lock guard for work-instruction edit (production-acknowledged rows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the lock scenario originally designed in mhkim 79962160 that was lost during the jskim-node merge. Production-acknowledged detail rows (those with batch_id-matched work_order_process having work_order_process_result) are protected from item/qty/routing changes and deletion during work-instruction edit. Layer 1 — backend list SQL (2 locations, paginated + non-paginated): - Add is_locked column via EXISTS subquery (wopr JOIN wop where wop.wo_id = wi.id AND wop.batch_id = d.id OR d.item_number) - Leverages idx_wop_company_wo for performance Layer 2 — backend save edit-mode rewrite (workInstructionController.ts:save): 1. Load existing detail rows with is_locked 2. Classify payload items: detailId match → updates, no match → inserts, missing in payload → deleteIds 3. Lock guard: - UPDATE: locked row item/qty/routing change → throw - DELETE: locked row → throw 4. work_instruction header UPDATE (existing) 5. deleteIds: cascade DELETE process_work_result → work_order_process → work_instruction_detail 6. updates: locked → schedule/equipment/worker only; unlocked → full + wop.plan_qty sync 7. inserts: standard INSERT (shared with new-mode path) Layer 3 — frontend COMPANY_7 mapping: - relatedDetails.map adds detailId: d.detail_id, locked: d.is_locked === true Verification: - backend npm run build PASS - frontend tsc work-instruction file errors 0 - DB SQL simulation: COMPANY_7 CODE-00010 detail correctly identified as locked Companion to previous commit (POP batch-id separation restoration). Together they re-establish mhkim's original batch-aware work-instruction lifecycle. Scope: COMPANY_7 only. COMPANY_8/9/10/16/28/29/30/31 frontend mapping pending verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/workInstructionController.ts | 134 +++++++++++++++++- .../production/work-instruction/page.tsx | 3 + 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 9d6a4d68..57ae7809 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -202,7 +202,15 @@ export async function getList(req: AuthenticatedRequest, res: Response) { wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq, - COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count, + EXISTS ( + SELECT 1 FROM work_order_process_result wopr + JOIN work_order_process wop + ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + WHERE wop.company_code = wi.company_code + AND wop.wo_id = wi.id + AND (wop.batch_id = d.id OR wop.batch_id = d.item_number) + ) AS is_locked FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id @@ -265,7 +273,15 @@ export async function getList(req: AuthenticatedRequest, res: Response) { wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq, - COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count, + EXISTS ( + SELECT 1 FROM work_order_process_result wopr + JOIN work_order_process wop + ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + WHERE wop.company_code = wi.company_code + AND wop.wo_id = wi.id + AND (wop.batch_id = d.id OR wop.batch_id = d.item_number) + ) AS is_locked FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id @@ -366,11 +382,123 @@ export async function save(req: AuthenticatedRequest, res: Response) { if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다"); wiId = editId; wiNo = check.rows[0].work_instruction_no; + + // 1) 기존 detail rows + 잠금 상태 조회 (batch_id 매칭 wop 에 wopr 존재 시 잠금) + const existingRes = await client.query( + `SELECT wid.id, wid.item_number, wid.qty, wid.routing_version_id, + EXISTS ( + SELECT 1 FROM work_order_process_result wopr + JOIN work_order_process wop + ON wop.id = wopr.wop_id AND wop.company_code = wopr.company_code + WHERE wop.company_code = $2 + AND wop.wo_id = wid.work_instruction_id + AND (wop.batch_id = wid.id OR wop.batch_id = wid.item_number) + ) AS is_locked + FROM work_instruction_detail wid + WHERE wid.work_instruction_id = $1`, + [wiId, companyCode] + ); + const existingMap = new Map(); + for (const row of existingRes.rows) { + existingMap.set(String(row.id), { + id: String(row.id), + item_number: String(row.item_number || ""), + qty: String(row.qty || ""), + routing_version_id: row.routing_version_id ?? null, + is_locked: row.is_locked === true, + }); + } + + // 2) payload items 분류 (detailId 매칭 → UPDATE, 없음 → INSERT, payload 누락 → DELETE) + const payloadIds = new Set(); + const updates: Array<{ item: any; detailId: string; locked: boolean }> = []; + const inserts: any[] = []; + for (const item of items) { + const did = item.detailId ? String(item.detailId) : ""; + if (did && existingMap.has(did)) { + payloadIds.add(did); + updates.push({ item, detailId: did, locked: existingMap.get(did)!.is_locked }); + } else { + inserts.push(item); + } + } + const deleteIds: string[] = []; + for (const id of existingMap.keys()) { + if (!payloadIds.has(id)) deleteIds.push(id); + } + + // 3) 잠금 가드 — UPDATE: 잠긴 row 의 품목/수량/라우팅 변경 거부 + for (const u of updates) { + if (!u.locked) continue; + const orig = existingMap.get(u.detailId)!; + const reqItem = String(u.item.itemNumber || u.item.itemCode || ""); + const reqQty = String(u.item.qty ?? ""); + const reqRouting = String(u.item.routing ?? ""); + if (reqItem && reqItem !== orig.item_number) { + throw new Error(`이미 생산접수된 row 의 품목은 변경할 수 없습니다 (id=${u.detailId}, 기존=${orig.item_number})`); + } + if (reqQty !== "" && reqQty !== String(orig.qty)) { + throw new Error(`이미 생산접수된 row 의 수량은 변경할 수 없습니다 (id=${u.detailId})`); + } + if (reqRouting && reqRouting !== String(orig.routing_version_id || "")) { + throw new Error(`이미 생산접수된 row 의 라우팅은 변경할 수 없습니다 (id=${u.detailId})`); + } + } + // 잠금 가드 — DELETE: 잠긴 row 삭제 거부 + for (const did of deleteIds) { + if (existingMap.get(did)!.is_locked) { + throw new Error(`이미 생산접수된 row 는 삭제할 수 없습니다 (id=${did})`); + } + } + + // 4) 헤더 UPDATE (qty 는 마지막에 detail 합계로 재계산) await client.query( `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`, [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode] ); - await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]); + + // 5) DELETE 처리 (process_work_result → work_order_process → work_instruction_detail 순) + for (const did of deleteIds) { + const orig = existingMap.get(did)!; + await client.query( + `DELETE FROM process_work_result + WHERE work_order_process_id IN ( + SELECT id FROM work_order_process + WHERE company_code = $1 AND (batch_id = $2 OR batch_id = $3) + )`, + [companyCode, did, orig.item_number] + ); + await client.query( + `DELETE FROM work_order_process + WHERE company_code = $1 AND (batch_id = $2 OR batch_id = $3)`, + [companyCode, did, orig.item_number] + ); + await client.query(`DELETE FROM work_instruction_detail WHERE id = $1`, [did]); + } + + // 6) UPDATE 처리 (잠금: 일정/설비/작업자/비고만, 비잠금: 전체 + wop.plan_qty 동기화) + for (const u of updates) { + if (u.locked) { + await client.query( + `UPDATE work_instruction_detail SET start_date=$1, end_date=$2, equipment_ids=$3, work_teams=$4, workers=$5, remark=$6, updated_date=NOW(), writer=$7 WHERE id=$8`, + [u.item.startDate||"", u.item.endDate||"", (u.item.equipmentIds||""), (u.item.workTeams||""), (u.item.workers||""), u.item.remark||"", userId, u.detailId] + ); + } else { + await client.query( + `UPDATE work_instruction_detail SET item_number=$1, qty=$2, remark=$3, source_table=$4, source_id=$5, part_code=$6, routing_version_id=$7, start_date=$8, end_date=$9, equipment_ids=$10, work_teams=$11, workers=$12, updated_date=NOW(), writer=$13 WHERE id=$14`, + [u.item.itemNumber||u.item.itemCode||"", String(u.item.qty||"0"), u.item.remark||"", u.item.sourceTable||"", u.item.sourceId||"", u.item.partCode||u.item.itemNumber||u.item.itemCode||"", u.item.routing||null, u.item.startDate||"", u.item.endDate||"", (u.item.equipmentIds||""), (u.item.workTeams||""), (u.item.workers||""), userId, u.detailId] + ); + // 비잠금 row 의 wop plan_qty 동기화 + await client.query( + `UPDATE work_order_process SET plan_qty=$1, updated_date=NOW() WHERE company_code=$2 AND batch_id=$3`, + [String(u.item.qty||"0"), companyCode, u.detailId] + ); + } + } + + // 7) INSERT 처리는 아래 공통 for 루프에서 inserts 만 처리하도록 items 를 교체 + (items as any[]).length = 0; + for (const it of inserts) (items as any[]).push(it); } else { try { const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index ea7a7daa..d22421ba 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -1094,6 +1094,9 @@ export default function WorkInstructionPage() { equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), workers: (d.detail_workers || "").split(",").filter(Boolean), + // 잠금 상태 (생산실적 있음) + 기존 detail row 식별자 + detailId: d.detail_id, + locked: d.is_locked === true, })); setEditItems(items); setAddQty("");