개발관리>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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user