Enhance Work Instruction Functionality with Material Overrides

- Added validation for `materialOverrides` in the `save` function of the work instruction controller, ensuring proper structure and required fields.
- Implemented logic to handle the insertion and deletion of material input details based on the provided `materialOverrides`, maintaining data integrity during edits.
- Introduced new routes for retrieving BOM tree and material overrides, enhancing the work instruction management process.
- Updated the frontend to support new material mapping features, including a structured approach for handling BOM substitutes and material inputs.

(TASK: ERP-node-090)
This commit is contained in:
kjs
2026-05-21 10:01:41 +09:00
parent c5364e1d20
commit 1ebd9348ae
5 changed files with 2938 additions and 619 deletions

View File

@@ -280,12 +280,42 @@ export async function save(req: AuthenticatedRequest, res: Response) {
await ensureDetailRoutingColumn();
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId } = req.body;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides } = req.body;
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
}
// ── materialOverrides 사전 검증 (옵셔널) ──
// 형식: [{ itemNumber, routingVersionId, materials: [{ bomItemId, bomItemName?, bomQty, bomUnit?, routingDetailId, content?, isOverride?, originalBomItemId? }] }]
// 비어있거나 materials 빈 배열이면 wi_* INSERT 자체를 건너뛰어 기존 마스터 폴백 동작 유지.
if (materialOverrides != null && !Array.isArray(materialOverrides)) {
return res.status(400).json({ success: false, message: "materialOverrides는 배열이어야 합니다" });
}
if (Array.isArray(materialOverrides)) {
for (const ov of materialOverrides) {
if (!ov || typeof ov !== "object") {
return res.status(400).json({ success: false, message: "materialOverrides 항목 형식 오류" });
}
if (!Array.isArray(ov.materials)) continue; // materials 없으면 스킵 (해당 품목 건너뛴 효과)
for (const m of ov.materials) {
if (!m || typeof m !== "object") {
return res.status(400).json({ success: false, message: "materialOverrides.materials 항목 형식 오류" });
}
if (!m.bomItemId || String(m.bomItemId).trim() === "") {
return res.status(400).json({ success: false, message: "자재 품목(bomItemId)을 선택해주세요" });
}
if (!m.routingDetailId || String(m.routingDetailId).trim() === "") {
return res.status(400).json({ success: false, message: "자재 투입 공정(routingDetailId)을 선택해주세요" });
}
const qtyNum = Number(m.bomQty);
if (!Number.isFinite(qtyNum) || qtyNum <= 0) {
return res.status(400).json({ success: false, message: "수량은 0보다 커야 합니다" });
}
}
}
}
const pool = getPool();
const client = await pool.connect();
try {
@@ -356,6 +386,137 @@ export async function save(req: AuthenticatedRequest, res: Response) {
[String(totalQty), effectiveRouting, wiId]
);
// ── materialOverrides 적용: wi_process_work_item + wi_process_work_item_detail INSERT ──
// 정책:
// - 수정(editId) 진입 시 이 작업지시의 기존 wi_* 자재(material_input) 데이터를 모두 삭제 후 재구축.
// - materialOverrides가 빈 배열/없음이면 wi_* INSERT 자체를 안 함 → 마스터 process_work_item 폴백 그대로.
// - 같은 (routingDetailId) 묶음마다 wi_process_work_item 1행을 만들고, 자재들은 detail_type='material_input'으로 묶음.
if (Array.isArray(materialOverrides) && materialOverrides.length > 0) {
// 1) 편집 모드: 기존 wi_* (material_input 한정) 삭제 — 마스터 체크리스트(검사/조건 등) 보존 위해 detail_type 한정 삭제 후, 빈 work_item은 따로 cleanup
if (editId) {
await client.query(
`DELETE FROM wi_process_work_item_detail
WHERE company_code = $1
AND detail_type = 'material_input'
AND wi_work_item_id IN (
SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1
)`,
[companyCode, wiNo]
);
}
// 2) routingDetailId 단위 묶음 — 동일 공정 자재는 한 work_item 아래로
for (const ov of materialOverrides) {
if (!ov || !Array.isArray(ov.materials) || ov.materials.length === 0) continue;
const byRouting = new Map<string, any[]>();
for (const m of ov.materials) {
const rdId = String(m.routingDetailId);
if (!byRouting.has(rdId)) byRouting.set(rdId, []);
byRouting.get(rdId)!.push(m);
}
for (const [routingDetailId, mats] of byRouting.entries()) {
// 동일 (work_instruction_no, routing_detail_id)에 자재투입 전용 work_item이 이미 있으면 재사용, 없으면 신규 INSERT.
// title은 식별용 고정 라벨 '자재투입' 사용. 사용자 화면(공정작업기준)과 충돌하지 않도록 별도 work_phase('material').
const existing = await client.query(
`SELECT id FROM wi_process_work_item
WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND work_phase = 'material'
LIMIT 1`,
[wiNo, routingDetailId, companyCode]
);
let wiWorkItemId: string;
if (existing.rowCount && existing.rowCount > 0) {
wiWorkItemId = existing.rows[0].id;
// 이 work_item의 기존 material_input detail 정리 (편집 모드 위 1차 삭제와 중복돼도 무해)
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 AND detail_type = 'material_input'`,
[wiWorkItemId, companyCode]
);
} else {
const ins = await client.query(
`INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[companyCode, wiNo, routingDetailId, "material", "자재투입", "N", 0, "작업지시 등록 시 매핑된 자재투입", null, userId]
);
wiWorkItemId = ins.rows[0].id;
}
// 3) 자재 detail INSERT
let sortOrder = 0;
for (const m of mats) {
sortOrder++;
const qtyStr = String(Number(m.bomQty));
const isOverride = m.isOverride === true || (m.originalBomItemId && String(m.originalBomItemId) !== String(m.bomItemId));
const contentMemo = m.content
? String(m.content)
: isOverride
? `대체${m.originalBomItemId ? `:${m.originalBomItemId}` : ""}`
: "원본";
await client.query(
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
[
companyCode,
wiWorkItemId,
"material_input",
contentMemo,
"N",
sortOrder,
m.remark || null,
null, null, null, null, null,
null, null, null, null,
null, null,
null, null, null,
null, null,
String(m.bomItemId),
m.bomItemName || null,
qtyStr,
m.bomUnit || null,
null, null,
null, null,
null, null,
null, null,
userId,
]
);
}
}
}
// 4) cleanup: material_input detail이 하나도 없는 work_phase='material' work_item 제거 (이번 페이로드에서 빠진 공정)
await client.query(
`DELETE FROM wi_process_work_item
WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material'
AND NOT EXISTS (
SELECT 1 FROM wi_process_work_item_detail d
WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1
)`,
[companyCode, wiNo]
);
} else if (editId) {
// 편집 시 명시적으로 빈 materialOverrides가 들어오면 기존 자재 매핑 비움 (사용자가 다시 폴백으로 돌리려는 의도)
if (Array.isArray(materialOverrides)) {
await client.query(
`DELETE FROM wi_process_work_item_detail
WHERE company_code = $1
AND detail_type = 'material_input'
AND wi_work_item_id IN (
SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1
)`,
[companyCode, wiNo]
);
await client.query(
`DELETE FROM wi_process_work_item
WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material'
AND NOT EXISTS (
SELECT 1 FROM wi_process_work_item_detail d
WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1
)`,
[companyCode, wiNo]
);
}
}
await client.query("COMMIT");
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
@@ -676,8 +837,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
);
// 커스텀 작업기준이 있는지 확인
// 주의: work_phase='material' 은 작업지시별 BOM 자재투입 매핑 전용 항목이므로 작업기준 화면 표시 대상에서 제외.
const customCheck = await pool.query(
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, companyCode]
);
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
@@ -687,12 +849,12 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
let workItems;
if (hasCustom) {
// 커스텀 버전에서 조회
// 커스텀 버전에서 조회 (work_phase='material'은 자재투입 매핑 전용이므로 제외)
const wiResult = await pool.query(
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
FROM wi_process_work_item wi
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 AND COALESCE(wi.work_phase, '') <> 'material'
ORDER BY wi.work_phase, wi.sort_order`,
[wiNo, proc.routing_detail_id, companyCode]
);
@@ -853,9 +1015,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
try {
await client.query("BEGIN");
// 기존 커스텀 데이터 삭제
// 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존)
const existingItems = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, companyCode]
);
for (const row of existingItems.rows) {
@@ -865,7 +1027,7 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, companyCode]
);
@@ -936,9 +1098,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
try {
await client.query("BEGIN");
// 해당 공정의 기존 커스텀 데이터 삭제
// 해당 공정의 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존)
const existing = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, routingDetailId, companyCode]
);
for (const row of existing.rows) {
@@ -948,7 +1110,7 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, routingDetailId, companyCode]
);
@@ -1009,8 +1171,9 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response
try {
await client.query("BEGIN");
// 원본 초기화 — work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존
const items = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, companyCode]
);
for (const row of items.rows) {
@@ -1020,7 +1183,7 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
[wiNo, companyCode]
);
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
@@ -1093,3 +1256,166 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response)
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용, TASK:ERP-node-090 트리화) ───
// GET /bom-tree/:itemCode
// itemCode → bom 헤더(현재 활성 버전) → bom_detail 전체를 parent_detail_id로 재귀 트리 구성하여 응답.
// 응답: { success, hasBom, treeRoots: [{ ...node, children }] } — 평면 호환 필드도 포함(child_item_id, child_item_name, child_item_code, quantity, unit, item_unit).
// 활성 버전이 없는 경우(미초기화 BOM): version_id IS NULL 인 detail을 폴백 조회 — 기존 process-info/bom-materials 동작과 정합.
export async function getBomTree(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
if (!itemCode) {
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
}
const pool = getPool();
// 1) BOM 헤더 찾기 — bom.item_code 우선, 없으면 item_info.id 경유
// 루트 노드(품목 자체)를 트리 0레벨로 표시하기 위해 item_info도 함께 조회 (BomTreeComponent 패턴 동일)
const bomRes = await pool.query(
`SELECT b.id AS bom_id, b.current_version_id, b.base_qty,
COALESCE(b.item_code, i.item_number) AS root_item_code,
i.id AS root_item_id,
i.item_number AS root_item_number,
i.item_name AS root_item_name,
i.unit AS root_unit
FROM bom b
LEFT JOIN item_info i ON i.id = b.item_id AND i.company_code = b.company_code
WHERE b.company_code = $1
AND (b.item_code = $2 OR i.item_number = $2)
LIMIT 1`,
[companyCode, itemCode]
);
if (bomRes.rowCount === 0) {
return res.json({ success: true, hasBom: false, treeRoots: [], rootItem: null });
}
const bomRow = bomRes.rows[0];
const bomId = bomRow.bom_id;
const versionId = bomRow.current_version_id || null;
const rootItem = {
itemId: bomRow.root_item_id || null,
itemCode: bomRow.root_item_number || bomRow.root_item_code || itemCode,
itemName: bomRow.root_item_name || "",
baseQty: bomRow.base_qty || "1",
unit: bomRow.root_unit || "",
};
// 2) bom_detail 전체 조회 — 활성 버전 또는 version_id IS NULL 폴백
const detailParams: any[] = [bomId, companyCode];
let whereVersion = "AND bd.version_id IS NULL";
if (versionId) {
whereVersion = "AND bd.version_id = $3";
detailParams.push(versionId);
}
const detailRes = await pool.query(
`SELECT bd.id, bd.bom_id, bd.parent_detail_id, bd.child_item_id,
bd.quantity, bd.unit AS detail_unit, bd.process_type,
bd.level, bd.seq_no,
ii.item_number AS child_item_code, ii.item_name AS child_item_name,
ii.unit AS item_unit
FROM bom_detail bd
LEFT JOIN item_info ii ON ii.id = bd.child_item_id AND ii.company_code = bd.company_code
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
ORDER BY bd.parent_detail_id NULLS FIRST, NULLIF(bd.seq_no, '')::int NULLS LAST, bd.id`,
detailParams
);
// 3) 클라이언트가 트리 빌드도 가능하지만, 백엔드에서 재귀 빌드까지 해 응답 (회사 표준 패턴)
type Node = Record<string, any> & { children: Node[] };
const nodeMap = new Map<string, Node>();
const roots: Node[] = [];
for (const r of detailRes.rows) {
nodeMap.set(r.id, { ...r, children: [] });
}
for (const r of detailRes.rows) {
const node = nodeMap.get(r.id)!;
if (r.parent_detail_id && nodeMap.has(r.parent_detail_id)) {
nodeMap.get(r.parent_detail_id)!.children.push(node);
} else {
roots.push(node);
}
}
return res.json({ success: true, hasBom: true, treeRoots: roots, rootItem });
} catch (error: any) {
logger.error("BOM 트리 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 단위 자재투입 매핑 조회 (편집 모달 복원용) ───
// GET /:wiNo/material-overrides
// 응답: { success, data: [{ routingDetailId, processCode, processName, materials: [{ bomItemId, bomItemName, bomQty, bomUnit, content, isOverride, originalBomItemId }] }] }
// work_phase='material' 인 wi_process_work_item과 그 하위 detail_type='material_input' detail만 묶어 반환.
export async function getMaterialOverrides(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
if (!wiNo) return res.status(400).json({ success: false, message: "wiNo 필요" });
const pool = getPool();
const rows = await pool.query(
`SELECT wi.id AS wi_work_item_id,
wi.routing_detail_id,
rd.process_code,
COALESCE(p.process_name, rd.process_code, '') AS process_name,
d.id AS detail_id,
d.bom_item_id,
d.bom_item_name,
d.bom_qty,
d.bom_unit,
d.content,
d.sort_order
FROM wi_process_work_item wi
LEFT JOIN wi_process_work_item_detail d
ON d.wi_work_item_id = wi.id AND d.company_code = wi.company_code AND d.detail_type = 'material_input'
LEFT JOIN item_routing_detail rd
ON rd.id = wi.routing_detail_id AND rd.company_code = wi.company_code
LEFT JOIN process_mng p
ON p.process_code = rd.process_code AND p.company_code = rd.company_code
WHERE wi.work_instruction_no = $1
AND wi.company_code = $2
AND wi.work_phase = 'material'
ORDER BY wi.routing_detail_id, d.sort_order NULLS LAST, d.id`,
[wiNo, companyCode]
);
// routingDetailId 단위로 그룹핑
const byRouting = new Map<string, any>();
for (const r of rows.rows) {
const rdId = r.routing_detail_id;
if (!rdId) continue;
if (!byRouting.has(rdId)) {
byRouting.set(rdId, {
routingDetailId: rdId,
processCode: r.process_code || "",
processName: r.process_name || "",
materials: [],
});
}
if (r.detail_id) {
const contentStr = String(r.content || "");
const isOverride = contentStr.startsWith("대체");
const originalBomItemId = isOverride && contentStr.includes(":")
? contentStr.split(":", 2)[1] || null
: null;
byRouting.get(rdId).materials.push({
detailId: r.detail_id,
bomItemId: r.bom_item_id || "",
bomItemName: r.bom_item_name || "",
bomQty: r.bom_qty || "",
bomUnit: r.bom_unit || "",
content: contentStr,
isOverride,
originalBomItemId,
});
}
}
return res.json({ success: true, data: Array.from(byRouting.values()) });
} catch (error: any) {
logger.error("작업지시 자재투입 매핑 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -21,6 +21,9 @@ router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk);
// BOM 기준수 일괄 조회 (작업지시 등록 모달 기준수/배치수 산출용)
router.post("/bom-base-qty", ctrl.getBomBaseQtyMap);
// BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용 — TASK:ERP-node-090 트리화)
router.get("/bom-tree/:itemCode", ctrl.getBomTree);
// 라우팅 & 공정작업기준
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.put("/:wiNo/routing", ctrl.updateRouting);
@@ -29,4 +32,7 @@ router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
// 작업지시별 자재투입(BOM 대체) 매핑 조회 — 편집 모달 복원용
router.get("/:wiNo/material-overrides", ctrl.getMaterialOverrides);
export default router;