From 629bc25cd5fc6eb04348affc51a47879af4cbe51 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 7 May 2026 12:01:03 +0900 Subject: [PATCH] Implement BOM base quantity retrieval functionality - Added a new endpoint `/work-instruction/bom-base-qty` to retrieve base quantities for items based on their codes. - Introduced the `getBomBaseQtyMap` function in the `workInstructionController` to handle the logic for fetching base quantities. - Updated the frontend to call the new API and integrate base quantity mapping into the work instruction registration process. - Enhanced the work instruction page to calculate batch counts and split quantities based on the retrieved base quantities. (TASK:ERP-020) --- .../controllers/workInstructionController.ts | 39 +++- .../src/routes/workInstructionRoutes.ts | 3 + .../production/work-instruction/page.tsx | 189 ++++++++++++------ frontend/lib/api/workInstruction.ts | 6 + 4 files changed, 180 insertions(+), 57 deletions(-) diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index f5c7ee9a..43dba5b3 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -314,7 +314,7 @@ export async function save(req: AuthenticatedRequest, res: Response) { if (!firstRouting && itemRouting) firstRouting = itemRouting; totalQty += Number(item.qty || 0); await client.query( - `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`, + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,clock_timestamp(),$16)`, [ companyCode, wiNo, @@ -993,3 +993,40 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response return res.status(500).json({ success: false, message: error.message }); } } + +// ─── BOM 기준수(0레벨 base_qty) 일괄 조회 ─── +// itemCodes(item_info.item_number 기준) 배열을 받아 { [itemCode]: base_qty | null } 맵 반환 +export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const itemCodes: string[] = Array.isArray(req.body?.itemCodes) ? req.body.itemCodes.filter(Boolean) : []; + if (itemCodes.length === 0) return res.json({ success: true, data: {} }); + + const pool = getPool(); + // bom.item_code 우선 매칭, 없으면 item_info.id 경유 매칭 + const result = await pool.query( + `SELECT i.item_number AS item_code, b.base_qty + 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 = ANY($2::text[]) OR i.item_number = ANY($2::text[]))`, + [companyCode, itemCodes] + ); + + const map: Record = {}; + for (const code of itemCodes) map[code] = null; + for (const row of result.rows) { + const code = row.item_code; + const base = parseFloat(row.base_qty || ""); + if (!code) continue; + if (Number.isFinite(base) && base > 0) { + // 동일 품목 다건 BOM 시 첫 유효값 유지 + if (map[code] == null) map[code] = base; + } + } + return res.json({ success: true, data: map }); + } catch (error: any) { + logger.error("BOM 기준수 일괄 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index 07507b25..1bec3b2d 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -18,6 +18,9 @@ router.get("/employees", ctrl.getEmployeeList); // 벌크 라우팅 조회 (품목별 공정 일괄 조회) router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk); +// BOM 기준수 일괄 조회 (작업지시 등록 모달 기준수/배치수 산출용) +router.post("/bom-base-qty", ctrl.getBomBaseQtyMap); + // 라우팅 & 공정작업기준 router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); router.put("/:wiNo/routing", ctrl.updateRouting); 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 110b2b4d..7a6de390 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -15,7 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; // API: /work-instruction/* import { - getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, + getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getBomBaseQtyMap, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, getRoutingVersions, RoutingVersionData, } from "@/lib/api/workInstruction"; @@ -65,6 +65,31 @@ interface SelectedItem { equipmentIds?: string[]; workTeams?: string[]; workers?: string[]; + // 기준수(BOM 0레벨 base_qty) / 배치수(자동) / 배분(균등|순차) + baseQty?: number | null; + splitMode?: "even" | "sequential"; +} + +// 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1 +function calcBatchCount(qty: number, baseQty: number | null | undefined): number { + const b = Number(baseQty || 0); + if (!Number.isFinite(b) || b <= 0) return 1; + if (!Number.isFinite(qty) || qty <= 0) return 1; + return qty > b ? Math.ceil(qty / b) : 1; +} + +// 분할 산출: batchCount<=1이면 [qty], 아니면 mode 따라 분할 +function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" | "sequential"): number[] { + if (batchCount <= 1) return [qty]; + if (mode === "sequential") { + const head = Array(batchCount - 1).fill(baseQty); + const tail = qty - baseQty * (batchCount - 1); + return [...head, tail]; + } + // even: 앞은 floor, 마지막이 잔여 흡수 + const base = Math.floor(qty / batchCount); + const remainder = qty - base * (batchCount - 1); + return [...Array(batchCount - 1).fill(base), remainder]; } // 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) @@ -334,6 +359,19 @@ export default function WorkInstructionPage() { }).catch(() => {}); } + // BOM 기준수 일괄 조회 (품목별 base_qty 매핑) + if (uniqueItemCodes.length > 0) { + getBomBaseQtyMap(uniqueItemCodes).then(r => { + if (r.success && r.data) { + setConfirmItems(prev => prev.map(it => ({ + ...it, + baseQty: r.data[it.itemCode] ?? null, + splitMode: it.splitMode || "even", + }))); + } + }).catch(() => {}); + } + setIsRegModalOpen(false); setIsConfirmModalOpen(true); }; @@ -364,13 +402,26 @@ export default function WorkInstructionPage() { const headerEquipment = first?.equipmentIds?.[0] || ""; const headerWorkTeam = first?.workTeams?.[0] || ""; const headerWorker = first?.workers?.[0] || ""; + // 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다. + const expandedItems: Array = []; + for (const i of confirmItems) { + const qty = Number(i.qty || 0); + const baseQty = Number(i.baseQty || 0); + const batchCount = calcBatchCount(qty, i.baseQty); + if (batchCount > 1 && baseQty > 0) { + const parts = splitQty(qty, baseQty, batchCount, i.splitMode || "even"); + for (const p of parts) expandedItems.push({ ...i, _qty: p }); + } else { + expandedItems.push({ ...i, _qty: qty }); + } + } const payload = { status: confirmStatus, startDate: headerStart, endDate: headerEnd, equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ - itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + items: expandedItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) @@ -764,7 +815,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -785,32 +836,57 @@ export default function WorkInstructionPage() {

품목 목록

- +
- 순번 - 품목코드 - 품목명 - 규격 - 수량 - 라우팅 - 시작일 - 완료예정일 - 설비 - 작업조 - 작업자 - 비고 + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 기준수 + 배치수 + 배분 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 - {confirmItems.map((item, idx) => ( - - {idx + 1} - {item.itemCode} - {item.itemName || item.itemCode} - {item.spec || "-"} - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + {confirmItems.map((item, idx) => { + const batchCount = calcBatchCount(Number(item.qty || 0), item.baseQty); + const splitDisabled = batchCount <= 1 || !item.baseQty; + return ( + + {idx + 1} + {item.itemCode} + {item.itemName || item.itemCode} + {item.spec || "-"} + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + {item.baseQty != null && item.baseQty > 0 ? Number(item.baseQty).toLocaleString() : "-"} + + 1 ? "text-primary" : "text-muted-foreground")}> + {item.baseQty != null && item.baseQty > 0 ? batchCount : "-"} + + + + - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> - ))} + ); + })}
@@ -884,7 +961,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -907,22 +984,22 @@ export default function WorkInstructionPage() { {editItems.length}건
- +
- 순번 - 품목코드 - 품목명 - 규격 - 수량 - 라우팅 - 공정작업기준 - 시작일 - 완료예정일 - 설비 - 작업조 - 작업자 - 비고 + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -930,12 +1007,12 @@ export default function WorkInstructionPage() { {editItems.length === 0 ? ( 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( - - {idx + 1} - {item.itemCode} - {item.itemName || "-"} - {item.spec || "-"} - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + {idx + 1} + {item.itemCode} + {item.itemName || "-"} + {item.spec || "-"} + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> ))} diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts index e7af6d22..3c6949b0 100644 --- a/frontend/lib/api/workInstruction.ts +++ b/frontend/lib/api/workInstruction.ts @@ -47,6 +47,12 @@ export async function getEmployeeList() { return res.data as { success: boolean; data: { user_id: string; user_name: string; dept_name: string | null }[] }; } +// BOM 기준수(0레벨 base_qty) 일괄 조회 — 작업지시 등록 모달의 기준수/배치수 산출용 +export async function getBomBaseQtyMap(itemCodes: string[]) { + const res = await apiClient.post("/work-instruction/bom-base-qty", { itemCodes }); + return res.data as { success: boolean; data: Record }; +} + // ─── 라우팅 & 공정작업기준 API ─── export interface RoutingProcess {