diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts index 50281618..2d88b5b7 100644 --- a/backend-node/src/controllers/devBomController.ts +++ b/backend-node/src/controllers/devBomController.ts @@ -12,6 +12,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/devBomService"; +import * as excelSvc from "../services/devBomExcelImportService"; import { logger } from "../utils/logger"; function parseListFilter(q: Record): svc.BomReportListFilter { @@ -78,6 +79,81 @@ export async function removeMany(req: AuthenticatedRequest, res: Response) { } } +// ─── Excel Import (M3) ──────────────────────────────────── +// POST /api/development/ebom/excel-parse (multipart, field: file) +// GET /api/development/ebom/excel-check-duplicate?partNo=&exclude= +// GET /api/development/ebom/excel-copy-source?productCd= +// GET /api/development/ebom/excel-copy/:objid (기존 BOM → 그리드 행) +// POST /api/development/ebom/excel-save (body: { bomReportObjid?, productCd, partNo, partName, version?, rows }) +// +// wace: parsingExcelFile.do + checkDuplicatePartNo.do + getBomDataForCopy.do + partBomApplySave.do +// + 메인의 code_map.bom_list (select 옵션) + +export async function excelParse(req: AuthenticatedRequest, res: Response) { + try { + const file = (req as any).file as Express.Multer.File | undefined; + if (!file) return res.status(400).json({ success: false, message: "엑셀 파일이 필요합니다." }); + const data = await excelSvc.parseAndValidate(file.buffer); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("BOM 엑셀 파싱 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelCheckDuplicate(req: AuthenticatedRequest, res: Response) { + try { + const partNo = String(req.query.partNo ?? "").trim(); + const exclude = String(req.query.exclude ?? "").trim() || undefined; + const isDuplicate = await excelSvc.checkDuplicateBomPartNo(partNo, exclude); + return res.json({ success: true, data: { isDuplicate } }); + } catch (e: any) { + logger.error("BOM 헤더 품번 중복 검사 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelCopySource(req: AuthenticatedRequest, res: Response) { + try { + const productCd = String(req.query.productCd ?? "").trim() || undefined; + const rows = await excelSvc.listForCopySelect(productCd); + return res.json({ success: true, data: rows }); + } catch (e: any) { + logger.error("BOM 복사 원본 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelCopy(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const rows = await excelSvc.copyBomForGrid(objid); + return res.json({ success: true, data: { rows } }); + } catch (e: any) { + logger.error("BOM 복사 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelSave(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const body = req.body as excelSvc.BomSaveInput; + if (!body || !Array.isArray(body.rows)) { + return res.status(400).json({ success: false, message: "잘못된 요청 본문입니다." }); + } + const result = await excelSvc.saveBomReport(userId, body); + return res.json({ + success: true, + data: result, + message: `${result.mode === "create" ? "등록" : "수정"} 완료 — BOM 행 ${result.bomRows}건 (PART 신규 ${result.insertedParts}건 / 업데이트 ${result.updatedParts}건)`, + }); + } catch (e: any) { + logger.error("BOM Excel 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // ─── M4 정전개 ───────────────────────────────────────────── export async function ascending(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts index ff550250..affa0977 100644 --- a/backend-node/src/controllers/devPartController.ts +++ b/backend-node/src/controllers/devPartController.ts @@ -13,6 +13,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/devPartService"; +import * as excelSvc from "../services/devPartExcelImportService"; import { logger } from "../utils/logger"; function parseListFilter(q: Record): svc.PartListFilter { @@ -109,6 +110,44 @@ export async function deploy(req: AuthenticatedRequest, res: Response) { } } +// ─── Excel Import (M1·M2 공용) ────────────────────────────── +// POST /api/development/part/excel-parse (multipart, field: file) +// POST /api/development/part/excel-save (body: { rows: [...] }) +// +// 운영판 wace: openPartExcelImportPopUp.jsp → partParsingExcelFile.do + partUploadSave.do +// 본 RPS 구현: 파일을 메모리 파싱 → 검증 결과(NOTE 포함) 반환 / 저장 시 신규 part_no 만 INSERT. + +export async function excelParse(req: AuthenticatedRequest, res: Response) { + try { + const file = (req as any).file as Express.Multer.File | undefined; + if (!file) return res.status(400).json({ success: false, message: "엑셀 파일이 필요합니다." }); + const data = await excelSvc.parseAndValidate(file.buffer); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("PART 엑셀 파싱 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelSave(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const rows = Array.isArray(req.body?.rows) ? (req.body.rows as excelSvc.SavePartExcelInput[]) : []; + if (rows.length === 0) { + return res.status(400).json({ success: false, message: "저장할 행이 없습니다." }); + } + const result = await excelSvc.saveExcelRows(userId, rows); + return res.json({ + success: true, + data: result, + message: `${result.inserted}건이 저장되었습니다.${result.skipped > 0 ? ` (중복 ${result.skipped}건 건너뜀)` : ""}`, + }); + } catch (e: any) { + logger.error("PART 엑셀 저장 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + // ─── 다중 삭제 ────────────────────────────────────────────── export async function removeMany(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts index 9e0676a5..8e3b892b 100644 --- a/backend-node/src/routes/devBomRoutes.ts +++ b/backend-node/src/routes/devBomRoutes.ts @@ -4,16 +4,29 @@ // ============================================================ import { Router } from "express"; +import multer from "multer"; import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/devBomController"; const router = Router(); router.use(authenticateToken); +const excelUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB +}); + // M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위) router.get("/ebom-tree/ascending", ctrl.ascending); router.get("/ebom-tree/descending", ctrl.descending); +// M3 Excel Import — /:objid 보다 위에 (라우트 충돌 방지) +router.post("/ebom/excel-parse", excelUpload.single("file"), ctrl.excelParse); +router.get("/ebom/excel-check-duplicate", ctrl.excelCheckDuplicate); +router.get("/ebom/excel-copy-source", ctrl.excelCopySource); +router.get("/ebom/excel-copy/:objid", ctrl.excelCopy); +router.post("/ebom/excel-save", ctrl.excelSave); + // M3 — 그리드 + CRUD router.get("/ebom/list", ctrl.getList); router.delete("/ebom", ctrl.removeMany); diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts index ee0db031..d7dbd428 100644 --- a/backend-node/src/routes/devPartRoutes.ts +++ b/backend-node/src/routes/devPartRoutes.ts @@ -4,12 +4,18 @@ // ============================================================ import { Router } from "express"; +import multer from "multer"; import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/devPartController"; const router = Router(); router.use(authenticateToken); +const excelUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB +}); + // M1 — 임시(등록) 그리드 router.get("/part-temp/list", ctrl.getTempList); router.post("/part-temp/deploy", ctrl.deploy); @@ -17,6 +23,10 @@ router.post("/part-temp/deploy", ctrl.deploy); // M2 — 릴리즈 그리드 router.get("/part/list", ctrl.getList); +// Excel Import (M1·M2 공용) — /:objid 보다 위에 위치 +router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse); +router.post("/part/excel-save", ctrl.excelSave); + // 다중 삭제 (body: { objids: string[] }) — /:objid 보다 위 router.delete("/part", ctrl.removeMany); diff --git a/backend-node/src/services/devBomExcelImportService.ts b/backend-node/src/services/devBomExcelImportService.ts new file mode 100644 index 00000000..9f6d0a6a --- /dev/null +++ b/backend-node/src/services/devBomExcelImportService.ts @@ -0,0 +1,476 @@ +// ============================================================ +// 개발관리 BOM Report Excel Import 서비스 — wace_plm PartMngService 1:1 +// +// 원본 흐름 (wace partMng/openBomReportExcelImportPopUp.jsp): +// 1) /partMng/parsingExcelFile.do → 업로드 파일 파싱 + 검증 (parsingExcelFile) +// 2) /partMng/checkDuplicatePartNo.do → PART_BOM_REPORT 헤더 PART_NO 중복 (수정 중인 자신 제외) +// 3) /partMng/partBomApplySave.do → savePartBomMaster() +// 3-1) 헤더 INSERT(신규) / UPDATE+자식트리 DELETE+STATUS reset(수정) +// 3-2) 행마다 (wace partBomMaster 1:1): +// ㄱ. partMng.getPartObjid(PART_NO, IS_LAST=1) +// → 있음: part_objid 재사용 + partMng.updatePartInfoFromCsv 로 기존 row UPDATE +// → 없음: createObjId + partMng.insertpartInfo (part_mng 신규 INSERT) +// ㄴ. partMng.getPartObjid(PARENT_PART_NO, IS_LAST=1) +// → 있음: parent_part_objid 사용 +// → 없음: parent_part_objid = "" (INSERT 절대 안 함 — wace 원본 5359-5361) +// ㄷ. partMng.getBomPartQtyObjid(PARENT_PART_NO, BOM_REPORT_OBJID) +// → bom_part_qty 부모행의 CHILD_OBJID 를 parent_objid 로 사용 +// ㄹ. partMng.relatePartInfo → bom_part_qty INSERT +// +// 검증 (wace parsingExcelFile 1:1): +// · 자품번 필수 / 엑셀 내 중복 +// · 모품번이 자품번 목록(allPartNumbers Set)에 존재해야 함 (1레벨 = 첫 데이터 행은 제외) +// · 품명 필수 +// · 수량 필수 + 숫자 + > 0 +// · PART_TYPE 코드명 → code_id (못 찾으면 NOTE "부품유형 확인") +// · PART_TYPE='0001788'(구매품표준) → part_mng.part_no 존재 검증 (NOTE "구매품표준 미등록") +// +// vexplor_rps 적응: +// · CUSTOMER_OBJID/CONTRACT_OBJID/UNIT_CODE 본 메뉴에서 입력받지 않음 → NULL +// · 운영판 BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx 10컬럼 그대로: +// 0:모품번 1:자품번 2:품명 3:수량 4:재질 5:사양(규격) 6:후처리 7:MAKER 8:부품유형 9:REMARK +// (자바 코드는 25컬럼까지 매핑하지만 실제 운영 템플릿은 10컬럼) +// ============================================================ + +import * as XLSX from "xlsx"; +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +const CODE_PARENT_PART_TYPE = "0000062"; + +export interface BomExcelRow { + NOTE: string; + PARENT_PART_NO: string; + PART_NO: string; + PART_NAME: string; + QTY: string; + ITEM_QTY: string; + MATERIAL: string; + SPEC: string; + POST_PROCESSING: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME?: string; + REMARK: string; +} + +function appendNote(r: BomExcelRow, msg: string) { + r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg; +} +function getCell(row: any[], idx: number): string { + const v = row?.[idx]; + if (v === undefined || v === null) return ""; + return String(v).trim(); +} + +async function fetchPartTypeMap(client: PoolClient): Promise> { + const r = await client.query( + `SELECT CODE_ID AS code_id, CODE_NAME AS code_name + FROM COMM_CODE WHERE PARENT_CODE_ID = $1`, + [CODE_PARENT_PART_TYPE] + ); + const m = new Map(); + for (const row of r.rows) if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row); + return m; +} + +// ─── 1) 파싱 + 검증 (parsingExcelFile 1:1) ────────────────── + +export async function parseAndValidate(buffer: Buffer): Promise<{ + rows: BomExcelRow[]; + hasError: boolean; + firstLevel: { part_no: string; part_name: string } | null; +}> { + const wb = XLSX.read(buffer, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const raw: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }); + + // wace 1: 모든 자품번을 먼저 Set 으로 수집 (모품번 검증용) + const allPartNumbers = new Set(); + for (let i = 2; i < raw.length; i++) { + const partNo = getCell(raw[i], 1); // column 1 = 자품번 (운영 템플릿) + if (partNo) allPartNumbers.add(partNo); + } + + const result: BomExcelRow[] = []; + let firstLevel: { part_no: string; part_name: string } | null = null; + + const client = await getPool().connect(); + try { + const partTypeMap = await fetchPartTypeMap(client); + const partNoSeenInFile = new Map(); + let emptyRowCnt = 0; + let dataRowIndex = 0; // 데이터 행 카운터 (1부터 — wace rowIndex 와 다름, 의미는 동일) + + for (let i = 2; i < raw.length; i++) { + const row = raw[i]; + if (!row) continue; + + const parentPartNo = getCell(row, 0); + const partNo = getCell(row, 1); + const partName = getCell(row, 2); + const qty = getCell(row, 3); + const material = getCell(row, 4); + const spec = getCell(row, 5); + const postProc = getCell(row, 6); + const maker = getCell(row, 7); + const partTypeName = getCell(row, 8); + const remark = getCell(row, 9); + + // wace: PART_NO+PART_NAME 둘 다 빈 행은 스킵 (3줄 연속이면 break) + if (!partNo && !partName) { + emptyRowCnt++; + if (emptyRowCnt > 3) break; + continue; + } + emptyRowCnt = 0; + dataRowIndex++; + + const cur: BomExcelRow = { + NOTE: "", + PARENT_PART_NO: parentPartNo, + PART_NO: partNo, + PART_NAME: partName, + QTY: qty, + ITEM_QTY: qty, // wace: ITEM_QTY 컬럼 없으면 QTY 와 동일 (savePartBomMaster 에서도 QTY 로 채움) + MATERIAL: material, + SPEC: spec, + POST_PROCESSING: postProc, + MAKER: maker, + PART_TYPE: "", + REMARK: remark, + }; + + // (1) 자품번 필수 + if (!partNo) { + appendNote(cur, "필수입력 - 품번"); + } else { + if (partNoSeenInFile.has(partNo)) { + appendNote(cur, `품번 중복: ${partNo}`); + } else { + partNoSeenInFile.set(partNo, dataRowIndex); + } + } + + // (2) 모품번 검증 (wace 1:1): 첫 데이터 행은 제외, 그 외는 자품번 목록에 있어야 함 + if (!parentPartNo && dataRowIndex > 1) { + appendNote(cur, "필수입력 - 모품번"); + } else if (parentPartNo && !allPartNumbers.has(parentPartNo)) { + appendNote(cur, `모품번이 자품번 목록에 없습니다: ${parentPartNo}`); + } + + // (3) 품명 필수 + if (!partName) appendNote(cur, "필수입력 - 품명"); + + // (4) 수량 필수 + 숫자 + > 0 (wace 1:1) + if (!qty) { + appendNote(cur, "필수입력 - 수량"); + } else { + const qtyValue = Number(qty); + if (!Number.isFinite(qtyValue)) { + appendNote(cur, `수량은 숫자여야 합니다: ${qty}`); + } else if (qtyValue <= 0) { + appendNote(cur, `수량은 0보다 커야 합니다: ${qty}`); + } + } + + // (5) PART_TYPE 코드 변환 + 구매품표준 검증 + if (partTypeName) { + const hit = partTypeMap.get(partTypeName.toUpperCase()); + if (hit) { + cur.PART_TYPE = hit.code_id; + cur.PART_TYPE_NAME = hit.code_name; + + // wace: rowIndex > 2 인 경우만 구매품표준 검증 (1~2 레벨은 면제) + if (hit.code_name === "구매품표준" && dataRowIndex > 2 && partNo) { + const exist = await client.query( + `SELECT 1 FROM PART_MNG WHERE PART_NO = $1 LIMIT 1`, + [partNo] + ); + if ((exist.rowCount ?? 0) === 0) { + appendNote(cur, `품번에 해당하는 구매품표준이 없습니다: ${partNo}`); + } + } + } else { + cur.PART_TYPE = partTypeName; + appendNote(cur, `부품유형 확인: ${partTypeName}`); + } + } + + // (6) 1레벨 (모품번 없는 첫 행)을 헤더 자동 채움 용도로 캡쳐 + if (!firstLevel && !parentPartNo && partNo) { + firstLevel = { part_no: partNo, part_name: partName }; + } + + result.push(cur); + } + } finally { + client.release(); + } + + const hasError = result.some((r) => r.NOTE); + return { rows: result, hasError, firstLevel }; +} + +// ─── 2) 헤더 part_no 중복 검사 (wace checkDuplicatePartNo) ── + +export async function checkDuplicateBomPartNo(partNo: string, excludeObjid?: string): Promise { + if (!partNo) return false; + const sql = excludeObjid + ? `SELECT 1 FROM PART_BOM_REPORT WHERE PART_NO = $1 AND OBJID <> $2 LIMIT 1` + : `SELECT 1 FROM PART_BOM_REPORT WHERE PART_NO = $1 LIMIT 1`; + const params = excludeObjid ? [partNo.trim(), excludeObjid] : [partNo.trim()]; + const r = await getPool().query(sql, params); + return (r.rowCount ?? 0) > 0; +} + +// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomExcelRow[] ────── + +export async function copyBomForGrid(sourceObjid: string): Promise { + const sql = ` + SELECT + Q.QTY, Q.ITEM_QTY, Q.SEQ, + PM.PART_NO AS PART_NO_REAL, + PM.PART_NAME AS PART_NAME, + PM.MATERIAL, PM.SPEC, PM.POST_PROCESSING, PM.MAKER, PM.REMARK, + PM.PART_TYPE, + CC.CODE_NAME AS PART_TYPE_NAME, + PM_PARENT.PART_NO AS PARENT_PART_NO_REAL + FROM BOM_PART_QTY Q + LEFT JOIN PART_MNG PM ON PM.OBJID::varchar = Q.PART_NO + LEFT JOIN PART_MNG PM_PARENT ON PM_PARENT.OBJID::varchar = Q.PARENT_PART_NO + LEFT JOIN COMM_CODE CC ON CC.CODE_ID = PM.PART_TYPE + WHERE Q.BOM_REPORT_OBJID = $1 + ORDER BY Q.SEQ + `; + const r = await getPool().query(sql, [sourceObjid]); + return r.rows.map((row): BomExcelRow => ({ + NOTE: "", + PARENT_PART_NO: row.parent_part_no_real ?? "", + PART_NO: row.part_no_real ?? "", + PART_NAME: row.part_name ?? "", + QTY: row.qty != null ? String(row.qty) : "", + ITEM_QTY: row.item_qty != null ? String(row.item_qty) : (row.qty != null ? String(row.qty) : ""), + MATERIAL: row.material ?? "", + SPEC: row.spec ?? "", + POST_PROCESSING: row.post_processing ?? "", + MAKER: row.maker ?? "", + PART_TYPE: row.part_type ?? "", + PART_TYPE_NAME: row.part_type_name ?? "", + REMARK: row.remark ?? "", + })); +} + +// ─── 4) BOM 저장 (savePartBomMaster 1:1) ──────────────────── + +export interface BomSaveInput { + bomReportObjid?: string; // 비어있으면 신규 + productCd: string; + partNo: string; // 헤더 품번 + partName: string; + version?: string; + rows: BomExcelRow[]; +} + +export interface BomSaveResult { + bomReportObjid: string; + insertedParts: number; + updatedParts: number; + bomRows: number; + mode: "create" | "update"; +} + +export async function saveBomReport(userId: string, input: BomSaveInput): Promise { + if (!input.productCd) throw new Error("제품구분은 필수입니다."); + if (!input.partNo) throw new Error("품번은 필수입니다."); + if (!input.partName) throw new Error("품명은 필수입니다."); + + let insertedParts = 0; + let updatedParts = 0; + let bomRows = 0; + let bomReportObjid = (input.bomReportObjid && input.bomReportObjid.trim()) ? input.bomReportObjid.trim() : ""; + const mode: "create" | "update" = bomReportObjid ? "update" : "create"; + + await transaction(async (client: PoolClient) => { + if (mode === "update") { + // wace: 기존 BOM 수정 시 자식 트리 DELETE + STATUS='N' reset (deleteBomPartQtyByBomObjid + resetBomReportStatus) + await client.query(`DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = $1`, [bomReportObjid]); + await client.query( + `UPDATE PART_BOM_REPORT + SET STATUS = 'N', WRITER = $1, edit_date = NOW(), + PRODUCT_CD = $2, PART_NO = $3, PART_NAME = $4, REVISION = $5 + WHERE OBJID = $6`, + [userId, input.productCd, input.partNo, input.partName, input.version ?? null, bomReportObjid] + ); + } else { + bomReportObjid = createObjId(); + await client.query( + `INSERT INTO PART_BOM_REPORT ( + OBJID, CUSTOMER_OBJID, CONTRACT_OBJID, UNIT_CODE, + STATUS, WRITER, REGDATE, + MULTI_YN, MULTI_MASTER_YN, MULTI_BREAK_YN, MULTI_MASTER_OBJID, + PRODUCT_CD, PART_NO, PART_NAME, REVISION + ) VALUES ( + $1, NULL, NULL, NULL, + 'N', $2, NOW(), + 'N', 'N', NULL, NULL, + $3, $4, $5, $6 + )`, + [bomReportObjid, userId, input.productCd, input.partNo, input.partName, input.version ?? null] + ); + } + + if (!input.rows || input.rows.length === 0) return; // wace: 빈 BOM 허용 (헤더만 생성) + + // 자식 PART_NO → part_mng.objid 캐시 (행 처리 후) + const childPartObjIdCache = new Map(); + // bom_part_qty 부모행의 CHILD_OBJID 캐시 (다음 자식들이 이 값을 PARENT_OBJID 로 사용) + const childBomObjIdByPartNo = new Map(); + + // 자식 PART 처리: 있으면 UPDATE / 없으면 INSERT (wace 1:1) + async function upsertChildPart(r: BomExcelRow): Promise { + if (childPartObjIdCache.has(r.PART_NO)) return childPartObjIdCache.get(r.PART_NO)!; + + const exist = await client.query( + `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [r.PART_NO] + ); + if ((exist.rowCount ?? 0) > 0) { + const id = exist.rows[0].part_objid; + // wace updatePartInfoFromCsv 1:1 (BOM Excel 에 있는 컬럼만 — 나머지는 그대로) + // 단, 원본 매퍼는 빈값이어도 UPDATE 함. RPS 에서는 BOM 엑셀에 없는 컬럼은 NULL로 덮어쓰지 않도록 COALESCE 처리. + await client.query( + `UPDATE PART_MNG SET + PART_NAME = $1, + SPEC = $2, + MATERIAL = $3, + REMARK = $4, + PART_TYPE = COALESCE(NULLIF($5, ''), PART_TYPE), + MAKER = $6, + POST_PROCESSING = $7, + EDIT_DATE = NOW() + WHERE OBJID = $8::numeric`, + [ + r.PART_NAME ?? "", + r.SPEC ?? "", + r.MATERIAL ?? "", + r.REMARK ?? "", + r.PART_TYPE ?? "", + r.MAKER ?? "", + r.POST_PROCESSING ?? "", + id, + ] + ); + updatedParts++; + childPartObjIdCache.set(r.PART_NO, id); + return id; + } + + // 신규 INSERT (insertpartInfo 1:1, BOM Excel 컬럼만 채움) + const newId = createObjId(); + await client.query( + `INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, SPEC, MATERIAL, REMARK, + STATUS, REG_DATE, WRITER, IS_LAST, + PART_TYPE, MAKER, POST_PROCESSING, + LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG + ) VALUES ( + $1::numeric, $2, $3, $4, $5, $6, + 'create', NOW(), $7, '1', + NULLIF($8, ''), $9, $10, + '0', '1', '0', '0', '0' + )`, + [ + newId, r.PART_NO, + r.PART_NAME ?? "", r.SPEC ?? "", r.MATERIAL ?? "", r.REMARK ?? "", + userId, + r.PART_TYPE ?? "", r.MAKER ?? "", r.POST_PROCESSING ?? "", + ] + ); + insertedParts++; + childPartObjIdCache.set(r.PART_NO, newId); + return newId; + } + + // 부모 PART 처리: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) + async function lookupParentPart(partNo: string): Promise { + if (!partNo) return ""; + if (childPartObjIdCache.has(partNo)) return childPartObjIdCache.get(partNo)!; + + const exist = await client.query( + `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [partNo] + ); + if ((exist.rowCount ?? 0) > 0) { + const id = exist.rows[0].part_objid; + childPartObjIdCache.set(partNo, id); + return id; + } + return ""; // wace 원본 5359-5361: 부모 part_mng 없으면 parent_part_no="" (INSERT 안 함) + } + + for (const r of input.rows) { + if (!r.PART_NO) continue; + + const partObjid = await upsertChildPart(r); + const parentPartObjid = await lookupParentPart(r.PARENT_PART_NO); + + // wace: bom_part_qty 에서 부모 행의 CHILD_OBJID 조회 (PARENT_PART_NO + BOM_REPORT_OBJID). + // 행이 엑셀 순서대로 들어오므로 메모리 캐시면 동등 (자식이 부모보다 항상 뒤에 등장). + const parentBomObjid = r.PARENT_PART_NO ? (childBomObjIdByPartNo.get(r.PARENT_PART_NO) ?? "") : ""; + + const newBomObjid = createObjId(); + const newChildObjid = createObjId(); + + // wace relatePartInfo 1:1 — QTY/ITEM_QTY/QTY_TEMP 모두 COALESCE(NULLIF, '0')::numeric + await client.query( + `INSERT INTO BOM_PART_QTY ( + BOM_REPORT_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PARENT_PART_NO, PART_NO, + QTY, ITEM_QTY, QTY_TEMP, + REGDATE, WRITER, SEQ, STATUS, LAST_PART_OBJID + ) VALUES ( + $1, $2, NULLIF($3, ''), $4, + $5, $6, + COALESCE(NULLIF($7, ''), '0')::numeric, + COALESCE(NULLIF($8, ''), '0')::numeric, + COALESCE(NULLIF($7, ''), '0')::numeric, + NOW(), $9, nextval('seq_bom_qty'), 'deploy', NULL + )`, + [ + bomReportObjid, newBomObjid, parentBomObjid, newChildObjid, + parentPartObjid, partObjid, + r.QTY ?? "", + r.ITEM_QTY ?? r.QTY ?? "", + userId, + ] + ); + + childBomObjIdByPartNo.set(r.PART_NO, newChildObjid); + bomRows++; + } + }); + + logger.info("BOM Excel Import 저장 완료", { userId, bomReportObjid, mode, insertedParts, updatedParts, bomRows }); + return { bomReportObjid, insertedParts, updatedParts, bomRows, mode }; +} + +// ─── 5) BOM 목록 (E-BOM 복사 select 옵션) ───────────────── + +export async function listForCopySelect(productCd?: string) { + const params: any[] = []; + let where = "1=1"; + if (productCd) { params.push(productCd); where = `T.PRODUCT_CD = $1`; } + const sql = ` + SELECT T.OBJID, T.PART_NO, T.PART_NAME, T.REVISION, T.PRODUCT_CD, + TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REGDATE + FROM PART_BOM_REPORT T + WHERE ${where} + ORDER BY T.REGDATE DESC NULLS LAST + LIMIT 200 + `; + const r = await getPool().query(sql, params); + return r.rows; +} diff --git a/backend-node/src/services/devPartExcelImportService.ts b/backend-node/src/services/devPartExcelImportService.ts new file mode 100644 index 00000000..f5d41a87 --- /dev/null +++ b/backend-node/src/services/devPartExcelImportService.ts @@ -0,0 +1,372 @@ +// ============================================================ +// 개발관리 PART Excel Import 서비스 — wace_plm PartMngService 1:1 +// +// 원본 흐름 (wace partMng/openPartExcelImportPopUp.jsp): +// 1) /partMng/partParsingExcelFile.do → 업로드 파일 파싱 + 검증 +// 엑셀 row[2..]부터 22컬럼 추출. 검증 결과는 행마다 NOTE 컬럼에 누적. +// 코드명 → 코드값 매핑 (PART_TYPE/ACCTFG/UNIT_DC/UNITMANG_DC, 한글 → 0/1/8/Y/N). +// 품번 중복 (PART_MNG 기존 + 엑셀 내 중복) → "품번중복" NOTE. +// 2) /partMng/partUploadSave.do → 그리드 데이터 INSERT +// partMng.getPartObjid 로 part_no 존재 여부 확인. 없으면 createObjId + mergePartMng INSERT. +// (NOTE에 에러 있는 행은 클라이언트가 차단) +// +// vexplor_rps 차이: +// · 운영판은 파일을 서버에 저장 → /partParsingExcelFile.do 가 그 파일을 읽음. +// · RPS는 multer memoryStorage 로 받은 Buffer 를 그 자리에서 파싱 (WbsTemplate parseExcel 패턴). +// · 결과 그리드는 클라이언트 메모리에만 유지 → 저장 호출 시 다시 검증 한 번 더 수행. +// ============================================================ + +import * as XLSX from "xlsx"; +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +// ─── wace 운영판 PARENT_CODE_ID (commCode) ────────────────── +const CODE_PARENT_PART_TYPE = "0000062"; // 범주 (PART_TYPE) +const CODE_PARENT_ACCTFG = "0900213"; // 계정구분 +const CODE_PARENT_UNIT_DC = "0001399"; // 단위 (UNIT_DC / UNITMANG_DC) + +// 엑셀 22컬럼 (1행 헤더 — wace JSP colModel 순서) +// 0:품번 1:품명 2:재료 3:열처리경도 4:열처리방법 5:표면처리 6:메이커 7:범주 이름 +// 8:규격 9:계정구분 10:조달구분 11:재고단위 12:관리단위 13:환산수량 +// 14:LOT구분 15:사용여부 16:검사여부 17:SET품여부 18:의뢰여부 19:개당길이 20:개당소요량 21:비고 + +export interface PartExcelRow { + NOTE: string; + PART_NO: string; + PART_NAME: string; + MATERIAL: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; + MAKER: string; + PART_TYPE: string; // 코드값(`code_id`) — 매핑 실패 시 원본 한글 + PART_TYPE_NAME?: string; // 화면용 라벨 + SPEC: string; + ACCTFG: string; + ACCTFG_NAME?: string; + ODRFG: string; + ODRFG_NAME?: string; + UNIT_DC: string; + UNIT_DC_NAME?: string; + UNITMANG_DC: string; + UNITMANG_DC_NAME?: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; + UNIT_LENGTH: string; + UNIT_QTY: string; + REMARK: string; +} + +function appendNote(r: PartExcelRow, msg: string) { + r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg; +} + +function getCell(row: any[], idx: number): string { + const v = row?.[idx]; + if (v === undefined || v === null) return ""; + return String(v).trim(); +} + +// ─── 코드명 → 코드값 매핑 (commCode 1:1) ──────────────────── + +async function fetchCodeMap( + client: PoolClient, + parentCodeId: string +): Promise> { + const r = await client.query( + `SELECT CODE_ID AS code_id, CODE_NAME AS code_name + FROM COMM_CODE + WHERE PARENT_CODE_ID = $1`, + [parentCodeId] + ); + const m = new Map(); + for (const row of r.rows) { + if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row); + } + return m; +} + +function mapKr01(value: string, t: string, f: string, defaultVal: string): string { + const v = value.trim(); + if (!v) return defaultVal; + if (v === t) return "1"; + if (v === f) return "0"; + return v; // 숫자 입력 시 그대로 +} +function mapOdrfg(value: string): string { + const v = value.trim(); + if (!v) return ""; + if (v === "구매") return "0"; + if (v === "생산") return "1"; + if (v.toUpperCase() === "PHANTOM") return "8"; + return v; +} + +// ─── 1) 파싱 + 검증 ───────────────────────────────────────── + +export async function parseAndValidate(buffer: Buffer): Promise<{ + rows: PartExcelRow[]; + hasError: boolean; +}> { + const wb = XLSX.read(buffer, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const raw: any[][] = XLSX.utils.sheet_to_json(sheet, { + header: 1, raw: false, defval: "", + }); + + const result: PartExcelRow[] = []; + const client = await getPool().connect(); + try { + const [partTypeMap, acctfgMap, unitDcMap] = await Promise.all([ + fetchCodeMap(client, CODE_PARENT_PART_TYPE), + fetchCodeMap(client, CODE_PARENT_ACCTFG), + fetchCodeMap(client, CODE_PARENT_UNIT_DC), + ]); + + const partNoSeenInFile = new Map(); + let emptyRowCnt = 0; + + // wace: rowIndex = 2 부터 (0=안내라벨 "입력", 1=헤더) + for (let i = 2; i < raw.length; i++) { + const row = raw[i]; + if (!row) continue; + + const cur: PartExcelRow = { + NOTE: "", + PART_NO: getCell(row, 0), + PART_NAME: getCell(row, 1), + MATERIAL: getCell(row, 2), + HEAT_TREATMENT_HARDNESS: getCell(row, 3), + HEAT_TREATMENT_METHOD: getCell(row, 4), + SURFACE_TREATMENT: getCell(row, 5), + MAKER: getCell(row, 6), + PART_TYPE: "", + SPEC: getCell(row, 8), + ACCTFG: "", + ODRFG: "", + UNIT_DC: "", + UNITMANG_DC: "", + UNITCHNG_NB: getCell(row, 13), + LOT_FG: "", + USE_YN: "", + QC_FG: "", + SETITEM_FG: "", + REQ_FG: "", + UNIT_LENGTH: getCell(row, 19), + UNIT_QTY: getCell(row, 20), + REMARK: getCell(row, 21), + }; + + // wace getCellValue 의 emptyColCnt(8 미만)로 행 채택. 단순화: 모든 컬럼 빈값이면 빈 행. + const nonEmptyCount = + (cur.PART_NO ? 1 : 0) + (cur.PART_NAME ? 1 : 0) + (cur.MATERIAL ? 1 : 0) + + (cur.HEAT_TREATMENT_HARDNESS ? 1 : 0) + (cur.HEAT_TREATMENT_METHOD ? 1 : 0) + + (cur.SURFACE_TREATMENT ? 1 : 0) + (cur.MAKER ? 1 : 0) + + (getCell(row, 7) ? 1 : 0) + (cur.SPEC ? 1 : 0) + + (getCell(row, 9) ? 1 : 0) + (getCell(row, 10) ? 1 : 0) + + (getCell(row, 11) ? 1 : 0) + (getCell(row, 12) ? 1 : 0) + (cur.UNITCHNG_NB ? 1 : 0) + + (getCell(row, 14) ? 1 : 0) + (getCell(row, 15) ? 1 : 0) + + (getCell(row, 16) ? 1 : 0) + (getCell(row, 17) ? 1 : 0) + + (getCell(row, 18) ? 1 : 0) + (cur.UNIT_LENGTH ? 1 : 0) + + (cur.UNIT_QTY ? 1 : 0) + (cur.REMARK ? 1 : 0); + + if (nonEmptyCount === 0) { + emptyRowCnt++; + if (emptyRowCnt > 3) break; // wace 동일 + continue; + } + + // 품번 필수 + 중복 검사 (DB + 엑셀 내) + if (!cur.PART_NO) { + appendNote(cur, "필수입력 - 품번"); + } else { + const dbRes = await client.query( + `SELECT 1 FROM PART_MNG WHERE PART_NO = $1 LIMIT 1`, + [cur.PART_NO] + ); + if ((dbRes.rowCount ?? 0) > 0) { + appendNote(cur, "품번중복"); + } + if (partNoSeenInFile.has(cur.PART_NO)) { + appendNote(cur, `엑셀 내 품번중복(row ${partNoSeenInFile.get(cur.PART_NO)})`); + } else { + partNoSeenInFile.set(cur.PART_NO, i + 1); + } + } + + if (!cur.PART_NAME) appendNote(cur, "필수입력 - 품명"); + + // PART_TYPE (범주 이름) — 코드명 → code_id + const partTypeIn = getCell(row, 7); + if (partTypeIn) { + const hit = partTypeMap.get(partTypeIn.toUpperCase()); + if (hit) { + cur.PART_TYPE = hit.code_id; + cur.PART_TYPE_NAME = hit.code_name; + } else { + cur.PART_TYPE = partTypeIn; + appendNote(cur, `범주 이름 확인:${partTypeIn}`); + } + } + + // ACCTFG (계정구분) + const acctfgIn = getCell(row, 9); + if (acctfgIn) { + if (/^\d+$/.test(acctfgIn)) { + cur.ACCTFG = acctfgIn; + } else { + const hit = acctfgMap.get(acctfgIn.toUpperCase()); + if (hit) { cur.ACCTFG = hit.code_id; cur.ACCTFG_NAME = hit.code_name; } + else cur.ACCTFG = acctfgIn; + } + } + + // ODRFG (조달구분) + const odrfgIn = getCell(row, 10); + cur.ODRFG = mapOdrfg(odrfgIn); + if (odrfgIn && cur.ODRFG === odrfgIn && !/^[018]$/.test(odrfgIn)) { + appendNote(cur, `조달구분 확인:${odrfgIn}`); + } + cur.ODRFG_NAME = cur.ODRFG === "0" ? "구매" + : cur.ODRFG === "1" ? "생산" + : cur.ODRFG === "8" ? "Phantom" : odrfgIn; + + // UNIT_DC (재고단위) + const unitDcIn = getCell(row, 11); + if (unitDcIn) { + if (/^\d+$/.test(unitDcIn)) cur.UNIT_DC = unitDcIn; + else { + const hit = unitDcMap.get(unitDcIn.toUpperCase()); + if (hit) { cur.UNIT_DC = hit.code_id; cur.UNIT_DC_NAME = hit.code_name; } + else cur.UNIT_DC = unitDcIn; + } + } + + // UNITMANG_DC (관리단위) — 동일 매핑 + const unitmangIn = getCell(row, 12); + if (unitmangIn) { + if (/^\d+$/.test(unitmangIn)) cur.UNITMANG_DC = unitmangIn; + else { + const hit = unitDcMap.get(unitmangIn.toUpperCase()); + if (hit) { cur.UNITMANG_DC = hit.code_id; cur.UNITMANG_DC_NAME = hit.code_name; } + else cur.UNITMANG_DC = unitmangIn; + } + } + + cur.LOT_FG = mapKr01(getCell(row, 14), "사용", "미사용", "0"); + cur.USE_YN = mapKr01(getCell(row, 15), "사용", "미사용", "1"); + cur.QC_FG = mapKr01(getCell(row, 16), "검사", "무검사", "0"); + cur.SETITEM_FG = mapKr01(getCell(row, 17), "여", "부", "0"); + cur.REQ_FG = mapKr01(getCell(row, 18), "여", "부", "0"); + + result.push(cur); + emptyRowCnt = 0; + } + } finally { + client.release(); + } + + const hasError = result.some((r) => r.NOTE); + return { rows: result, hasError }; +} + +// ─── 2) 저장 (mergePartMng 1:1, 신규만 INSERT) ────────────── + +export interface SavePartExcelInput { + PART_NO: string; + PART_NAME: string; + MATERIAL?: string; + HEAT_TREATMENT_HARDNESS?: string; + HEAT_TREATMENT_METHOD?: string; + SURFACE_TREATMENT?: string; + MAKER?: string; + PART_TYPE?: string; + SPEC?: string; + ACCTFG?: string; + ODRFG?: string; + UNIT_DC?: string; + UNITMANG_DC?: string; + UNITCHNG_NB?: string | number; + LOT_FG?: string; + USE_YN?: string; + QC_FG?: string; + SETITEM_FG?: string; + REQ_FG?: string; + UNIT_LENGTH?: string; + UNIT_QTY?: string; + REMARK?: string; +} + +export async function saveExcelRows( + userId: string, + rows: SavePartExcelInput[] +): Promise<{ inserted: number; skipped: number; skippedPartNos: string[] }> { + if (!rows || rows.length === 0) return { inserted: 0, skipped: 0, skippedPartNos: [] }; + + let inserted = 0; + let skipped = 0; + const skippedPartNos: string[] = []; + + await transaction(async (client: PoolClient) => { + for (const r of rows) { + const partNo = (r.PART_NO ?? "").trim(); + if (!partNo) { skipped++; continue; } + + // wace partMng.getPartObjid — IS_LAST='1' 인 동일 part_no 가 있으면 INSERT 스킵 + const existRes = await client.query( + `SELECT OBJID FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [partNo] + ); + if ((existRes.rowCount ?? 0) > 0) { + skipped++; + skippedPartNos.push(partNo); + continue; + } + + const objid = createObjId(); + // wace mergePartMng 1:1 (엑셀 임포트가 채우는 컬럼만) + await client.query( + `INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, SPEC, MATERIAL, PART_TYPE, REMARK, + STATUS, REG_DATE, WRITER, IS_LAST, + MAKER, + HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, + ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB, + LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG, + UNIT_LENGTH, UNIT_QTY + ) VALUES ( + $1::numeric, $2, $3, $4, $5, $6, $7, + 'create', NOW(), $8, '1', + $9, + $10, $11, $12, + $13, $14, $15, $16, + CASE WHEN $17 = '' OR $17 IS NULL THEN NULL ELSE $17::numeric END, + COALESCE($18, '0'), COALESCE($19, '1'), COALESCE($20, '0'), + COALESCE($21, '0'), COALESCE($22, '0'), + $23, $24 + )`, + [ + objid, partNo, r.PART_NAME ?? "", r.SPEC ?? null, r.MATERIAL ?? null, + r.PART_TYPE ?? null, r.REMARK ?? null, + userId, + r.MAKER ?? null, + r.HEAT_TREATMENT_HARDNESS ?? null, r.HEAT_TREATMENT_METHOD ?? null, r.SURFACE_TREATMENT ?? null, + r.ACCTFG ?? null, r.ODRFG ?? null, r.UNIT_DC ?? null, r.UNITMANG_DC ?? null, + r.UNITCHNG_NB === undefined || r.UNITCHNG_NB === null ? "" : String(r.UNITCHNG_NB), + r.LOT_FG ?? null, r.USE_YN ?? null, r.QC_FG ?? null, r.SETITEM_FG ?? null, r.REQ_FG ?? null, + r.UNIT_LENGTH ?? null, r.UNIT_QTY ?? null, + ] + ); + inserted++; + } + }); + + logger.info("PART 엑셀 임포트 저장 완료", { userId, inserted, skipped }); + return { inserted, skipped, skippedPartNos }; +} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index 52f17273..c6ea0fd1 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -10,13 +10,14 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Search, Loader2, RotateCcw, Trash2, Settings, + Search, Loader2, RotateCcw, Trash2, Settings, FileSpreadsheet, } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom"; import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog"; +import { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog"; const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용) @@ -53,6 +54,7 @@ export default function EbomRegistPage() { const [statusOpen, setStatusOpen] = useState(false); const [statusObjid, setStatusObjid] = useState(null); + const [excelOpen, setExcelOpen] = useState(false); const fetchList = useCallback(async (override?: Partial) => { setLoading(true); @@ -137,6 +139,10 @@ export default function EbomRegistPage() { {loading ? : } 조회 + + +
@@ -190,6 +195,11 @@ export default function PartSearchPage() { objid={detailObjid} onEdit={handleEditFromDetail} /> +
); } diff --git a/frontend/components/development/BomReportExcelImportDialog.tsx b/frontend/components/development/BomReportExcelImportDialog.tsx new file mode 100644 index 00000000..e0f4d2fa --- /dev/null +++ b/frontend/components/development/BomReportExcelImportDialog.tsx @@ -0,0 +1,370 @@ +"use client"; + +// 개발관리 E-BOM 등록 Excel Import 다이얼로그 +// wace partMng/openBomReportExcelImportPopUp.jsp 1:1 +// - 헤더: 제품구분 / 품번(readonly, 1레벨 자동) / 품명(readonly, 1레벨 자동) / Version +// - E-BOM 복사 (기존 BOM 선택 → 그리드 채움) +// - Drag&Drop 엑셀 → 백엔드 파싱 + 검증 (NOTE 누적) +// - 저장: part_bom_report 헤더 + bom_part_qty 트리. NOTE 있는 행이 1건이라도 있으면 차단. + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { Download, Upload, Save, Loader2, FileX, Copy } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { devBomApi, BomExcelRow, BomCopySourceRow } from "@/lib/api/devBom"; + +const PRODUCT_GROUP = "0000001"; +const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + // 수정 모드 시: 기존 BOM_REPORT_OBJID (메인 그리드 행 클릭 → "Excel로 재등록" 등의 진입점) + editObjid?: string | null; + initialProductCd?: string; + onSaved: () => void; +} + +interface Column { + key: keyof BomExcelRow; + label: string; + width: string; + align?: "left" | "center" | "right"; + showNameFor?: keyof BomExcelRow; +} + +const COLUMNS: Column[] = [ + { key: "NOTE", label: "결과", width: "min-w-[220px]", align: "left" }, + { key: "PARENT_PART_NO", label: "모품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NO", label: "자품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" }, + { key: "QTY", label: "수량", width: "min-w-[70px]", align: "right" }, + { key: "ITEM_QTY", label: "항목수량", width: "min-w-[80px]", align: "right" }, + { key: "MATERIAL", label: "재질", width: "min-w-[100px]" }, + { key: "SPEC", label: "사양(규격)", width: "min-w-[110px]" }, + { key: "POST_PROCESSING", label: "후처리", width: "min-w-[100px]" }, + { key: "MAKER", label: "MAKER", width: "min-w-[110px]" }, + { key: "PART_TYPE", label: "부품유형", width: "min-w-[100px]", align: "center", showNameFor: "PART_TYPE_NAME" }, + { key: "REMARK", label: "REMARK", width: "min-w-[130px]", align: "left" }, +]; + +function displayValue(r: BomExcelRow, col: Column): string { + if (col.showNameFor) { + const name = r[col.showNameFor]; + if (name) return String(name); + } + return String(r[col.key] ?? ""); +} + +export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, initialProductCd, onSaved }: Props) { + const fileInputRef = useRef(null); + + const [productCd, setProductCd] = useState(""); + const [bomPartNo, setBomPartNo] = useState(""); + const [bomPartName, setBomPartName] = useState(""); + const [version, setVersion] = useState(""); + + const [copyOptions, setCopyOptions] = useState([]); + const [copySelect, setCopySelect] = useState(""); + + const [rows, setRows] = useState([]); + const [hasError, setHasError] = useState(false); + const [fileName, setFileName] = useState(""); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [copying, setCopying] = useState(false); + const [dragOver, setDragOver] = useState(false); + + const reset = useCallback(() => { + setProductCd(initialProductCd ?? ""); + setBomPartNo(""); + setBomPartName(""); + setVersion(""); + setRows([]); + setHasError(false); + setFileName(""); + setCopySelect(""); + }, [initialProductCd]); + + // open 시 초기화 + 복사 옵션 로드 + useEffect(() => { + if (!open) return; + reset(); + devBomApi.excelCopySource().then(setCopyOptions).catch(() => setCopyOptions([])); + }, [open, reset]); + + const handleDialogChange = (v: boolean) => { + if (!v) reset(); + onOpenChange(v); + }; + + // wace gridFn.search().gridComplete — 1레벨(PARENT_PART_NO 없는 첫 행) → 헤더 자동 채움 + const applyFirstLevelToHeader = (first: { part_no: string; part_name: string } | null) => { + if (!first) return; + if (first.part_no) setBomPartNo(first.part_no); + if (first.part_name) setBomPartName(first.part_name); + }; + + const parseFile = useCallback(async (file: File) => { + if (!/\.xlsx?$/i.test(file.name)) { + toast.error("xlsx 또는 xls 파일만 업로드 가능합니다."); + return; + } + setParsing(true); + setFileName(file.name); + try { + const data = await devBomApi.excelParse(file); + setRows(data.rows ?? []); + setHasError(!!data.hasError); + applyFirstLevelToHeader(data.firstLevel); + if (!data.rows || data.rows.length === 0) { + toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요."); + } else if (data.hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + } else { + toast.success(`${data.rows.length}건 파싱 완료`); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); + setRows([]); setHasError(false); setFileName(""); + } finally { + setParsing(false); + } + }, []); + + const handleFileInput = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) parseFile(f); + e.target.value = ""; + }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) parseFile(f); + }; + + const handleCopy = async () => { + if (!copySelect) { toast.error("복사할 BOM을 선택하세요."); return; } + setCopying(true); + try { + const copied = await devBomApi.excelCopy(copySelect); + setRows(copied); + setHasError(false); + const first = copied.find((r) => !r.PARENT_PART_NO); + if (first) applyFirstLevelToHeader({ part_no: first.PART_NO, part_name: first.PART_NAME }); + toast.success(`BOM 데이터 ${copied.length}건 불러왔습니다.`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 복사 실패"); + } finally { + setCopying(false); + } + }; + + const handleSave = async () => { + if (!productCd) { toast.error("제품구분을 선택해 주세요."); return; } + if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; } + if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; } + if (hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + return; + } + // wace fn_checkDuplicatePartNo 1:1 — 헤더 PART_NO 가 다른 BOM 에 이미 있으면 거부 (편집 중 자신 제외) + try { + const dup = await devBomApi.excelCheckDuplicate(bomPartNo, editObjid ?? undefined); + if (dup) { + toast.error("입력한 품번이 이미 존재합니다. 다른 품번을 입력해주세요."); + return; + } + } catch { /* 중복 확인 실패는 비차단 */ } + + const confirmMsg = rows.length > 0 ? "저장 하시겠습니까?" : "품번, 품명으로 빈 E-BOM을 생성하시겠습니까?"; + if (!confirm(confirmMsg)) return; + + setSaving(true); + try { + const result = await devBomApi.excelSave({ + bomReportObjid: editObjid ?? undefined, + productCd, partNo: bomPartNo, partName: bomPartName, version, + rows, + }); + toast.success(`${result.mode === "create" ? "등록" : "수정"} 완료 — BOM ${result.bomRows}건 (PART 신규 ${result.insertedParts} / 수정 ${result.updatedParts})`); + onSaved(); + handleDialogChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const errorCount = useMemo(() => rows.filter((r) => r.NOTE).length, [rows]); + + return ( + + + + PART 및 구조등록 Excel upload + + + {/* 헤더 */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + setVersion(e.target.value)} placeholder="REV 등" /> +
+
+ + {/* E-BOM 복사 + 액션 버튼 */} +
+
+ + + +
+
+ + + + {rows.length > 0 && ( + + )} +
+
+ +
+ {fileName && {fileName}} + 총 {rows.length}건 + {errorCount > 0 && 에러 {errorCount}건} +
+ + {/* Drop Zone */} + {rows.length === 0 && !parsing && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +
+ Drag & Drop 또는 클릭하여 BOM 엑셀 템플릿 업로드 (.xlsx, .xls) +
+
+ )} + + {parsing && ( +
+ 파싱 중... +
+ )} + + {/* 결과 그리드 */} + {rows.length > 0 && !parsing && ( +
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {rows.map((r, i) => ( + + + {COLUMNS.map((c) => { + const value = displayValue(r, c); + const isNote = c.key === "NOTE"; + return ( + + ); + })} + + ))} + +
# + {c.label} +
{i + 1} + {value} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/development/PartExcelImportDialog.tsx b/frontend/components/development/PartExcelImportDialog.tsx new file mode 100644 index 00000000..df943cee --- /dev/null +++ b/frontend/components/development/PartExcelImportDialog.tsx @@ -0,0 +1,280 @@ +"use client"; + +// 개발관리 PART Excel Import 다이얼로그 +// wace partMng/openPartExcelImportPopUp.jsp 1:1 +// - Template Download / Drag & Drop / 파일선택 → 백엔드 파싱 + 검증 +// - 검증 그리드 22컬럼 + NOTE (에러는 빨강) — wace expenseDetailGrid 1:1 +// - NOTE 있는 행이 하나라도 있으면 저장 차단 (wace fn_save 1:1) + +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Download, Upload, Save, Loader2, FileX } from "lucide-react"; +import { toast } from "sonner"; +import { devPartApi, PartExcelRow } from "@/lib/api/devPart"; +import { cn } from "@/lib/utils"; + +const TEMPLATE_DOWNLOAD_URL = "/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +} + +interface Column { + key: keyof PartExcelRow; + label: string; + width: string; + align?: "left" | "center" | "right"; + showNameFor?: keyof PartExcelRow; +} + +const COLUMNS: Column[] = [ + { key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" }, + { key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" }, + { key: "MATERIAL", label: "재료", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[110px]" }, + { key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[110px]" }, + { key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" }, + { key: "MAKER", label: "메이커", width: "min-w-[110px]" }, + { key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" }, + { key: "SPEC", label: "규격", width: "min-w-[100px]" }, + { key: "ACCTFG", label: "계정구분", width: "min-w-[90px]", align: "center", showNameFor: "ACCTFG_NAME" }, + { key: "ODRFG", label: "조달구분", width: "min-w-[90px]", align: "center", showNameFor: "ODRFG_NAME" }, + { key: "UNIT_DC", label: "재고단위", width: "min-w-[80px]", align: "center", showNameFor: "UNIT_DC_NAME" }, + { key: "UNITMANG_DC", label: "관리단위", width: "min-w-[80px]", align: "center", showNameFor: "UNITMANG_DC_NAME" }, + { key: "UNITCHNG_NB", label: "환산수량", width: "min-w-[80px]", align: "right" }, + { key: "LOT_FG", label: "LOT구분", width: "min-w-[80px]", align: "center" }, + { key: "USE_YN", label: "사용여부", width: "min-w-[80px]", align: "center" }, + { key: "QC_FG", label: "검사여부", width: "min-w-[80px]", align: "center" }, + { key: "SETITEM_FG", label: "SET품여부", width: "min-w-[90px]", align: "center" }, + { key: "REQ_FG", label: "의뢰여부", width: "min-w-[80px]", align: "center" }, + { key: "UNIT_LENGTH", label: "개당길이", width: "min-w-[80px]", align: "right" }, + { key: "UNIT_QTY", label: "개당소요량", width: "min-w-[90px]", align: "right" }, + { key: "REMARK", label: "비고", width: "min-w-[130px]", align: "left" }, +]; + +const LABEL_LOT = { "0": "미사용", "1": "사용" } as Record; +const LABEL_USE = { "0": "미사용", "1": "사용" } as Record; +const LABEL_QC = { "0": "무검사", "1": "검사" } as Record; +const LABEL_YN = { "0": "부", "1": "여" } as Record; + +function displayValue(r: PartExcelRow, col: Column): string { + if (col.showNameFor) { + const name = r[col.showNameFor]; + if (name) return String(name); + return String(r[col.key] ?? ""); + } + const v = String(r[col.key] ?? ""); + if (col.key === "LOT_FG") return LABEL_LOT[v] ?? v; + if (col.key === "USE_YN") return LABEL_USE[v] ?? v; + if (col.key === "QC_FG") return LABEL_QC[v] ?? v; + if (col.key === "SETITEM_FG" || col.key === "REQ_FG") return LABEL_YN[v] ?? v; + return v; +} + +export function PartExcelImportDialog({ open, onOpenChange, onSaved }: Props) { + const fileInputRef = useRef(null); + const [parsedRows, setParsedRows] = useState([]); + const [hasError, setHasError] = useState(false); + const [fileName, setFileName] = useState(""); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [dragOver, setDragOver] = useState(false); + + const reset = useCallback(() => { + setParsedRows([]); + setHasError(false); + setFileName(""); + }, []); + + const handleDialogChange = (v: boolean) => { + if (!v) reset(); + onOpenChange(v); + }; + + const parseFile = useCallback(async (file: File) => { + if (!/\.xlsx?$/i.test(file.name)) { + toast.error("xlsx 또는 xls 파일만 업로드 가능합니다."); + return; + } + setParsing(true); + setFileName(file.name); + try { + const data = await devPartApi.excelParse(file); + setParsedRows(data.rows ?? []); + setHasError(!!data.hasError); + if (!data.rows || data.rows.length === 0) { + toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요."); + } else if (data.hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + } else { + toast.success(`${data.rows.length}건 파싱 완료`); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); + reset(); + } finally { + setParsing(false); + } + }, [reset]); + + const handleFileInput = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) parseFile(f); + e.target.value = ""; + }; + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) parseFile(f); + }; + + const handleSave = async () => { + if (parsedRows.length === 0) { toast.error("저장할 데이터가 없습니다."); return; } + if (hasError) { + toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + return; + } + if (!confirm("저장 하시겠습니까?")) return; + setSaving(true); + try { + const res = await devPartApi.excelSave(parsedRows); + toast.success(`${res.inserted}건이 저장되었습니다.${res.skipped > 0 ? ` (중복 ${res.skipped}건 건너뜀)` : ""}`); + onSaved(); + handleDialogChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const errorCount = useMemo(() => parsedRows.filter((r) => r.NOTE).length, [parsedRows]); + + return ( + + + + PART 등록 Excel upload + + +
+ + + + {fileName && ( + + {fileName} + + )} + {parsedRows.length > 0 && ( + + )} +
+ 총 {parsedRows.length}건 + {errorCount > 0 && 에러 {errorCount}건} +
+
+ + {/* Drop Zone — 파싱 전에만 노출 */} + {parsedRows.length === 0 && !parsing && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +
+ Drag & Drop 또는 클릭하여 엑셀 템플릿 업로드 (.xlsx, .xls) +
+
+ )} + + {parsing && ( +
+ 파싱 중... +
+ )} + + {/* 결과 그리드 */} + {parsedRows.length > 0 && !parsing && ( +
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {parsedRows.map((r, i) => ( + + + {COLUMNS.map((c) => { + const value = displayValue(r, c); + const isNote = c.key === "NOTE"; + return ( + + ); + })} + + ))} + +
# + {c.label} +
{i + 1} + {value} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index fff3363b..90fde805 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -143,4 +143,88 @@ export const devBomApi = { const res = await apiClient.get("/development/ebom-tree/descending", { params: filter }); return res.data?.data as BomTreeResponse; }, + + // Excel Import + async excelParse(file: File): Promise { + const fd = new FormData(); + fd.append("file", file); + const res = await apiClient.post("/development/ebom/excel-parse", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data?.data as BomExcelParseResponse; + }, + + async excelCheckDuplicate(partNo: string, exclude?: string): Promise { + const res = await apiClient.get("/development/ebom/excel-check-duplicate", { + params: { partNo, exclude }, + }); + return !!res.data?.data?.isDuplicate; + }, + + async excelCopySource(productCd?: string): Promise { + const res = await apiClient.get("/development/ebom/excel-copy-source", { + params: productCd ? { productCd } : undefined, + }); + return (res.data?.data as BomCopySourceRow[]) ?? []; + }, + + async excelCopy(objid: string): Promise { + const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`); + return ((res.data?.data?.rows as BomExcelRow[]) ?? []); + }, + + async excelSave(input: BomExcelSaveInput): Promise { + const res = await apiClient.post("/development/ebom/excel-save", input); + return res.data?.data as BomExcelSaveResult; + }, }; + +// ─── Excel Import 타입 ───────────────────────────────────── + +export interface BomExcelRow { + NOTE: string; + PARENT_PART_NO: string; + PART_NO: string; + PART_NAME: string; + QTY: string; + ITEM_QTY: string; + MATERIAL: string; + SPEC: string; + POST_PROCESSING: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME?: string; + REMARK: string; +} + +export interface BomExcelParseResponse { + rows: BomExcelRow[]; + hasError: boolean; + firstLevel: { part_no: string; part_name: string } | null; +} + +export interface BomCopySourceRow { + objid: string; + part_no: string; + part_name: string; + revision: string | null; + product_cd: string | null; + regdate: string | null; +} + +export interface BomExcelSaveInput { + bomReportObjid?: string; + productCd: string; + partNo: string; + partName: string; + version?: string; + rows: BomExcelRow[]; +} + +export interface BomExcelSaveResult { + bomReportObjid: string; + insertedParts: number; + updatedParts: number; + bomRows: number; + mode: "create" | "update"; +} diff --git a/frontend/lib/api/devPart.ts b/frontend/lib/api/devPart.ts index 3798a600..caea8991 100644 --- a/frontend/lib/api/devPart.ts +++ b/frontend/lib/api/devPart.ts @@ -203,6 +203,50 @@ export interface DeployResult { eo_nos: Record; } +// ─── Excel Import ──────────────────────────────────────────── + +export interface PartExcelRow { + NOTE: string; + PART_NO: string; + PART_NAME: string; + MATERIAL: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME?: string; + SPEC: string; + ACCTFG: string; + ACCTFG_NAME?: string; + ODRFG: string; + ODRFG_NAME?: string; + UNIT_DC: string; + UNIT_DC_NAME?: string; + UNITMANG_DC: string; + UNITMANG_DC_NAME?: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; + UNIT_LENGTH: string; + UNIT_QTY: string; + REMARK: string; +} + +export interface ExcelParseResponse { + rows: PartExcelRow[]; + hasError: boolean; +} + +export interface ExcelSaveResponse { + inserted: number; + skipped: number; + skippedPartNos: string[]; +} + // ─── API ──────────────────────────────────────────────────── export const devPartApi = { @@ -246,4 +290,20 @@ export const devPartApi = { const res = await apiClient.delete("/development/part", { data: { objids } }); return res.data; }, + + // Excel Import — 파싱 + 검증 + async excelParse(file: File): Promise { + const fd = new FormData(); + fd.append("file", file); + const res = await apiClient.post("/development/part/excel-parse", fd, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data?.data as ExcelParseResponse; + }, + + // Excel Import — 저장 (신규 PART_NO 만 INSERT) + async excelSave(rows: PartExcelRow[]): Promise { + const res = await apiClient.post("/development/part/excel-save", { rows }); + return res.data?.data as ExcelSaveResponse; + }, }; diff --git a/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx new file mode 100644 index 00000000..e763b48c Binary files /dev/null and b/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx differ diff --git a/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx new file mode 100644 index 00000000..ebf7d495 Binary files /dev/null and b/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx differ