From 929b68299a401566d8f7c5fc0d48fc8b0cf07c01 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Feb 2026 07:50:22 +0900 Subject: [PATCH] feat: Implement BOM Excel upload and download functionality - Added endpoints for uploading BOM data from Excel and downloading BOM data in Excel format. - Developed the `createBomFromExcel` function to handle Excel uploads, including validation and error handling. - Implemented the `downloadBomExcelData` function to retrieve BOM data for Excel downloads. - Created a new `BomExcelUploadModal` component for the frontend to facilitate Excel file uploads. - Updated BOM routes to include new Excel upload and download routes, enhancing BOM management capabilities. --- backend-node/src/controllers/bomController.ts | 64 ++ backend-node/src/routes/bomRoutes.ts | 5 + backend-node/src/services/bomService.ts | 479 ++++++++++++++ docs/plan-bom-excel-upload.md | 78 +++ frontend/components/screen/EditModal.tsx | 17 +- frontend/components/v2/V2Repeater.tsx | 5 +- .../RepeaterFieldGroupRenderer.tsx | 5 +- .../v2-bom-tree/BomExcelUploadModal.tsx | 609 ++++++++++++++++++ .../v2-bom-tree/BomTreeComponent.tsx | 24 + .../SplitPanelLayoutComponent.tsx | 33 +- 10 files changed, 1299 insertions(+), 20 deletions(-) create mode 100644 docs/plan-bom-excel-upload.md create mode 100644 frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 3508fca4..b98baad1 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -143,6 +143,70 @@ export async function initializeBomVersion(req: Request, res: Response) { } } +// ─── BOM 엑셀 업로드/다운로드 ───────────────────────── + +export async function createBomFromExcel(req: Request, res: Response) { + try { + const companyCode = (req as any).user?.companyCode || "*"; + const userId = (req as any).user?.userName || (req as any).user?.userId || ""; + const { rows } = req.body; + + if (!rows || !Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" }); + return; + } + + const result = await bomService.createBomFromExcel(companyCode, userId, rows); + if (!result.success) { + res.status(400).json({ success: false, message: result.errors.join(", "), data: result }); + return; + } + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 엑셀 업로드 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createBomVersionFromExcel(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const userId = (req as any).user?.userName || (req as any).user?.userId || ""; + const { rows, versionName } = req.body; + + if (!rows || !Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" }); + return; + } + + const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName); + if (!result.success) { + res.status(400).json({ success: false, message: result.errors.join(", "), data: result }); + return; + } + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function downloadBomExcelData(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + + const data = await bomService.downloadBomExcelData(bomId, companyCode); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteBomVersion(req: Request, res: Response) { try { const { bomId, versionId } = req.params; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index 4aa8838d..ccdbad64 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -17,6 +17,11 @@ router.get("/:bomId/header", bomController.getBomHeader); router.get("/:bomId/history", bomController.getBomHistory); router.post("/:bomId/history", bomController.addBomHistory); +// 엑셀 업로드/다운로드 +router.post("/excel-upload", bomController.createBomFromExcel); +router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel); +router.get("/:bomId/excel-download", bomController.downloadBomExcelData); + // 버전 router.get("/:bomId/versions", bomController.getBomVersions); router.post("/:bomId/versions", bomController.createBomVersion); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index b5cff246..91e2d019 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -319,6 +319,485 @@ export async function initializeBomVersion( }); } +// ─── BOM 엑셀 업로드 ───────────────────────────── + +interface BomExcelRow { + level: number; + item_number: string; + item_name?: string; + quantity: number; + unit?: string; + process_type?: string; + remark?: string; +} + +interface BomExcelUploadResult { + success: boolean; + insertedCount: number; + skippedCount: number; + errors: string[]; + unmatchedItems: string[]; + createdBomId?: string; +} + +/** + * BOM 엑셀 업로드 - 새 BOM 생성 + * + * 엑셀 레벨 체계: + * 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT + * 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0) + * 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1) + * 레벨 N = ... → bom_detail (DB level=N-1) + */ +export async function createBomFromExcel( + companyCode: string, + userId: string, + rows: BomExcelRow[], +): Promise { + const result: BomExcelUploadResult = { + success: false, + insertedCount: 0, + skippedCount: 0, + errors: [], + unmatchedItems: [], + }; + + if (!rows || rows.length === 0) { + result.errors.push("업로드할 데이터가 없습니다"); + return result; + } + + const headerRow = rows.find(r => r.level === 0); + const detailRows = rows.filter(r => r.level > 0); + + if (!headerRow) { + result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다"); + return result; + } + if (!headerRow.item_number?.trim()) { + result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다"); + return result; + } + if (detailRows.length === 0) { + result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)"); + return result; + } + + // 레벨 유효성 검사 + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.level < 0) { + result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`); + } + if (i > 0 && row.level > rows[i - 1].level + 1) { + result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`); + } + if (row.level > 0 && !row.item_number?.trim()) { + result.errors.push(`${i + 1}행: 품번은 필수입니다`); + } + } + + if (result.errors.length > 0) { + return result; + } + + return transaction(async (client) => { + // 1. 모든 품번 일괄 조회 (헤더 + 디테일) + const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))]; + const itemLookup = await client.query( + `SELECT id, item_number, item_name, unit FROM item_info + WHERE company_code = $1 AND item_number = ANY($2::text[])`, + [companyCode, allItemNumbers], + ); + + const itemMap = new Map(); + for (const item of itemLookup.rows) { + itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit }); + } + + for (const num of allItemNumbers) { + if (!itemMap.has(num)) { + result.unmatchedItems.push(num); + } + } + if (result.unmatchedItems.length > 0) { + result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`); + return result; + } + + // 2. bom 마스터 생성 (레벨 0) + const headerItemInfo = itemMap.get(headerRow.item_number.trim())!; + + // 동일 품목으로 이미 BOM이 존재하는지 확인 + const dupCheck = await client.query( + `SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`, + [headerItemInfo.id, companyCode], + ); + if (dupCheck.rows.length > 0) { + result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`); + return result; + } + + const bomInsert = await client.query( + `INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code) + VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8) + RETURNING id`, + [ + headerItemInfo.id, + headerRow.item_number.trim(), + headerItemInfo.item_name, + String(headerRow.quantity || 1), + headerRow.unit || headerItemInfo.unit || null, + headerRow.remark || null, + userId, + companyCode, + ], + ); + const newBomId = bomInsert.rows[0].id; + result.createdBomId = newBomId; + + // 3. bom_version 생성 + const versionInsert = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`, + [newBomId, userId, companyCode], + ); + const versionId = versionInsert.rows[0].id; + + await client.query( + `UPDATE bom SET current_version_id = $1 WHERE id = $2`, + [versionId, newBomId], + ); + + // 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1) + const levelStack: string[] = []; + const seqCounterByParent = new Map(); + + for (let i = 0; i < detailRows.length; i++) { + const row = detailRows[i]; + const itemInfo = itemMap.get(row.item_number.trim())!; + const dbLevel = row.level - 1; + + while (levelStack.length > dbLevel) { + levelStack.pop(); + } + + const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null; + const parentKey = parentDetailId || "__root__"; + const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1; + seqCounterByParent.set(parentKey, currentSeq); + + const insertResult = await client.query( + `INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12) + RETURNING id`, + [ + newBomId, + versionId, + parentDetailId, + itemInfo.id, + String(dbLevel), + String(currentSeq), + String(row.quantity || 1), + row.unit || itemInfo.unit || null, + row.process_type || null, + row.remark || null, + userId, + companyCode, + ], + ); + + levelStack.push(insertResult.rows[0].id); + result.insertedCount++; + } + + // 5. 이력 기록 + await client.query( + `INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code) + VALUES ($1, 'excel_upload', $2, $3, $4)`, + [newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode], + ); + + result.success = true; + logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", { + newBomId, companyCode, + insertedCount: result.insertedCount, + }); + + return result; + }); +} + +/** + * BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성 + * + * 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재) + * 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결 + */ +export async function createBomVersionFromExcel( + bomId: string, + companyCode: string, + userId: string, + rows: BomExcelRow[], + versionName?: string, +): Promise { + const result: BomExcelUploadResult = { + success: false, + insertedCount: 0, + skippedCount: 0, + errors: [], + unmatchedItems: [], + }; + + if (!rows || rows.length === 0) { + result.errors.push("업로드할 데이터가 없습니다"); + return result; + } + + const detailRows = rows.filter(r => r.level > 0); + result.skippedCount = rows.length - detailRows.length; + + if (detailRows.length === 0) { + result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)"); + return result; + } + + // 레벨 유효성 검사 + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.level < 0) { + result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`); + } + if (i > 0 && row.level > rows[i - 1].level + 1) { + result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`); + } + if (row.level > 0 && !row.item_number?.trim()) { + result.errors.push(`${i + 1}행: 품번은 필수입니다`); + } + } + + if (result.errors.length > 0) { + return result; + } + + return transaction(async (client) => { + // 1. BOM 존재 확인 + const bomRow = await client.query( + `SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`, + [bomId, companyCode], + ); + if (bomRow.rows.length === 0) { + result.errors.push("BOM을 찾을 수 없습니다"); + return result; + } + + // 2. 품번 → item_info 매핑 + const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))]; + const itemLookup = await client.query( + `SELECT id, item_number, item_name, unit FROM item_info + WHERE company_code = $1 AND item_number = ANY($2::text[])`, + [companyCode, uniqueItemNumbers], + ); + + const itemMap = new Map(); + for (const item of itemLookup.rows) { + itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit }); + } + + for (const num of uniqueItemNumbers) { + if (!itemMap.has(num)) { + result.unmatchedItems.push(num); + } + } + if (result.unmatchedItems.length > 0) { + result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`); + return result; + } + + // 3. 버전명 결정 (미입력 시 자동 채번) + let finalVersionName = versionName?.trim(); + if (!finalVersionName) { + const countResult = await client.query( + `SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`, + [bomId], + ); + finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`; + } + + // 중복 체크 + const dupCheck = await client.query( + `SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`, + [bomId, finalVersionName], + ); + if (dupCheck.rows.length > 0) { + result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`); + return result; + } + + // 4. bom_version 생성 + const versionInsert = await client.query( + `INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`, + [bomId, finalVersionName, userId, companyCode], + ); + const newVersionId = versionInsert.rows[0].id; + + // 5. bom_detail INSERT + const levelStack: string[] = []; + const seqCounterByParent = new Map(); + + for (let i = 0; i < detailRows.length; i++) { + const row = detailRows[i]; + const itemInfo = itemMap.get(row.item_number.trim())!; + const dbLevel = row.level - 1; + + while (levelStack.length > dbLevel) { + levelStack.pop(); + } + + const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null; + const parentKey = parentDetailId || "__root__"; + const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1; + seqCounterByParent.set(parentKey, currentSeq); + + const insertResult = await client.query( + `INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12) + RETURNING id`, + [ + bomId, + newVersionId, + parentDetailId, + itemInfo.id, + String(dbLevel), + String(currentSeq), + String(row.quantity || 1), + row.unit || itemInfo.unit || null, + row.process_type || null, + row.remark || null, + userId, + companyCode, + ], + ); + + levelStack.push(insertResult.rows[0].id); + result.insertedCount++; + } + + // 6. BOM 헤더의 version과 current_version_id 갱신 + await client.query( + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [finalVersionName, newVersionId, bomId], + ); + + // 7. 이력 기록 + await client.query( + `INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code) + VALUES ($1, 'excel_upload', $2, $3, $4)`, + [bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode], + ); + + result.success = true; + result.createdBomId = bomId; + logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", { + bomId, companyCode, versionName: finalVersionName, + insertedCount: result.insertedCount, + }); + + return result; + }); +} + +/** + * BOM 엑셀 다운로드용 데이터 조회 + * + * 화면과 동일한 레벨 체계로 출력: + * 레벨 0 = BOM 헤더 (최상위 품목) + * 레벨 1 = 직접 자품목 (DB level=0) + * 레벨 N = DB level N-1 + * + * DFS로 순회하여 부모-자식 순서 보장 + */ +export async function downloadBomExcelData( + bomId: string, + companyCode: string, +): Promise[]> { + // BOM 헤더 정보 조회 (최상위 품목) + const bomHeader = await queryOne>( + `SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit + FROM bom b + LEFT JOIN item_info ii ON b.item_id = ii.id + WHERE b.id = $1 AND b.company_code = $2`, + [bomId, companyCode], + ); + + if (!bomHeader) return []; + + const flatList: Record[] = []; + + // 레벨 0: BOM 헤더 (최상위 품목) + flatList.push({ + level: 0, + item_number: bomHeader.item_number || "", + item_name: bomHeader.item_name || "", + quantity: bomHeader.base_qty || "1", + unit: bomHeader.item_unit || bomHeader.unit || "", + process_type: "", + remark: bomHeader.remark || "", + _is_header: true, + }); + + // 하위 품목 조회 + const versionId = bomHeader.current_version_id; + const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`; + const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode]; + + const details = await query( + `SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material + FROM bom_detail bd + LEFT JOIN item_info ii ON bd.child_item_id = ii.id + WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion} + ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`, + params, + ); + + // 부모 ID별 자식 목록으로 맵 구성 + const childrenMap = new Map(); + const roots: any[] = []; + for (const d of details) { + if (!d.parent_detail_id) { + roots.push(d); + } else { + if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []); + childrenMap.get(d.parent_detail_id)!.push(d); + } + } + + // DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용) + const dfs = (nodes: any[], depth: number) => { + for (const node of nodes) { + flatList.push({ + level: depth, + item_number: node.item_number || "", + item_name: node.item_name || "", + quantity: node.quantity || "1", + unit: node.unit || node.item_unit || "", + process_type: node.process_type || "", + remark: node.remark || "", + }); + const children = childrenMap.get(node.id) || []; + if (children.length > 0) { + dfs(children, depth + 1); + } + } + }; + + // 루트 노드들은 레벨 1 (BOM 헤더가 0이므로) + dfs(roots, 1); + + return flatList; +} + /** * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 */ diff --git a/docs/plan-bom-excel-upload.md b/docs/plan-bom-excel-upload.md new file mode 100644 index 00000000..d4c91afd --- /dev/null +++ b/docs/plan-bom-excel-upload.md @@ -0,0 +1,78 @@ +# BOM 엑셀 업로드 기능 개발 계획 + +## 개요 +탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다. +BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고, +BOM 전용 엑셀 업로드 컴포넌트를 개발한다. + +## 핵심 구조 + +### DB 테이블 +- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id +- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id +- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material + +### 엑셀 포맷 설계 (화면과 동일한 레벨 체계) +엑셀 파일은 다음 컬럼으로 구성: + +| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 | +|------|------|------|--------|------|-----------|----------|------| +| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) | +| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 | +| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 | +| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 | +| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 | +| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 | + +- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재) +- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0) +- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1) +- 레벨 N: → bom_detail (DB level=N-1) +- 품번으로 item_info를 조회하여 child_item_id 자동 매핑 + +### 트리 변환 로직 (레벨 1 이상만 처리) +엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀): +1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산 +2. 스택으로 부모-자식 관계 추적 + +``` +행1(레벨0) → BOM 헤더, 건너뜀 +행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null +행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id +행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null +행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id +행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null +``` + +## 테스트 계획 + +### 1단계: 백엔드 API +- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번) +- [x] 테스트 2: 존재하지 않는 품번 에러 처리 +- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산) +- [x] 테스트 4: bom_detail INSERT (version_id 포함) +- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드) + +### 2단계: 프론트엔드 모달 +- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기 +- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패) +- [x] 테스트 8: 업로드 실행 및 결과 표시 + +### 3단계: 통합 +- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가 +- [x] 테스트 10: 업로드 후 트리 자동 새로고침 + +## 구현 파일 목록 + +### 백엔드 +1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가 +2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가 +3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가 + +### 프론트엔드 +4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규 +5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가 + +## 진행 상태 +- 완료된 테스트는 [x]로 표시 +- 현재 진행 중인 테스트는 [진행중]으로 표시 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 49aed98b..442c51cb 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -1242,8 +1242,8 @@ export const EditModal: React.FC = ({ className }) => { } } else { // UPDATE 모드 - PUT (전체 업데이트) - // originalData 비교 없이 formData 전체를 보냄 - const recordId = formData.id; + // VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조) + const recordId = formData.master_id || formData.id; if (!recordId) { console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", { @@ -1296,15 +1296,6 @@ export const EditModal: React.FC = ({ className }) => { if (response.success) { toast.success("데이터가 수정되었습니다."); - // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) - if (modalState.onSave) { - try { - modalState.onSave(); - } catch (callbackError) { - console.error("onSave 콜백 에러:", callbackError); - } - } - // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) try { @@ -1375,6 +1366,10 @@ export const EditModal: React.FC = ({ className }) => { console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); } + // 리피터 저장 완료 후 메인 테이블 새로고침 + if (modalState.onSave) { + try { modalState.onSave(); } catch {} + } handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 1853ebe7..8b769b56 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -428,7 +428,10 @@ export const V2Repeater: React.FC = ({ { page: 1, size: 1000, - search: { [config.foreignKeyColumn]: fkValue }, + dataFilter: { + enabled: true, + filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], + }, autoFilter: true, } ); diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index 63e1cbb9..57fe91d7 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 그룹화 설정 (예: groupByColumn: "inbound_number") const groupByColumn = rawConfig.groupByColumn; + const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn; const targetTable = rawConfig.targetTable; // 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑) @@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 formData와 config.fields의 필드 이름 매칭 확인 const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined); - // 🆕 그룹 키 값 (예: formData.inbound_number) - const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null; + // 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리 + const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null; // 🆕 분할 패널 위치 및 좌측 선택 데이터 확인 const splitPanelPosition = screenContext?.splitPanelPosition; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx new file mode 100644 index 00000000..98a9e823 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx @@ -0,0 +1,609 @@ +"use client"; + +import React, { useState, useRef, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { + Upload, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + Download, + Loader2, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { importFromExcel } from "@/lib/utils/excelExport"; +import { apiClient } from "@/lib/api/client"; + +interface BomExcelUploadModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; + /** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */ + bomId?: string; + bomName?: string; +} + +interface ParsedRow { + rowIndex: number; + level: number; + item_number: string; + item_name: string; + quantity: number; + unit: string; + process_type: string; + remark: string; + valid: boolean; + error?: string; + isHeader?: boolean; +} + +type UploadStep = "upload" | "preview" | "result"; + +const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"]; + +const HEADER_MAP: Record = { + "레벨": "level", + "level": "level", + "품번": "item_number", + "품목코드": "item_number", + "item_number": "item_number", + "item_code": "item_number", + "품명": "item_name", + "품목명": "item_name", + "item_name": "item_name", + "소요량": "quantity", + "수량": "quantity", + "quantity": "quantity", + "qty": "quantity", + "단위": "unit", + "unit": "unit", + "공정구분": "process_type", + "공정": "process_type", + "process_type": "process_type", + "비고": "remark", + "remark": "remark", +}; + +export function BomExcelUploadModal({ + open, + onOpenChange, + onSuccess, + bomId, + bomName, +}: BomExcelUploadModalProps) { + const isVersionMode = !!bomId; + + const [step, setStep] = useState("upload"); + const [parsedRows, setParsedRows] = useState([]); + const [fileName, setFileName] = useState(""); + const [uploading, setUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const [downloading, setDownloading] = useState(false); + const [versionName, setVersionName] = useState(""); + const fileInputRef = useRef(null); + + const reset = useCallback(() => { + setStep("upload"); + setParsedRows([]); + setFileName(""); + setUploadResult(null); + setUploading(false); + setVersionName(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const handleClose = useCallback(() => { + reset(); + onOpenChange(false); + }, [reset, onOpenChange]); + + const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setFileName(file.name); + + try { + const rawData = await importFromExcel(file); + if (!rawData || rawData.length === 0) { + toast.error("엑셀 파일에 데이터가 없습니다"); + return; + } + + const firstRow = rawData[0]; + const excelHeaders = Object.keys(firstRow); + const fieldMap: Record = {}; + + for (const header of excelHeaders) { + const normalized = header.trim().toLowerCase(); + const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()]; + if (mapped) { + fieldMap[header] = mapped; + } + } + + const hasItemNumber = excelHeaders.some(h => { + const n = h.trim().toLowerCase(); + return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number"; + }); + if (!hasItemNumber) { + toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요."); + return; + } + + const parsed: ParsedRow[] = []; + for (let index = 0; index < rawData.length; index++) { + const row = rawData[index]; + const getField = (fieldName: string): any => { + for (const [excelKey, mappedField] of Object.entries(fieldMap)) { + if (mappedField === fieldName) return row[excelKey]; + } + return undefined; + }; + + const levelRaw = getField("level"); + const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10); + const itemNumber = String(getField("item_number") || "").trim(); + const itemName = String(getField("item_name") || "").trim(); + const quantityRaw = getField("quantity"); + const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1")); + const unit = String(getField("unit") || "").trim(); + const processType = String(getField("process_type") || "").trim(); + const remark = String(getField("remark") || "").trim(); + + let valid = true; + let error = ""; + const isHeader = level === 0; + + if (!itemNumber) { + valid = false; + error = "품번 필수"; + } else if (isNaN(level) || level < 0) { + valid = false; + error = "레벨 오류"; + } else if (index > 0) { + const prevLevel = parsed[index - 1]?.level ?? 0; + if (level > prevLevel + 1) { + valid = false; + error = `레벨 점프 (이전: ${prevLevel})`; + } + } + + parsed.push({ + rowIndex: index + 1, + isHeader, + level, + item_number: itemNumber, + item_name: itemName, + quantity: isNaN(quantity) ? 1 : quantity, + unit, + process_type: processType, + remark, + valid, + error, + }); + } + + const filtered = parsed.filter(r => r.item_number !== ""); + + // 새 BOM 생성 모드: 레벨 0 필수 + if (!isVersionMode) { + const hasHeader = filtered.some(r => r.level === 0); + if (!hasHeader) { + toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요."); + return; + } + } + + setParsedRows(filtered); + setStep("preview"); + } catch (err: any) { + toast.error(`파일 파싱 실패: ${err.message}`); + } + }, [isVersionMode]); + + const handleUpload = useCallback(async () => { + const invalidRows = parsedRows.filter(r => !r.valid); + if (invalidRows.length > 0) { + toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`); + return; + } + + setUploading(true); + try { + const rowPayload = parsedRows.map(r => ({ + level: r.level, + item_number: r.item_number, + item_name: r.item_name, + quantity: r.quantity, + unit: r.unit, + process_type: r.process_type, + remark: r.remark, + })); + + let res; + if (isVersionMode) { + res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, { + rows: rowPayload, + versionName: versionName.trim() || undefined, + }); + } else { + res = await apiClient.post("/bom/excel-upload", { rows: rowPayload }); + } + + if (res.data?.success) { + setUploadResult(res.data.data); + setStep("result"); + const msg = isVersionMode + ? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}건` + : `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}건`; + toast.success(msg); + onSuccess?.(); + } else { + const errData = res.data?.data; + if (errData?.unmatchedItems?.length > 0) { + toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`); + setParsedRows(prev => prev.map(r => { + if (errData.unmatchedItems.includes(r.item_number)) { + return { ...r, valid: false, error: "품번 미등록" }; + } + return r; + })); + } else { + toast.error(res.data?.message || "업로드 실패"); + } + } + } catch (err: any) { + toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`); + } finally { + setUploading(false); + } + }, [parsedRows, isVersionMode, bomId, versionName, onSuccess]); + + const handleDownloadTemplate = useCallback(async () => { + setDownloading(true); + try { + const XLSX = await import("xlsx"); + let data: Record[] = []; + + if (isVersionMode && bomId) { + // 기존 BOM 데이터를 템플릿으로 다운로드 + try { + const res = await apiClient.get(`/bom/${bomId}/excel-download`); + if (res.data?.success && res.data.data?.length > 0) { + data = res.data.data.map((row: any) => ({ + "레벨": row.level, + "품번": row.item_number, + "품명": row.item_name, + "소요량": row.quantity, + "단위": row.unit, + "공정구분": row.process_type, + "비고": row.remark, + })); + } + } catch { /* 데이터 없으면 빈 템플릿 */ } + } + + if (data.length === 0) { + if (isVersionMode) { + data = [ + { "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" }, + { "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" }, + ]; + } else { + data = [ + { "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" }, + { "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" }, + { "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" }, + ]; + } + } + + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM"); + ws["!cols"] = [ + { wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 }, + { wch: 8 }, { wch: 12 }, { wch: 20 }, + ]; + + const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx"; + XLSX.writeFile(wb, filename); + toast.success("템플릿 다운로드 완료"); + } catch (err: any) { + toast.error(`다운로드 실패: ${err.message}`); + } finally { + setDownloading(false); + } + }, [isVersionMode, bomId, bomName]); + + const headerRow = parsedRows.find(r => r.isHeader); + const detailRows = parsedRows.filter(r => !r.isHeader); + const validCount = parsedRows.filter(r => r.valid).length; + const invalidCount = parsedRows.filter(r => !r.valid).length; + + const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드"; + const description = isVersionMode + ? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.` + : "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목."; + + return ( + { if (!v) handleClose(); }}> + + + {title} + {description} + + + {/* Step 1: 파일 업로드 */} + {step === "upload" && ( +
+ {/* 새 버전 모드: 버전명 입력 */} + {isVersionMode && ( +
+ + setVersionName(e.target.value)} + placeholder="예: 2.0" + className="h-8 text-xs sm:h-10 sm:text-sm mt-1" + /> +
+ )} + +
fileInputRef.current?.click()} + > + +

엑셀 파일을 선택하세요

+

.xlsx, .xls, .csv 형식 지원

+ +
+ +
+

엑셀 컬럼 형식

+
+ {EXPECTED_HEADERS.map((h, i) => ( + + {h}{i < 2 ? " *" : ""} + + ))} +
+

+ {isVersionMode + ? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다." + : "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목." + } +

+
+ + +
+ )} + + {/* Step 2: 미리보기 */} + {step === "preview" && ( +
+
+
+ {fileName} + {!isVersionMode && headerRow && ( + 마스터: {headerRow.item_number} + )} + + 하위품목 {detailRows.length}건 + + {invalidCount > 0 && ( + + {invalidCount}건 오류 + + )} +
+ +
+ +
+ + + + + + + + + + + + + + + + {parsedRows.map((row) => ( + + + + + + + + + + + + ))} + +
#구분레벨품번품명소요량단위공정비고
{row.rowIndex} + {row.isHeader ? ( + + {isVersionMode ? "건너뜀" : "마스터"} + + ) : row.valid ? ( + + ) : ( + + + + )} + + + {row.level} + + {row.item_number}{row.item_name}{row.quantity}{row.unit}{row.process_type}{row.remark}
+
+ + {invalidCount > 0 && ( +
+
유효하지 않은 행 ({invalidCount}건)
+
    + {parsedRows.filter(r => !r.valid).slice(0, 5).map(r => ( +
  • {r.rowIndex}행: {r.error}
  • + ))} + {invalidCount > 5 &&
  • ...외 {invalidCount - 5}건
  • } +
+
+ )} + +
+ {isVersionMode + ? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다." + : "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다." + } +
+
+ )} + + {/* Step 3: 결과 */} + {step === "result" && uploadResult && ( +
+
+
+ +
+

+ {isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"} +

+

+ 하위품목 {uploadResult.insertedCount}건이 등록되었습니다. +

+
+ +
+ {!isVersionMode && ( +
+
1
+
BOM 마스터
+
+ )} +
+
{uploadResult.insertedCount}
+
하위품목
+
+
+
+ )} + + + {step === "upload" && ( + + )} + {step === "preview" && ( + <> + + + + )} + {step === "result" && ( + + )} + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 5234a74d..1aede9de 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -14,6 +14,7 @@ import { History, GitBranch, Check, + FileSpreadsheet, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { BomDetailEditModal } from "./BomDetailEditModal"; import { BomHistoryModal } from "./BomHistoryModal"; import { BomVersionModal } from "./BomVersionModal"; +import { BomExcelUploadModal } from "./BomExcelUploadModal"; interface BomTreeNode { id: string; @@ -77,6 +79,7 @@ export function BomTreeComponent({ const [editTargetNode, setEditTargetNode] = useState(null); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false); + const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [colWidths, setColWidths] = useState>({}); const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => { @@ -824,6 +827,15 @@ export function BomTreeComponent({ 버전 )} +
); } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 427f2da5..5a839620 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -20,6 +20,7 @@ import { Trash2, Settings, Move, + FileSpreadsheet, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { PanelInlineComponent } from "./types"; import { cn } from "@/lib/utils"; +import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); @@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {!isDesignMode && componentConfig.leftPanel?.showAdd && ( - - )} +
+ {!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && ( + + )} + {!isDesignMode && componentConfig.leftPanel?.showAdd && ( + + )} +
{componentConfig.leftPanel?.showSearch && ( @@ -5070,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC + + {(componentConfig.leftPanel as any)?.showBomExcelUpload && ( + { + loadLeftData(); + }} + /> + )} ); };