개발관리>PART·E-BOM Excel Import 메뉴 신설 — wace partMng 1:1 이식

PART Excel Import (M1·M2 공용):
- /partMng/openPartExcelImportPopUp.do + partParsingExcelFile.do + partUploadSave.do 1:1
- 22컬럼 파싱 + NOTE 누적 검증 (품번 필수/중복, PART_TYPE/ACCTFG/UNIT_DC commCode 매핑,
  ODRFG/LOT_FG/USE_YN/QC_FG/SETITEM_FG/REQ_FG 한글 → 코드값)
- 저장은 신규 PART_NO 만 mergePartMng INSERT (기존 IS_LAST='1' 행은 skip)
- part-regist + part-search 페이지에 Excel Upload 버튼 + 다이얼로그 연결

BOM Report Excel Import (M3 = openBomReportExcelImportPopUp = "PART 및 구조등록 Excel upload"):
- /partMng/parsingExcelFile.do + checkDuplicatePartNo.do + getBomDataForCopy.do
  + partBomApplySave.do (savePartBomMaster) 1:1
- 10컬럼 파싱 + 자품번/모품번/품명/수량 필수 검증, 모품번이 자품번 목록(Set)에 존재 검증,
  수량 숫자+>0 검증, PART_TYPE='0001788'(구매품표준) part_mng 존재 검증
- 1레벨(모품번 없는 첫 행) → 헤더 PART_NO/PART_NAME 자동 채움
- 저장 트랜잭션 (wace 1:1):
    헤더 part_bom_report INSERT(신규) / DELETE 자식트리+UPDATE(수정)
    자식 PART: part_mng IS_LAST='1' 존재 시 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT
    부모 PART: 존재 시 lookup, 없으면 "" (절대 INSERT 안 함 — wace 5359-5361)
    bom_part_qty INSERT (relatePartInfo) — 부모행 CHILD_OBJID 를 PARENT_OBJID 로 체인
- 헤더 PART_NO 중복 검사 (편집 중인 자신 제외)
- E-BOM 복사 기능 (기존 BOM → 그리드 행) + Template Download
- ebom-regist 페이지에 "E-BOM 등록(Excel)" 버튼 + 다이얼로그 연결

운영 템플릿 정적 자산:
- frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx
- frontend/public/templates/BOM_REPORT_EXCEL_IMPORT_TEMPLATE.xlsx (.gitignore 우회 git add -f)

wace structureExcelImportPopup.jsp 는 옛 차종/제품군/사양 도메인 화면으로 운영 메뉴 트리에
서 더이상 호출되지 않아 이식 대상 제외.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-12 17:55:17 +09:00
parent c9adfd7327
commit 7779f37c17
15 changed files with 1815 additions and 3 deletions
@@ -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<string, any>): 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) {
@@ -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<string, any>): 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) {
+13
View File
@@ -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);
+10
View File
@@ -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);
@@ -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<Map<string, { code_id: string; code_name: string }>> {
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<string, { code_id: string; code_name: string }>();
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<string>();
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<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;
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<boolean> {
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<BomExcelRow[]> {
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<BomSaveResult> {
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<string, string>();
// bom_part_qty 부모행의 CHILD_OBJID 캐시 (다음 자식들이 이 값을 PARENT_OBJID 로 사용)
const childBomObjIdByPartNo = new Map<string, string>();
// 자식 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)!;
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<string> {
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;
}
@@ -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<Map<string, { code_id: string; code_name: string }>> {
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<string, { code_id: string; code_name: string }>();
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<string, number>();
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 };
}
@@ -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<string | null>(null);
const [excelOpen, setExcelOpen] = useState(false);
const fetchList = useCallback(async (override?: Partial<BomReportListFilter>) => {
setLoading(true);
@@ -137,6 +139,10 @@ export default function EbomRegistPage() {
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" onClick={() => setExcelOpen(true)}
className="bg-emerald-600 hover:bg-emerald-700 text-white">
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">E-BOM (Excel)</span>
</Button>
<Button size="sm" variant="secondary" onClick={handleStatusChange}
disabled={checkedIds.length !== 1}>
<Settings className="h-4 w-4" /><span className="ml-1"></span>
@@ -169,6 +175,12 @@ export default function EbomRegistPage() {
objid={statusObjid}
onSaved={fetchList}
/>
<BomReportExcelImportDialog
open={excelOpen}
onOpenChange={setExcelOpen}
initialProductCd={filter.product_cd ?? ""}
onSaved={fetchList}
/>
</div>
);
}
@@ -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, Plus, Pencil, Trash2, CheckSquare,
Search, Loader2, RotateCcw, Plus, Pencil, Trash2, CheckSquare, FileSpreadsheet,
} from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
import { PartFormDialog } from "@/components/development/PartFormDialog";
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
// wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY)
const GRID_COLUMNS: DataGridColumn[] = [
@@ -70,6 +71,7 @@ export default function PartRegistPage() {
const [formObjid, setFormObjid] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailObjid, setDetailObjid] = useState<string | null>(null);
const [excelOpen, setExcelOpen] = useState(false);
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
setLoading(true);
@@ -188,6 +190,9 @@ export default function PartRegistPage() {
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
</Button>
<Button size="sm" onClick={handleDeploy}
disabled={checkedIds.length === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white">
@@ -227,6 +232,11 @@ export default function PartRegistPage() {
objid={detailObjid}
onEdit={handleEditFromDetail}
/>
<PartExcelImportDialog
open={excelOpen}
onOpenChange={setExcelOpen}
onSaved={fetchList}
/>
</div>
);
}
@@ -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, Plus, Pencil, Trash2,
Search, Loader2, RotateCcw, Plus, Pencil, Trash2, FileSpreadsheet,
} from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
import { PartFormDialog } from "@/components/development/PartFormDialog";
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
@@ -66,6 +67,7 @@ export default function PartSearchPage() {
const [formObjid, setFormObjid] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailObjid, setDetailObjid] = useState<string | null>(null);
const [excelOpen, setExcelOpen] = useState(false);
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
setLoading(true);
@@ -156,6 +158,9 @@ export default function PartSearchPage() {
disabled={checkedIds.length === 0}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
</Button>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
@@ -190,6 +195,11 @@ export default function PartSearchPage() {
objid={detailObjid}
onEdit={handleEditFromDetail}
/>
<PartExcelImportDialog
open={excelOpen}
onOpenChange={setExcelOpen}
onSaved={fetchList}
/>
</div>
);
}
@@ -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<HTMLInputElement>(null);
const [productCd, setProductCd] = useState<string>("");
const [bomPartNo, setBomPartNo] = useState<string>("");
const [bomPartName, setBomPartName] = useState<string>("");
const [version, setVersion] = useState<string>("");
const [copyOptions, setCopyOptions] = useState<BomCopySourceRow[]>([]);
const [copySelect, setCopySelect] = useState<string>("");
const [rows, setRows] = useState<BomExcelRow[]>([]);
const [hasError, setHasError] = useState(false);
const [fileName, setFileName] = useState<string>("");
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<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) parseFile(f);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
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 (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-[1400px] w-[96vw] max-h-[92vh] flex flex-col">
<DialogHeader>
<DialogTitle>PART Excel upload</DialogTitle>
</DialogHeader>
{/* 헤더 */}
<div className="grid grid-cols-4 gap-3 border-b pb-3">
<div>
<Label className="mb-1 block text-xs text-muted-foreground"> *</Label>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={productCd}
onValueChange={setProductCd}
/>
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground"> * (1 )</Label>
<Input value={bomPartNo} readOnly placeholder="엑셀 1레벨에서 자동 채움" />
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground"> * (1 )</Label>
<Input value={bomPartName} readOnly placeholder="엑셀 1레벨에서 자동 채움" />
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="REV 등" />
</div>
</div>
{/* E-BOM 복사 + 액션 버튼 */}
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground whitespace-nowrap">E-BOM </Label>
<select
className="h-9 rounded-md border bg-background px-2 text-sm min-w-[280px]"
value={copySelect}
onChange={(e) => setCopySelect(e.target.value)}
>
<option value=""></option>
{copyOptions.map((o) => (
<option key={o.objid} value={o.objid}>
{o.part_no} / {o.part_name} {o.revision ? `(v${o.revision})` : ""} - {o.regdate ?? ""}
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={handleCopy} disabled={copying || !copySelect}>
{copying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Copy className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={TEMPLATE_DOWNLOAD_URL} download>
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
</a>
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
<Upload className="h-4 w-4" /><span className="ml-1"> </span>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFileInput}
/>
{rows.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => { setRows([]); setHasError(false); setFileName(""); }}>
<FileX className="h-4 w-4" /><span className="ml-1"></span>
</Button>
)}
</div>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-3">
{fileName && <span className="truncate max-w-[400px]">{fileName}</span>}
<span> {rows.length}</span>
{errorCount > 0 && <span className="text-destructive font-semibold"> {errorCount}</span>}
</div>
{/* Drop Zone */}
{rows.length === 0 && !parsing && (
<div
className={cn(
"border-2 border-dashed rounded p-8 text-center transition-colors cursor-pointer",
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
)}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-9 w-9 mx-auto text-muted-foreground mb-2" />
<div className="text-sm text-muted-foreground">
Drag &amp; Drop BOM 릿 (.xlsx, .xls)
</div>
</div>
)}
{parsing && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2"> ...</span>
</div>
)}
{/* 결과 그리드 */}
{rows.length > 0 && !parsing && (
<div className="flex-1 min-h-0 overflow-auto border rounded">
<table className="text-xs border-collapse w-max min-w-full">
<thead className="bg-muted sticky top-0">
<tr>
<th className="border px-2 py-1 w-[40px] text-center">#</th>
{COLUMNS.map((c) => (
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
<td className="border px-2 py-1 text-center">{i + 1}</td>
{COLUMNS.map((c) => {
const value = displayValue(r, c);
const isNote = c.key === "NOTE";
return (
<td
key={c.key as string}
className={cn(
"border px-2 py-1 whitespace-nowrap",
c.align === "right" && "text-right",
c.align === "center" && "text-center",
isNote && r.NOTE && "text-destructive font-semibold"
)}
title={value}
>
{value}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => handleDialogChange(false)}></Button>
<Button onClick={handleSave} disabled={saving || hasError}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<string, string>;
const LABEL_USE = { "0": "미사용", "1": "사용" } as Record<string, string>;
const LABEL_QC = { "0": "무검사", "1": "검사" } as Record<string, string>;
const LABEL_YN = { "0": "부", "1": "여" } as Record<string, string>;
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<HTMLInputElement>(null);
const [parsedRows, setParsedRows] = useState<PartExcelRow[]>([]);
const [hasError, setHasError] = useState(false);
const [fileName, setFileName] = useState<string>("");
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<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) parseFile(f);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
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 (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
<DialogHeader>
<DialogTitle>PART Excel upload</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
<Button variant="outline" size="sm" asChild>
<a href={TEMPLATE_DOWNLOAD_URL} download>
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
</a>
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
<Upload className="h-4 w-4" /><span className="ml-1"> </span>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFileInput}
/>
{fileName && (
<span className="text-sm text-muted-foreground ml-2 truncate max-w-[300px]">
{fileName}
</span>
)}
{parsedRows.length > 0 && (
<Button variant="ghost" size="sm" onClick={reset}>
<FileX className="h-4 w-4" /><span className="ml-1"></span>
</Button>
)}
<div className="ml-auto text-xs text-muted-foreground">
{parsedRows.length}
{errorCount > 0 && <span className="ml-2 text-destructive"> {errorCount}</span>}
</div>
</div>
{/* Drop Zone — 파싱 전에만 노출 */}
{parsedRows.length === 0 && !parsing && (
<div
className={cn(
"border-2 border-dashed rounded p-10 text-center transition-colors cursor-pointer",
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
)}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
<div className="text-sm text-muted-foreground">
Drag &amp; Drop 릿 (.xlsx, .xls)
</div>
</div>
)}
{parsing && (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2"> ...</span>
</div>
)}
{/* 결과 그리드 */}
{parsedRows.length > 0 && !parsing && (
<div className="flex-1 min-h-0 overflow-auto border rounded">
<table className="text-xs border-collapse w-max min-w-full">
<thead className="bg-muted sticky top-0">
<tr>
<th className="border px-2 py-1 w-[40px] text-center">#</th>
{COLUMNS.map((c) => (
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{parsedRows.map((r, i) => (
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
<td className="border px-2 py-1 text-center">{i + 1}</td>
{COLUMNS.map((c) => {
const value = displayValue(r, c);
const isNote = c.key === "NOTE";
return (
<td
key={c.key as string}
className={cn(
"border px-2 py-1 whitespace-nowrap",
c.align === "right" && "text-right",
c.align === "center" && "text-center",
isNote && r.NOTE && "text-destructive font-semibold"
)}
title={value}
>
{value}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => handleDialogChange(false)}></Button>
<Button
onClick={handleSave}
disabled={saving || parsedRows.length === 0 || hasError}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+84
View File
@@ -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<BomExcelParseResponse> {
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<boolean> {
const res = await apiClient.get("/development/ebom/excel-check-duplicate", {
params: { partNo, exclude },
});
return !!res.data?.data?.isDuplicate;
},
async excelCopySource(productCd?: string): Promise<BomCopySourceRow[]> {
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<BomExcelRow[]> {
const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`);
return ((res.data?.data?.rows as BomExcelRow[]) ?? []);
},
async excelSave(input: BomExcelSaveInput): Promise<BomExcelSaveResult> {
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";
}
+60
View File
@@ -203,6 +203,50 @@ export interface DeployResult {
eo_nos: Record<string, string>;
}
// ─── 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<ExcelParseResponse> {
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<ExcelSaveResponse> {
const res = await apiClient.post("/development/part/excel-save", { rows });
return res.data?.data as ExcelSaveResponse;
},
};