diff --git a/backend-node/src/services/devBomExcelImportService.ts b/backend-node/src/services/devBomExcelImportService.ts index 9f6d0a6a..b118aced 100644 --- a/backend-node/src/services/devBomExcelImportService.ts +++ b/backend-node/src/services/devBomExcelImportService.ts @@ -1,68 +1,170 @@ // ============================================================ -// 개발관리 BOM Report Excel Import 서비스 — wace_plm PartMngService 1:1 +// 개발관리 BOM Report CSV Import 서비스 — wace_plm PartMngService.parsingCsvFile 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 partMng/openBomReportExcelImportPopUp.jsp): +// · Drop Zone: "Drag & Drop CSV 템플릿" + fnc_setFileDropZone(..., "csv") → CSV 전용 +// · /partMng/parsingExcelFile.do 의 .csv 분기에서 parsingCsvFile() 호출 +// · /partMng/partBomApplySave.do 의 savePartBomMaster() 로 저장 // -// 검증 (wace parsingExcelFile 1:1): -// · 자품번 필수 / 엑셀 내 중복 -// · 모품번이 자품번 목록(allPartNumbers Set)에 존재해야 함 (1레벨 = 첫 데이터 행은 제외) -// · 품명 필수 -// · 수량 필수 + 숫자 + > 0 -// · PART_TYPE 코드명 → code_id (못 찾으면 NOTE "부품유형 확인") -// · PART_TYPE='0001788'(구매품표준) → part_mng.part_no 존재 검증 (NOTE "구매품표준 미등록") +// CSV 컬럼 (11개, 헤더 1줄 후 데이터): +// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법 +// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE) +// (11번 이후 컬럼은 파싱하지 않음 — 범주이름에 따라 자동값 설정) // -// 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컬럼) +// 핵심: PARENT_PART_NO 컬럼이 없음. "수준(level)" 으로 부모-자식 자동 결정. +// · 숫자("1","2","3","4"): 깊이 D 행의 부모 = depthToPartNoMap[D-1] (마지막에 등록된 D-1 깊이 품번) +// · 점 표기법("1","1.1","1.4.1"): 마지막 점 이전이 부모 수준 → levelToPartNoMap[parentLevel] +// +// CSV 자동 변환: +// · Assy/ASSY → 조립품, Buy/BUY → 구매품, Make/MAKE → 부품 (영문 PART_TYPE → 한글) +// · 범주 → 계정구분/조달구분 자동 (조립품 0001813 / 부품 0001812 → ACCTFG=4, ODRFG=1; +// 구매품 0000063 → ACCTFG=7, ODRFG=0) +// · 기본값 일괄: UNIT_DC=0001400(EA), UNITMANG_DC=0001400, UNITCHNG_NB=1, +// LOT_FG=1(사용), USE_YN=1(사용), QC_FG=0(무검사), SETITEM_FG=0(부), REQ_FG=0(부) +// +// 검증 (wace 1:1): +// · 모품번 자품번 목록 존재 검증은 rowIndex > 2 일 때만 (1·2 레벨 면제) +// · PART_TYPE 매핑 실패: NOTE "부품유형 확인:..." +// · 결과 누적: emptyColCnt < 9 또는 NOTE 있으면 row 채택 +// +// 인코딩 자동 감지 (wace detectFileEncoding 1:1): +// CP949 → UTF-8 → EUC-KR → MS949 순서. 각각 디코딩 → 깨진 문자(�) 개수 세서 0이면 즉시 선택, +// 아니면 가장 적은 것. UTF-8 BOM(EF BB BF) 자동 제거. +// +// 저장 (savePartBomMaster 1:1): +// · 헤더 part_bom_report INSERT(신규) / DELETE 자식트리+UPDATE(수정) +// · 자식 PART: IS_LAST='1' 존재 시 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT +// · 부모 PART: 존재 시 lookup, 없으면 "" (절대 INSERT 안 함) +// · bom_part_qty INSERT (relatePartInfo) — 부모행 CHILD_OBJID 체인 // ============================================================ -import * as XLSX from "xlsx"; +import * as iconv from "iconv-lite"; 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"; +const PART_TYPE_ASSEMBLY = "0001813"; // 조립품 +const PART_TYPE_PART = "0001812"; // 부품 +const PART_TYPE_BUY = "0000063"; // 구매품 -export interface BomExcelRow { +const DEFAULT_UNIT_DC = "0001400"; // EA +const DEFAULT_UNITMANG_DC = "0001400"; // EA +const DEFAULT_UNITCHNG_NB = "1"; +const DEFAULT_LOT_FG = "1"; // 사용 +const DEFAULT_USE_YN = "1"; // 사용 +const DEFAULT_QC_FG = "0"; // 무검사 +const DEFAULT_SETITEM_FG = "0"; // 부 +const DEFAULT_REQ_FG = "0"; // 부 + +export interface BomCsvRow { NOTE: string; - PARENT_PART_NO: string; + LEVEL: string; // 표시용 (CSV 수준) + PARENT_PART_NO: string; // 수준에서 계산된 부모 품번 PART_NO: string; PART_NAME: string; QTY: string; ITEM_QTY: string; MATERIAL: string; - SPEC: string; - POST_PROCESSING: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; MAKER: string; - PART_TYPE: string; - PART_TYPE_NAME?: string; - REMARK: string; + PART_TYPE: string; // code_id (자동 변환된 한글 → DB 조회 결과) + PART_TYPE_NAME: string; // 한글 (Assy→조립품 변환 적용) + ACCTFG: string; + ODRFG: string; + UNIT_DC: string; + UNITMANG_DC: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; } -function appendNote(r: BomExcelRow, msg: string) { +function appendNote(r: BomCsvRow, 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(); + +// ─── 헬퍼 ─────────────────────────────────────────────────── + +// wace getCsvValue 1:1 — 따옴표 제거 + 빈 카운트 +function getCsvValue(values: string[], idx: number, emptyCounter: { count: number }): string { + if (idx >= values.length) { + emptyCounter.count++; + return ""; + } + let v = (values[idx] ?? "").trim(); + if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { + v = v.substring(1, v.length - 1); + } + if (!v) emptyCounter.count++; + return v; +} + +// wace getParentLevel 1:1: "1.4.1" → "1.4" / "1.8" → "1" / "1" → "" +function getParentLevel(level: string): string { + if (!level) return ""; + const lastDot = level.lastIndexOf("."); + return lastDot > 0 ? level.substring(0, lastDot) : ""; +} + +// CSV 영문 범주명 → 한글 (wace 1:1) +function normalizePartTypeName(raw: string): string { + const upper = raw.toUpperCase(); + if (upper === "ASSY") return "조립품"; + if (upper === "BUY") return "구매품"; + if (upper === "MAKE") return "부품"; + return raw; +} + +// 범주 code_id → ACCTFG/ODRFG 자동 매핑 (wace 1:1) +function autoAcctfgOdrfg(partTypeCode: string): { acctfg: string; odrfg: string } { + if (partTypeCode === PART_TYPE_ASSEMBLY || partTypeCode === PART_TYPE_PART) { + return { acctfg: "4", odrfg: "1" }; // 반제품/생산 + } + if (partTypeCode === PART_TYPE_BUY) { + return { acctfg: "7", odrfg: "0" }; // 비용/구매 + } + return { acctfg: "", odrfg: "" }; +} + +// 인코딩 자동 감지 (wace detectFileEncoding 1:1) +function detectAndDecode(buffer: Buffer): { text: string; encoding: string } { + const encodings = ["cp949", "utf-8", "euc-kr", "ms949"]; + let bestEncoding = "utf-8"; + let bestText = ""; + let minReplacement = Number.POSITIVE_INFINITY; + + for (const enc of encodings) { + try { + const decoded = iconv.decode(buffer, enc); + let replacementCount = 0; + const sample = decoded.substring(0, 4096); + for (let i = 0; i < sample.length; i++) { + if (sample.charCodeAt(i) === 0xFFFD) replacementCount++; + } + if (replacementCount === 0) { + return { text: stripBom(decoded), encoding: enc }; + } + if (replacementCount < minReplacement) { + minReplacement = replacementCount; + bestEncoding = enc; + bestText = decoded; + } + } catch { + continue; + } + } + return { text: stripBom(bestText), encoding: bestEncoding }; +} + +function stripBom(s: string): string { + if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) return s.substring(1); + return s; } async function fetchPartTypeMap(client: PoolClient): Promise> { @@ -76,142 +178,151 @@ async function fetchPartTypeMap(client: PoolClient): Promise { - 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 { text, encoding } = detectAndDecode(buffer); + const lines = text.split(/\r\n|\r|\n/); - // wace 1: 모든 자품번을 먼저 Set 으로 수집 (모품번 검증용) + // 1차 스캔: 모든 자품번 + 수준→품번 매핑 (wace 1:1) 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 allRows: string[][] = []; + const levelToPartNoMap = new Map(); + + for (let i = 0; i < lines.length; i++) { + if (lines[i] === undefined) continue; + const values = lines[i].split(","); + allRows.push(values); + if (i > 0 && values.length > 1) { + let level = (values[0] ?? "").trim(); + let partNo = (values[1] ?? "").trim(); + if (level.length > 1 && level.startsWith('"') && level.endsWith('"')) level = level.substring(1, level.length - 1); + if (partNo.length > 1 && partNo.startsWith('"') && partNo.endsWith('"')) partNo = partNo.substring(1, partNo.length - 1); + if (partNo) { + allPartNumbers.add(partNo); + if (level) levelToPartNoMap.set(level, partNo); + } + } } - const result: BomExcelRow[] = []; + const result: BomCsvRow[] = []; let firstLevel: { part_no: string; part_name: string } | null = null; + // 2차 스캔: 행 파싱 + 부모 결정 + 검증 + const currentDepthPartNoMap = new Map(); // 숫자 수준용 (wace 1:1) + 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; + let rowIndex = 0; + for (const values of allRows) { + if (rowIndex === 0) { rowIndex++; continue; } // 헤더 skip + if (values.length < 11) { rowIndex++; continue; } // wace: 최소 11컬럼 (수준 포함) - 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); + const emptyCounter = { count: 0 }; + let colIndex = 0; + const level = getCsvValue(values, colIndex++, emptyCounter); + const partNo = getCsvValue(values, colIndex++, emptyCounter); + const partName = getCsvValue(values, colIndex++, emptyCounter); + const qty = getCsvValue(values, colIndex++, emptyCounter); + const itemQty = getCsvValue(values, colIndex++, emptyCounter); + const material = getCsvValue(values, colIndex++, emptyCounter); + const heatHardness = getCsvValue(values, colIndex++, emptyCounter); + const heatMethod = getCsvValue(values, colIndex++, emptyCounter); + const surfaceTreatment = getCsvValue(values, colIndex++, emptyCounter); + const supplier = getCsvValue(values, colIndex++, emptyCounter); + const partTypeRaw = getCsvValue(values, colIndex++, emptyCounter); - // wace: PART_NO+PART_NAME 둘 다 빈 행은 스킵 (3줄 연속이면 break) - if (!partNo && !partName) { - emptyRowCnt++; - if (emptyRowCnt > 3) break; - continue; + // 수준 → 부모 결정 (wace 1:1) + let parentPartNo = ""; + if (level) { + const asInt = Number(level); + if (Number.isInteger(asInt) && /^\d+$/.test(level)) { + // 숫자 수준: 깊이 D 행 → D-1 깊이의 최신 품번이 부모 + const currentDepth = asInt; + if (partNo) currentDepthPartNoMap.set(currentDepth, partNo); + if (currentDepth > 1) { + parentPartNo = currentDepthPartNoMap.get(currentDepth - 1) ?? ""; + } + } else { + // 점 표기법: 마지막 점 이전 = 부모 수준 + const parentLevel = getParentLevel(level); + if (parentLevel) parentPartNo = levelToPartNoMap.get(parentLevel) ?? ""; + } } - emptyRowCnt = 0; - dataRowIndex++; - const cur: BomExcelRow = { - NOTE: "", + let noteMsg = ""; + + // 모품번 자품번 목록 존재 검증 (wace: rowIndex > 2 일 때만) + if (parentPartNo && rowIndex > 2 && !allPartNumbers.has(parentPartNo)) { + noteMsg += `모품번 미존재: ${parentPartNo} ; `; + } + + // PART_TYPE 처리 (wace 1:1) + const partTypeNormalized = partTypeRaw ? normalizePartTypeName(partTypeRaw) : ""; + let partTypeCode = ""; + if (partTypeNormalized) { + const hit = partTypeMap.get(partTypeNormalized.toUpperCase()); + if (hit) { + partTypeCode = hit.code_id; + } else if (rowIndex > 2) { + noteMsg += `부품유형 확인: ${partTypeRaw} ; `; + } + } + + const { acctfg, odrfg } = autoAcctfgOdrfg(partTypeCode); + + const row: BomCsvRow = { + NOTE: noteMsg.trim().replace(/\s*;\s*$/, ""), + LEVEL: level, PARENT_PART_NO: parentPartNo, PART_NO: partNo, PART_NAME: partName, QTY: qty, - ITEM_QTY: qty, // wace: ITEM_QTY 컬럼 없으면 QTY 와 동일 (savePartBomMaster 에서도 QTY 로 채움) + ITEM_QTY: itemQty || qty, MATERIAL: material, - SPEC: spec, - POST_PROCESSING: postProc, - MAKER: maker, - PART_TYPE: "", - REMARK: remark, + HEAT_TREATMENT_HARDNESS: heatHardness, + HEAT_TREATMENT_METHOD: heatMethod, + SURFACE_TREATMENT: surfaceTreatment, + MAKER: supplier, + PART_TYPE: partTypeCode, + PART_TYPE_NAME: partTypeNormalized, + ACCTFG: acctfg, + ODRFG: odrfg, + UNIT_DC: DEFAULT_UNIT_DC, + UNITMANG_DC: DEFAULT_UNITMANG_DC, + UNITCHNG_NB: DEFAULT_UNITCHNG_NB, + LOT_FG: DEFAULT_LOT_FG, + USE_YN: DEFAULT_USE_YN, + QC_FG: DEFAULT_QC_FG, + SETITEM_FG: DEFAULT_SETITEM_FG, + REQ_FG: DEFAULT_REQ_FG, }; - // (1) 자품번 필수 - if (!partNo) { - appendNote(cur, "필수입력 - 품번"); - } else { - if (partNoSeenInFile.has(partNo)) { - appendNote(cur, `품번 중복: ${partNo}`); - } else { - partNoSeenInFile.set(partNo, dataRowIndex); - } + // wace: NOTE 있거나 emptyColCnt < 9 면 결과 채택 + if (row.NOTE || emptyCounter.count < 9) { + result.push(row); } - // (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레벨 (모품번 없는 첫 행)을 헤더 자동 채움 용도로 캡쳐 + // 첫 1레벨(parent 없는 첫 행) → 헤더 자동 if (!firstLevel && !parentPartNo && partNo) { firstLevel = { part_no: partNo, part_name: partName }; } - result.push(cur); + rowIndex++; } } finally { client.release(); } const hasError = result.some((r) => r.NOTE); - return { rows: result, hasError, firstLevel }; + return { rows: result, hasError, firstLevel, encoding }; } // ─── 2) 헤더 part_no 중복 검사 (wace checkDuplicatePartNo) ── @@ -226,52 +337,79 @@ export async function checkDuplicateBomPartNo(partNo: string, excludeObjid?: str return (r.rowCount ?? 0) > 0; } -// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomExcelRow[] ────── +// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomCsvRow[] ──────── -export async function copyBomForGrid(sourceObjid: string): Promise { +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 + WITH RECURSIVE TREE AS ( + SELECT BP.OBJID, BP.PARENT_OBJID, BP.CHILD_OBJID, BP.PART_NO, BP.PARENT_PART_NO, BP.QTY, BP.ITEM_QTY, + 1 AS LEV, ARRAY[BP.SEQ] AS PATH + FROM BOM_PART_QTY BP + WHERE BP.BOM_REPORT_OBJID = $1 + AND (BP.PARENT_OBJID IS NULL OR BP.PARENT_OBJID = '') + UNION ALL + SELECT B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, B.PART_NO, B.PARENT_PART_NO, B.QTY, B.ITEM_QTY, + T.LEV + 1, T.PATH || B.SEQ + FROM BOM_PART_QTY B + JOIN TREE T ON B.PARENT_OBJID = T.CHILD_OBJID + WHERE B.BOM_REPORT_OBJID = $1 + ) + SELECT T.LEV, + PM.PART_NO AS PART_NO_REAL, + PM.PART_NAME AS PART_NAME, + T.QTY, T.ITEM_QTY, + PM.MATERIAL, PM.HEAT_TREATMENT_HARDNESS, PM.HEAT_TREATMENT_METHOD, PM.SURFACE_TREATMENT, + PM.MAKER, PM.PART_TYPE, + CC.CODE_NAME AS PART_TYPE_NAME, + PM_PARENT.PART_NO AS PARENT_PART_NO_REAL, + PM.ACCTFG, PM.ODRFG, + PM.UNIT_DC, PM.UNITMANG_DC, PM.UNITCHNG_NB, + PM.LOT_FG, PM.USE_YN, PM.QC_FG, PM.SETITEM_FG, PM.REQ_FG, + T.PATH + FROM TREE T + LEFT JOIN PART_MNG PM ON PM.OBJID::varchar = T.PART_NO + LEFT JOIN PART_MNG PM_PARENT ON PM_PARENT.OBJID::varchar = T.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 + ORDER BY T.PATH `; const r = await getPool().query(sql, [sourceObjid]); - return r.rows.map((row): BomExcelRow => ({ + return r.rows.map((row): BomCsvRow => ({ NOTE: "", + LEVEL: String(row.lev ?? ""), 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 ?? "", + HEAT_TREATMENT_HARDNESS: row.heat_treatment_hardness ?? "", + HEAT_TREATMENT_METHOD: row.heat_treatment_method ?? "", + SURFACE_TREATMENT: row.surface_treatment ?? "", MAKER: row.maker ?? "", PART_TYPE: row.part_type ?? "", PART_TYPE_NAME: row.part_type_name ?? "", - REMARK: row.remark ?? "", + ACCTFG: row.acctfg ?? "", + ODRFG: row.odrfg ?? "", + UNIT_DC: row.unit_dc ?? DEFAULT_UNIT_DC, + UNITMANG_DC: row.unitmang_dc ?? DEFAULT_UNITMANG_DC, + UNITCHNG_NB: row.unitchng_nb != null ? String(row.unitchng_nb) : DEFAULT_UNITCHNG_NB, + LOT_FG: row.lot_fg ?? DEFAULT_LOT_FG, + USE_YN: row.use_yn ?? DEFAULT_USE_YN, + QC_FG: row.qc_fg ?? DEFAULT_QC_FG, + SETITEM_FG: row.setitem_fg ?? DEFAULT_SETITEM_FG, + REQ_FG: row.req_fg ?? DEFAULT_REQ_FG, })); } // ─── 4) BOM 저장 (savePartBomMaster 1:1) ──────────────────── export interface BomSaveInput { - bomReportObjid?: string; // 비어있으면 신규 + bomReportObjid?: string; productCd: string; - partNo: string; // 헤더 품번 + partNo: string; partName: string; version?: string; - rows: BomExcelRow[]; + rows: BomCsvRow[]; } export interface BomSaveResult { @@ -295,7 +433,6 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis 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 @@ -322,16 +459,14 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis ); } - if (!input.rows || input.rows.length === 0) return; // wace: 빈 BOM 허용 (헤더만 생성) + if (!input.rows || input.rows.length === 0) return; - // 자식 PART_NO → part_mng.objid 캐시 (행 처리 후) - const childPartObjIdCache = new Map(); - // bom_part_qty 부모행의 CHILD_OBJID 캐시 (다음 자식들이 이 값을 PARENT_OBJID 로 사용) - const childBomObjIdByPartNo = new Map(); + const partObjIdCache = new Map(); // PART_NO → part_mng.objid (자식·부모 공용 캐시) + const childBomObjIdByPartNo = new Map(); // PART_NO → bom_part_qty.child_objid - // 자식 PART 처리: 있으면 UPDATE / 없으면 INSERT (wace 1:1) - async function upsertChildPart(r: BomExcelRow): Promise { - if (childPartObjIdCache.has(r.PART_NO)) return childPartObjIdCache.get(r.PART_NO)!; + // 자식 PART: 있으면 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT (wace 1:1) + async function upsertChildPart(r: BomCsvRow): Promise { + if (partObjIdCache.has(r.PART_NO)) return partObjIdCache.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`, @@ -339,76 +474,89 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis ); 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`, + PART_NAME = $1, + MATERIAL = $2, + HEAT_TREATMENT_HARDNESS = $3, + HEAT_TREATMENT_METHOD = $4, + SURFACE_TREATMENT = $5, + PART_TYPE = COALESCE(NULLIF($6, ''), PART_TYPE), + MAKER = $7, + ACCTFG = $8, + ODRFG = $9, + UNIT_DC = $10, + UNITMANG_DC = $11, + UNITCHNG_NB = COALESCE(NULLIF($12, ''), '0')::numeric, + LOT_FG = COALESCE(NULLIF($13, ''), LOT_FG), + USE_YN = COALESCE(NULLIF($14, ''), USE_YN), + QC_FG = COALESCE(NULLIF($15, ''), QC_FG), + SETITEM_FG = COALESCE(NULLIF($16, ''), SETITEM_FG), + REQ_FG = COALESCE(NULLIF($17, ''), REQ_FG), + EDIT_DATE = NOW() + WHERE OBJID = $18::numeric`, [ - r.PART_NAME ?? "", - r.SPEC ?? "", - r.MATERIAL ?? "", - r.REMARK ?? "", - r.PART_TYPE ?? "", - r.MAKER ?? "", - r.POST_PROCESSING ?? "", + r.PART_NAME, r.MATERIAL, + r.HEAT_TREATMENT_HARDNESS, r.HEAT_TREATMENT_METHOD, r.SURFACE_TREATMENT, + r.PART_TYPE, r.MAKER, + r.ACCTFG, r.ODRFG, + r.UNIT_DC, r.UNITMANG_DC, r.UNITCHNG_NB, + r.LOT_FG, r.USE_YN, r.QC_FG, r.SETITEM_FG, r.REQ_FG, id, ] ); updatedParts++; - childPartObjIdCache.set(r.PART_NO, id); + partObjIdCache.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, + OBJID, PART_NO, PART_NAME, MATERIAL, REMARK, STATUS, REG_DATE, WRITER, IS_LAST, - PART_TYPE, MAKER, POST_PROCESSING, + HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, + PART_TYPE, MAKER, + ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB, 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' + $1::numeric, $2, $3, $4, '', + 'create', NOW(), $5, '1', + $6, $7, $8, + NULLIF($9, ''), $10, + $11, $12, $13, $14, + COALESCE(NULLIF($15, ''), '0')::numeric, + COALESCE($16, '0'), COALESCE($17, '1'), COALESCE($18, '0'), + COALESCE($19, '0'), COALESCE($20, '0') )`, [ - newId, r.PART_NO, - r.PART_NAME ?? "", r.SPEC ?? "", r.MATERIAL ?? "", r.REMARK ?? "", + newId, r.PART_NO, r.PART_NAME, r.MATERIAL, userId, - r.PART_TYPE ?? "", r.MAKER ?? "", r.POST_PROCESSING ?? "", + r.HEAT_TREATMENT_HARDNESS, r.HEAT_TREATMENT_METHOD, r.SURFACE_TREATMENT, + r.PART_TYPE, r.MAKER, + r.ACCTFG, r.ODRFG, r.UNIT_DC, r.UNITMANG_DC, r.UNITCHNG_NB, + r.LOT_FG, r.USE_YN, r.QC_FG, r.SETITEM_FG, r.REQ_FG, ] ); insertedParts++; - childPartObjIdCache.set(r.PART_NO, newId); + partObjIdCache.set(r.PART_NO, newId); return newId; } - // 부모 PART 처리: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) + // 부모 PART: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) async function lookupParentPart(partNo: string): Promise { if (!partNo) return ""; - if (childPartObjIdCache.has(partNo)) return childPartObjIdCache.get(partNo)!; - + if (partObjIdCache.has(partNo)) return partObjIdCache.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); + partObjIdCache.set(partNo, id); return id; } - return ""; // wace 원본 5359-5361: 부모 part_mng 없으면 parent_part_no="" (INSERT 안 함) + return ""; } for (const r of input.rows) { @@ -416,15 +564,10 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis const partObjid = await upsertChildPart(r); const parentPartObjid = await lookupParentPart(r.PARENT_PART_NO); + const parentBomObjid = r.PARENT_PART_NO ? (childBomObjIdByPartNo.get(r.PARENT_PART_NO) ?? "") : ""; + const newBomObjid = createObjId(); + const newChildObjid = createObjId(); - // 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, @@ -453,7 +596,7 @@ export async function saveBomReport(userId: string, input: BomSaveInput): Promis } }); - logger.info("BOM Excel Import 저장 완료", { userId, bomReportObjid, mode, insertedParts, updatedParts, bomRows }); + logger.info("BOM CSV Import 저장 완료", { userId, bomReportObjid, mode, insertedParts, updatedParts, bomRows }); return { bomReportObjid, insertedParts, updatedParts, bomRows, mode }; } diff --git a/frontend/components/development/BomReportExcelImportDialog.tsx b/frontend/components/development/BomReportExcelImportDialog.tsx index e0f4d2fa..fae60795 100644 --- a/frontend/components/development/BomReportExcelImportDialog.tsx +++ b/frontend/components/development/BomReportExcelImportDialog.tsx @@ -1,11 +1,16 @@ "use client"; -// 개발관리 E-BOM 등록 Excel Import 다이얼로그 +// 개발관리 E-BOM 등록 CSV 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건이라도 있으면 차단. +// +// 운영판 흐름: +// · Drop Zone: Drag & Drop CSV 템플릿 (fnc_setFileDropZone(..., "csv")) +// · 파싱: parsingExcelFile.do 의 .csv 분기 → parsingCsvFile (수준 기반 부모 자동 매핑) +// · 저장: partBomApplySave.do → savePartBomMaster +// +// CSV 컬럼 (11개, 헤더 1줄 후 데이터): +// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법 +// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE) import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -18,44 +23,58 @@ 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"; +import { devBomApi, BomCsvRow, BomCopySourceRow } from "@/lib/api/devBom"; const PRODUCT_GROUP = "0000001"; -const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx"; +const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv"; interface Props { open: boolean; onOpenChange: (open: boolean) => void; - // 수정 모드 시: 기존 BOM_REPORT_OBJID (메인 그리드 행 클릭 → "Excel로 재등록" 등의 진입점) editObjid?: string | null; initialProductCd?: string; onSaved: () => void; } interface Column { - key: keyof BomExcelRow; + key: keyof BomCsvRow; label: string; width: string; align?: "left" | "center" | "right"; - showNameFor?: keyof BomExcelRow; + showNameFor?: keyof BomCsvRow; } +// 그리드 컬럼: 화면 표시는 핵심 + 자동 채움 컬럼 (운영 그리드 25컬럼 중 CSV 11컬럼 + 자동 ACCTFG/ODRFG) 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: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" }, + { key: "LEVEL", label: "수준", width: "min-w-[60px]", align: "center" }, + { 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" }, + { key: "MATERIAL", label: "재료", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[100px]" }, + { 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: "ACCTFG", label: "계정구분(자동)", width: "min-w-[90px]", align: "center" }, + { key: "ODRFG", label: "조달구분(자동)", width: "min-w-[90px]", align: "center" }, ]; -function displayValue(r: BomExcelRow, col: Column): string { +const ACCTFG_LABEL: Record = { "4": "반제품", "7": "비용" }; +const ODRFG_LABEL: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; + +function displayValue(r: BomCsvRow, col: Column): string { + if (col.key === "ACCTFG") { + const v = String(r.ACCTFG ?? ""); + return ACCTFG_LABEL[v] ?? v; + } + if (col.key === "ODRFG") { + const v = String(r.ODRFG ?? ""); + return ODRFG_LABEL[v] ?? v; + } if (col.showNameFor) { const name = r[col.showNameFor]; if (name) return String(name); @@ -74,9 +93,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init const [copyOptions, setCopyOptions] = useState([]); const [copySelect, setCopySelect] = useState(""); - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [hasError, setHasError] = useState(false); const [fileName, setFileName] = useState(""); + const [encoding, setEncoding] = useState(""); const [parsing, setParsing] = useState(false); const [saving, setSaving] = useState(false); const [copying, setCopying] = useState(false); @@ -90,10 +110,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init setRows([]); setHasError(false); setFileName(""); + setEncoding(""); setCopySelect(""); }, [initialProductCd]); - // open 시 초기화 + 복사 옵션 로드 useEffect(() => { if (!open) return; reset(); @@ -105,7 +125,6 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init 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); @@ -113,8 +132,8 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init }; const parseFile = useCallback(async (file: File) => { - if (!/\.xlsx?$/i.test(file.name)) { - toast.error("xlsx 또는 xls 파일만 업로드 가능합니다."); + if (!/\.csv$/i.test(file.name)) { + toast.error("CSV(.csv) 파일만 업로드 가능합니다."); return; } setParsing(true); @@ -123,17 +142,18 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init const data = await devBomApi.excelParse(file); setRows(data.rows ?? []); setHasError(!!data.hasError); + setEncoding(data.encoding ?? ""); applyFirstLevelToHeader(data.firstLevel); if (!data.rows || data.rows.length === 0) { - toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요."); + toast.warning("파싱된 데이터가 없습니다. CSV 형식을 확인해 주세요."); } else if (data.hasError) { - toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요."); } else { - toast.success(`${data.rows.length}건 파싱 완료`); + toast.success(`${data.rows.length}건 파싱 완료 (인코딩: ${data.encoding})`); } } catch (e: any) { - toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패"); - setRows([]); setHasError(false); setFileName(""); + toast.error(e?.response?.data?.message ?? e?.message ?? "CSV 파싱 실패"); + setRows([]); setHasError(false); setFileName(""); setEncoding(""); } finally { setParsing(false); } @@ -173,17 +193,16 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; } if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; } if (hasError) { - toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요."); + toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요."); return; } - // wace fn_checkDuplicatePartNo 1:1 — 헤더 PART_NO 가 다른 BOM 에 이미 있으면 거부 (편집 중 자신 제외) try { const dup = await devBomApi.excelCheckDuplicate(bomPartNo, editObjid ?? undefined); if (dup) { toast.error("입력한 품번이 이미 존재합니다. 다른 품번을 입력해주세요."); return; } - } catch { /* 중복 확인 실패는 비차단 */ } + } catch { /* 비차단 */ } const confirmMsg = rows.length > 0 ? "저장 하시겠습니까?" : "품번, 품명으로 빈 E-BOM을 생성하시겠습니까?"; if (!confirm(confirmMsg)) return; @@ -209,9 +228,9 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init return ( - + - PART 및 구조등록 Excel upload + PART 및 구조등록 CSV upload {/* 헤더 */} @@ -226,11 +245,11 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
- +
- +
@@ -266,17 +285,18 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init {rows.length > 0 && ( - )} @@ -285,6 +305,7 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
{fileName && {fileName}} + {encoding && 인코딩: {encoding}} 총 {rows.length}건 {errorCount > 0 && 에러 {errorCount}건}
@@ -303,7 +324,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init >
- Drag & Drop 또는 클릭하여 BOM 엑셀 템플릿 업로드 (.xlsx, .xls) + Drag & Drop 또는 클릭하여 CSV 템플릿 업로드 (.csv) +
+
+ 컬럼: 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체 / 범주이름
)} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 90fde805..fc4ba30c 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -168,9 +168,9 @@ export const devBomApi = { return (res.data?.data as BomCopySourceRow[]) ?? []; }, - async excelCopy(objid: string): Promise { + async excelCopy(objid: string): Promise { const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`); - return ((res.data?.data?.rows as BomExcelRow[]) ?? []); + return ((res.data?.data?.rows as BomCsvRow[]) ?? []); }, async excelSave(input: BomExcelSaveInput): Promise { @@ -179,28 +179,43 @@ export const devBomApi = { }, }; -// ─── Excel Import 타입 ───────────────────────────────────── +// ─── CSV Import 타입 (wace parsingCsvFile 1:1) ───────────── -export interface BomExcelRow { +export interface BomCsvRow { NOTE: string; + LEVEL: string; PARENT_PART_NO: string; PART_NO: string; PART_NAME: string; QTY: string; ITEM_QTY: string; MATERIAL: string; - SPEC: string; - POST_PROCESSING: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; MAKER: string; PART_TYPE: string; - PART_TYPE_NAME?: string; - REMARK: string; + PART_TYPE_NAME: string; + ACCTFG: string; + ODRFG: string; + UNIT_DC: string; + UNITMANG_DC: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; } +// 기존 코드 호환용 별칭 (필요 시 마이그레이션) +export type BomExcelRow = BomCsvRow; + export interface BomExcelParseResponse { - rows: BomExcelRow[]; + rows: BomCsvRow[]; hasError: boolean; firstLevel: { part_no: string; part_name: string } | null; + encoding: string; } export interface BomCopySourceRow { @@ -218,7 +233,7 @@ export interface BomExcelSaveInput { partNo: string; partName: string; version?: string; - rows: BomExcelRow[]; + rows: BomCsvRow[]; } export interface BomExcelSaveResult { diff --git a/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv b/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv new file mode 100644 index 00000000..3d9d21c1 --- /dev/null +++ b/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv @@ -0,0 +1,6 @@ +수준,품번,품명,수량,항목수량,재료,열처리경도,열처리방법,표면처리,공급업체,범주이름 +1,RFX-140,Peak 소성로,1,1,재질1,,,,(주)배관랜드,구매품 +2,RFX-140-010,치환실 앗세이,1,1,재질2,,,,(주)네온테크,조립품 +3,RFX-140-010-001,치환실 앗세이_001,1,1,재질3,,,,(주)우리전열,구매품 +3,RFX-140-010-002,AC전원,1,1,,,,,ABB,구매품 +2,RFX-140-020,전원부 앗세이,1,1,,,,,,부품 diff --git a/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx deleted file mode 100644 index e763b48c..00000000 Binary files a/frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx and /dev/null differ