개발관리>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 };
|
||||
}
|
||||
Reference in New Issue
Block a user