개발관리>E-BOM 등록 — XLSX 구현 폐기 후 CSV(11컬럼·수준 기반)로 재작성
운영판 wace 재확인 결과 BOM 등록은 XLSX가 아니라 CSV 가 진짜 입력 포맷이었음.
근거:
· openBomReportExcelImportPopUp.jsp : 제목 "PART 및 구조등록 CSV upload",
Drop Zone "Drag & Drop CSV 템플릿", fnc_setFileDropZone(..., "csv") 로 CSV 만 허용
· /partMng/parsingExcelFile.do 가 .csv 분기에서 별도 함수 parsingCsvFile() 호출
· CSV 11컬럼 : 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 /
표면처리 / 공급업체(MAKER) / 범주이름(PART_TYPE)
· CSV 는 PARENT_PART_NO 컬럼이 없고 "수준" 으로 부모-자식 자동 결정
· 숫자("1","2","3"): 깊이 D 의 부모 = D-1 의 최신 품번
· 점 표기법("1","1.1","1.4.1"): 마지막 점 이전 수준이 부모
backend (devBomExcelImportService.ts):
· XLSX(xlsx 라이브러리) 파싱 → CSV(iconv-lite) 파싱으로 완전 재작성
· 인코딩 자동 감지 1:1 — CP949 → UTF-8 → EUC-KR → MS949 순서, � 카운트로 베스트 선택,
UTF-8 BOM() 자동 제거
· CSV 영문 범주명 자동 변환 — Assy/ASSY→조립품, Buy/BUY→구매품, Make/MAKE→부품
· 범주별 ACCTFG/ODRFG 자동 설정 — 조립품(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
· 검증 1:1 — rowIndex > 2 에서만 모품번 자품번 목록 존재 검사, NOTE 누적
· 저장 로직(savePartBomMaster)은 동일 유지 — 자식 PART updatePartInfoFromCsv UPDATE /
insertpartInfo INSERT, 부모 PART 존재 시 lookup·없으면 "" (INSERT 안 함),
bom_part_qty INSERT 시 ACCTFG/ODRFG/UNIT_DC/UNITCHNG_NB 등 모든 컬럼 동기
frontend:
· BomExcelRow → BomCsvRow (LEVEL 컬럼 + ACCTFG/ODRFG/UNIT_DC/UNITCHNG_NB/LOT_FG/USE_YN/
QC_FG/SETITEM_FG/REQ_FG 자동 채움 필드 추가). BomExcelRow 는 호환 type alias 로 보존
· BomReportExcelImportDialog : 제목 "PART 및 구조등록 CSV upload", accept=".csv", Drop Zone
안내 텍스트 변경, 그리드 컬럼 — 결과/수준/모품번(자동)/품번/품명/수량/항목수량/재료/
열처리경도/열처리방법/표면처리/공급업체/범주/계정구분(자동)/조달구분(자동)
· 파싱 결과의 encoding 정보 표시 (CP949/UTF-8 등)
정적 자산:
· 운영 BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx 제거 (운영판도 실제 사용 안 함)
· 신규 BOM_REPORT_CSV_IMPORT_TEMPLATE.csv 추가 (11컬럼 헤더 + 4행 샘플)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Map<string, { code_id: string; code_name: string }>> {
|
||||
@@ -76,142 +178,151 @@ async function fetchPartTypeMap(client: PoolClient): Promise<Map<string, { code_
|
||||
return m;
|
||||
}
|
||||
|
||||
// ─── 1) 파싱 + 검증 (parsingExcelFile 1:1) ──────────────────
|
||||
// ─── 1) CSV 파싱 + 검증 (parsingCsvFile 1:1) ────────────────
|
||||
|
||||
export async function parseAndValidate(buffer: Buffer): Promise<{
|
||||
rows: BomExcelRow[];
|
||||
rows: BomCsvRow[];
|
||||
hasError: boolean;
|
||||
firstLevel: { part_no: string; part_name: string } | null;
|
||||
encoding: string;
|
||||
}> {
|
||||
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<string>();
|
||||
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<string, string>();
|
||||
|
||||
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<number, string>(); // 숫자 수준용 (wace 1:1)
|
||||
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
const partTypeMap = await fetchPartTypeMap(client);
|
||||
const partNoSeenInFile = new Map<string, number>();
|
||||
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<BomExcelRow[]> {
|
||||
export async function copyBomForGrid(sourceObjid: string): Promise<BomCsvRow[]> {
|
||||
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<string, string>();
|
||||
// bom_part_qty 부모행의 CHILD_OBJID 캐시 (다음 자식들이 이 값을 PARENT_OBJID 로 사용)
|
||||
const childBomObjIdByPartNo = new Map<string, string>();
|
||||
const partObjIdCache = new Map<string, string>(); // PART_NO → part_mng.objid (자식·부모 공용 캐시)
|
||||
const childBomObjIdByPartNo = new Map<string, string>(); // PART_NO → bom_part_qty.child_objid
|
||||
|
||||
// 자식 PART 처리: 있으면 UPDATE / 없으면 INSERT (wace 1:1)
|
||||
async function upsertChildPart(r: BomExcelRow): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = { "4": "반제품", "7": "비용" };
|
||||
const ODRFG_LABEL: Record<string, string> = { "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<BomCopySourceRow[]>([]);
|
||||
const [copySelect, setCopySelect] = useState<string>("");
|
||||
|
||||
const [rows, setRows] = useState<BomExcelRow[]>([]);
|
||||
const [rows, setRows] = useState<BomCsvRow[]>([]);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [encoding, setEncoding] = useState<string>("");
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent className="max-w-[1400px] w-[96vw] max-h-[92vh] flex flex-col">
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PART 및 구조등록 Excel upload</DialogTitle>
|
||||
<DialogTitle>PART 및 구조등록 CSV upload</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 헤더 */}
|
||||
@@ -226,11 +245,11 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번 * (1레벨 자동)</Label>
|
||||
<Input value={bomPartNo} readOnly placeholder="엑셀 1레벨에서 자동 채움" />
|
||||
<Input value={bomPartNo} readOnly placeholder="CSV 1레벨에서 자동 채움" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명 * (1레벨 자동)</Label>
|
||||
<Input value={bomPartName} readOnly placeholder="엑셀 1레벨에서 자동 채움" />
|
||||
<Input value={bomPartName} readOnly placeholder="CSV 1레벨에서 자동 채움" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||
@@ -266,17 +285,18 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
|
||||
<Upload className="h-4 w-4" /><span className="ml-1">파일 선택</span>
|
||||
<Upload className="h-4 w-4" /><span className="ml-1">CSV 파일 선택</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
{rows.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => { setRows([]); setHasError(false); setFileName(""); }}>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setRows([]); setHasError(false); setFileName(""); setEncoding(""); }}>
|
||||
<FileX className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
)}
|
||||
@@ -285,6 +305,7 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
|
||||
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-3">
|
||||
{fileName && <span className="truncate max-w-[400px]">{fileName}</span>}
|
||||
{encoding && <span>인코딩: <b>{encoding}</b></span>}
|
||||
<span>총 {rows.length}건</span>
|
||||
{errorCount > 0 && <span className="text-destructive font-semibold">에러 {errorCount}건</span>}
|
||||
</div>
|
||||
@@ -303,7 +324,10 @@ export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, init
|
||||
>
|
||||
<Upload className="h-9 w-9 mx-auto text-muted-foreground mb-2" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Drag & Drop 또는 클릭하여 BOM 엑셀 템플릿 업로드 (.xlsx, .xls)
|
||||
Drag & Drop 또는 클릭하여 CSV 템플릿 업로드 (.csv)
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
컬럼: 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체 / 범주이름
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+25
-10
@@ -168,9 +168,9 @@ export const devBomApi = {
|
||||
return (res.data?.data as BomCopySourceRow[]) ?? [];
|
||||
},
|
||||
|
||||
async excelCopy(objid: string): Promise<BomExcelRow[]> {
|
||||
async excelCopy(objid: string): Promise<BomCsvRow[]> {
|
||||
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<BomExcelSaveResult> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,,,,,,부품
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user