개발관리>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 };
}