Add lock guard for work-instruction edit (production-acknowledged rows)

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) <noreply@anthropic.com>
This commit is contained in:
kmh
2026-05-22 11:18:30 +09:00
parent 1961385ddf
commit 471704445b
2 changed files with 134 additions and 3 deletions

View File

@@ -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<string, { id: string; item_number: string; qty: string; routing_version_id: string | null; is_locked: boolean }>();
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<string>();
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");

View File

@@ -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("");