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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user