diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 12507fbc..04f4f11c 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -16,6 +16,7 @@ "cheerio": "^1.2.0", "compression": "^1.7.4", "cors": "^2.8.5", + "csv-parse": "^6.2.1", "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", @@ -5314,6 +5315,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csv-parse": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz", + "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 23360432..527c068f 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -30,6 +30,7 @@ "cheerio": "^1.2.0", "compression": "^1.7.4", "cors": "^2.8.5", + "csv-parse": "^6.2.1", "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 574b49c8..3b35c385 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -177,6 +177,9 @@ import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+ import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers) import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식) import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) +import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식) +import devBomRoutes from "./routes/devBomRoutes"; // 개발관리>E-BOM 등록/조회 (wace_plm 도메인 이식) +import devEoHistoryRoutes from "./routes/devEoHistoryRoutes"; // 개발관리>설계변경 리스트 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -423,6 +426,9 @@ app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers) app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인) app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) +app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인) +app.use("/api/development", devBomRoutes); // 개발관리>E-BOM 등록/조회 (wace_plm 도메인) +app.use("/api/development", devEoHistoryRoutes); // 개발관리>설계변경 리스트 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts new file mode 100644 index 00000000..383acaf6 --- /dev/null +++ b/backend-node/src/controllers/devBomController.ts @@ -0,0 +1,224 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) 컨트롤러. +// 라우트: +// GET /api/development/ebom/list (M3 그리드) +// GET /api/development/ebom/:objid (M3 단건) +// PUT /api/development/ebom/:objid/status (M3 상태변경) +// DELETE /api/development/ebom (M3 다중 삭제, body: { objids }) +// GET /api/development/ebom-tree/ascending (M4 정전개) +// GET /api/development/ebom-tree/descending (M4 역전개) +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/devBomService"; +import * as excelSvc from "../services/devBomExcelImportService"; +import * as excelExportSvc from "../services/devBomExcelExportService"; +import { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.BomReportListFilter { + const filter: svc.BomReportListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +// ─── M3 그리드 ────────────────────────────────────────────── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM(M3) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M3 단건 ──────────────────────────────────────────────── + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("E-BOM 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M3 상태 변경 ────────────────────────────────────────── + +export async function updateStatus(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { objid } = req.params; + const rowCount = await svc.updateStatus(userId, objid, req.body); + if (rowCount === 0) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, message: "상태가 변경되었습니다." }); + } catch (e: any) { + logger.error("E-BOM 상태 변경 실패", { error: e.message }); + return res.status(400).json({ success: false, message: e.message }); + } +} + +// ─── M3 다중 삭제 ────────────────────────────────────────── + +export async function removeMany(req: AuthenticatedRequest, res: Response) { + try { + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const removed = await svc.removeMany(objids); + return res.json({ success: true, data: { removed }, message: `${removed}건이 삭제되었습니다.` }); + } catch (e: any) { + logger.error("E-BOM 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 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) { + try { + const data = await svc.ascending(req.query as svc.BomTreeFilter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM 정전개 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── E-BOM 트리 보기 (M3 행 클릭 → 다이얼로그) ───────────── +// GET /api/development/ebom-tree/full?bom_report_objid=... — ascendingForExcel 1:1, 풀 컬럼 JSON +export async function treeFull(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.ascendingForExcel(req.query as svc.BomTreeFilter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM 트리 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M4 엑셀 다운로드 (정/역전개) ────────────────────────── +// GET /api/development/ebom-tree/ascending/excel +// GET /api/development/ebom-tree/descending/excel +// wace structureAscendingListExcel.jsp / structureDescendingListExcel.jsp 1:1 + +function sendExcel(res: Response, buffer: Buffer, fileName: string) { + const encoded = encodeURIComponent(fileName); + res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + res.setHeader("Content-Disposition", `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`); + res.send(buffer); +} + +export async function excelAscending(req: AuthenticatedRequest, res: Response) { + try { + const { buffer, fileName } = await excelExportSvc.generateAscendingExcel(req.query as svc.BomTreeFilter); + return sendExcel(res, buffer, fileName); + } catch (e: any) { + logger.error("E-BOM 정전개 엑셀 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelDescending(req: AuthenticatedRequest, res: Response) { + try { + const { buffer, fileName } = await excelExportSvc.generateDescendingExcel(req.query as svc.BomTreeFilter); + return sendExcel(res, buffer, fileName); + } catch (e: any) { + logger.error("E-BOM 역전개 엑셀 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M4 역전개 ───────────────────────────────────────────── + +export async function descending(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.descending(req.query as svc.BomTreeFilter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM 역전개 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/controllers/devEoHistoryController.ts b/backend-node/src/controllers/devEoHistoryController.ts new file mode 100644 index 00000000..1ff1af29 --- /dev/null +++ b/backend-node/src/controllers/devEoHistoryController.ts @@ -0,0 +1,39 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) 컨트롤러 — read-only. +// GET /api/development/eo-history/list +// GET /api/development/eo-history/:objid +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/devEoHistoryService"; +import { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.EoHistoryListFilter { + const filter: svc.EoHistoryListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("설계변경 리스트 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("설계변경 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/controllers/devPartController.ts b/backend-node/src/controllers/devPartController.ts new file mode 100644 index 00000000..affa0977 --- /dev/null +++ b/backend-node/src/controllers/devPartController.ts @@ -0,0 +1,165 @@ +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) 컨트롤러. +// 라우트: +// GET /api/development/part-temp/list (M1 그리드) +// POST /api/development/part-temp/deploy (M1 → M2 확정) +// GET /api/development/part/list (M2 그리드) +// GET /api/development/part/:objid (단건 상세) +// POST /api/development/part (신규 등록) +// PUT /api/development/part/:objid (상세 수정) +// DELETE /api/development/part (다중 삭제, body: { objids }) +// ============================================================ + +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): svc.PartListFilter { + const filter: svc.PartListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + // status_arr 는 ?status_arr=a&status_arr=b 또는 콤마 직렬화 둘 다 수용 + if (q.status_arr) { + if (Array.isArray(q.status_arr)) filter.status_arr = q.status_arr.map(String); + else filter.status_arr = String(q.status_arr).split(",").map((s) => s.trim()).filter(Boolean); + } + return filter; +} + +// ─── M1 그리드 ────────────────────────────────────────────── + +export async function getTempList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listTemp(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("개발관리 PART(M1) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M2 그리드 ────────────────────────────────────────────── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listRelease(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("개발관리 PART(M2) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 단건 상세 ────────────────────────────────────────────── + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("개발관리 PART 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 신규 등록 ────────────────────────────────────────────── + +export async function create(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const objid = await svc.create(userId, req.body); + return res.status(201).json({ success: true, data: { objid }, message: "PART가 등록되었습니다." }); + } catch (e: any) { + logger.error("개발관리 PART 등록 실패", { error: e.message }); + return res.status(400).json({ success: false, message: e.message }); + } +} + +// ─── 상세 수정 ────────────────────────────────────────────── + +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const rowCount = await svc.update(objid, req.body); + if (rowCount === 0) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, message: "PART가 수정되었습니다." }); + } catch (e: any) { + logger.error("개발관리 PART 수정 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 확정 (M1 → M2) ───────────────────────────────────────── + +export async function deploy(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const result = await svc.deploy(userId, objids); + return res.json({ success: true, data: result, message: `${result.deployed}건이 확정되었습니다.` }); + } catch (e: any) { + logger.error("개발관리 PART 확정 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 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) { + try { + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const removed = await svc.removeMany(objids); + return res.json({ success: true, data: { removed }, message: `${removed}건이 삭제되었습니다.` }); + } catch (e: any) { + logger.error("개발관리 PART 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts new file mode 100644 index 00000000..b3fbf792 --- /dev/null +++ b/backend-node/src/routes/devBomRoutes.ts @@ -0,0 +1,39 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) 라우트. +// app.ts: app.use("/api/development", devBomRoutes) — devPart 라우터와 prefix 공유. +// ============================================================ + +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); +router.get("/ebom-tree/ascending/excel", ctrl.excelAscending); +router.get("/ebom-tree/descending/excel", ctrl.excelDescending); +router.get("/ebom-tree/full", ctrl.treeFull); + +// 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); +router.put("/ebom/:objid/status", ctrl.updateStatus); +router.get("/ebom/:objid", ctrl.getByObjid); + +export default router; diff --git a/backend-node/src/routes/devEoHistoryRoutes.ts b/backend-node/src/routes/devEoHistoryRoutes.ts new file mode 100644 index 00000000..6ea255c0 --- /dev/null +++ b/backend-node/src/routes/devEoHistoryRoutes.ts @@ -0,0 +1,16 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) — read-only 라우트. +// app.ts: app.use("/api/development", devEoHistoryRoutes) — prefix 공유. +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/devEoHistoryController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/eo-history/list", ctrl.getList); +router.get("/eo-history/:objid", ctrl.getByObjid); + +export default router; diff --git a/backend-node/src/routes/devPartRoutes.ts b/backend-node/src/routes/devPartRoutes.ts new file mode 100644 index 00000000..d7dbd428 --- /dev/null +++ b/backend-node/src/routes/devPartRoutes.ts @@ -0,0 +1,38 @@ +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) 라우트. +// app.ts: app.use("/api/development", devPartRoutes) +// ============================================================ + +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); + +// 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); + +// 단건 + CRUD +router.get("/part/:objid", ctrl.getByObjid); +router.post("/part", ctrl.create); +router.put("/part/:objid", ctrl.update); + +export default router; diff --git a/backend-node/src/services/devBomExcelExportService.ts b/backend-node/src/services/devBomExcelExportService.ts new file mode 100644 index 00000000..05167ffc --- /dev/null +++ b/backend-node/src/services/devBomExcelExportService.ts @@ -0,0 +1,98 @@ +// ============================================================ +// 개발관리 M4 E-BOM 엑셀 다운로드 (정/역전개) — wace 1:1 +// structureAscendingListExcel.jsp / structureDescendingListExcel.jsp +// +// 시트 구성 (wace 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량 / 항목수량(P_QTY) / +// 3D / 2D / PDF (Y/공백) / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / +// 범주 이름 / 비고 +// 헤더 행: 노란색 배경 + bold (운영판 스타일) +// +// 파일명: "BOM 조회(정전개)_YYYY-MM-DD_HH-mm.xlsx" / "BOM 조회(역전개)..." +// ============================================================ + +import * as XLSX from "xlsx"; +import { BomTreeFilter } from "./devBomService"; +import { ascendingForExcel, descendingForExcel } from "./devBomService"; + +type ExcelDirection = "ascending" | "descending"; + +function pad(n: number): string { return n < 10 ? `0${n}` : String(n); } +function nowStamp(): string { + const d = new Date(); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; +} + +function buildSheet(rows: any[], maxLevel: number): XLSX.WorkSheet { + const effectiveMax = Math.max(1, maxLevel); + + // 헤더 행 + const header: string[] = []; + for (let i = 1; i <= effectiveMax; i++) header.push(String(i)); + header.push("품번", "품명", "수량", "항목수량", "3D", "2D", "PDF", + "재료", "열처리경도", "열처리방법", "표면처리", "메이커", "범주 이름", "비고"); + + const aoa: any[][] = [header]; + + for (const r of rows) { + const lev = Number(r.lev ?? 1); + const row: any[] = []; + for (let i = 1; i <= effectiveMax; i++) row.push(i === lev ? "*" : ""); + row.push( + r.pm_part_no ?? "", + r.pm_part_name ?? "", + r.qty ?? "", + r.p_qty ?? "", + Number(r.cu01_cnt ?? 0) > 0 ? "Y" : "", + Number(r.cu02_cnt ?? 0) > 0 ? "Y" : "", + Number(r.cu03_cnt ?? 0) > 0 ? "Y" : "", + r.material ?? "", + r.heat_treatment_hardness ?? "", + r.heat_treatment_method ?? "", + r.surface_treatment ?? "", + r.maker ?? "", + r.part_type_title ?? "", + r.remark ?? "", + ); + aoa.push(row); + } + + const ws = XLSX.utils.aoa_to_sheet(aoa); + + // 컬럼 너비 (LEVEL 컬럼은 좁게, 텍스트 컬럼은 넓게) + const cols: XLSX.ColInfo[] = []; + for (let i = 0; i < effectiveMax; i++) cols.push({ wch: 4 }); + cols.push( + { wch: 18 }, { wch: 24 }, { wch: 8 }, { wch: 10 }, + { wch: 6 }, { wch: 6 }, { wch: 6 }, + { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 16 }, + ); + ws["!cols"] = cols; + + // 헤더 셀 노란 배경 + bold (XLSX 무료 라이브러리는 스타일 미저장. cellStyles:true 옵션과 함께 쓰면 일부만 동작) + // → 운영판 노란 배경은 시각적 효과. SheetJS Community 빌드는 스타일 무시. + // 필요 시 exceljs로 교체. 본 구현은 데이터 1:1 정확성 우선. + + return ws; +} + +export async function generateAscendingExcel(filter: BomTreeFilter): Promise<{ buffer: Buffer; fileName: string }> { + const { rows, max_level } = await ascendingForExcel(filter); + const ws = buildSheet(rows, max_level); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM 정전개"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { buffer: buf, fileName: `BOM 조회(정전개)_${nowStamp()}.xlsx` }; +} + +export async function generateDescendingExcel(filter: BomTreeFilter): Promise<{ buffer: Buffer; fileName: string }> { + const { rows, max_level } = await descendingForExcel(filter); + const ws = buildSheet(rows, max_level); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM 역전개"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { buffer: buf, fileName: `BOM 조회(역전개)_${nowStamp()}.xlsx` }; +} + +// 호환 export +export type { ExcelDirection }; diff --git a/backend-node/src/services/devBomExcelImportService.ts b/backend-node/src/services/devBomExcelImportService.ts new file mode 100644 index 00000000..7500d497 --- /dev/null +++ b/backend-node/src/services/devBomExcelImportService.ts @@ -0,0 +1,625 @@ +// ============================================================ +// 개발관리 BOM Report CSV Import 서비스 — wace_plm PartMngService.parsingCsvFile 1:1 +// +// 운영판 흐름 (wace partMng/openBomReportExcelImportPopUp.jsp): +// · Drop Zone: "Drag & Drop CSV 템플릿" + fnc_setFileDropZone(..., "csv") → CSV 전용 +// · /partMng/parsingExcelFile.do 의 .csv 분기에서 parsingCsvFile() 호출 +// · /partMng/partBomApplySave.do 의 savePartBomMaster() 로 저장 +// +// CSV 컬럼 (11개, 헤더 1줄 후 데이터): +// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법 +// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE) +// (11번 이후 컬럼은 파싱하지 않음 — 범주이름에 따라 자동값 설정) +// +// 핵심: PARENT_PART_NO 컬럼이 없음. "수준(level)" 으로 부모-자식 자동 결정. +// · 숫자("1","2","3","4"): 깊이 D 행의 부모 = depthToPartNoMap[D-1] (마지막에 등록된 D-1 깊이 품번) +// · 점 표기법("1","1.1","1.4.1"): 마지막 점 이전이 부모 수준 → levelToPartNoMap[parentLevel] +// +// CSV 자동 변환: +// · Assy/ASSY → 조립품, Buy/BUY → 구매품, Make/MAKE → 부품 (영문 PART_TYPE → 한글) +// · 범주 → 계정구분/조달구분 자동 (조립품 0001813 / 부품 0001812 → ACCTFG=4, ODRFG=1; +// 구매품 0000063 → ACCTFG=7, ODRFG=0) +// · 기본값 일괄: UNIT_DC=0001400(EA), UNITMANG_DC=0001400, UNITCHNG_NB=1, +// LOT_FG=1(사용), USE_YN=1(사용), QC_FG=0(무검사), SETITEM_FG=0(부), REQ_FG=0(부) +// +// 검증 (wace 1:1): +// · 모품번 자품번 목록 존재 검증은 rowIndex > 2 일 때만 (1·2 레벨 면제) +// · PART_TYPE 매핑 실패: NOTE "부품유형 확인:..." +// · 결과 누적: emptyColCnt < 9 또는 NOTE 있으면 row 채택 +// +// 인코딩 자동 감지 (wace detectFileEncoding 1:1): +// CP949 → UTF-8 → EUC-KR → MS949 순서. 각각 디코딩 → 깨진 문자(�) 개수 세서 0이면 즉시 선택, +// 아니면 가장 적은 것. UTF-8 BOM(EF BB BF) 자동 제거. +// +// 저장 (savePartBomMaster 1:1): +// · 헤더 part_bom_report INSERT(신규) / DELETE 자식트리+UPDATE(수정) +// · 자식 PART: IS_LAST='1' 존재 시 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT +// · 부모 PART: 존재 시 lookup, 없으면 "" (절대 INSERT 안 함) +// · bom_part_qty INSERT (relatePartInfo) — 부모행 CHILD_OBJID 체인 +// ============================================================ + +import * as iconv from "iconv-lite"; +import { parse as parseCsvSync } from "csv-parse/sync"; +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; + +const CODE_PARENT_PART_TYPE = "0000062"; +const PART_TYPE_ASSEMBLY = "0001813"; // 조립품 +const PART_TYPE_PART = "0001812"; // 부품 +const PART_TYPE_BUY = "0000063"; // 구매품 + +const DEFAULT_UNIT_DC = "0001400"; // EA +const DEFAULT_UNITMANG_DC = "0001400"; // EA +const DEFAULT_UNITCHNG_NB = "1"; +const DEFAULT_LOT_FG = "1"; // 사용 +const DEFAULT_USE_YN = "1"; // 사용 +const DEFAULT_QC_FG = "0"; // 무검사 +const DEFAULT_SETITEM_FG = "0"; // 부 +const DEFAULT_REQ_FG = "0"; // 부 + +export interface BomCsvRow { + NOTE: string; + LEVEL: string; // 표시용 (CSV 수준) + PARENT_PART_NO: string; // 수준에서 계산된 부모 품번 + PART_NO: string; + PART_NAME: string; + QTY: string; + ITEM_QTY: string; + MATERIAL: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; + MAKER: string; + PART_TYPE: string; // code_id (자동 변환된 한글 → DB 조회 결과) + PART_TYPE_NAME: string; // 한글 (Assy→조립품 변환 적용) + ACCTFG: string; + ODRFG: string; + UNIT_DC: string; + UNITMANG_DC: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; +} + +function appendNote(r: BomCsvRow, msg: string) { + r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg; +} + +// ─── 헬퍼 ─────────────────────────────────────────────────── + +// wace getCsvValue 1:1 — 따옴표 제거 + 빈 카운트 +function getCsvValue(values: string[], idx: number, emptyCounter: { count: number }): string { + if (idx >= values.length) { + emptyCounter.count++; + return ""; + } + let v = (values[idx] ?? "").trim(); + if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { + v = v.substring(1, v.length - 1); + } + if (!v) emptyCounter.count++; + return v; +} + +// wace getParentLevel 1:1: "1.4.1" → "1.4" / "1.8" → "1" / "1" → "" +function getParentLevel(level: string): string { + if (!level) return ""; + const lastDot = level.lastIndexOf("."); + return lastDot > 0 ? level.substring(0, lastDot) : ""; +} + +// CSV 영문 범주명 → 한글 (wace 1:1) +function normalizePartTypeName(raw: string): string { + const upper = raw.toUpperCase(); + if (upper === "ASSY") return "조립품"; + if (upper === "BUY") return "구매품"; + if (upper === "MAKE") return "부품"; + return raw; +} + +// 범주 code_id → ACCTFG/ODRFG 자동 매핑 (wace 1:1) +function autoAcctfgOdrfg(partTypeCode: string): { acctfg: string; odrfg: string } { + if (partTypeCode === PART_TYPE_ASSEMBLY || partTypeCode === PART_TYPE_PART) { + return { acctfg: "4", odrfg: "1" }; // 반제품/생산 + } + if (partTypeCode === PART_TYPE_BUY) { + return { acctfg: "7", odrfg: "0" }; // 비용/구매 + } + return { acctfg: "", odrfg: "" }; +} + +// 인코딩 자동 감지 (wace detectFileEncoding 1:1) +function detectAndDecode(buffer: Buffer): { text: string; encoding: string } { + const encodings = ["cp949", "utf-8", "euc-kr", "ms949"]; + let bestEncoding = "utf-8"; + let bestText = ""; + let minReplacement = Number.POSITIVE_INFINITY; + + for (const enc of encodings) { + try { + const decoded = iconv.decode(buffer, enc); + let replacementCount = 0; + const sample = decoded.substring(0, 4096); + for (let i = 0; i < sample.length; i++) { + if (sample.charCodeAt(i) === 0xFFFD) replacementCount++; + } + if (replacementCount === 0) { + return { text: stripBom(decoded), encoding: enc }; + } + if (replacementCount < minReplacement) { + minReplacement = replacementCount; + bestEncoding = enc; + bestText = decoded; + } + } catch { + continue; + } + } + return { text: stripBom(bestText), encoding: bestEncoding }; +} + +function stripBom(s: string): string { + if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) return s.substring(1); + return s; +} + +async function fetchPartTypeMap(client: PoolClient): Promise> { + 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(); + for (const row of r.rows) if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row); + return m; +} + +// ─── 1) CSV 파싱 + 검증 (parsingCsvFile 1:1) ──────────────── + +export async function parseAndValidate(buffer: Buffer): Promise<{ + rows: BomCsvRow[]; + hasError: boolean; + firstLevel: { part_no: string; part_name: string } | null; + encoding: string; +}> { + const { text, encoding } = detectAndDecode(buffer); + + // RFC4180 호환 파서 — 따옴표 내부 콤마/줄바꿈, "" 이스케이프 모두 안전 처리 + // (이전: line.split(",") 단순 split → 따옴표 안 콤마/멀티라인 셀 깨짐) + // relax_quotes / relax_column_count : 운영판 wace 사용자가 만든 CSV 의 비정형 quote 도 관대 처리 + const allRows: string[][] = parseCsvSync(text, { + columns: false, + skip_empty_lines: false, + relax_quotes: true, + relax_column_count: true, + trim: false, + }); + + // 1차 스캔: 모든 자품번 + 수준→품번 매핑 (wace 1:1) + const allPartNumbers = new Set(); + const levelToPartNoMap = new Map(); + + for (let i = 0; i < allRows.length; i++) { + const values = allRows[i]; + if (!values || values.length < 2) continue; + if (i === 0) continue; // 헤더 + const level = (values[0] ?? "").trim(); + const partNo = (values[1] ?? "").trim(); + if (partNo) { + allPartNumbers.add(partNo); + if (level) levelToPartNoMap.set(level, partNo); + } + } + + const result: BomCsvRow[] = []; + let firstLevel: { part_no: string; part_name: string } | null = null; + + // 2차 스캔: 행 파싱 + 부모 결정 + 검증 + const currentDepthPartNoMap = new Map(); // 숫자 수준용 (wace 1:1) + + const client = await getPool().connect(); + try { + const partTypeMap = await fetchPartTypeMap(client); + + let rowIndex = 0; + for (const values of allRows) { + if (rowIndex === 0) { rowIndex++; continue; } // 헤더 skip + if (values.length < 11) { rowIndex++; continue; } // wace: 최소 11컬럼 (수준 포함) + + const emptyCounter = { count: 0 }; + let colIndex = 0; + const level = getCsvValue(values, colIndex++, emptyCounter); + const partNo = getCsvValue(values, colIndex++, emptyCounter); + const partName = getCsvValue(values, colIndex++, emptyCounter); + const qty = getCsvValue(values, colIndex++, emptyCounter); + const itemQty = getCsvValue(values, colIndex++, emptyCounter); + const material = getCsvValue(values, colIndex++, emptyCounter); + const heatHardness = getCsvValue(values, colIndex++, emptyCounter); + const heatMethod = getCsvValue(values, colIndex++, emptyCounter); + const surfaceTreatment = getCsvValue(values, colIndex++, emptyCounter); + const supplier = getCsvValue(values, colIndex++, emptyCounter); + const partTypeRaw = getCsvValue(values, colIndex++, emptyCounter); + + // 수준 → 부모 결정 (wace 1:1) + let parentPartNo = ""; + if (level) { + const asInt = Number(level); + if (Number.isInteger(asInt) && /^\d+$/.test(level)) { + // 숫자 수준: 깊이 D 행 → D-1 깊이의 최신 품번이 부모 + const currentDepth = asInt; + if (partNo) currentDepthPartNoMap.set(currentDepth, partNo); + if (currentDepth > 1) { + parentPartNo = currentDepthPartNoMap.get(currentDepth - 1) ?? ""; + } + } else { + // 점 표기법: 마지막 점 이전 = 부모 수준 + const parentLevel = getParentLevel(level); + if (parentLevel) parentPartNo = levelToPartNoMap.get(parentLevel) ?? ""; + } + } + + let noteMsg = ""; + + // 모품번 자품번 목록 존재 검증 (wace: rowIndex > 2 일 때만) + if (parentPartNo && rowIndex > 2 && !allPartNumbers.has(parentPartNo)) { + noteMsg += `모품번 미존재: ${parentPartNo} ; `; + } + + // PART_TYPE 처리 (wace 1:1) + const partTypeNormalized = partTypeRaw ? normalizePartTypeName(partTypeRaw) : ""; + let partTypeCode = ""; + if (partTypeNormalized) { + const hit = partTypeMap.get(partTypeNormalized.toUpperCase()); + if (hit) { + partTypeCode = hit.code_id; + } else if (rowIndex > 2) { + noteMsg += `부품유형 확인: ${partTypeRaw} ; `; + } + } + + const { acctfg, odrfg } = autoAcctfgOdrfg(partTypeCode); + + const row: BomCsvRow = { + NOTE: noteMsg.trim().replace(/\s*;\s*$/, ""), + LEVEL: level, + PARENT_PART_NO: parentPartNo, + PART_NO: partNo, + PART_NAME: partName, + QTY: qty, + ITEM_QTY: itemQty || qty, + MATERIAL: material, + HEAT_TREATMENT_HARDNESS: heatHardness, + HEAT_TREATMENT_METHOD: heatMethod, + SURFACE_TREATMENT: surfaceTreatment, + MAKER: supplier, + PART_TYPE: partTypeCode, + PART_TYPE_NAME: partTypeNormalized, + ACCTFG: acctfg, + ODRFG: odrfg, + UNIT_DC: DEFAULT_UNIT_DC, + UNITMANG_DC: DEFAULT_UNITMANG_DC, + UNITCHNG_NB: DEFAULT_UNITCHNG_NB, + LOT_FG: DEFAULT_LOT_FG, + USE_YN: DEFAULT_USE_YN, + QC_FG: DEFAULT_QC_FG, + SETITEM_FG: DEFAULT_SETITEM_FG, + REQ_FG: DEFAULT_REQ_FG, + }; + + // wace: NOTE 있거나 emptyColCnt < 9 면 결과 채택 + if (row.NOTE || emptyCounter.count < 9) { + result.push(row); + } + + // 첫 1레벨(parent 없는 첫 행) → 헤더 자동 + if (!firstLevel && !parentPartNo && partNo) { + firstLevel = { part_no: partNo, part_name: partName }; + } + + rowIndex++; + } + } finally { + client.release(); + } + + const hasError = result.some((r) => r.NOTE); + return { rows: result, hasError, firstLevel, encoding }; +} + +// ─── 2) 헤더 part_no 중복 검사 (wace checkDuplicatePartNo) ── + +export async function checkDuplicateBomPartNo(partNo: string, excludeObjid?: string): Promise { + 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 → BomCsvRow[] ──────── + +export async function copyBomForGrid(sourceObjid: string): Promise { + const sql = ` + WITH RECURSIVE TREE AS ( + SELECT BP.OBJID, BP.PARENT_OBJID, BP.CHILD_OBJID, BP.PART_NO, BP.PARENT_PART_NO, BP.QTY, BP.ITEM_QTY, + 1 AS LEV, ARRAY[BP.SEQ] AS PATH + FROM BOM_PART_QTY BP + WHERE BP.BOM_REPORT_OBJID = $1 + AND (BP.PARENT_OBJID IS NULL OR BP.PARENT_OBJID = '') + UNION ALL + SELECT B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, B.PART_NO, B.PARENT_PART_NO, B.QTY, B.ITEM_QTY, + T.LEV + 1, T.PATH || B.SEQ + FROM BOM_PART_QTY B + JOIN TREE T ON B.PARENT_OBJID = T.CHILD_OBJID + WHERE B.BOM_REPORT_OBJID = $1 + ) + SELECT T.LEV, + PM.PART_NO AS PART_NO_REAL, + PM.PART_NAME AS PART_NAME, + T.QTY, T.ITEM_QTY, + PM.MATERIAL, PM.HEAT_TREATMENT_HARDNESS, PM.HEAT_TREATMENT_METHOD, PM.SURFACE_TREATMENT, + PM.MAKER, PM.PART_TYPE, + CC.CODE_NAME AS PART_TYPE_NAME, + PM_PARENT.PART_NO AS PARENT_PART_NO_REAL, + PM.ACCTFG, PM.ODRFG, + PM.UNIT_DC, PM.UNITMANG_DC, PM.UNITCHNG_NB, + PM.LOT_FG, PM.USE_YN, PM.QC_FG, PM.SETITEM_FG, PM.REQ_FG, + T.PATH + FROM TREE T + LEFT JOIN PART_MNG PM ON PM.OBJID::varchar = T.PART_NO + LEFT JOIN PART_MNG PM_PARENT ON PM_PARENT.OBJID::varchar = T.PARENT_PART_NO + LEFT JOIN COMM_CODE CC ON CC.CODE_ID = PM.PART_TYPE + ORDER BY T.PATH + `; + const r = await getPool().query(sql, [sourceObjid]); + return r.rows.map((row): BomCsvRow => ({ + NOTE: "", + LEVEL: String(row.lev ?? ""), + PARENT_PART_NO: row.parent_part_no_real ?? "", + PART_NO: row.part_no_real ?? "", + PART_NAME: row.part_name ?? "", + QTY: row.qty != null ? String(row.qty) : "", + ITEM_QTY: row.item_qty != null ? String(row.item_qty) : (row.qty != null ? String(row.qty) : ""), + MATERIAL: row.material ?? "", + HEAT_TREATMENT_HARDNESS: row.heat_treatment_hardness ?? "", + HEAT_TREATMENT_METHOD: row.heat_treatment_method ?? "", + SURFACE_TREATMENT: row.surface_treatment ?? "", + MAKER: row.maker ?? "", + PART_TYPE: row.part_type ?? "", + PART_TYPE_NAME: row.part_type_name ?? "", + ACCTFG: row.acctfg ?? "", + ODRFG: row.odrfg ?? "", + UNIT_DC: row.unit_dc ?? DEFAULT_UNIT_DC, + UNITMANG_DC: row.unitmang_dc ?? DEFAULT_UNITMANG_DC, + UNITCHNG_NB: row.unitchng_nb != null ? String(row.unitchng_nb) : DEFAULT_UNITCHNG_NB, + LOT_FG: row.lot_fg ?? DEFAULT_LOT_FG, + USE_YN: row.use_yn ?? DEFAULT_USE_YN, + QC_FG: row.qc_fg ?? DEFAULT_QC_FG, + SETITEM_FG: row.setitem_fg ?? DEFAULT_SETITEM_FG, + REQ_FG: row.req_fg ?? DEFAULT_REQ_FG, + })); +} + +// ─── 4) BOM 저장 (savePartBomMaster 1:1) ──────────────────── + +export interface BomSaveInput { + bomReportObjid?: string; + productCd: string; + partNo: string; + partName: string; + version?: string; + rows: BomCsvRow[]; +} + +export interface BomSaveResult { + bomReportObjid: string; + insertedParts: number; + updatedParts: number; + bomRows: number; + mode: "create" | "update"; +} + +export async function saveBomReport(userId: string, input: BomSaveInput): Promise { + 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") { + 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; + + const partObjIdCache = new Map(); // PART_NO → part_mng.objid (자식·부모 공용 캐시) + const childBomObjIdByPartNo = new Map(); // PART_NO → bom_part_qty.child_objid + + // 자식 PART: 있으면 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT (wace 1:1) + async function upsertChildPart(r: BomCsvRow): Promise { + if (partObjIdCache.has(r.PART_NO)) return partObjIdCache.get(r.PART_NO)!; + + const exist = await client.query( + `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [r.PART_NO] + ); + if ((exist.rowCount ?? 0) > 0) { + const id = exist.rows[0].part_objid; + await client.query( + `UPDATE PART_MNG SET + PART_NAME = $1, + MATERIAL = $2, + HEAT_TREATMENT_HARDNESS = $3, + HEAT_TREATMENT_METHOD = $4, + SURFACE_TREATMENT = $5, + PART_TYPE = COALESCE(NULLIF($6, ''), PART_TYPE), + MAKER = $7, + ACCTFG = $8, + ODRFG = $9, + UNIT_DC = $10, + UNITMANG_DC = $11, + UNITCHNG_NB = COALESCE(NULLIF($12, ''), '0')::numeric, + LOT_FG = COALESCE(NULLIF($13, ''), LOT_FG), + USE_YN = COALESCE(NULLIF($14, ''), USE_YN), + QC_FG = COALESCE(NULLIF($15, ''), QC_FG), + SETITEM_FG = COALESCE(NULLIF($16, ''), SETITEM_FG), + REQ_FG = COALESCE(NULLIF($17, ''), REQ_FG), + EDIT_DATE = NOW() + WHERE OBJID = $18::numeric`, + [ + r.PART_NAME, r.MATERIAL, + r.HEAT_TREATMENT_HARDNESS, r.HEAT_TREATMENT_METHOD, r.SURFACE_TREATMENT, + r.PART_TYPE, r.MAKER, + r.ACCTFG, r.ODRFG, + r.UNIT_DC, r.UNITMANG_DC, r.UNITCHNG_NB, + r.LOT_FG, r.USE_YN, r.QC_FG, r.SETITEM_FG, r.REQ_FG, + id, + ] + ); + updatedParts++; + partObjIdCache.set(r.PART_NO, id); + return id; + } + + const newId = createObjId(); + await client.query( + `INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, MATERIAL, REMARK, + STATUS, REG_DATE, WRITER, IS_LAST, + HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, + PART_TYPE, MAKER, + ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB, + LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG + ) VALUES ( + $1::numeric, $2, $3, $4, '', + 'create', NOW(), $5, '1', + $6, $7, $8, + NULLIF($9, ''), $10, + $11, $12, $13, $14, + COALESCE(NULLIF($15, ''), '0')::numeric, + COALESCE($16, '0'), COALESCE($17, '1'), COALESCE($18, '0'), + COALESCE($19, '0'), COALESCE($20, '0') + )`, + [ + newId, r.PART_NO, r.PART_NAME, r.MATERIAL, + userId, + r.HEAT_TREATMENT_HARDNESS, r.HEAT_TREATMENT_METHOD, r.SURFACE_TREATMENT, + r.PART_TYPE, r.MAKER, + r.ACCTFG, r.ODRFG, r.UNIT_DC, r.UNITMANG_DC, r.UNITCHNG_NB, + r.LOT_FG, r.USE_YN, r.QC_FG, r.SETITEM_FG, r.REQ_FG, + ] + ); + insertedParts++; + partObjIdCache.set(r.PART_NO, newId); + return newId; + } + + // 부모 PART: 있으면 lookup, 없으면 "" (wace 1:1 — INSERT 절대 안 함) + async function lookupParentPart(partNo: string): Promise { + if (!partNo) return ""; + if (partObjIdCache.has(partNo)) return partObjIdCache.get(partNo)!; + const exist = await client.query( + `SELECT OBJID::varchar AS part_objid FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`, + [partNo] + ); + if ((exist.rowCount ?? 0) > 0) { + const id = exist.rows[0].part_objid; + partObjIdCache.set(partNo, id); + return id; + } + return ""; + } + + for (const r of input.rows) { + if (!r.PART_NO) continue; + + const partObjid = await upsertChildPart(r); + const parentPartObjid = await lookupParentPart(r.PARENT_PART_NO); + const parentBomObjid = r.PARENT_PART_NO ? (childBomObjIdByPartNo.get(r.PARENT_PART_NO) ?? "") : ""; + const newBomObjid = createObjId(); + const newChildObjid = createObjId(); + + 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 CSV 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; +} diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts new file mode 100644 index 00000000..dfcf0d20 --- /dev/null +++ b/backend-node/src/services/devBomService.ts @@ -0,0 +1,480 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 이식. +// +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/partMng.xml): +// getBOMStandardStructureGridList → list() (M3 그리드, part_bom_report) +// updateStructureStatus → updateStatus() (M3 상태변경) +// deleteBomReport + deleteBomQty → removeMany() (M3 다중 삭제 트랜잭션) +// structureAscendingList → ascending() (M4 정전개, 재귀 CTE) +// selectStructureDescendingList → descending() (M4 역전개, 재귀 CTE) +// +// vexplor_rps 적응: +// · customer_mng 매핑: wace SUPPLY_MNG.OBJID → vexplor customer_mng.customer_code +// · PRODUCT_NAME: wace CODE_NAME() → vexplor LEFT JOIN comm_code CC_PRD +// · M4 product_mgmt_spec/upg/vc 분기 제거 (vexplor part_bom_report 단순화) +// ============================================================ + +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 필터/바디 타입 ────────────────────────────────────────── + +export interface BomReportListFilter { + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_unit_name?: string; + search_writer?: string; + product_cd?: string; + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + status?: string; + page?: number; + page_size?: number; +} + +export interface BomReportStatusBody { + product_cd?: string; + part_no?: string; + part_name?: string; + version?: string; + status: string; +} + +export interface BomTreeFilter { + bom_report_objid?: string; + project_name?: string; // part_bom_report.contract_objid + unit_code?: string; + search_part_no?: string; + search_part_name?: string; +} + +// ─── 공용 파라미터 빌더 ──────────────────────────────────── + +function buildListWhere(filter: BomReportListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.customer_cd) { conds.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_cd); } + if (filter.project_name) { conds.push(`T.CONTRACT_OBJID = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { conds.push(`T.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); } + if (filter.search_unit_name) { + conds.push(`EXISTS (SELECT 1 FROM PMS_WBS_TASK W + WHERE W.OBJID = T.UNIT_CODE + AND (UPPER(W.TASK_NAME) LIKE UPPER($${idx}) OR UPPER(W.UNIT_NO) LIKE UPPER($${idx})))`); + params.push(`%${filter.search_unit_name}%`); idx++; + } + if (filter.search_writer) { conds.push(`T.WRITER = $${idx++}`); params.push(filter.search_writer); } + if (filter.product_cd) { conds.push(`T.PRODUCT_CD = $${idx++}`); params.push(filter.product_cd); } + if (filter.search_part_no) { conds.push(`UPPER(T.PART_NO) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_no}%`); } + if (filter.search_part_name) { conds.push(`UPPER(T.PART_NAME) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_name}%`); } + if (filter.search_from_date) { conds.push(`T.REGDATE::date >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_from_date); } + if (filter.search_to_date) { conds.push(`T.REGDATE::date <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_to_date); } + if (filter.status) { conds.push(`T.STATUS = $${idx++}`); params.push(filter.status); } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── M3 그리드 ────────────────────────────────────────────── + +export async function list(filter: BomReportListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildListWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + SELECT + ROW_NUMBER() OVER (ORDER BY T.REGDATE DESC) AS NUM, + T.OBJID, T.CUSTOMER_OBJID, SM.customer_name AS CUSTOMER_NAME, + T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO, + T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME, + T.STATUS, + CASE UPPER(T.STATUS) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' END AS STATUS_TITLE, + T.WRITER, UI.dept_name AS DEPT_NAME, UI.user_name AS USER_NAME, + COALESCE(UI.dept_name || '/' || UI.user_name, '') AS DEPT_USER_NAME, + T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE, + T.DEPLOY_DATE, T.REVISION, + EO_DATA.EO_NO, EO_DATA.EO_DATE, + T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID, + COALESCE(EO_DATA.BOM_CNT, 0)::int AS BOM_CNT, + T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME, + T.PART_NO, T.PART_NAME + FROM PART_BOM_REPORT T + LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = T.WRITER + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active' + LEFT JOIN ( + SELECT BP.BOM_REPORT_OBJID, + MAX(PM2.EO_NO) AS EO_NO, + MAX(PM2.EO_DATE) AS EO_DATE, + COUNT(*) AS BOM_CNT + FROM BOM_PART_QTY BP + LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar + GROUP BY BP.BOM_REPORT_OBJID + ) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID + WHERE ${where.sql} + `; + + const dataSql = `${baseSql} ORDER BY T.REGDATE DESC NULLS LAST LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize }; +} + +// ─── M3 단건 ──────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = ` + SELECT T.*, + CC_PRD.code_name AS PRODUCT_NAME, + (SELECT COUNT(*) FROM BOM_PART_QTY Q WHERE Q.BOM_REPORT_OBJID = T.OBJID) AS BOM_CNT + FROM PART_BOM_REPORT T + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status='active' + WHERE T.OBJID = $1 + `; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── M3 상태 변경 ────────────────────────────────────────── + +export async function updateStatus(userId: string, objid: string, body: BomReportStatusBody) { + if (!body.status) throw new Error("status는 필수입니다."); + const sql = ` + UPDATE PART_BOM_REPORT + SET PRODUCT_CD = COALESCE($1, PRODUCT_CD), + PART_NO = COALESCE($2, PART_NO), + PART_NAME = COALESCE($3, PART_NAME), + REVISION = COALESCE($4, REVISION), + STATUS = $5, + editer = $6, + edit_date = NOW() + WHERE OBJID = $7 + `; + const r = await getPool().query(sql, [ + body.product_cd ?? null, + body.part_no ?? null, + body.part_name ?? null, + body.version ?? null, + body.status, + userId, + objid, + ]); + return r.rowCount ?? 0; +} + +// ─── M3 다중 삭제 (트랜잭션) ─────────────────────────────── + +export async function removeMany(objids: string[]): Promise { + if (!objids || objids.length === 0) return 0; + let removed = 0; + await transaction(async (client: PoolClient) => { + // 1) 자식 트리 + await client.query( + `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1::varchar[])`, + [objids] + ); + // 2) 메인 + const r = await client.query( + `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1::varchar[])`, + [objids] + ); + removed = r.rowCount ?? 0; + }); + logger.info("E-BOM 삭제 완료", { removed }); + return removed; +} + +// ─── M4 정전개 (재귀 CTE) ────────────────────────────────── + +export async function ascending(filter: BomTreeFilter) { + const pool = getPool(); + const params: any[] = []; + const conds: string[] = []; + let idx = 1; + + // 시작점 필터: 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘 + if (filter.bom_report_objid) { + conds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + const startWhere = conds.length ? conds.join(" AND ") : "1=1"; + + // PART 검색 필터는 결과 단계 적용 + const finalConds: string[] = []; + if (filter.search_part_no) { + finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_name}%`); + } + const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; + + const sql = ` + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') + AND ${startWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, B.seq, B.status, + T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.parent_objid = T.child_objid AND NOT T.cycle + ) + SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status, + T.lev, T.path, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + PM.edit_date, + PM.eo_no, PM.revision, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ${finalWhere} + ORDER BY T.path + `; + const r = await pool.query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + +// ─── M4 엑셀 다운로드용 풀 컬럼 (wace structureAscendingListExcel.jsp 1:1) ── +// 그리드 ascending/descending 보다 추가: P_QTY(=bom_part_qty.item_qty), MAKER, PART_TYPE_TITLE, +// HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT +// +// 엑셀 컬럼 (wace 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량(QTY) / 항목수량(P_QTY) / +// 3D / 2D / PDF (Y/공백) / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 + +function buildAscendingExcelSql(filter: BomTreeFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.bom_report_objid) { + conds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + const startWhere = conds.length ? conds.join(" AND ") : "1=1"; + + const finalConds: string[] = []; + if (filter.search_part_no) { + finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_name}%`); + } + const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; + + return { params, startWhere, finalWhere }; +} + +export async function ascendingForExcel(filter: BomTreeFilter) { + const { params, startWhere, finalWhere } = buildAscendingExcelSql(filter, 1); + const sql = ` + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, item_qty, seq, status, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, BP.item_qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') + AND ${startWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, B.item_qty, B.seq, B.status, + T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.parent_objid = T.child_objid AND NOT T.cycle + ) + SELECT T.lev, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + T.qty, T.item_qty AS p_qty, + PM.material, PM.remark, + PM.heat_treatment_hardness, PM.heat_treatment_method, PM.surface_treatment, + PM.maker, PM.part_type, + CC.code_name AS part_type_title, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + LEFT JOIN comm_code CC ON CC.code_id = PM.part_type + ${finalWhere} + ORDER BY T.path + `; + const r = await getPool().query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + +export async function descendingForExcel(filter: BomTreeFilter) { + const params: any[] = []; + const anchorConds: string[] = []; + let idx = 1; + + if (filter.search_part_no) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_no) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_name) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_name}%`); + } + if (filter.bom_report_objid) { + anchorConds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + anchorConds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + if (anchorConds.length === 0) return { rows: [], max_level: 0 }; + const anchorWhere = anchorConds.join(" AND "); + + const sql = ` + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, item_qty, seq, status, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, BP.item_qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE ${anchorWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, B.item_qty, B.seq, B.status, + T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.child_objid = T.parent_objid AND NOT T.cycle + ) + SELECT T.lev, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + T.qty, T.item_qty AS p_qty, + PM.material, PM.remark, + PM.heat_treatment_hardness, PM.heat_treatment_method, PM.surface_treatment, + PM.maker, PM.part_type, + CC.code_name AS part_type_title, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + LEFT JOIN comm_code CC ON CC.code_id = PM.part_type + ORDER BY T.path + `; + const r = await getPool().query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + +// ─── M4 역전개 (재귀 CTE — parent 방향) ──────────────────── + +export async function descending(filter: BomTreeFilter) { + const pool = getPool(); + const params: any[] = []; + const anchorConds: string[] = []; + let idx = 1; + + // anchor: PART 검색 필터로 leaf 후보 선택. 없으면 전체 (잎-가지가 자식 없는 행) + if (filter.search_part_no) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_no) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_name) LIKE UPPER($${idx++}))`); + params.push(`%${filter.search_part_name}%`); + } + if (filter.bom_report_objid) { + anchorConds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + anchorConds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + if (anchorConds.length === 0) { + // 검색 조건이 전혀 없으면 빈 결과 반환 (역전개는 통상 PART 한정 조회) + return { rows: [], max_level: 0 }; + } + const anchorWhere = anchorConds.join(" AND "); + + const sql = ` + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE ${anchorWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, B.seq, B.status, + T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.child_objid = T.parent_objid AND NOT T.cycle + ) + SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status, + T.lev, T.path, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + PM.edit_date, + PM.eo_no, PM.revision, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ORDER BY T.path + `; + const r = await pool.query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} diff --git a/backend-node/src/services/devEoHistoryService.ts b/backend-node/src/services/devEoHistoryService.ts new file mode 100644 index 00000000..3c874444 --- /dev/null +++ b/backend-node/src/services/devEoHistoryService.ts @@ -0,0 +1,155 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) — wace partMng.xml#partMngHistList 1:1 read-only. +// +// 매퍼 매핑: +// partMngHistList (line 7,787) → list() +// + getByObjid() — 행 클릭 상세 다이얼로그용 (raw PART_MNG_HISTORY 행) +// +// vexplor_rps 적응: +// · NVL() → COALESCE() +// · PART_MNG.OBJID(bigint) = PM.PARENT_PART_NO(varchar) → ::varchar cast +// · CODE_NAME() → LEFT JOIN comm_code 별칭 +// ============================================================ + +import { getPool } from "../database/db"; + +export interface EoHistoryListFilter { + Year?: string; + contract_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + change_option?: string; + eo_start_date?: string; + eo_end_date?: string; + change_type?: string; + part_type?: string; + writer_id?: string; + page?: number; + page_size?: number; +} + +function buildWhere(filter: EoHistoryListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.Year) { conds.push(`TO_CHAR(CM.REGDATE,'YYYY') = $${idx++}`); params.push(filter.Year); } + if (filter.contract_objid) { conds.push(`CM.OBJID = $${idx++}`); params.push(filter.contract_objid); } + if (filter.unit_code) { conds.push(`B.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); } + if (filter.part_no) { conds.push(`UPPER(PM.PART_NO) LIKE UPPER($${idx++})`); params.push(`%${filter.part_no}%`); } + if (filter.part_name) { conds.push(`UPPER(PM.PART_NAME) LIKE UPPER($${idx++})`); params.push(`%${filter.part_name}%`); } + if (filter.change_option) { conds.push(`PM.CHANGE_OPTION = $${idx++}`); params.push(filter.change_option); } + if (filter.eo_start_date) { conds.push(`TO_DATE(PM.EO_DATE,'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); params.push(filter.eo_start_date); } + if (filter.eo_end_date) { conds.push(`TO_DATE(PM.EO_DATE,'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); params.push(filter.eo_end_date); } + if (filter.change_type) { conds.push(`PM.CHANGE_TYPE = $${idx++}`); params.push(filter.change_type); } + if (filter.part_type) { conds.push(`PM.PART_TYPE = $${idx++}`); params.push(filter.part_type); } + if (filter.writer_id) { conds.push(`PM.WRITER = $${idx++}`); params.push(filter.writer_id); } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── 그리드 ──────────────────────────────────────────────── + +export async function list(filter: EoHistoryListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + SELECT + PM.OBJID, + PM.EO_NO, + TO_CHAR(CM.REGDATE, 'YYYY') AS YEAR, + COALESCE(CM.CUSTOMER_PROJECT_NAME, CM2.CUSTOMER_PROJECT_NAME) AS PROJECT_NAME, + COALESCE(CM2.PROJECT_NO, CM.PROJECT_NO) AS PROJECT_NO, + (SELECT SP.PART_NO || ' ' || SP.PART_NAME + FROM PART_MNG SP + WHERE SP.OBJID::varchar = PM.PARENT_PART_NO) AS PARENT_PART_INFO, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NO,'') || '->' || COALESCE(PM.CHG_PART_NO,'') + ELSE PM.PART_NO END AS PART_NO_DISP, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NAME,'') || '->' || + COALESCE((SELECT P.PART_NAME FROM PART_MNG P WHERE P.OBJID::varchar = PM.CHG_PART_OBJID), '') + ELSE PM.PART_NAME END AS PART_NAME_DISP, + PM.PART_NO, + PM.PART_NAME, + PM.BOM_QTY_STATUS, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN PM.QTY_TEMP ELSE PM.QTY END AS QTY, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN '' + WHEN PM.BOM_QTY_STATUS = 'beforeEdit' AND PM.QTY = PM.QTY_TEMP THEN '' + ELSE PM.QTY_TEMP END AS QTY_TEMP, + PM.CHANGE_TYPE, + CC_CHGTYPE.code_name AS CHANGE_TYPE_NAME, + PM.CHANGE_OPTION, + CC_CHGOPT.code_name AS CHANGE_OPTION_NAME, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.REVISION,'') || '->' || COALESCE(PM.CHG_PART_REV,'') + ELSE PM.REVISION END AS REVISION_DISP, + PM.REVISION, + PM.EO_DATE, + PM.PART_TYPE, + CC_PARTTYPE.code_name AS PART_TYPE_NAME, + PM.WRITER, + UI.user_name AS WRITER_NAME, + COALESCE(WTS.UNIT_NO || '-' || WTS.TASK_NAME, '') AS UNIT_NAME, + TO_CHAR(PM.HIS_REG_DATE, 'YYYY-MM-DD') AS HIS_REG_DATE_TITLE, + PM.BOM_DEPLOY_DATE, + TO_CHAR(PM.BOM_DEPLOY_DATE, 'YYYY-MM-DD') AS BOM_DEPLOY_DATE_TITLE + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN PART_BOM_REPORT B ON B.OBJID = PM.BOM_REPORT_OBJID + LEFT JOIN PROJECT_MGMT CM2 ON CM2.OBJID = B.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WTS ON WTS.OBJID = B.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE NOT (PM.HIS_STATUS = 'DEPLOY' AND PM.CHANGE_TYPE IS NULL AND PM.REVISION = 'RE') + AND PM.REVISION IS NOT NULL + AND COALESCE(PM.BOM_STATUS, '') = 'deploy' + AND ${where.sql} + `; + + const dataSql = `${baseSql} + ORDER BY COALESCE(PM.HIS_REG_DATE, PM.REG_DATE) DESC, PM.PART_NO + LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize }; +} + +// ─── 단건 ──────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = ` + SELECT PM.*, + CC_CHGTYPE.code_name AS change_type_name, + CC_CHGOPT.code_name AS change_option_name, + CC_PARTTYPE.code_name AS part_type_name, + UI.user_name AS writer_name, + CM.customer_project_name, + CM.project_no + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE PM.OBJID = $1::numeric + `; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} diff --git a/backend-node/src/services/devPartExcelImportService.ts b/backend-node/src/services/devPartExcelImportService.ts new file mode 100644 index 00000000..f5d41a87 --- /dev/null +++ b/backend-node/src/services/devPartExcelImportService.ts @@ -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> { + 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(); + 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(); + 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 }; +} diff --git a/backend-node/src/services/devPartService.ts b/backend-node/src/services/devPartService.ts new file mode 100644 index 00000000..1a9acb62 --- /dev/null +++ b/backend-node/src/services/devPartService.ts @@ -0,0 +1,513 @@ +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) — wace_plm partMng.xml 1:1 이식. +// +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/partMng.xml): +// partMngTempGridList → listTemp() (M1, status != 'release') +// partMngGridList → listRelease() (M2, status = 'release') +// partMngInfo → getByObjid() +// insertpartInfo → create() (38 컬럼 INSERT, wace 24 + 추가 15) +// updatePartDetail → update() (21 컬럼 UPDATE) +// partMngDeploy → deploy() (3-step 트랜잭션: isLastInit + history + deploy) +// partMngIsLastInit → (deploy 내부) +// insertPartMngHistory → (deploy 내부) +// partMngDelete → removeMany() +// +// 채번 정책: part_mng.objid 는 bigint. 클라이언트가 part_objid 안 보내면 +// wace CommonUtils.createObjId() 1:1 구현(objidUtil.createObjId) 사용. +// +// EO_NO 채번: IS_LONGD='1' 이면 EOB{yy}-{seq} / 아니면 EO{yy}-{seq}. wace 그대로. +// ============================================================ + +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; +import { PART_BASE_SIMPLE } from "./devPartSqlFragments"; + +// ─── 필터/바디 타입 ────────────────────────────────────────── + +export interface PartTempListFilter { + search_part_no?: string; + search_part_name?: string; + search_material?: string; + search_spec?: string; + search_part_type?: string; + writer?: string; + status?: string; + status_arr?: string[]; + product_code?: string; + upg_no?: string; + page?: number; + page_size?: number; +} + +export interface PartListFilter extends PartTempListFilter { + search_year?: string; + search_hardness?: string; + search_method?: string; + search_surface?: string; + customer_objid?: string; + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_design_date_from?: string; + search_design_date_to?: string; + is_last?: string; + eo?: string; +} + +export interface PartCreateBody { + part_objid?: string; + part_no: string; + part_name: string; + unit?: string; + qty?: string; + spec?: string; + material?: string; + thickness?: string; + width?: string; + height?: string; + out_diameter?: string; + in_diameter?: string; + length?: string; + remark?: string; + part_type: string; + product_mgmt_objid?: string; + supply_code?: string; + maker?: string; + contract_objid?: string; + post_processing?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: 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; +} + +export interface PartUpdateBody { + part_name?: string; + material?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + maker?: string; + part_type?: string; + acctfg?: string; + odrfg?: string; + spec?: 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; +} + +// ─── 공용 검색 절 생성 ────────────────────────────────────── + +function buildCommonWhere( + filter: PartTempListFilter & Partial, + startIdx: number +): { sql: string; params: any[] } { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.product_code) { + conds.push(`T.PRODUCT_MGMT_OBJID = $${idx++}`); + params.push(filter.product_code); + } + if (filter.upg_no) { + conds.push(`T.UPG_NO = $${idx++}`); + params.push(filter.upg_no); + } + if (filter.search_part_no) { + conds.push(`UPPER(T.PART_NO) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + conds.push(`UPPER(T.PART_NAME) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_name}%`); + } + if (filter.search_material) { + conds.push(`UPPER(T.MATERIAL) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_material}%`); + } + if (filter.search_spec) { + conds.push(`UPPER(T.SPEC) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_spec}%`); + } + if (filter.search_part_type) { + conds.push(`T.PART_TYPE = $${idx++}`); + params.push(filter.search_part_type); + } + if (filter.writer) { + conds.push(`T.WRITER = $${idx++}`); + params.push(filter.writer); + } + if (filter.status) { + conds.push(`T.STATUS = $${idx++}`); + params.push(filter.status); + } + if (filter.status_arr && filter.status_arr.length > 0) { + const placeholders = filter.status_arr.map(() => `$${idx++}`).join(","); + conds.push(`T.STATUS IN (${placeholders})`); + params.push(...filter.status_arr); + } + + // M2 전용 추가 필터 + if (filter.search_design_date_from) { + conds.push(`TO_DATE(T.DESIGN_DATE, 'YYYY-MM-DD') >= $${idx++}::timestamp`); + params.push(filter.search_design_date_from); + } + if (filter.search_design_date_to) { + conds.push(`TO_DATE(T.DESIGN_DATE, 'YYYY-MM-DD') <= $${idx++}::timestamp`); + params.push(filter.search_design_date_to); + } + if (filter.is_last) { + conds.push(`T.IS_LAST = $${idx++}`); + params.push(filter.is_last); + } + if (filter.eo) { + // wace 원본: AND T.EO_TEMP IS NULL OR EO_TEMP = '' — eo=1일 때만 적용 + conds.push(`(T.EO_TEMP IS NULL OR T.EO_TEMP = '')`); + } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }): { limit: number; offset: number; page: number; pageSize: number } { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 20)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── M1 그리드 (status != 'release') ──────────────────────── + +export async function listTemp(filter: PartTempListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildCommonWhere(filter, 1); + + // M1 추가 컬럼: PARTNER_TITLE, BOM_REPORT_OBJID/CHILD_OBJID/QTY/QTY_TEMP, Q_QTY, PARENT_PART_INFO, SORT + const baseSql = ` + SELECT T.*, + CASE WHEN T.REVISION IS NULL THEN '0' ELSE T.REVISION END AS SORT, + O.PARTNER_TITLE, + (SELECT PART_NO FROM PART_MNG SP WHERE SP.OBJID::varchar = Q.PARENT_PART_NO) AS PARENT_PART_INFO, + Q.BOM_REPORT_OBJID, + Q.OBJID AS OBJID_QTY, + Q.CHILD_OBJID, + Q.QTY AS Q_QTY_RAW, + Q.QTY_TEMP, + (CASE + WHEN Q.STATUS = 'deploy' THEN Q.QTY + WHEN (Q.QTY_TEMP IS NULL OR Q.QTY_TEMP = '') THEN Q.QTY + ELSE Q.QTY_TEMP + END) AS Q_QTY + FROM (${PART_BASE_SIMPLE}) T + LEFT JOIN ( + SELECT PART_OBJID, + ARRAY_TO_STRING(ARRAY_AGG(PARTNER_TITLE ORDER BY SEQ), ',') AS PARTNER_TITLE + FROM ( + SELECT OSM.PART_OBJID, OSM.SEQ, + OSM.SEQ || '. ' || (SELECT SUPPLY_NAME FROM ADMIN_SUPPLY_MNG + WHERE OBJID::varchar = OSM.PARTNER_OBJID::varchar) AS PARTNER_TITLE + FROM ORDER_SPEC_MNG OSM + ) OSMO + GROUP BY PART_OBJID + ) O ON T.OBJID::varchar = O.PART_OBJID::varchar + LEFT JOIN BOM_PART_QTY Q ON ( + T.OBJID::varchar IN ( + SELECT DISTINCT PM1.OBJID::varchar + FROM PART_MNG PM1, PART_MNG PM2 + WHERE PM1.STATUS = 'changing' + AND PM2.OBJID::varchar = Q.PART_NO + AND PM1.PART_NO = PM2.PART_NO + ) + AND Q.STATUS = 'beforeEdit' + ) + WHERE ${where.sql} + AND COALESCE(T.STATUS, '') <> 'release' + ORDER BY PARENT_PART_INFO NULLS LAST, T.PART_NO + `; + + const pool = getPool(); + const dataSql = `${baseSql} LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { + rows: dataRes.rows, + total: countRes.rows[0]?.total ?? 0, + page, + pageSize, + }; +} + +// ─── M2 그리드 (status = 'release') ───────────────────────── + +export async function listRelease(filter: PartListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildCommonWhere(filter, 1); + + const baseSql = ` + SELECT + ROW_NUMBER() OVER ( + ORDER BY T.PART_NO, + CASE WHEN T.REVISION LIKE 'RE%' THEN 0 ELSE 1 END, + T.REVISION DESC + ) AS NUM, + T.*, + /* wace 1:1 — PART_TYPE='0000063'(SET) 면 '1', 그 외엔 BOM_QTY 합 */ + CASE WHEN T.PART_TYPE = '0000063' THEN '1' + ELSE (SELECT SUM(CASE WHEN COALESCE(Q.QTY,'') = '' THEN 0 ELSE Q.QTY::numeric END)::varchar + FROM BOM_PART_QTY Q + WHERE Q.LAST_PART_OBJID = T.OBJID::varchar) + END AS BOM_QTY + FROM (${PART_BASE_SIMPLE}) T + WHERE ${where.sql} + AND T.STATUS = 'release' + ORDER BY T.PART_NO, + CASE WHEN T.REVISION LIKE 'RE%' THEN 0 ELSE 1 END, + T.REVISION DESC + `; + + const pool = getPool(); + const dataSql = `${baseSql} LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { + rows: dataRes.rows, + total: countRes.rows[0]?.total ?? 0, + page, + pageSize, + }; +} + +// ─── 단건 상세 ────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = `SELECT T.* FROM (${PART_BASE_SIMPLE}) T WHERE T.OBJID = $1`; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── 신규 등록 (insertpartInfo + 15 추가컬럼) ─────────────── + +export async function create(userId: string, body: PartCreateBody): Promise { + if (!body.part_no || !body.part_name) { + throw new Error("필수값 누락: part_no, part_name"); + } + const partObjid = body.part_objid && String(body.part_objid).trim() !== "" + ? String(body.part_objid) + : createObjId(); + + const sql = ` + INSERT INTO PART_MNG ( + OBJID, PART_NO, PART_NAME, UNIT, QTY, SPEC, MATERIAL, + THICKNESS, WIDTH, HEIGHT, OUT_DIAMETER, IN_DIAMETER, LENGTH, + REMARK, STATUS, REG_DATE, WRITER, IS_LAST, + PART_TYPE, PRODUCT_MGMT_OBJID, SUPPLY_CODE, MAKER, CONTRACT_OBJID, POST_PROCESSING, + 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, + $8, $9, $10, $11, $12, $13, + $14, 'create', NOW(), $15, '1', + $16, $17, $18, $19, $20, $21, + $22, $23, $24, + $25, $26, $27, $28, $29, + $30, $31, $32, $33, $34, $35, $36 + ) + `; + + const params = [ + partObjid, + body.part_no, body.part_name, body.unit ?? null, body.qty ?? null, body.spec ?? null, body.material ?? null, + body.thickness ?? null, body.width ?? null, body.height ?? null, body.out_diameter ?? null, body.in_diameter ?? null, body.length ?? null, + body.remark ?? null, userId, + body.part_type, body.product_mgmt_objid ?? null, body.supply_code ?? null, body.maker ?? null, body.contract_objid ?? null, body.post_processing ?? null, + body.heat_treatment_hardness ?? null, body.heat_treatment_method ?? null, body.surface_treatment ?? null, + body.acctfg ?? null, body.odrfg ?? null, body.unit_dc ?? null, body.unitmang_dc ?? null, body.unitchng_nb ?? null, + body.lot_fg ?? null, body.use_yn ?? null, body.qc_fg ?? null, body.setitem_fg ?? null, body.req_fg ?? null, body.unit_length ?? null, body.unit_qty ?? null, + ]; + + await getPool().query(sql, params); + return partObjid; +} + +// ─── 상세 수정 (updatePartDetail, 21 컬럼) ────────────────── + +export async function update(objid: string, body: PartUpdateBody): Promise { + const sql = ` + UPDATE PART_MNG SET + PART_NAME = $1, + MATERIAL = $2, + HEAT_TREATMENT_HARDNESS = $3, + HEAT_TREATMENT_METHOD = $4, + SURFACE_TREATMENT = $5, + MAKER = $6, + PART_TYPE = $7, + ACCTFG = $8, + ODRFG = $9, + SPEC = $10, + UNIT_DC = $11, + UNITMANG_DC = $12, + UNITCHNG_NB = $13, + LOT_FG = $14, + USE_YN = $15, + QC_FG = $16, + SETITEM_FG = $17, + REQ_FG = $18, + UNIT_LENGTH = $19, + UNIT_QTY = $20, + REMARK = $21, + EDIT_DATE = NOW() + WHERE OBJID = $22 + `; + const r = await getPool().query(sql, [ + body.part_name ?? null, body.material ?? null, + body.heat_treatment_hardness ?? null, body.heat_treatment_method ?? null, body.surface_treatment ?? null, + body.maker ?? null, body.part_type ?? null, + body.acctfg ?? null, body.odrfg ?? null, body.spec ?? null, + body.unit_dc ?? null, body.unitmang_dc ?? null, + body.unitchng_nb ?? null, + body.lot_fg ?? null, body.use_yn ?? null, body.qc_fg ?? null, + body.setitem_fg ?? null, body.req_fg ?? null, + body.unit_length ?? null, body.unit_qty ?? null, + body.remark ?? null, + objid, + ]); + return r.rowCount ?? 0; +} + +// ─── 확정 (M1→M2): 다중 objids 순차 트랜잭션 ──────────────── + +export async function deploy(userId: string, objids: string[]): Promise<{ deployed: number; eo_nos: Record }> { + if (!objids || objids.length === 0) return { deployed: 0, eo_nos: {} }; + + const eoNos: Record = {}; + let deployed = 0; + + await transaction(async (client: PoolClient) => { + for (const objid of objids) { + // 1) 동일 PART_NO 모두 IS_LAST='0' (wace partMngIsLastInit 1:1) + await client.query( + `UPDATE PART_MNG SET IS_LAST = '0', EDIT_DATE = NOW() + WHERE PART_NO = (SELECT PART_NO FROM PART_MNG WHERE OBJID = $1)`, + [objid] + ); + + // 2) PART_MNG_HISTORY 이력 INSERT (wace insertPartMngHistory 1:1, BOM_PART_QTY 미연계) + // BOM 컨텍스트(CHILD_OBJID/CHANGE_OPTION 등)는 deploy 단계에선 NULL. + await client.query( + `INSERT INTO PART_MNG_HISTORY ( + objid, product_mgmt_objid, upg_no, part_no, part_name, unit, + qty, spec, material, weight, part_type, remark, + es_spec, ms_spec, change_option, design_apply_point, management_flag, + revision, status, reg_date, edit_date, writer, is_last, + eo_no, eo_temp, excel_upload_seq, sourcing_code, sub_material, + parent_part_no, design_date, eo_date, deploy_date, + thickness, width, height, out_diameter, in_diameter, length, + supply_code, change_type, contract_objid, maker, + his_reg_date, his_writer, his_status, + heat_treatment_hardness, heat_treatment_method, surface_treatment + ) + SELECT + P.OBJID::numeric, P.PRODUCT_MGMT_OBJID, P.UPG_NO, P.PART_NO, P.PART_NAME, P.UNIT, + P.QTY, P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REMARK, + P.ES_SPEC, P.MS_SPEC, P.CHANGE_OPTION, P.DESIGN_APPLY_POINT, P.MANAGEMENT_FLAG, + P.REVISION, P.STATUS, P.REG_DATE, NOW(), $2, P.IS_LAST, + P.EO_NO, P.EO_TEMP, P.EXCEL_UPLOAD_SEQ::varchar, P.SOURCING_CODE, P.SUB_MATERIAL, + P.PARENT_PART_NO, P.DESIGN_DATE, P.EO_DATE, P.DEPLOY_DATE, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SUPPLY_CODE, P.CHANGE_TYPE, P.CONTRACT_OBJID, P.MAKER, + NOW(), $2, 'DEPLOY', + P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT + FROM PART_MNG P + WHERE P.OBJID = $1`, + [objid, userId] + ); + + // 3) 본 행 deploy: IS_LAST='1', STATUS='release', DEPLOY_DATE=NOW(), + // REVISION=COALESCE→'RE', EO_DATE=오늘, EO_NO=신규 채번 (IS_LONGD에 따라 EOB/EO 분기). + const deployRes = await client.query<{ eo_no: string }>( + `UPDATE PART_MNG P SET + IS_LAST = '1', + EDIT_DATE = NOW(), + DEPLOY_DATE = NOW(), + STATUS = 'release', + REVISION = (CASE WHEN COALESCE(P.REVISION,'') = '' THEN 'RE' ELSE P.REVISION END), + EO_DATE = TO_CHAR(NOW(),'YYYY-MM-DD'), + EO_NO = ( + CASE WHEN P.IS_LONGD = '1' THEN + 'EOB' || TO_CHAR(NOW(),'yy') || '-' || LPAD( + (SELECT COALESCE(SUBSTR(MAX(SP.EO_NO), 7, 8)::integer + 1, 1)::varchar + FROM PART_MNG SP + WHERE SP.EO_NO LIKE 'EOB' || TO_CHAR(NOW(),'yy') || '-%' + AND SP.PART_NO <> P.PART_NO + AND COALESCE(SP.REVISION, '') <> COALESCE(P.REVISION, '') + ), 4, '0') + ELSE + 'EO' || TO_CHAR(NOW(),'yy') || '-' || LPAD( + (SELECT COALESCE(SUBSTR(MAX(SP.EO_NO), 6, 8)::integer + 1, 1)::varchar + FROM PART_MNG SP + WHERE SP.EO_NO IS NOT NULL + AND SP.EO_NO NOT LIKE 'EOB%' + AND SP.PART_NO <> P.PART_NO + AND COALESCE(SP.REVISION, '') <> COALESCE(P.REVISION, '') + ), 4, '0') + END + ) + WHERE OBJID = $1 + RETURNING EO_NO AS eo_no`, + [objid] + ); + + if (deployRes.rowCount && deployRes.rowCount > 0) { + deployed += deployRes.rowCount; + eoNos[objid] = deployRes.rows[0]?.eo_no ?? ""; + } + } + }); + + logger.info("PART deploy 완료", { count: deployed, userId }); + return { deployed, eo_nos: eoNos }; +} + +// ─── 다중 삭제 (wace partMngDelete — POSITION 트릭 → ANY 배열) ─ + +export async function removeMany(objids: string[]): Promise { + if (!objids || objids.length === 0) return 0; + // bigint 컬럼이므로 numeric[] 캐스팅 + const r = await getPool().query( + `DELETE FROM PART_MNG WHERE OBJID = ANY($1::numeric[])`, + [objids] + ); + return r.rowCount ?? 0; +} diff --git a/backend-node/src/services/devPartSqlFragments.ts b/backend-node/src/services/devPartSqlFragments.ts new file mode 100644 index 00000000..719d854d --- /dev/null +++ b/backend-node/src/services/devPartSqlFragments.ts @@ -0,0 +1,125 @@ +// ============================================================ +// 개발관리 PART (M1·M2·상세) 공용 SELECT fragment. +// wace_plm/src/com/pms/mapper/partMng.xml#partMngBaseSimple 1:1 이식 + +// vexplor_rps part_mng 의 15 추가컬럼(acctfg/odrfg/unit_dc/unitmang_dc/ +// lot_fg/use_yn/qc_fg/setitem_fg/req_fg/unit_length/unit_qty/ +// heat_treatment_hardness/heat_treatment_method/surface_treatment/unitchng_nb) +// 의 *_NM(comm_code 라벨) / Y/N CASE 변환 추가. +// +// 검색/페이징은 호출 측에서 WHERE 절·LIMIT/OFFSET 만 덧붙여 사용. +// ============================================================ + +/** + * partMngBaseSimple — wace 운영판 1:1. + * SELECT 컬럼만 정의. 호출 측에서 `${PART_BASE_SIMPLE} WHERE … ORDER BY …` 형태로 조합. + */ +export const PART_BASE_SIMPLE = ` + SELECT + -- 기본 컬럼 (wace partMngBaseSimple 1:1) + P.OBJID, + P.PART_NO, + P.PART_NAME, + P.PRODUCT_MGMT_OBJID, + P.UPG_NO, + P.UNIT, + CC_UNIT.code_name AS UNIT_TITLE, + COALESCE( + (SELECT QTY FROM BOM_PART_QTY Q + WHERE Q.LAST_PART_OBJID = P.OBJID::varchar + AND Q.STATUS = 'deploy' + ORDER BY Q.DEPLOY_DATE DESC LIMIT 1), + P.QTY + ) AS QTY, + P.SPEC, + P.POST_PROCESSING, + P.MATERIAL, + P.WEIGHT, + P.PART_TYPE, + CC_PART.code_name AS PART_TYPE_TITLE, + P.REMARK, + P.ES_SPEC, + P.MS_SPEC, + P.CHANGE_TYPE, + P.DESIGN_APPLY_POINT, + P.CHANGE_OPTION, + -- CHANGE_OPTION 다중 라벨 ARRAY_AGG (wace 1:1) + (SELECT ARRAY_TO_STRING(ARRAY_AGG(CC.code_name), ',') + FROM COMM_CODE CC + WHERE CC.code_id IN ( + SELECT UNNEST(STRING_TO_ARRAY(P.CHANGE_OPTION, ',')) + )) AS CHANGE_OPTION_NAME, + P.MANAGEMENT_FLAG, + P.REVISION, + P.STATUS, + P.REG_DATE, + TO_CHAR(P.REG_DATE, 'YYYY-MM-DD') AS PART_REGDATE_TITLE, + P.EDIT_DATE, + P.WRITER, + P.IS_LAST, + P.IS_LONGD, + P.EO_DATE, + P.EO_NO, + P.EO_TEMP, + P.MAKER, + P.CONTRACT_OBJID, + P.THICKNESS, + P.WIDTH, + P.HEIGHT, + P.OUT_DIAMETER, + P.IN_DIAMETER, + P.LENGTH, + P.SOURCING_CODE, + P.SUPPLY_CODE, + SUP.SUPPLY_NAME AS SUPPLY_NAME, + P.SUB_MATERIAL, + P.PARENT_PART_NO, + P.DESIGN_DATE, + P.DEPLOY_DATE, + P.EXCEL_UPLOAD_SEQ, + + -- 첨부 파일 카운트 + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar AND F.DOC_TYPE = '3D_CAD') AS CU01_CNT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar AND F.DOC_TYPE = '2D_DRAWING_CAD') AS CU02_CNT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar AND F.DOC_TYPE = '2D_PDF_CAD') AS CU03_CNT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F + WHERE F.TARGET_OBJID = P.OBJID::varchar + AND F.DOC_TYPE IN ('2D_PDF_CAD','2D_DRAWING_CAD')) AS CU_TOTAL_CNT, + + -- 추가 15컬럼(301_alter_part_mng.sql) + 라벨 + P.HEAT_TREATMENT_HARDNESS, + P.HEAT_TREATMENT_METHOD, + P.SURFACE_TREATMENT, + P.ACCTFG, + CC_ACCT.code_name AS ACCTFG_NM, + P.ODRFG, + CC_ODR.code_name AS ODRFG_NM, + P.UNIT_DC, + CC_UDC.code_name AS UNIT_DC_NM, + P.UNITMANG_DC, + CC_UMDC.code_name AS UNITMANG_DC_NM, + P.UNITCHNG_NB, + P.LOT_FG, + CASE WHEN P.LOT_FG = '1' THEN '예' WHEN P.LOT_FG = '0' THEN '아니오' ELSE '' END AS LOT_FG_NM, + P.USE_YN, + CASE WHEN P.USE_YN = '1' THEN '예' WHEN P.USE_YN = '0' THEN '아니오' ELSE '' END AS USE_YN_NM, + P.QC_FG, + CASE WHEN P.QC_FG = '1' THEN '예' WHEN P.QC_FG = '0' THEN '아니오' ELSE '' END AS QC_FG_NM, + P.SETITEM_FG, + CASE WHEN P.SETITEM_FG = '1' THEN '예' WHEN P.SETITEM_FG = '0' THEN '아니오' ELSE '' END AS SETITEM_FG_NM, + P.REQ_FG, + CASE WHEN P.REQ_FG = '1' THEN '예' WHEN P.REQ_FG = '0' THEN '아니오' ELSE '' END AS REQ_FG_NM, + P.UNIT_LENGTH, + P.UNIT_QTY + + FROM PART_MNG P + LEFT JOIN COMM_CODE CC_UNIT ON CC_UNIT.code_id = P.UNIT AND CC_UNIT.status = 'active' + LEFT JOIN COMM_CODE CC_PART ON CC_PART.code_id = P.PART_TYPE AND CC_PART.status = 'active' + LEFT JOIN COMM_CODE CC_ACCT ON CC_ACCT.code_id = P.ACCTFG AND CC_ACCT.status = 'active' + LEFT JOIN COMM_CODE CC_ODR ON CC_ODR.code_id = P.ODRFG AND CC_ODR.status = 'active' + LEFT JOIN COMM_CODE CC_UDC ON CC_UDC.code_id = P.UNIT_DC AND CC_UDC.status = 'active' + LEFT JOIN COMM_CODE CC_UMDC ON CC_UMDC.code_id = P.UNITMANG_DC AND CC_UMDC.status = 'active' + LEFT JOIN admin_supply_mng SUP ON SUP.OBJID::varchar = P.SUPPLY_CODE +`; diff --git a/backend-node/src/utils/objidUtil.ts b/backend-node/src/utils/objidUtil.ts new file mode 100644 index 00000000..6c502f0b --- /dev/null +++ b/backend-node/src/utils/objidUtil.ts @@ -0,0 +1,24 @@ +// ============================================================ +// part_mng / part_mng_history / attach_file_info 등 wace 운영판 +// `objid bigint`(또는 numeric) 컬럼 채번 유틸. +// +// wace java `com.pms.common.CommonUtils.createObjId()` 1:1 이식: +// 1) UUID v4 생성 +// 2) 하이픈 제거 → 32 hex 문자열 +// 3) Java String.hashCode() (int32) 적용 +// 4) 결과 정수를 문자열로 반환 +// 결과 범위: -2,147,483,648 ~ 2,147,483,647 (Java int 범위). +// ============================================================ +import { randomUUID } from "crypto"; + +function javaStringHashCode(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + } + return h; +} + +export function createObjId(): string { + return String(javaStringHashCode(randomUUID().replace(/-/g, ""))); +} diff --git a/docs/migration/development/00-gap.md b/docs/migration/development/00-gap.md new file mode 100644 index 00000000..1c8badbc --- /dev/null +++ b/docs/migration/development/00-gap.md @@ -0,0 +1,197 @@ +# 개발관리 이식 GAP 분석 (원본 wace_plm 대비) + +> 작성: 2026-05-12 / 작성자: hjjeong +> 대상 메뉴 5종 (1 도메인 `development/`): +> - PART 등록 / PART 조회 / E-BOM 등록 / E-BOM 조회 / 설계변경 리스트 +> 원본 위치: `wace_plm/WebContent/WEB-INF/view/partMng/` (단일 디렉토리) + `mapper/partMng.xml` 단일 매퍼. + +--- + +## 0. 한 문장 요약 + +5개 메뉴 모두 wace `partMng/` 단일 디렉토리 + `partMng.xml` 매퍼에 1:1 매핑됨. 의존 테이블 15개 중 **6개 보유(`part_mng`/`comm_code`/`pms_wbs_task`/`project_mgmt`/`user_info`/`product_mgmt`)** · **9개 신규 추가 완료(`300_part_bom.sql`)** · **`part_mng`에 누락 15컬럼 ALTER 완료(`301_alter_part_mng.sql`)**. 5개 메뉴 모두 P1에서 실데이터 표시 가능. + +## 0.1 이식 원칙 + +- JSP/매퍼XML 안의 `/* */`, ``, `//` 주석 블록은 비활성. 활성 코드만 이식. +- `company_code` 멀티테넌시 분기는 vexplor_rps 측에 만들지 않음 (COMPANY_16 단독). +- `CODE_NAME()`은 영업/프로젝트와 동일하게 `LEFT JOIN comm_code CC_X ON CC_X.code_id=...` 패턴 통일. +- `client_mng`/`supply_mng` → vexplor는 `customer_mng`로 통합되어 있으나, 개발관리 5개 메뉴는 `customer_mng`를 직접 참조하지 않음(`project_mgmt.customer_objid` 경유). 분기 변환 불필요. +- 금액 1,234.00 / 수량 1,234 / 모든 숫자 right-align (memory `feedback_number_format.md`). +- wace JSP 컬럼 정의 끝의 주석 블록은 비활성 항목 — grep만으로 카운트하지 말 것 (memory `feedback_wace_jsp_columns.md`). + +--- + +## 1. 메뉴 ↔ JSP ↔ 매퍼 1:1 매핑 + +| # | 메뉴 | wace JSP | 매퍼 쿼리 (partMng.xml) | LOC | +|---|---|---|---|---:| +| M1 | **PART 등록** | `partMngTempList.jsp` | `partMngTempGridList` (S), `partMngDeploy` (U), `partMngDelete` (D) | 649 | +| M2 | **PART 조회** | `partMngList.jsp` | `partMngGridList` (S), `partMngDelete` (D), `partMngFormPopUp` (S) | 834 | +| M3 | **E-BOM 등록** | `structureList.jsp` | `getBOMStandardStructureGridList` (S), `deleteStructure` (D), `structureStatusChange` (U) | 782 | +| M4 | **E-BOM 조회** | `structureAscendingList.jsp` | `structureAscendingList`/`structureAscendingListExcel`/`structureDescendingExcelList` (S) | 1,064 | +| M5 | **설계변경 리스트** | `partMngHisList.jsp` | `partMngHistList` (S, read-only) | 198 | + +vexplor_rps 측 라우트(예정): + +``` +GET /api/development/part-temp/list (M1 그리드) +POST /api/development/part-temp/deploy (M1 확정) +DEL /api/development/part-temp (M1·M2 삭제 공용) +GET /api/development/part/list (M2 그리드) +GET /api/development/part/:objid (M2 상세 팝업) +GET /api/development/ebom/list (M3 그리드) +PUT /api/development/ebom/status (M3 상태변경) +DEL /api/development/ebom/:objid (M3 삭제) +GET /api/development/ebom/ascending (M4 정전개) +GET /api/development/ebom/descending (M4 역전개) +GET /api/development/eo/history/list (M5 그리드) +``` + +--- + +## 2. 메뉴별 검색 필드 & 그리드 컬럼 (활성만) + +### M1 PART 등록 (`partMngTempList.jsp`) + +**검색**: SEARCH_PART_NO, SEARCH_PART_NAME (둘 다 autocomplete) +**그리드 23셀**: PART_NO · PART_NAME · CU01_CNT(3D) · CU02_CNT(2D) · CU03_CNT(PDF) · MATERIAL · HEAT_TREATMENT_HARDNESS · HEAT_TREATMENT_METHOD · SURFACE_TREATMENT · MAKER · PART_TYPE_TITLE · SPEC · ACCTFG_NM · ODRFG_NM · UNIT_DC_NM · UNITMANG_DC_NM · UNITCHNG_NB · LOT_FG_NM · USE_YN_NM · QC_FG_NM · SETITEM_FG_NM · REQ_FG_NM · UNIT_LENGTH/QTY +**액션**: 확정(Deploy) · 삭제 · 등록 · Excel Upload · 도면다중업로드 · 조회 +**팝업**: partMngFormPopUp(신규) · partMngDetailPopUp(편집) · openPartExcelImportPopUp +**핵심 의존 테이블**: `part_mng` (메인) · `order_spec_mng` · `admin_supply_mng` · `bom_part_qty` + +### M2 PART 조회 (`partMngList.jsp`) + +**검색**: 없음(메인 조회 화면). 그리드 컬럼 동일하게 23셀(M1과 동일). +**액션**: 등록 · 삭제 · 도면연동 · ERP업로드(전체/단일/모두) · Excel Upload · 조회 +**팝업**: partMngFormPopUp · partMngDetailPopUp · FileRegistPopup · openPartExcelImportPopUp +**핵심 의존 테이블**: `part_mng` · `bom_part_qty` + +### M3 E-BOM 등록 (`structureList.jsp`) + +**검색 9 필드**: customer_cd · project_name · unit_code · SEARCH_UNIT_NAME · SEARCH_WRITER · product_cd · SEARCH_PART_NO · SEARCH_PART_NAME · search_fromDate~toDate · status +**그리드 9셀**: PRODUCT_NAME · PART_NO · PART_NAME · BOM_CNT · DEPT_USER_NAME · REG_DATE · DEPLOY_DATE · REVISION · STATUS +**액션**: 조회 · 삭제 · E-BOM등록 · 상태변경 +**팝업**: setStructureStandardFormPopup · setBomCopyFormPopup · setStructurePopupMainFS · changeDesignNotePopUp · structureStatusChangePopup · openBomReportExcelImportPopUp +**핵심 의존 테이블**: `part_bom_report` · `supply_mng` · `project_mgmt` · `pms_wbs_task` · `user_info` · `bom_part_qty` · `part_mng` · `comm_code` + +### M4 E-BOM 조회 (`structureAscendingList.jsp`) + +**검색 4 필드**: project_name · unit_code · search_partNo · search_partName +**그리드**: 동적 — MAX_LEVEL 레벨 컬럼 + 품번 · 품명 · 3D/2D/PDF · 수량 · 변경일 · 변경항목 · 규격 · 재질 · 중량 · 비고 +**액션**: 정전개조회 · 역전개조회 · 엑셀다운로드(정/역전개) +**팝업**: partMngDetailPopUp(클릭) · FileRegistPopup(도면) +**핵심 의존 테이블**: `bom_part_qty` · `sales_bom_report` · `part_bom_report` · `product_mgmt_upg_detail`/`_master` · `part_mng` · `project_mgmt` · `pms_wbs_task` · `user_info` · `product_mgmt` + +### M5 설계변경 리스트 (`partMngHisList.jsp`) + +**검색 10 필드**: Year · contract_objid · unit_code · part_no · part_name · change_option · eo_start_date~end_date · change_type · part_type · writer_id +**그리드 16셀**: EO_NO · PROJECT_NO · PROJECT_NAME · UNIT_NAME · PARENT_PART_INFO · PART_NO · PART_NAME · QTY · QTY_TEMP · CHANGE_TYPE_NAME · CHANGE_OPTION_NAME · REVISION · EO_DATE · PART_TYPE_NAME · WRITER_NAME · HIS_REG_DATE_TITLE +**액션**: 조회만(Read-Only) +**팝업**: partMngHisDetailPopUp(행 클릭) +**핵심 의존 테이블**: `part_mng_history` · `project_mgmt` · `part_bom_report` · `pms_wbs_task` · `user_info` · `comm_code` + +--- + +## 3. RPS DB 보유 매트릭스 (적용 완료) + +| 테이블 | M1 | M2 | M3 | M4 | M5 | 종류 | RPS 상태 | +|---|:-:|:-:|:-:|:-:|:-:|---|---| +| `part_mng` | R/W | R/W | R | R | R | 메인 | ✅ +15컬럼 ALTER(`301_alter_part_mng.sql`) | +| `bom_part_qty` | R | R | R/W | R | R | BOM 수량 | ✅ 신규(`300`) | +| `part_bom_report` | R | R | R/W | R | R | BOM 리포트 헤더 | ✅ 신규(`300`) | +| `part_mng_history` | – | – | – | – | R | 변경이력 | ✅ 신규(`300`) | +| `order_spec_mng` | R | – | – | – | – | 발주 스펙 | ✅ 신규(`300`) | +| `admin_supply_mng` | R | – | – | – | – | 공급사(관리자) | ✅ 신규(`300`) | +| `supply_mng` | – | – | R | – | – | 공급사 | ✅ 신규(`300`) | +| `sales_bom_report` | – | – | – | R | – | 영업 BOM 단가 | ✅ 신규(`300`) | +| `product_mgmt_upg_master` | – | – | – | R | – | 제품 업그레이드 마스터 | ✅ 신규(`300`) | +| `product_mgmt_upg_detail` | – | – | – | R | – | 제품 업그레이드 디테일 | ✅ 신규(`300`) | +| `project_mgmt` | – | – | R | R | R | 프로젝트 | ✅ 기존 | +| `pms_wbs_task` | – | – | R | R | R | 작업/유닛 | ✅ 기존 | +| `user_info` | – | – | R | R | R | 사용자 | ✅ 기존(컬럼명 매핑 필요) | +| `comm_code` | – | – | R | R | R | 공통코드 | ✅ 기존 | +| `product_mgmt` | – | – | R | R | – | 제품 | ✅ 기존 | + +**→ 5개 메뉴 모두 P1에서 실데이터 표시 가능.** + +--- + +## 4. GAP 매트릭스 + +| # | 우선 | 항목 | 권장 작업 | +|---|---|---|---| +| **DEV-1** | 🔴 | 개발관리 메뉴 자체 부재 → 5개 메뉴 운영판 1:1 이식 | **본 PR 시리즈 (3 묶음)** | +| **DEV-2** | 🔴 | `part_mng` 15컬럼 누락 (열처리/표면처리/단위/Y-N flag) | ✅ **완료** — `301_alter_part_mng.sql` | +| **DEV-3** | 🔴 | 9개 테이블 부재 | ✅ **완료** — `300_part_bom.sql` (BEGIN/COMMIT 트랜잭션, IDEMPOTENT) | +| **DEV-4** | 🟠 | `user_info` 컬럼명 매핑 (wace `empseq`/`rank` ↔ vexplor `emp_seq`/`rank_code`+`rank_name`) | 코드 측 alias로 처리 | +| **DEV-5** | 🟠 | M3 상태값(작성중/적용완료 등) — wace는 `comm_code` 0000099 자식 사용 | comm_code 그대로 사용. RPS DB에 이미 존재 여부 확인 후 부재 시 INSERT | +| **DEV-6** | 🟠 | M1·M2 팝업(등록/상세) 다이얼로그 — wace `partMngFormPopUp.jsp` 별도 LOC 큼 | M1·M2 묶음 PR에 포함 (한 번에 가는 게 효율) | +| **DEV-7** | 🟡 | M1 도면 다중 업로드 / M2 ERP 업로드 | 본 PR 시리즈 제외 (별 PR) | +| **DEV-8** | 🟡 | M3 BOM Excel Import / M4 엑셀 다운로드 | 본 PR 시리즈 제외 (별 PR) | +| **DEV-9** | 🟢 | M4 동적 MAX_LEVEL 컬럼 — BOM 트리 깊이에 따라 컬럼 추가 | DataGrid 동적 컬럼 모드. 본 PR(E-BOM 묶음)에 포함 | +| **DEV-10** | 🟢 | `admin_supply_mng.employee_email` 운영 타입 버그(`xid`) | ✅ **완료** — `300` 추출 시 `character varying`으로 정정 | + +--- + +## 5. PR 묶음 스코프 (3 PR 시리즈) + +### 5.1 PR-A : PART 등록·조회 묶음 (M1+M2) + +**범위**: +- backend: `routes/devPartRoutes.ts` + `services/devPartService.ts` + `controllers/devPartController.ts` + - 엔드포인트: `/api/development/part-temp/list`·`/deploy`, `/api/development/part/list`·`/:objid`, `/api/development/part` (DELETE) +- frontend: + - `app/(main)/COMPANY_16/development/part-regist/page.tsx` (M1) + - `app/(main)/COMPANY_16/development/part-search/page.tsx` (M2) + - `components/development/PartFormDialog.tsx` (등록/수정 공용) + - `components/development/PartDetailDialog.tsx` (상세) + - `lib/api/devPart.ts` +- 매퍼 1:1: `partMngTempGridList` · `partMngGridList` · `partMngFormPopUp` · `partMngDeploy` · `partMngDelete` + +**제외**: 도면 다중 업로드 · ERP 업로드 · Excel Import → 별 PR + +### 5.2 PR-B : E-BOM 등록·조회 묶음 (M3+M4) + +**범위**: +- backend: `routes/devBomRoutes.ts` + `services/devBomService.ts` + `controllers/devBomController.ts` + - 엔드포인트: `/api/development/ebom/list`·`/status`·`/:objid` (DELETE), `/api/development/ebom/ascending`·`/descending` +- frontend: + - `app/(main)/COMPANY_16/development/ebom-regist/page.tsx` (M3) + - `app/(main)/COMPANY_16/development/ebom-search/page.tsx` (M4) + - `components/development/BomStandardFormDialog.tsx` (M3 등록) + - `components/development/BomStatusChangeDialog.tsx` (M3 상태변경) + - `lib/api/devBom.ts` +- M4 동적 MAX_LEVEL 컬럼 처리 (DataGrid 동적 컬럼) + +**제외**: BOM Excel Import · 정/역전개 엑셀 다운로드 → 별 PR + +### 5.3 PR-C : 설계변경 리스트 (M5) + +**범위**: +- backend: `routes/devEoHistoryRoutes.ts` + `services/devEoHistoryService.ts` + `controllers/devEoHistoryController.ts` + - 엔드포인트: `/api/development/eo/history/list` (read-only) +- frontend: + - `app/(main)/COMPANY_16/development/change-list/page.tsx` + - `components/development/PartHisDetailDialog.tsx` (행 클릭 상세) + - `lib/api/devEoHistory.ts` +- read-only — INSERT/UPDATE/DELETE 없음 + +--- + +## 6. 사용자 결정 사항 (2026-05-12) + +| # | 항목 | 결정 | +|---|---|---| +| 1 | 도메인 폴더 | 단일 `development/` | +| 2 | 메뉴 진행 순서 | PART 묶음(M1+M2) → E-BOM 묶음(M3+M4) → 설계변경(M5) | +| 3 | 문서 구조 | 단일 00-gap.md (본 문서) + 묶음별 *.md (총 3개) | +| 4 | DDL 적용 | 운영DB → vexplor_rps 직접 적용 완료 (9 신규 + 1 ALTER) | + +--- + +## 7. 다음 단계 + +1. **PR-A** : `01-part.md` 작성 → backend route → frontend page 2개 → verify +2. **PR-B** : `02-ebom.md` 작성 → backend route → frontend page 2개 → verify +3. **PR-C** : `03-eo-history.md` 작성 → backend route → frontend page → verify diff --git a/docs/migration/development/01-part.md b/docs/migration/development/01-part.md new file mode 100644 index 00000000..b85608b5 --- /dev/null +++ b/docs/migration/development/01-part.md @@ -0,0 +1,313 @@ +# PR-A : PART 등록·조회 묶음 구현 명세 + +> 작성: 2026-05-12 / 범위: 개발관리 M1(PART 등록) + M2(PART 조회) — 같은 `part_mng` 테이블 R/W, 매퍼 공유. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +원본 `wace_plm/src/com/pms/mapper/partMng.xml`: + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `partMngBaseSimple` (sql) | 78 | (서비스 측 공통 SELECT fragment) | PART_MNG 메인 88+ 컬럼 SELECT | +| `partMngTempGridList` | 2,354 | `GET /api/development/part-temp/list` | M1 그리드 (status != 'release') + ORDER_SPEC_MNG·ADMIN_SUPPLY_MNG JOIN | +| `partMngGridList` | 1,903 | `GET /api/development/part/list` | M2 그리드 (status = 'release' 고정) | +| `partMngInfo` | 2,699 | `GET /api/development/part/:objid` | 상세 단건 (편집 팝업 진입) | +| `insertpartInfo` | 7,625 | `POST /api/development/part` | 신규 등록 (38 컬럼 INSERT) | +| `updatePartDetail` | 2,711 | `PUT /api/development/part/:objid` | 상세 수정 (21 컬럼 UPDATE) | +| `partMngDeploy` | 4,190 | `POST /api/development/part-temp/deploy` | 확정 (M1→M2) STATUS='release', EO_NO 채번 | +| `partMngIsLastInit` | 4,230 | (deploy 트랜잭션 내부) | 동일 PART_NO 이전 IS_LAST='0' | +| `insertPartMngHistory` | 4,244 | (deploy 트랜잭션 내부) | PART_MNG_HISTORY 이력 INSERT | +| `partMngDelete` | 4,486 | `DELETE /api/development/part` (body: `objids: string[]`) | 다중 삭제 | + +`partMngBaseSimple` SELECT 핵심: `PART_MNG P` + `COMM_CODE CC_UNIT`(UNIT) + `COMM_CODE CC_PART`(PART_TYPE) + `admin_supply_mng SUP`(SUPPLY_CODE) + LATERAL `BOM_PART_QTY`(LAST_PART_OBJID·status='deploy'·최신 1행) + LATERAL `COMM_CODE`(CHANGE_OPTION 다중 라벨) + `ATTACH_FILE_INFO`(3D/2D/PDF 파일 카운트). 23개 그리드 컬럼 + CODE_NAME 라벨 + Y/N flag CASE 변환 자체 처리. + +--- + +## 2. API 엔드포인트 명세 + +### 2.1 M1 그리드 — `GET /api/development/part-temp/list` + +**Query**: +``` +search_part_no?: string +search_part_name?: string +search_material?: string +search_spec?: string +search_part_type?: string (PART_TYPE_CODE comm_code id) +writer?: string +status?: string // 단일: 'create'/'changing'/'editing' +status_arr?: string[] // 다중 (둘 중 하나만 사용) +product_code?: string +upg_no?: string +page?: number // 기본 1 +page_size?: number // 기본 20 +``` + +**SQL** (요약): +```sql +SELECT T.*, SORT (REVISION), O.PARTNER_TITLE, Q.OBJID/CHILD_OBJID/QTY/QTY_TEMP, Q_QTY (CASE), + (SELECT PART_NO FROM PART_MNG SP WHERE SP.OBJID = Q.PARENT_PART_NO) PARENT_PART_INFO +FROM T +LEFT JOIN (ORDER_SPEC_MNG OSM JOIN ADMIN_SUPPLY_MNG SUP) O ON T.OBJID::VARCHAR = O.PART_OBJID::VARCHAR +LEFT JOIN BOM_PART_QTY Q ON ( + T.OBJID IN (SELECT DISTINCT PM1.OBJID FROM PART_MNG PM1, PART_MNG PM2 + WHERE PM1.STATUS='changing' AND PM2.STATUS!='changing' + AND PM2.OBJID = Q.PART_NO AND PM1.PART_NO = PM2.PART_NO) + AND Q.STATUS = 'beforeEdit' +) +WHERE 1=1 + 동적 (SEARCH_PART_NO/NAME/MATERIAL/SPEC/PART_TYPE, WRITER, STATUS, STATUS_ARR) +ORDER BY PARENT_PART_INFO, T.PART_NO +``` + +**Response**: +```ts +{ + rows: PartTempRow[]; // 그리드 23셀 + 추가 필드 + total: number; + page: number; + pageSize: number; +} +``` + +### 2.2 M2 그리드 — `GET /api/development/part/list` + +**Query**: 위 + `search_year?` `search_hardness?` `search_method?` `search_surface?` `customer_objid?` `customer_cd?` `project_name?` `unit_code?` `search_design_date_from?` `search_design_date_to?` `is_last?` `eo?` + +**SQL** (요약): +```sql +SELECT NUM (ROW_NUMBER), T.*, + DECODE(PART_TYPE, '0000063', '1', + (SELECT SUM(...) FROM BOM_PART_QTY Q WHERE Q.LAST_PART_OBJID=T.OBJID)::CHARACTER) BOM_QTY +FROM T +WHERE 1=1 AND T.status='release' -- M1 vs M2 핵심 차이 + + 동적 (M1 검색 필드 + 추가 5종) +``` + +**Response**: `{ rows: PartRow[]; total, page, pageSize }` + +### 2.3 단건 상세 — `GET /api/development/part/:objid` + +```sql +SELECT T.* FROM T WHERE T.OBJID = #{OBJID} +``` + +→ `PartRow` 단일 반환. 404 시 `{ error: 'not_found' }`. + +### 2.4 신규 등록 — `POST /api/development/part` + +**Body** (38 컬럼, 핵심): +```ts +{ + part_objid: string; // numeric, 클라이언트 채번 (nanoid-based) 또는 서버 채번 + part_no: string; + part_name: string; + unit?: string; // comm_code + qty?: string; + spec?: string; + material?: string; + thickness?: string; width?: string; height?: string; + out_diameter?: string; in_diameter?: string; length?: string; + remark?: string; + part_type: string; // comm_code (PART_TYPE_CODE) + product_mgmt_objid?: string; + supply_code?: string; + maker?: string; + contract_objid?: string; + post_processing?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + acctfg?: string; // comm_code 계정구분 + odrfg?: string; // 0=구매/1=생산/8=Phantom + unit_dc?: string; unitmang_dc?: string; + unitchng_nb?: string; + lot_fg?: '0'|'1'; + use_yn?: '0'|'1'; + qc_fg?: '0'|'1'; + setitem_fg?: '0'|'1'; + req_fg?: '0'|'1'; + unit_length?: string; + unit_qty?: string; +} +``` + +**SQL**: `insertpartInfo` (7,625) 그대로. `STATUS='create'`, `REG_DATE=now()`, `IS_LAST='1'`, `WRITER=#{CONNECTUSERID}` (서버에서 `req.user.user_id` 주입). + +**채번 정책**: `part_mng.objid` 는 **`bigint`** 타입(다른 영업관리 테이블 `contract_mgmt.objid` 등은 varchar — `genObjid("CM")` 패턴 사용). bigint 컬럼은 prefix-string 못 쓰므로 **wace `CommonUtils.createObjId()` 1:1 구현** 사용: + +```typescript +// backend-node/src/utils/objidUtil.ts (신규) +import { randomUUID } from 'crypto'; + +function javaStringHashCode(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + return h; +} + +/** wace CommonUtils.createObjId() 1:1 — UUID v4 → 하이픈 제거(32 hex) → Java String.hashCode (int32) → String. 결과: -2,147,483,648 ~ 2,147,483,647. */ +export function createObjId(): string { + return String(javaStringHashCode(randomUUID().replaceAll('-', ''))); +} +``` + +INSERT 시 `body.part_objid` 가 비어 있으면 서버에서 `createObjId()` 호출(클라이언트 채번도 허용하되 권장 X). + +### 2.5 상세 수정 — `PUT /api/development/part/:objid` + +**Body** (21 컬럼, `updatePartDetail` 1:1): +`part_name, material, heat_treatment_hardness, heat_treatment_method, surface_treatment, maker, part_type, acctfg, odrfg, spec, unit_dc, unitmang_dc, unitchng_nb, lot_fg, use_yn, qc_fg, setitem_fg, req_fg, unit_length, unit_qty, remark` + +→ `EDIT_DATE = NOW()` 자동. + +### 2.6 확정 — `POST /api/development/part-temp/deploy` + +**Body**: `{ objids: string[] }` — 다중 선택 확정. + +**트랜잭션 (각 objid에 대해 순차 처리)**: +1. `partMngIsLastInit`: 같은 PART_NO 모든 행 `IS_LAST='0'` +2. `insertPartMngHistory`: 현재 행을 `PART_MNG_HISTORY`로 복사 (이력 보존) +3. `partMngDeploy`: 본 행 `IS_LAST='1'`, `STATUS='release'`, `DEPLOY_DATE=NOW()`, `REVISION=COALESCE(REVISION,'RE')`, `EO_DATE=...`, `EO_NO=` 채번 (IS_LONGD에 따라 `EOB{yy}-{seq}` or `EO{yy}-{seq}`) + +**EO_NO 채번 SQL** (wace 운영판 그대로): +```sql +CASE WHEN P.IS_LONGD = '1' THEN + 'EOB' || TO_CHAR(NOW(),'yy') || '-' || LPAD( + (SELECT COALESCE(SUBSTR(MAX(EO_NO),7,8)::INTEGER+1, 1) + FROM PART_MNG SP + WHERE SP.EO_NO LIKE 'EOB' || TO_CHAR(NOW(),'yy') || '-%' + AND SP.PART_NO != P.PART_NO + AND SP.REVISION != P.REVISION + )||'', 4, '0') + ELSE + 'EO' || TO_CHAR(NOW(),'yy') || '-' || LPAD(... 'EO{yy}-{seq}' ...) +END +``` + +**Response**: `{ deployed: number, eo_nos: Record }` + +### 2.7 다중 삭제 — `DELETE /api/development/part` + +**Body**: `{ objids: string[] }` + +**SQL** (wace 그대로 POSITION 트릭): +```sql +DELETE FROM PART_MNG WHERE POSITION(OBJID||',' IN #{checkArr}||',') > 0 +``` + +→ backend-node에서는 PostgreSQL 표준인 `WHERE OBJID = ANY($1::numeric[])` 로 정리(동일 효과 + 인덱스 활용 가능). + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/ + devPartRoutes.ts // Express Router — 7 endpoint + controllers/ + devPartController.ts // req/res 처리, validation + services/ + devPartService.ts // SQL 실행 (pg 트랜잭션 처리 포함) + devPartSqlFragments.ts // partMngBaseSimple SELECT fragment 재사용 +``` + +`app.ts`에 `app.use('/api/development', devPartRoutes)` 추가 (또는 메뉴 묶음 라우터 도입 시 그쪽). + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/ + part-regist/ + page.tsx // M1 그리드 + 상단 액션 + 페이징 + part-search/ + page.tsx // M2 그리드 + 상단 액션 + 페이징 + components/development/ + PartFormDialog.tsx // 신규/수정 통합 (mode prop) + PartDetailDialog.tsx // 읽기 전용 상세 + lib/api/ + devPart.ts // 7 endpoint 호출 함수 + 타입 +``` + +### 4.1 그리드 23셀 (M1·M2 공통) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| part_no | 품번 | left | 140 | +| part_name | 품명 | left | 220 | +| cu01_cnt | 3D | right | 60 | +| cu02_cnt | 2D | right | 60 | +| cu03_cnt | PDF | right | 60 | +| material | 재료 | left | 100 | +| heat_treatment_hardness | 열처리경도 | left | 110 | +| heat_treatment_method | 열처리방법 | left | 110 | +| surface_treatment | 표면처리 | left | 100 | +| maker | 메이커 | left | 100 | +| part_type_title | 범주이름 | left | 100 | +| spec | 규격 | left | 140 | +| acctfg_nm | 계정구분 | center | 80 | +| odrfg_nm | 조달구분 | center | 80 | +| unit_dc_nm | 재고단위 | center | 80 | +| unitmang_dc_nm | 관리단위 | center | 80 | +| unitchng_nb | 환산수량 | right | 90 | +| lot_fg_nm | LOT구분 | center | 80 | +| use_yn_nm | 사용여부 | center | 80 | +| qc_fg_nm | 검사여부 | center | 80 | +| setitem_fg_nm | SET품여부 | center | 90 | +| req_fg_nm | 의뢰여부 | center | 80 | +| unit_length / unit_qty | 개당길이/수량 | right | 100 | + +추가 (M1만): `partner_title`, `q_qty`, `parent_part_info` +추가 (M2만): `bom_qty` + +### 4.2 검색 폼 + +**M1 (PART 등록)** — 2 필드: SEARCH_PART_NO · SEARCH_PART_NAME (둘 다 PartSelect autocomplete) +**M2 (PART 조회)** — 메인 조회 화면 (별도 검색 폼 없음, 그리드 헤더 inline 필터로 처리하거나 상단 간소화 검색바 1줄로 통합 — 본 PR 우선 `` 2개로 시작, 추후 보강) + +### 4.3 액션 버튼 (각 page 상단) + +**M1**: 등록 · 수정 · 삭제 · 확정 · 조회 +**M2**: 등록 · 수정 · 삭제 · 조회 (도면연동/ERP업로드/Excel은 본 PR 제외) + +### 4.4 PartFormDialog (신규/수정 통합) + +- mode: `'create' | 'edit'` +- 38 필드 — `` + `` 조합 +- 검증: part_no/part_name 필수, comm_code 필드는 SmartSelect +- 신규: POST → 신규 행 추가 +- 수정: PUT → 21 필드만 전송 (insertpartInfo는 38, updatePartDetail는 21 — wace 그대로) + +### 4.5 PartDetailDialog (읽기 전용) + +행 더블클릭 시 진입. 모든 필드 disabled. "수정" 버튼 → PartFormDialog(mode='edit') 전환. + +--- + +## 5. 본 PR 제외 항목 + +| 항목 | 사유 / 후속 | +|---|---| +| 도면 다중 업로드 (M1) | `ATTACH_FILE_INFO` 다파일 업로드 — 별 PR | +| ERP 업로드 (M2) | wace 외부 시스템 연동 — 별 PR | +| Excel Upload (M1·M2) | `openPartExcelImportPopUp.jsp` 별도 — 별 PR | +| BOM_PART_QTY R/W (M3 영역) | PR-B 에서 다룸 | +| EO_NO 채번 분기 일부 (`IS_LONGD` flag) | 본 PR 포함 — 운영판 그대로 | + +--- + +## 6. 검증 시나리오 (verify.md 기준) + +1. M1 페이지 진입 → 그리드 표시(status != 'release') 확인 +2. "등록" → PartFormDialog 신규 → POST → M1 그리드에 새 행 +3. M1 행 선택 → "확정" → POST deploy → STATUS='release', EO_NO 채번 확인 +4. M2 페이지 진입 → deploy된 행이 M2 그리드에 표시 +5. M2 행 선택 → "수정" → PartFormDialog 수정 → PUT +6. M2 행 다중 선택 → "삭제" → DELETE → 그리드에서 제거 +7. 검색 (SEARCH_PART_NO/NAME) → 필터 적용 확인 +8. 운영DB 11133/waceplm 의 동일 SQL 결과와 vexplor_rps 결과 행 수 비교 (sanity) diff --git a/docs/migration/development/02-ebom.md b/docs/migration/development/02-ebom.md new file mode 100644 index 00000000..d50acd14 --- /dev/null +++ b/docs/migration/development/02-ebom.md @@ -0,0 +1,282 @@ +# PR-B : E-BOM 등록·조회 묶음 구현 명세 (M3+M4) + +> 작성: 2026-05-12 / 범위: 개발관리 M3(E-BOM 등록) + M4(E-BOM 조회) — `part_bom_report` 메인, `bom_part_qty` 트리. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +원본 `wace_plm/src/com/pms/mapper/partMng.xml`: + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `getBOMStandardStructureGridList` | 2,859 | `GET /api/development/ebom/list` | M3 그리드 (PART_BOM_REPORT + 집계) | +| `updateStructureStatus` | 8,027 | `PUT /api/development/ebom/status` | M3 상태변경 (PRODUCT_CD/PART_NO/NAME/VERSION/STATUS) | +| `deleteBomReport` | 6,838 | `DELETE /api/development/ebom` (body `objids`) | M3 다중 삭제 + BOM_PART_QTY CASCADE | +| `deleteBomQty` | 6,847 | (deleteBomReport 내부) | M3 삭제 시 자식 트리 동시 삭제 | +| `structureAscendingList` | 7,361 | `GET /api/development/ebom/ascending` | M4 정전개 (root → leaf) | +| `selectStructureDescendingList` | 6,582 | `GET /api/development/ebom/descending` | M4 역전개 (leaf → root) | + +--- + +## 2. API 엔드포인트 명세 + +### 2.1 M3 그리드 — `GET /api/development/ebom/list` + +**Query**: +``` +customer_cd?: string // part_bom_report.customer_objid +project_name?: string // part_bom_report.contract_objid (project_mgmt.objid) +unit_code?: string // pms_wbs_task.objid +search_unit_name?: string // pms_wbs_task.unit_no/task_name LIKE +search_writer?: string // part_bom_report.writer +product_cd?: string // wace 'product_code' 검색 (part_bom_report.product_cd) +search_part_no?: string // part_bom_report.part_no LIKE +search_part_name?: string // part_bom_report.part_name LIKE +search_from_date?: string // regdate from +search_to_date?: string // regdate to +status?: string // part_bom_report.status (create/changeDesign/deploy) +page?, page_size? +``` + +**SQL** (vexplor_rps part_bom_report 스키마 적응 — wace `getBOMStandardStructureGridList` 의 PRODUCT_CD/PART_NO/PART_NAME 분기 활성, MULTI_* 컬럼 그대로): +```sql +SELECT + ROW_NUMBER() OVER(ORDER BY T.REGDATE DESC) AS NUM, + T.OBJID, T.CUSTOMER_OBJID, SM.SUPPLY_NAME AS CUSTOMER_NAME, + T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO, + T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME, + T.STATUS, + CASE UPPER(T.STATUS) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' END AS STATUS_TITLE, + T.WRITER, UI.DEPT_NAME, UI.USER_NAME, + COALESCE(UI.DEPT_NAME || '/' || UI.USER_NAME, '') AS DEPT_USER_NAME, + T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE, + T.DEPLOY_DATE, T.REVISION, + EO_DATA.EO_NO, EO_DATA.EO_DATE, + T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID, + COALESCE(EO_DATA.BOM_CNT, 0) AS BOM_CNT, + T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME, + T.PART_NO, T.PART_NAME + FROM PART_BOM_REPORT T + LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID -- vexplor 매핑 + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE + LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active' + LEFT JOIN ( + SELECT BP.BOM_REPORT_OBJID, + MAX(PM2.EO_NO) AS EO_NO, + MAX(PM2.EO_DATE) AS EO_DATE, + COUNT(*) AS BOM_CNT + FROM BOM_PART_QTY BP + LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar + GROUP BY BP.BOM_REPORT_OBJID + ) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID + WHERE 1=1 + 동적 (위 11 필터) +``` + +**Response**: `{ rows: BomReportRow[], total, page, pageSize }` + +### 2.2 M3 단건 상세 — `GET /api/development/ebom/:objid` + +`SELECT T.* FROM PART_BOM_REPORT T WHERE T.OBJID = $1` + (옵션) BOM_PART_QTY 카운트. +편집 다이얼로그 진입용. + +### 2.3 M3 상태 변경 — `PUT /api/development/ebom/:objid/status` + +**Body**: `{ product_cd?, part_no?, part_name?, version?, status }` — wace `updateStructureStatus` 1:1. +`STATUS` 만 변경하는 단순 케이스도 지원 (다른 필드 NULL 허용). + +```sql +UPDATE PART_BOM_REPORT + SET PRODUCT_CD = COALESCE($1, PRODUCT_CD), + PART_NO = COALESCE($2, PART_NO), + PART_NAME = COALESCE($3, PART_NAME), + REVISION = COALESCE($4, REVISION), + STATUS = $5, + EDITER = $6, + EDIT_DATE = NOW() + WHERE OBJID = $7 +``` + +### 2.4 M3 다중 삭제 — `DELETE /api/development/ebom` (body: `{ objids: string[] }`) + +**트랜잭션**: +1. `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1)` (자식 트리) +2. `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1)` (메인) + +wace는 part_mng도 정리(`deleteBomQtyPart`, status='create'만)하지만 본 PR에서는 part_mng 보존 (M1·M2와 결합 안 됨). + +### 2.5 M4 정전개 — `GET /api/development/ebom/ascending` + +**Query**: +``` +bom_report_objid?: string // 단일 BOM 한정 조회 +project_name?: string // PART_BOM_REPORT.contract_objid +unit_code?: string +search_part_no?: string +search_part_name?: string +``` + +**SQL** (재귀 CTE — wace `structureAscendingList` 의 BOM_PART_QTY 트리 1:1): +```sql +WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, 1, ARRAY[BP.objid], FALSE + FROM bom_part_qty BP + WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') + AND BP.bom_report_objid = $bom_report_objid /* 또는 필터 적용된 PART_BOM_REPORT 서브쿼리 */ + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.parent_objid = T.objid AND NOT T.cycle +) +SELECT T.*, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + PM.edit_date, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT MAX(lev) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ORDER BY T.path +``` + +**Response**: `{ rows: AscRow[], max_level: number }` + +### 2.6 M4 역전개 — `GET /api/development/ebom/descending` + +같은 Query 파라미터. 재귀 방향 반대: +- 시작점: 리프(`child_objid` 가 다른 행의 `parent_objid` 가 아닌 행) 또는 사용자가 지정한 PART +- 트리 부모 방향으로 traverse + +**SQL** (역전개 — wace `selectStructureDescendingList` 단순 매핑): +```sql +WITH RECURSIVE TREE(...) AS ( + /* 1. anchor: 조건 매칭 BOM 또는 leaf part */ + SELECT BP.* , 1 AS lev, ARRAY[BP.objid] AS path, FALSE AS cycle + FROM bom_part_qty BP + WHERE ... /* PART_NO 매칭 등 */ + UNION ALL + /* 2. parent 방향 traverse */ + SELECT B.*, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.objid = T.parent_objid AND NOT T.cycle +) +SELECT ... (정전개와 동일 part_mng / attach_file_info JOIN) +``` + +**Response**: `{ rows: DescRow[], max_level: number }` + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/ + devBomRoutes.ts // 6 endpoint + controllers/ + devBomController.ts + services/ + devBomService.ts // list/getById/updateStatus/removeMany/ascending/descending +``` + +`app.ts`: `app.use("/api/development", devBomRoutes)` (devPart 라우터와 prefix 공유 — Express 중복 등록 안전, 경로 충돌 없음). + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/ + ebom-regist/page.tsx // M3 그리드 + 검색 + 액션 + ebom-search/page.tsx // M4 정/역전개 (동적 LEVEL 컬럼) + components/development/ + BomReportStatusDialog.tsx // M3 상태 변경 다이얼로그 (status select) + lib/api/ + devBom.ts // 6 endpoint 호출 + 타입 +``` + +### 4.1 M3 그리드 (9 셀, wace structureList.jsp:185~215 1:1) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| product_name | 제품구분 | center | 160 | +| part_no | 품번 | left | 210 | +| part_name | 품명 | left | flex | +| bom_cnt | E-BOM (folder click) | center | 150 | +| dept_user_name | 등록자 | center | 120 | +| reg_date | 등록일 | center | 130 | +| deploy_date | 확정일 | center | 100 | +| revision | Version | center | 110 | +| status_title | 상태 | center | 110 | + +### 4.2 M3 검색 폼 (wace 1:1 — 9 필드) + +customer_cd · project_name · unit_code · SEARCH_UNIT_NAME · SEARCH_WRITER · product_cd · SEARCH_PART_NO · SEARCH_PART_NAME · search_fromDate~toDate · status + +본 PR 1차: PRODUCT_CD · SEARCH_PART_NO · SEARCH_PART_NAME · STATUS 4필드로 시작. 나머지 추후 보강. + +### 4.3 M3 액션 버튼 (wace 1:1) + +- 조회 / 삭제 / E-BOM등록(Excel Import — 별 PR) / 상태변경 + +본 PR 포함: 조회 · 삭제 · 상태변경. **E-BOM등록(Excel Import)은 별 PR**. + +### 4.4 M4 동적 LEVEL 컬럼 + +backend response의 `max_level` 값에 따라 컬럼을 동적 생성: +- LEVEL 1..max_level: 각 레벨 컬럼은 `row.lev === i` 인 행의 `part_no` 표시 (트리 들여쓰기 효과) +- 고정 컬럼: 품번 · 품명 · 3D/2D/PDF · 수량 · 변경일 · 규격 · 재질 · 중량 · 비고 + +DataGrid의 컬럼 배열을 fetch 결과 도착 시 동적 생성 (state). + +### 4.5 M4 액션 + +- 정전개 조회 (default) +- 역전개 조회 +- (Excel Download — 별 PR) + +--- + +## 5. 본 PR 제외 항목 + +| 항목 | 사유 / 후속 | +|---|---| +| E-BOM 등록 (Excel Import) | `openBomReportExcelImportPopUp.jsp` — 별 PR | +| 정/역전개 Excel Download | `structureAscendingListExcel`/`structureDescendingExcelList` — 별 PR | +| `BOM_PART_QTY` 직접 편집 (수량 변경) | `structureQtySave` — wace 운영판에서도 별 화면 | +| 다중 BOM(MULTI_*) 분기 처리 | 현재 vexplor 데이터 없음 — 기본 1:1만 | +| wace_plm `product_mgmt_spec/upg/vc` 분기 | vexplor 스키마는 product_cd 단순 — 운영판 1:1 적응 | + +--- + +## 6. 검증 시나리오 (verify.md 기준) + +1. M3 페이지 진입 → part_bom_report 그리드 (현재 0건, schema/UI 동작 확인) +2. M3 상태변경 다이얼로그 → status='deploy' → DB 반영 확인 +3. M3 다중 삭제 → bom_part_qty CASCADE 확인 +4. M4 페이지 진입 → 정전개 (`/ascending`) 0건 응답 → 페이지 정상 표시 +5. M4 역전개 토글 → `/descending` 응답 +6. (시드 후) MAX_LEVEL=3 트리에서 동적 컬럼 3개 생성 확인 + +--- + +## 7. 적응 사항 (운영판 대비 변경점) + +| # | 항목 | 변경 | +|---|---|---| +| 1 | `customer_mng` 매핑 | wace `SUPPLY_MNG.OBJID::VARCHAR = T.CUSTOMER_OBJID` → vexplor `customer_mng.customer_code = T.CUSTOMER_OBJID` | +| 2 | `PRODUCT_NAME` lookup | wace `CODE_NAME(PRODUCT_CD)` → vexplor `LEFT JOIN comm_code` (`CC_PRD`) | +| 3 | M4 `PRODUCT_MGMT_*` 분기 제거 | vexplor part_bom_report 는 product_cd/version 단순화 — wace `product_mgmt_spec/upg/vc` 컬럼 없음 → JOIN 생략, 정전개는 BOM_PART_QTY 트리만 | +| 4 | 다중 삭제 트랜잭션 | wace 두 매퍼(`deleteBomQty` + `deleteBomReport`) 호출 → backend `transaction()` 한 번 | diff --git a/docs/migration/development/03-eo-history.md b/docs/migration/development/03-eo-history.md new file mode 100644 index 00000000..2ef4024c --- /dev/null +++ b/docs/migration/development/03-eo-history.md @@ -0,0 +1,181 @@ +# PR-C : 설계변경 리스트 구현 명세 (M5) + +> 작성: 2026-05-12 / 범위: 개발관리 M5 (설계변경 리스트) — read-only `part_mng_history` 조회. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `partMngHistList` | 7,787 | `GET /api/development/eo-history/list` | M5 그리드 (read-only) | + +추가 엔드포인트: +- `GET /api/development/eo-history/:objid` — 행 클릭 상세 다이얼로그용 (raw row 반환). + +--- + +## 2. API 명세 + +### 2.1 그리드 — `GET /api/development/eo-history/list` + +**Query** (wace 10 필터 1:1): +``` +Year?: string // TO_CHAR(project_mgmt.regdate, 'YYYY') +contract_objid?: string // project_mgmt.objid +unit_code?: string // part_bom_report.unit_code +part_no?: string // LIKE +part_name?: string // LIKE +change_option?: string // comm_code id (EO사유) +eo_start_date?: string +eo_end_date?: string +change_type?: string // comm_code id (EO구분) +part_type?: string // comm_code id +writer_id?: string +page?, page_size? +``` + +**SQL** (wace `partMngHistList` 1:1, vexplor 적응): +```sql +SELECT + PM.OBJID, + PM.EO_NO, + TO_CHAR(CM.REGDATE, 'YYYY') AS YEAR, + COALESCE(CM.CUSTOMER_PROJECT_NAME, CM2.CUSTOMER_PROJECT_NAME) AS PROJECT_NAME, + COALESCE(CM2.PROJECT_NO, CM.PROJECT_NO) AS PROJECT_NO, + /* 모품번: PART_MNG SP에서 PARENT_PART_NO로 조회. PART_MNG.OBJID는 bigint → varchar cast */ + (SELECT SP.PART_NO || ' ' || SP.PART_NAME + FROM PART_MNG SP + WHERE SP.OBJID::varchar = PM.PARENT_PART_NO) AS PARENT_PART_INFO, + /* 품번변경(0001790) 시 'A->B' 머지 */ + CASE WHEN PM.CHANGE_OPTION = '0001790' THEN COALESCE(PM.PART_NO,'') || '->' || COALESCE(PM.CHG_PART_NO,'') + ELSE PM.PART_NO END AS PART_NO_DISP, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NAME,'') || '->' || + COALESCE((SELECT P.PART_NAME FROM PART_MNG P WHERE P.OBJID::varchar = PM.CHG_PART_OBJID), '') + ELSE PM.PART_NAME END AS PART_NAME_DISP, + PM.PART_NO, -- raw + PM.PART_NAME, -- raw + PM.BOM_QTY_STATUS, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN PM.QTY_TEMP ELSE PM.QTY END AS QTY, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN '' + WHEN PM.BOM_QTY_STATUS = 'beforeEdit' AND PM.QTY = PM.QTY_TEMP THEN '' + ELSE PM.QTY_TEMP END AS QTY_TEMP, + PM.CHANGE_TYPE, + CC_CHGTYPE.code_name AS CHANGE_TYPE_NAME, + PM.CHANGE_OPTION, + CC_CHGOPT.code_name AS CHANGE_OPTION_NAME, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.REVISION,'') || '->' || COALESCE(PM.CHG_PART_REV,'') + ELSE PM.REVISION END AS REVISION_DISP, + PM.REVISION, -- raw + PM.EO_DATE, + PM.PART_TYPE, + CC_PARTTYPE.code_name AS PART_TYPE_NAME, + PM.WRITER, + UI.user_name AS WRITER_NAME, + COALESCE(WTS.UNIT_NO || '-' || WTS.TASK_NAME, '') AS UNIT_NAME, + TO_CHAR(PM.HIS_REG_DATE, 'YYYY-MM-DD') AS HIS_REG_DATE_TITLE, + PM.BOM_DEPLOY_DATE, + TO_CHAR(PM.BOM_DEPLOY_DATE, 'YYYY-MM-DD') AS BOM_DEPLOY_DATE_TITLE + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN PART_BOM_REPORT B ON B.OBJID = PM.BOM_REPORT_OBJID + LEFT JOIN PROJECT_MGMT CM2 ON CM2.OBJID = B.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WTS ON WTS.OBJID = B.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE 1=1 + /* 파트 신규등록건 조회 제외 — wace 1:1 */ + AND NOT (PM.HIS_STATUS = 'DEPLOY' AND PM.CHANGE_TYPE IS NULL AND PM.REVISION = 'RE') + AND PM.REVISION IS NOT NULL + AND COALESCE(PM.BOM_STATUS, '') = 'deploy' + + 동적 (위 10 필터) + ORDER BY COALESCE(PM.HIS_REG_DATE, PM.REG_DATE) DESC, PM.PART_NO +``` + +**Response**: `{ rows: EoHistoryRow[], total, page, pageSize }` + +### 2.2 단건 — `GET /api/development/eo-history/:objid` + +`SELECT PM.* FROM PART_MNG_HISTORY PM WHERE PM.OBJID = $1::numeric` + comm_code JOIN (CHANGE_TYPE/OPTION/PART_TYPE 라벨). + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/devEoHistoryRoutes.ts // 2 endpoint + controllers/devEoHistoryController.ts + services/devEoHistoryService.ts // list + getByObjid +``` + +`app.ts`: `app.use("/api/development", devEoHistoryRoutes)`. + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/change-list/page.tsx + components/development/PartHisDetailDialog.tsx + lib/api/devEoHistory.ts +``` + +### 4.1 그리드 16셀 (wace partMngHisList.jsp:50~75 1:1) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| eo_no | EO No | left | 100 | +| project_no | 프로젝트번호 | left | 120 | +| project_name | 프로젝트명 | left | 180 | +| unit_name | 유닛명 | left | 160 | +| parent_part_info | 모품번 | left | 160 | +| part_no_disp | 품번 | left | 160 | +| part_name_disp | 품명 | left | flex | +| qty | 수량 | right | 70 | +| qty_temp | 변경수량 | right | 80 | +| change_type_name | EO구분 | center | 90 | +| change_option_name | EO사유 | center | 100 | +| revision_disp | Revision | center | 90 | +| eo_date | EO Date | center | 100 | +| part_type_name | PART구분 | center | 90 | +| writer_name | 담당자 | center | 90 | +| his_reg_date_title | 실행일 | center | 100 | + +### 4.2 검색 폼 (wace 10 필드 1:1) + +Year · contract_objid · unit_code · part_no · part_name · change_option · eo_start_date~end_date · change_type · part_type · writer_id + +본 PR 1차: Year · 프로젝트 OBJID · part_no · part_name · eo_start_date~end_date · change_type · change_option · part_type (Smart 폼). writer_id 는 UserSelect 별 PR. + +### 4.3 액션 + +- 조회 (only) — read-only 메뉴 +- 행 클릭 → PartHisDetailDialog (모든 필드 disabled) + +--- + +## 5. 검증 시나리오 + +1. M5 페이지 진입 → part_mng_history 빈 그리드 (시드 후 표시 확인) +2. PART 등록(M1) → 확정(deploy) 트랜잭션으로 part_mng_history INSERT (PR-A 의 deploy 경로) + - 단, deploy 시 `bom_status` 가 NULL 이므로 본 SQL의 `bom_status='deploy'` 필터에서 제외됨 + - 본 메뉴는 **BOM 변경 이력** 표시 목적 — 실제 데이터는 M3 BOM 배포 시 INSERT됨 (PR-B 범위 밖) +3. 검색 필터(year/part_no/change_type/eo_start_date) → 동적 WHERE 적용 확인 +4. 행 클릭 → 상세 다이얼로그에 모든 필드 표시 (raw + 라벨) + +--- + +## 6. 적응 사항 + +| # | 항목 | 변경 | +|---|---|---| +| 1 | `NVL()` → `COALESCE()` | wace는 Oracle 호환 NVL 사용, PG 표준은 COALESCE | +| 2 | `PART_MNG.OBJID = PM.PARENT_PART_NO` JOIN | OBJID bigint vs PARENT_PART_NO varchar → cast `::varchar` | +| 3 | `CODE_NAME()` 함수 | LEFT JOIN comm_code 별칭으로 풀어 쓰기 | +| 4 | wace `STATUS_NQ`/`SEARCH_TYPE=CHANGE_LIST` 필터 제거 | 본 PR 메뉴는 변경리스트 한 화면 — 필터 단순화 | diff --git a/docs/migration/development/data-sync/01_part_mng_sync.sql b/docs/migration/development/data-sync/01_part_mng_sync.sql new file mode 100644 index 00000000..64a01c30 --- /dev/null +++ b/docs/migration/development/data-sync/01_part_mng_sync.sql @@ -0,0 +1,67 @@ +BEGIN; + +CREATE TEMP TABLE part_mng_sync_stage ( + part_no varchar, + material varchar, + heat_treatment_hardness varchar, + heat_treatment_method varchar, + surface_treatment varchar, + maker varchar, + part_type varchar, + spec varchar, + acctfg varchar, + odrfg varchar, + unit_dc varchar, + unitmang_dc varchar, + unitchng_nb varchar, + lot_fg varchar, + use_yn varchar, + qc_fg varchar, + setitem_fg varchar, + req_fg varchar, + unit_length varchar, + unit_qty varchar +); + +\copy part_mng_sync_stage FROM '/tmp/part_mng_sync.csv' WITH CSV HEADER + +UPDATE part_mng P SET + material = NULLIF(S.material, ''), + heat_treatment_hardness = NULLIF(S.heat_treatment_hardness, ''), + heat_treatment_method = NULLIF(S.heat_treatment_method, ''), + surface_treatment = NULLIF(S.surface_treatment, ''), + maker = NULLIF(S.maker, ''), + part_type = NULLIF(S.part_type, ''), + spec = NULLIF(S.spec, ''), + acctfg = NULLIF(S.acctfg, ''), + odrfg = NULLIF(S.odrfg, ''), + unit_dc = NULLIF(S.unit_dc, ''), + unitmang_dc = NULLIF(S.unitmang_dc, ''), + unitchng_nb = CASE WHEN S.unitchng_nb = '' THEN P.unitchng_nb ELSE S.unitchng_nb::numeric END, + lot_fg = NULLIF(S.lot_fg, ''), + use_yn = NULLIF(S.use_yn, ''), + qc_fg = NULLIF(S.qc_fg, ''), + setitem_fg = NULLIF(S.setitem_fg, ''), + req_fg = NULLIF(S.req_fg, ''), + unit_length = NULLIF(S.unit_length, ''), + unit_qty = NULLIF(S.unit_qty, ''), + is_last = COALESCE(P.is_last, '1'), + edit_date = NOW() +FROM part_mng_sync_stage S +WHERE P.part_no = S.part_no; + +COMMIT; + +SELECT + COUNT(*) AS total, + COUNT(NULLIF(material,'')) AS material_filled, + COUNT(NULLIF(acctfg,'')) AS acctfg_filled, + COUNT(NULLIF(unit_dc,'')) AS unit_dc_filled, + COUNT(NULLIF(part_type,'')) AS part_type_filled, + COUNT(NULLIF(spec,'')) AS spec_filled, + COUNT(NULLIF(maker,'')) AS maker_filled, + COUNT(NULLIF(is_last,'')) AS is_last_filled +FROM part_mng; + +SELECT part_no, material, spec, part_type, acctfg, odrfg, unit_dc, unitmang_dc, unitchng_nb, lot_fg, use_yn, qc_fg, setitem_fg, req_fg + FROM part_mng WHERE part_no = '000AN003000'; diff --git a/docs/migration/development/data-sync/02_sequences.sql b/docs/migration/development/data-sync/02_sequences.sql new file mode 100644 index 00000000..431d0f8c --- /dev/null +++ b/docs/migration/development/data-sync/02_sequences.sql @@ -0,0 +1,37 @@ +-- wace 운영판 매퍼에서 사용하는 시퀀스 5종을 RPS DB 에 생성. +-- 운영DB last_value 보다 충분히 큰 값으로 setval — 향후 운영 데이터 sync 시 PK 충돌 방지. +-- +-- 운영DB current last_value (2026-05-12 기준): +-- seq_bom_qty 179,258 → RPS 200,000 +-- seq_as_no 109 → RPS 1,000 +-- seq_comm_code 1,839 → RPS 10,000 +-- seq_eo_no 62 → RPS 1,000 +-- seq_ecr_no 33 → RPS 이미 존재 (보존) +-- +-- 매퍼 사용처: +-- seq_bom_qty — partMng.relatePartInfo (BOM_PART_QTY.SEQ) +-- seq_as_no — 영업관리 (AS 번호 채번) +-- seq_comm_code — comm_code 신규 등록 +-- seq_ecr_no — 설계변경 ECR 번호 +-- seq_eo_no — wace 일부 매퍼 (현재 partMng deploy 는 EO_NO 직접 SUBSTR 채번) + +BEGIN; + +CREATE SEQUENCE IF NOT EXISTS seq_bom_qty AS bigint MINVALUE 1 NO CYCLE; +CREATE SEQUENCE IF NOT EXISTS seq_as_no AS bigint MINVALUE 1 NO CYCLE; +CREATE SEQUENCE IF NOT EXISTS seq_comm_code AS bigint MINVALUE 1 NO CYCLE; +CREATE SEQUENCE IF NOT EXISTS seq_eo_no AS bigint MINVALUE 1 NO CYCLE; + +SELECT setval('seq_bom_qty', 200000, true); +SELECT setval('seq_as_no', 1000, true); +SELECT setval('seq_comm_code', 10000, true); +SELECT setval('seq_eo_no', 1000, true); + +COMMIT; + +-- 검증 +SELECT sequence_name, last_value + FROM information_schema.sequences s + JOIN pg_sequences ps ON ps.sequencename = s.sequence_name + WHERE sequence_name IN ('seq_bom_qty','seq_as_no','seq_comm_code','seq_ecr_no','seq_eo_no') + ORDER BY 1; diff --git a/docs/migration/development/data-sync/README.md b/docs/migration/development/data-sync/README.md new file mode 100644 index 00000000..6d400fd7 --- /dev/null +++ b/docs/migration/development/data-sync/README.md @@ -0,0 +1,69 @@ +# 개발관리 데이터 동기화 스크립트 + +운영DB(`211.115.91.141:11133/waceplm`) → RPS DB(`11134/vexplor_rps`)로 마이그레이션 시 누락된 데이터를 part_no 매칭으로 채워 넣는 1회성 스크립트 모음. + +테이블 DDL/스키마는 [../ddl-extracted/](../ddl-extracted/)에서 별도 관리. 본 디렉토리는 **데이터 row 동기화** 전용. + +--- + +## 01_part_mng_sync.sql + +**대상**: `part_mng` 8,176건 — 운영DB에서 채워져 있던 컬럼이 RPS 마이그레이션 시 누락된 사례. + +**왜 필요했나**: 2026-05-12 PART 상세 다이얼로그 검증 중 발견. 품번/품명만 표시되고 재료/규격/계정구분/조달구분/재고단위/관리단위/환산수량/LOT구분/사용여부/검사여부/SET품여부/의뢰여부 등 거의 모든 컬럼이 NULL. 운영DB 같은 part_no는 정상적으로 채워져 있어서 마이그레이션 누락이 원인. + +**동기화 대상 컬럼 20개**: +- 재료/형상: `material` / `heat_treatment_hardness` / `heat_treatment_method` / `surface_treatment` +- 기본: `maker` / `part_type` / `spec` +- ERP 분류: `acctfg` / `odrfg` / `unit_dc` / `unitmang_dc` / `unitchng_nb` +- Y/N: `lot_fg` / `use_yn` / `qc_fg` / `setitem_fg` / `req_fg` +- 단위: `unit_length` / `unit_qty` +- 상태: `is_last` (마이그레이션 시 NULL이라 PART_MNG.is_last='1' 조건의 모든 매퍼 쿼리가 0건 반환되던 부수 문제도 함께 수정) + +**실행 절차**: + +```bash +# 1) 운영DB → CSV export (is_last='1' 인 8,243건) +PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm -c "\copy (SELECT part_no, COALESCE(material,''), COALESCE(heat_treatment_hardness,''), ..., COALESCE(unit_qty::text,'') FROM part_mng WHERE is_last='1') TO '/tmp/part_mng_sync.csv' WITH CSV HEADER" + +# 2) RPS 에 import + UPDATE FROM JOIN +PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps -f 01_part_mng_sync.sql +``` + +**결과 (2026-05-12 실행)**: + +| 컬럼 | 동기화 전 | 동기화 후 | +|---|---:|---:| +| material | 0 | 301 | +| acctfg | 0 | 8,172 | +| unit_dc | 0 | 8,176 | +| part_type | 639 | 703 | +| spec | 0 | 7,466 | +| is_last | 0 | 8,176 | +| (전체) | 8,176 | 8,176 | + +**미동기화 (의도적 보류)**: 운영DB에만 있는 67건 (운영 8,243 - RPS 8,176). part_no 자체가 RPS 에 미존재. 신규 INSERT 별 작업 필요. + +**검증 SQL**: + +```sql +-- 채움 비율 +SELECT + COUNT(*) AS total, + COUNT(NULLIF(material,'')) AS material_filled, + COUNT(NULLIF(acctfg,'')) AS acctfg_filled, + COUNT(NULLIF(unit_dc,'')) AS unit_dc_filled, + COUNT(NULLIF(part_type,'')) AS part_type_filled, + COUNT(NULLIF(spec,'')) AS spec_filled, + COUNT(NULLIF(is_last,'')) AS is_last_filled +FROM part_mng; + +-- 샘플 행 (운영 스크린샷과 비교) +SELECT part_no, material, spec, part_type, acctfg, odrfg, unit_dc, unitmang_dc, + unitchng_nb, lot_fg, use_yn, qc_fg, setitem_fg, req_fg + FROM part_mng WHERE part_no = '000AN003000'; +``` + +**1:1 정합성**: 운영DB의 컬럼 값을 그대로 복사. NULL 인 운영 컬럼은 RPS 도 NULL 유지 (덮어쓰기 안 함). `unitchng_nb` 만 numeric 캐스팅. + +**재실행 안전**: idempotent — 같은 데이터로 다시 UPDATE 만 일어남. diff --git a/docs/migration/development/ddl-extracted/300_part_bom.sql b/docs/migration/development/ddl-extracted/300_part_bom.sql new file mode 100644 index 00000000..376df2b7 --- /dev/null +++ b/docs/migration/development/ddl-extracted/300_part_bom.sql @@ -0,0 +1,428 @@ +-- ============================================================ +-- 개발관리(PART/E-BOM/설계변경) 운영 DDL — wace_plm 운영DB(211.115.91.141:11133/waceplm) 추출 +-- 추출일: 2026-05-12 +-- 추출 방법: information_schema + pg_indexes + pg_description 쿼리 +-- (pg_dump 14.19 ↔ PG 16.8 mismatch로 pg_dump 사용 불가) +-- +-- 대상 테이블 9개 (운영 카운트): +-- admin_supply_mng 28 cols / 7건 ← 공급업체 마스터(관리자) +-- bom_part_qty 19 cols / 835건 ← BOM 수량 트리 +-- order_spec_mng 12 cols / 12,522건 ← 발주 스펙 이력 +-- part_bom_report 23 cols / 40건 ← BOM 리포트 헤더 +-- part_mng_history 59 cols / 263건 ← 파트 이력(설계변경) +-- product_mgmt_upg_detail 7 cols / 87건 ← 제품 업그레이드 디테일 +-- product_mgmt_upg_master 5 cols / 6건 ← 제품 업그레이드 마스터 +-- sales_bom_report 16 cols / 1,529건 ← 영업 BOM 단가 +-- supply_mng 29 cols / 1건 ← 공급업체(고객) +-- +-- 비고: +-- · 운영 스키마 1:1 보존 — 컬럼 순서/타입/길이/default 모두 운영과 동일. +-- · objid 컬럼은 wace Java 측에서 UUID/임의 채번(연관 시퀀스 없음 — 확인 완료). +-- · admin_supply_mng.objid·supply_mng.objid는 numeric, default 0(운영 그대로). +-- · admin_supply_mng.employee_email 운영 타입이 'xid'(PostgreSQL 시스템 타입, transaction id) +-- → 데이터 의미(이메일)와 무관한 운영 측 추정 실수. character varying 으로 정정 적용. +-- · part_mng_history.objid는 numeric NOT NULL (PK)이지만 default 미지정. +-- · product_mgmt_upg_master PK는 (objid, target_objid) 복합키. +-- · sales_bom_report 의 supply_objid*/price* 컬럼 length 가 들쭉날쭉한 부분은 운영 그대로. +-- · sales_bom_report_parent_objid_idx 는 UNIQUE 인덱스(운영 정의 그대로 — 1 BOM당 1 단가). +-- · company_code 분기 없음(vexplor_rps는 COMPANY_16 단독). +-- ============================================================ + +BEGIN; + +-- ------------------------------------------------------------ +-- 1) admin_supply_mng (공급업체 마스터 — 관리자 측 입력) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS admin_supply_mng CASCADE; +CREATE TABLE admin_supply_mng ( + objid numeric NOT NULL DEFAULT '0'::numeric, + supply_code character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_name character varying(100) DEFAULT 'NULL::character varying'::character varying, + reg_no character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_address character varying(500) DEFAULT 'NULL::character varying'::character varying, + supply_busname character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_stockname character varying(100) DEFAULT 'NULL::character varying'::character varying, + supply_tel_no character varying DEFAULT 'NULL::character varying'::character varying, + supply_fax_no character varying DEFAULT 'NULL::character varying'::character varying, + charge_user_name character varying DEFAULT 'NULL::character varying'::character varying, + payment_method character varying, + reg_id character varying DEFAULT 'NULL::character varying'::character varying, + reg_date timestamp, + status character varying DEFAULT 'NULL::character varying'::character varying, + area_cd character varying DEFAULT 'NULL::character varying'::character varying, + bus_reg_no character varying DEFAULT 'NULL::character varying'::character varying, + office_no character varying DEFAULT 'NULL::character varying'::character varying, + email character varying DEFAULT 'NULL::character varying'::character varying, + account_code character varying, + remark character varying, + account_bank character varying, + account_number character varying, + account_user_name character varying, + employee_name character varying, + employee_position character varying, + employee_number character varying, + -- 운영은 'xid' 시스템 타입으로 잘못 정의됨. 의미상 이메일 → character varying 으로 정정. + employee_email character varying, + david character varying(50), + CONSTRAINT admin_supply_mng_pkey PRIMARY KEY (objid) +); + +-- ------------------------------------------------------------ +-- 2) bom_part_qty (BOM 수량 트리 — 835건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS bom_part_qty CASCADE; +CREATE TABLE bom_part_qty ( + bom_report_objid character varying(64) NOT NULL, + objid character varying(64) NOT NULL, + parent_objid character varying(64) DEFAULT 'NULL::character varying'::character varying, + child_objid character varying(64) DEFAULT 'NULL::character varying'::character varying, + parent_part_no character varying(64) DEFAULT 'NULL::character varying'::character varying, + part_no character varying(64) DEFAULT 'NULL::character varying'::character varying, + qty character varying, + regdate timestamp, + seq integer, + status character varying, + deploy_date character varying, + deploy_user_id character varying, + edit_date character varying, + writer character varying, + qty_temp character varying, + last_part_objid character varying, + editer character varying, + item_qty character varying, + supplier character varying, + CONSTRAINT bom_part_qty_pkey PRIMARY KEY (objid) +); +CREATE INDEX bom_part_qty_bom_report_objid2_idx ON bom_part_qty USING btree (bom_report_objid, last_part_objid, part_no); +CREATE INDEX bom_part_qty_bom_report_objid_idx ON bom_part_qty USING btree (bom_report_objid); +CREATE INDEX bom_part_qty_last_part_objid_idx ON bom_part_qty USING btree (last_part_objid); +CREATE INDEX bom_part_qty_parent_objid_idx ON bom_part_qty USING btree (parent_objid); +CREATE INDEX idx_bom_part_qty_part_no ON bom_part_qty USING btree (part_no) WHERE ((part_no IS NOT NULL) AND ((part_no)::text <> ''::text)); + +COMMENT ON COLUMN bom_part_qty.status IS '상태'; +COMMENT ON COLUMN bom_part_qty.deploy_date IS '배포일'; +COMMENT ON COLUMN bom_part_qty.deploy_user_id IS '배포자'; +COMMENT ON COLUMN bom_part_qty.edit_date IS '수정일'; +COMMENT ON COLUMN bom_part_qty.writer IS '등록자'; +COMMENT ON COLUMN bom_part_qty.qty_temp IS '수량(설변중)'; +COMMENT ON COLUMN bom_part_qty.last_part_objid IS '마지막 품번키'; +COMMENT ON COLUMN bom_part_qty.editer IS '수정자'; +COMMENT ON COLUMN bom_part_qty.item_qty IS '항목수량'; +COMMENT ON COLUMN bom_part_qty.supplier IS '공급업체'; + +-- ------------------------------------------------------------ +-- 3) order_spec_mng (발주 스펙 이력 — 12,522건, 운영 최다) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS order_spec_mng CASCADE; +CREATE TABLE order_spec_mng ( + objid character varying NOT NULL, + seq character varying NOT NULL, + part_objid character varying NOT NULL, + partner_rank character varying, + partner_objid character varying, + partner_price character varying, + partner_qty character varying, + apply_date character varying, + remark character varying, + regdate timestamp, + is_last character varying, + writer character varying, + CONSTRAINT order_spec_mng_pkey PRIMARY KEY (objid) +); + +-- ------------------------------------------------------------ +-- 4) part_bom_report (BOM 리포트 헤더 — 40건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS part_bom_report CASCADE; +CREATE TABLE part_bom_report ( + objid character varying NOT NULL DEFAULT ''::character varying, + customer_objid character varying, + contract_objid character varying, + unit_code character varying, + revision character varying, + writer character varying(64), + regdate timestamp, + status character varying(64), + deploy_date character varying(64), + eo_no character varying(100), + eo_date character varying(100), + note character varying(2000), + edit_date timestamp, + editer character varying, + unit_code_old character varying, + multi_break_yn character varying, + multi_yn character varying, + multi_master_yn character varying, + multi_master_objid character varying, + product_cd character varying, + part_no character varying, + part_name character varying, + version character varying, + CONSTRAINT part_bom_report_pkey PRIMARY KEY (objid) +); +CREATE INDEX idx_part_bom_report_customer ON part_bom_report USING btree (customer_objid) WHERE ((customer_objid IS NOT NULL) AND ((customer_objid)::text <> ''::text)); +CREATE INDEX idx_part_bom_report_regdate ON part_bom_report USING btree (regdate DESC); +CREATE INDEX idx_part_bom_report_writer ON part_bom_report USING btree (writer); +CREATE INDEX part_bom_report_contract_objid_idx ON part_bom_report USING btree (contract_objid); +CREATE INDEX part_bom_report_unit_code_idx ON part_bom_report USING btree (unit_code, contract_objid); + +COMMENT ON COLUMN part_bom_report.objid IS 'OBJECT ID'; +COMMENT ON COLUMN part_bom_report.customer_objid IS '고객사 OBJID'; +COMMENT ON COLUMN part_bom_report.contract_objid IS '계약objid'; +COMMENT ON COLUMN part_bom_report.unit_code IS 'unit'; +COMMENT ON COLUMN part_bom_report.revision IS 'rev'; +COMMENT ON COLUMN part_bom_report.writer IS '작성자'; +COMMENT ON COLUMN part_bom_report.regdate IS '등록일'; +COMMENT ON COLUMN part_bom_report.status IS '상태'; +COMMENT ON COLUMN part_bom_report.deploy_date IS '배포일'; +COMMENT ON COLUMN part_bom_report.edit_date IS '수정일'; +COMMENT ON COLUMN part_bom_report.editer IS '수정자'; +COMMENT ON COLUMN part_bom_report.unit_code_old IS 'UNIT_CODE'; +COMMENT ON COLUMN part_bom_report.multi_break_yn IS '동시적용프로젝트 깨짐 여부'; +COMMENT ON COLUMN part_bom_report.multi_yn IS '동시적용프로젝트 여부'; +COMMENT ON COLUMN part_bom_report.multi_master_yn IS '동시적용프로젝트 마스터 여부'; +COMMENT ON COLUMN part_bom_report.multi_master_objid IS '동시적용프로젝트 마스터 키'; + +-- ------------------------------------------------------------ +-- 5) part_mng_history (파트 이력 — 설계변경 핵심, 59 cols / 263건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS part_mng_history CASCADE; +CREATE TABLE part_mng_history ( + objid numeric NOT NULL, + product_mgmt_objid character varying(100) DEFAULT NULL::character varying, + upg_no character varying(100) DEFAULT NULL::character varying, + part_no character varying(100) DEFAULT NULL::character varying, + part_name character varying(100) DEFAULT NULL::character varying, + unit character varying(50) DEFAULT NULL::character varying, + qty character varying(50) DEFAULT NULL::character varying, + spec character varying(100) DEFAULT 'NULL::character varying'::character varying, + material character varying(100) DEFAULT NULL::character varying, + weight character varying(50) DEFAULT NULL::character varying, + part_type character varying(100) DEFAULT NULL::character varying, + remark character varying(1000) DEFAULT NULL::character varying, + es_spec character varying(100) DEFAULT NULL::character varying, + ms_spec character varying(100) DEFAULT NULL::character varying, + change_option character varying(50) DEFAULT NULL::character varying, + design_apply_point character varying(50) DEFAULT NULL::character varying, + management_flag character varying(50) DEFAULT NULL::character varying, + revision character varying(50) DEFAULT NULL::character varying, + status character varying(30) DEFAULT NULL::character varying, + reg_date timestamp, + edit_date timestamp, + writer character varying(30) DEFAULT NULL::character varying, + is_last character varying(5) DEFAULT NULL::character varying, + eo_no character varying, + eo_temp character varying, + excel_upload_seq character varying, + sourcing_code character varying, + sub_material character varying(100) DEFAULT 'NULL::character varying'::character varying, + parent_part_no character varying, + design_date character varying, + eo_date character varying, + deploy_date timestamp, + thickness character varying, + width character varying, + height character varying, + out_diameter character varying, + in_diameter character varying, + length character varying, + supply_code character varying, + change_type character varying, + contract_objid character varying, + maker character varying, + qty_temp character varying, + bom_report_objid character varying, + parent_part_objid character varying, + parent_qty_child_objid character varying, + bom_qty_status character varying, + his_reg_date timestamp, + his_writer character varying, + his_status character varying, + qty_child_objid character varying, + bom_status character varying, + bom_deploy_date timestamp, + chg_part_objid character varying, + chg_part_no character varying, + chg_part_rev character varying, + heat_treatment_hardness character varying, + heat_treatment_method character varying, + surface_treatment character varying, + CONSTRAINT part_mng_history_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN part_mng_history.qty_temp IS '수량(설변중)'; +COMMENT ON COLUMN part_mng_history.bom_report_objid IS 'BOM 키'; +COMMENT ON COLUMN part_mng_history.parent_part_objid IS '부모 파트 키'; +COMMENT ON COLUMN part_mng_history.parent_qty_child_objid IS '부모 구조 키'; +COMMENT ON COLUMN part_mng_history.bom_qty_status IS 'BOM 상태'; +COMMENT ON COLUMN part_mng_history.his_reg_date IS '등록일'; +COMMENT ON COLUMN part_mng_history.his_writer IS '등록자'; +COMMENT ON COLUMN part_mng_history.his_status IS '상태'; +COMMENT ON COLUMN part_mng_history.qty_child_objid IS '구조 키'; +COMMENT ON COLUMN part_mng_history.bom_status IS 'BOM 상태'; +COMMENT ON COLUMN part_mng_history.bom_deploy_date IS '배포일'; + +-- ------------------------------------------------------------ +-- 6) product_mgmt_upg_detail (제품 업그레이드 디테일 — 87건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS product_mgmt_upg_detail CASCADE; +CREATE TABLE product_mgmt_upg_detail ( + objid integer NOT NULL, + target_objid integer, + upg_name character varying(100), + upg_code character varying(100), + vc character varying(100), + note character varying(1000), + product_objid integer, + CONSTRAINT product_mgmt_upg_detail_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN product_mgmt_upg_detail.objid IS 'objid'; +COMMENT ON COLUMN product_mgmt_upg_detail.target_objid IS 'upg_masterobjid'; +COMMENT ON COLUMN product_mgmt_upg_detail.upg_name IS 'upg명'; +COMMENT ON COLUMN product_mgmt_upg_detail.upg_code IS 'upg코드'; +COMMENT ON COLUMN product_mgmt_upg_detail.vc IS 'vc'; +COMMENT ON COLUMN product_mgmt_upg_detail.note IS '비고'; + +-- ------------------------------------------------------------ +-- 7) product_mgmt_upg_master (제품 업그레이드 마스터 — 6건, 복합 PK) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS product_mgmt_upg_master CASCADE; +CREATE TABLE product_mgmt_upg_master ( + objid integer NOT NULL, + target_objid integer NOT NULL, + spec_name character varying NOT NULL, + writer character varying, + regdate timestamp, + CONSTRAINT product_mgmt_upg_master_pkey PRIMARY KEY (objid, target_objid) +); + +COMMENT ON COLUMN product_mgmt_upg_master.objid IS '제품사양마스터코드'; +COMMENT ON COLUMN product_mgmt_upg_master.target_objid IS '양산마스터코드'; +COMMENT ON COLUMN product_mgmt_upg_master.spec_name IS '사양명'; +COMMENT ON COLUMN product_mgmt_upg_master.writer IS '작성자'; +COMMENT ON COLUMN product_mgmt_upg_master.regdate IS '동록일'; + +-- ------------------------------------------------------------ +-- 8) sales_bom_report (영업 BOM 단가 — 1,529건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS sales_bom_report CASCADE; +CREATE TABLE sales_bom_report ( + objid character varying NOT NULL DEFAULT ''::character varying, + parent_objid character varying, + supply_objid character varying, + price character varying, + supply_objid1 character varying, + price1 character varying(64), + supply_objid2 character varying(100), + price2 character varying(64), + supply_objid3 character varying(64), + price3 character varying(100), + supply_objid4 character varying(100), + price4 character varying(2000), + writer character varying, + regdate timestamp, + update_date timestamp, + modifier character varying, + CONSTRAINT sales_bom_report_pkey PRIMARY KEY (objid) +); +-- 운영 인덱스는 UNIQUE — 1 BOM(parent_objid) 당 1 단가행 +CREATE UNIQUE INDEX sales_bom_report_parent_objid_idx ON sales_bom_report USING btree (parent_objid); + +COMMENT ON COLUMN sales_bom_report.objid IS '키'; +COMMENT ON COLUMN sales_bom_report.parent_objid IS 'bom_report_objid'; +COMMENT ON COLUMN sales_bom_report.supply_objid IS '공급업체key'; +COMMENT ON COLUMN sales_bom_report.price IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid1 IS '레이져업체명'; +COMMENT ON COLUMN sales_bom_report.price1 IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid2 IS '용접업체명'; +COMMENT ON COLUMN sales_bom_report.price2 IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid3 IS '가공업체명'; +COMMENT ON COLUMN sales_bom_report.price3 IS '단가'; +COMMENT ON COLUMN sales_bom_report.supply_objid4 IS '후처리'; +COMMENT ON COLUMN sales_bom_report.price4 IS '단가'; +COMMENT ON COLUMN sales_bom_report.writer IS '담당자'; +COMMENT ON COLUMN sales_bom_report.regdate IS '작성일'; +COMMENT ON COLUMN sales_bom_report.update_date IS '수정일'; +COMMENT ON COLUMN sales_bom_report.modifier IS '수정자'; + +-- ------------------------------------------------------------ +-- 9) supply_mng (공급업체/고객 — 1건) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS supply_mng CASCADE; +CREATE TABLE supply_mng ( + objid numeric NOT NULL DEFAULT 0, + supply_code character varying(100) DEFAULT NULL::character varying, + supply_name character varying(100) DEFAULT NULL::character varying, + reg_no character varying(100) DEFAULT NULL::character varying, + supply_address character varying(500) DEFAULT NULL::character varying, + supply_busname character varying(100) DEFAULT NULL::character varying, + supply_stockname character varying(100) DEFAULT NULL::character varying, + supply_tel_no character varying(30) DEFAULT NULL::character varying, + supply_fax_no character varying(30) DEFAULT NULL::character varying, + charge_user_name character varying(100) DEFAULT NULL::character varying, + payment_method character varying(100), + reg_id character varying(100) DEFAULT NULL::character varying, + reg_date timestamp, + status character varying(32) DEFAULT NULL::character varying, + area_cd character varying(32) DEFAULT NULL::character varying, + bus_reg_no character varying(100) DEFAULT NULL::character varying, + office_no character varying(32) DEFAULT 'NULL::character varying'::character varying, + email character varying(32) DEFAULT 'NULL::character varying'::character varying, + cus_no character varying, + manager1_name character varying(100), + manager1_email character varying(100), + manager2_name character varying(100), + manager2_email character varying(100), + manager3_name character varying(100), + manager3_email character varying(100), + manager4_name character varying(100), + manager4_email character varying(100), + manager5_name character varying(100), + manager5_email character varying(100), + CONSTRAINT supply_mng_pkey PRIMARY KEY (objid) +); + +COMMENT ON COLUMN supply_mng.supply_code IS '구분'; +COMMENT ON COLUMN supply_mng.supply_name IS '고객명'; +COMMENT ON COLUMN supply_mng.reg_no IS '법인/주민번호'; +COMMENT ON COLUMN supply_mng.supply_address IS '주소'; +COMMENT ON COLUMN supply_mng.supply_busname IS '업태'; +COMMENT ON COLUMN supply_mng.supply_stockname IS '업종'; +COMMENT ON COLUMN supply_mng.supply_tel_no IS '핸드폰'; +COMMENT ON COLUMN supply_mng.supply_fax_no IS '팩스번호'; +COMMENT ON COLUMN supply_mng.charge_user_name IS '대표자명'; +COMMENT ON COLUMN supply_mng.reg_id IS '실사용자명'; +COMMENT ON COLUMN supply_mng.reg_date IS '등록일'; +COMMENT ON COLUMN supply_mng.status IS '상태'; +COMMENT ON COLUMN supply_mng.area_cd IS '지역'; +COMMENT ON COLUMN supply_mng.bus_reg_no IS '사업자등록번호'; +COMMENT ON COLUMN supply_mng.office_no IS '오피스no'; +COMMENT ON COLUMN supply_mng.email IS '이메일'; +COMMENT ON COLUMN supply_mng.cus_no IS '고객번호'; +COMMENT ON COLUMN supply_mng.manager1_name IS '담당자1 이름'; +COMMENT ON COLUMN supply_mng.manager1_email IS '담당자1 이메일'; +COMMENT ON COLUMN supply_mng.manager2_name IS '담당자2 이름'; +COMMENT ON COLUMN supply_mng.manager2_email IS '담당자2 이메일'; +COMMENT ON COLUMN supply_mng.manager3_name IS '담당자3 이름'; +COMMENT ON COLUMN supply_mng.manager3_email IS '담당자3 이메일'; +COMMENT ON COLUMN supply_mng.manager4_name IS '담당자4 이름'; +COMMENT ON COLUMN supply_mng.manager4_email IS '담당자4 이메일'; +COMMENT ON COLUMN supply_mng.manager5_name IS '담당자5 이름'; +COMMENT ON COLUMN supply_mng.manager5_email IS '담당자5 이메일'; + +COMMIT; + +-- ============================================================ +-- vexplor_rps 적용 방법 (메인 agent 검토 후 직접 실행): +-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \ +-- -f /Users/jhj/vexplor_rps/docs/migration/development/ddl-extracted/300_part_bom.sql +-- +-- 적용 후 검증: +-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps -c " +-- SELECT relname, (SELECT count(*) FROM pg_attribute WHERE attrelid = c.oid AND attnum > 0 AND NOT attisdropped) AS cols +-- FROM pg_class c WHERE relkind='r' AND relnamespace=(SELECT oid FROM pg_namespace WHERE nspname='public') +-- AND relname IN ('admin_supply_mng','bom_part_qty','order_spec_mng','part_bom_report','part_mng_history','product_mgmt_upg_detail','product_mgmt_upg_master','sales_bom_report','supply_mng') +-- ORDER BY relname;" +-- 기대값: 28 / 19 / 12 / 23 / 59 / 7 / 5 / 16 / 29 +-- ============================================================ diff --git a/docs/migration/development/ddl-extracted/301_alter_part_mng.sql b/docs/migration/development/ddl-extracted/301_alter_part_mng.sql new file mode 100644 index 00000000..7250bc8b --- /dev/null +++ b/docs/migration/development/ddl-extracted/301_alter_part_mng.sql @@ -0,0 +1,45 @@ +-- ============================================================ +-- part_mng ALTER — 개발관리 메뉴(PART 등록/조회) 누락 컬럼 추가 +-- 추출일: 2026-05-12 +-- 출처: 211.115.91.141:11133/waceplm (PG 16.8) +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 사유: wace PART 등록/조회 그리드 23개 컬럼 중 15개가 vexplor part_mng 에 부재. +-- 전부 ADD COLUMN IF NOT EXISTS 로 안전하게 추가 (IDEMPOTENT). +-- ============================================================ + +BEGIN; + +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS heat_treatment_hardness character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS heat_treatment_method character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS surface_treatment character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS acctfg character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS odrfg character varying; +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit_dc character varying(20); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unitmang_dc character varying(20); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unitchng_nb numeric(11,6); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS lot_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS use_yn character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS qc_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS setitem_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS req_fg character(1); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit_length character varying(20); +ALTER TABLE part_mng ADD COLUMN IF NOT EXISTS unit_qty character varying(20); + +COMMENT ON COLUMN part_mng.heat_treatment_hardness IS '열처리경도'; +COMMENT ON COLUMN part_mng.heat_treatment_method IS '열처리방법'; +COMMENT ON COLUMN part_mng.surface_treatment IS '표면처리'; +COMMENT ON COLUMN part_mng.acctfg IS '계정구분 (comm_code)'; +COMMENT ON COLUMN part_mng.odrfg IS '조달구분 (comm_code)'; +COMMENT ON COLUMN part_mng.unit_dc IS '재고단위 (comm_code)'; +COMMENT ON COLUMN part_mng.unitmang_dc IS '관리단위 (comm_code)'; +COMMENT ON COLUMN part_mng.unitchng_nb IS '환산수량'; +COMMENT ON COLUMN part_mng.lot_fg IS 'LOT구분 (Y/N)'; +COMMENT ON COLUMN part_mng.use_yn IS '사용여부 (Y/N)'; +COMMENT ON COLUMN part_mng.qc_fg IS '검사여부 (Y/N)'; +COMMENT ON COLUMN part_mng.setitem_fg IS 'SET품여부 (Y/N)'; +COMMENT ON COLUMN part_mng.req_fg IS '의뢰여부 (Y/N)'; +COMMENT ON COLUMN part_mng.unit_length IS '개당길이'; +COMMENT ON COLUMN part_mng.unit_qty IS '개당수량'; + +COMMIT; diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx new file mode 100644 index 00000000..405f5d0a --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +// 개발관리 > 설계변경 리스트 (M5, read-only) — wace partMngHisList.jsp 1:1 +// 그리드: part_mng_history 16셀 +// 검색: 8 필드 (1차) — Year/contract_objid/part_no/part_name/eo_start~end/change_type/change_option/part_type +// 참조: docs/migration/development/03-eo-history.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Search, Loader2, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory"; +import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog"; + +// comm_code 그룹 (vexplor_rps) +const GROUP_PART_TYPE = "0000062"; +// change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영. +// (시드 후 그룹 ID 확인되면 SmartSelect 전환) + +const YEAR_OPTIONS = (() => { + const cur = new Date().getFullYear(); + const arr: string[] = []; + for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y)); + return arr; +})(); + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "eo_no", label: "EO No", width: "w-[100px]", frozen: true }, + { key: "project_no", label: "프로젝트번호", width: "w-[120px]" }, + { key: "project_name", label: "프로젝트명", width: "w-[180px]" }, + { key: "unit_name", label: "유닛명", width: "w-[160px]" }, + { key: "parent_part_info", label: "모품번", width: "w-[160px]" }, + { key: "part_no_disp", label: "품번", width: "w-[160px]" }, + { key: "part_name_disp", label: "품명", minWidth: "min-w-[180px]" }, + { key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true }, + { key: "qty_temp", label: "변경수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "change_type_name", label: "EO구분", width: "w-[90px]", align: "center" }, + { key: "change_option_name", label: "EO사유", width: "w-[100px]", align: "center" }, + { key: "revision_disp", label: "Revision", width: "w-[90px]", align: "center" }, + { key: "eo_date", label: "EO Date", width: "w-[100px]", align: "center" }, + { key: "part_type_name", label: "PART구분", width: "w-[90px]", align: "center" }, + { key: "writer_name", label: "담당자", width: "w-[90px]", align: "center" }, + { key: "his_reg_date_title", label: "실행일", width: "w-[100px]", align: "center" }, +]; + +const EMPTY_FILTER: EoHistoryListFilter = { + Year: "", contract_objid: "", + part_no: "", part_name: "", + change_option: "", change_type: "", part_type: "", + eo_start_date: "", eo_end_date: "", + page: 1, page_size: 50, +}; + +export default function EoHistoryPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devEoHistoryApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + // EO_NO 셀 클릭 → 상세 다이얼로그 + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "eo_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + return ( +
+
+
+ + + + + setFilter({ ...filter, contract_objid: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, part_name: e.target.value })} + placeholder="part_name LIKE" /> + + + + setFilter({ ...filter, eo_start_date: e.target.value })} /> + + + setFilter({ ...filter, eo_end_date: e.target.value })} /> + + + setFilter({ ...filter, part_type: v })} /> + + +
+ setFilter({ ...filter, change_type: e.target.value })} + placeholder="EO구분 code_id" /> + setFilter({ ...filter, change_option: e.target.value })} + placeholder="EO사유 code_id" /> +
+
+
+
+
총 {total.toLocaleString()}건 (read-only)
+
+ + +
+
+
+ +
+ +
+ + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx new file mode 100644 index 00000000..2b8340f9 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +// 개발관리 > E-BOM 등록 (M3) — wace structureList.jsp 1:1 +// 그리드: part_bom_report 9셀 +// 액션: 조회 / 삭제 / 상태변경 (E-BOM등록 Excel Import는 별 PR) +// 참조: docs/migration/development/02-ebom.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + 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"; +import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialog"; + +const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용) + +const STATUS_OPTIONS = [ + { code: "create", label: "등록중" }, + { code: "changeDesign", label: "설계변경미배포" }, + { code: "deploy", label: "배포완료" }, +]; + +const BASE_GRID_COLUMNS: DataGridColumn[] = [ + { key: "product_name", label: "제품구분", width: "w-[160px]", align: "center", frozen: true }, + { key: "part_no", label: "품번", width: "w-[210px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, + // wace fnc_getFolderIcon 1:1 — 폴더 아이콘 클릭 시 BOM 구조 다이얼로그 + { key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "center", renderType: "folder" }, + { key: "dept_user_name", label: "등록자", width: "w-[140px]", align: "center" }, + { key: "reg_date", label: "등록일", width: "w-[120px]", align: "center" }, + { key: "deploy_date", label: "확정일", width: "w-[120px]", align: "center" }, + { key: "revision", label: "Version", width: "w-[100px]", align: "center" }, + { key: "status_title", label: "상태", width: "w-[120px]", align: "center" }, +]; + +const EMPTY_FILTER: BomReportListFilter = { + product_cd: "", status: "", + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function EbomRegistPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [statusOpen, setStatusOpen] = useState(false); + const [statusObjid, setStatusObjid] = useState(null); + const [excelOpen, setExcelOpen] = useState(false); + const [treeOpen, setTreeOpen] = useState(false); + const [treeReport, setTreeReport] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devBomApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까? (자식 BOM 트리도 함께 삭제됨)`)) return; + try { + const res = await devBomApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + const handleStatusChange = () => { + if (checkedIds.length !== 1) return toast.error("상태 변경할 행 1개를 선택하세요."); + setStatusObjid(checkedIds[0]); + setStatusOpen(true); + }; + + // 품번 셀 클릭 → BOM 트리 다이얼로그 (wace fn_openSetStructure 1:1) + const openTree = useCallback((row: BomReportRow) => { + setTreeReport(row); + setTreeOpen(true); + }, []); + + const columns: DataGridColumn[] = useMemo( + () => BASE_GRID_COLUMNS.map((c) => + c.key === "bom_cnt" + ? { ...c, onClick: (row: any) => openTree(row as BomReportRow) } + : c, + ), + [openTree], + ); + + return ( +
+
+
+ + setFilter({ ...filter, product_cd: v })} + /> + + + + + + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> + +
+
+
총 {total.toLocaleString()}건
+
+ + + + + +
+
+
+ +
+ +
+ + + + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx new file mode 100644 index 00000000..3804d63b --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +// 개발관리 > E-BOM 조회 (M4) — wace structureAscendingList.jsp 1:1 +// 정전개(루트→리프) / 역전개(리프→부모) 토글. 동적 LEVEL 컬럼. +// 참조: docs/migration/development/02-ebom.md + +import React, { useCallback, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, FileSpreadsheet, +} from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom"; + +type Direction = "ascending" | "descending"; + +const EMPTY_FILTER: BomTreeFilter = { + project_name: "", unit_code: "", + search_part_no: "", search_part_name: "", +}; + +export default function EbomSearchPage() { + const [filter, setFilter] = useState(EMPTY_FILTER); + const [direction, setDirection] = useState("ascending"); + const [rows, setRows] = useState([]); + const [maxLevel, setMaxLevel] = useState(0); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + + const runQuery = useCallback(async (dir: Direction) => { + setLoading(true); + try { + const fn = dir === "ascending" ? devBomApi.ascending : devBomApi.descending; + const res = await fn(filter); + setRows(res.rows ?? []); + setMaxLevel(Number(res.max_level) || 0); + setDirection(dir); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + // wace structureAscending/DescendingListExcel.jsp 1:1 — 현재 검색 조건 그대로 .xlsx 다운로드 + const downloadExcel = useCallback(async (dir: Direction) => { + setExporting(true); + try { + const fn = dir === "ascending" ? devBomApi.excelAscending : devBomApi.excelDescending; + const { blob, fileName } = await fn(filter); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = fileName; + document.body.appendChild(a); a.click(); + a.remove(); URL.revokeObjectURL(url); + toast.success(`${fileName} 다운로드`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패"); + } finally { + setExporting(false); + } + }, [filter]); + + // 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시 + const columns: DataGridColumn[] = useMemo(() => { + const levelCols: DataGridColumn[] = []; + for (let i = 1; i <= Math.max(1, maxLevel); i++) { + levelCols.push({ + key: `__lev_${i}`, + label: `L${i}`, + width: "w-[140px]", + }); + } + return [ + ...levelCols, + { key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false }, + { key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" }, + { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" }, + { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, + { key: "spec", label: "규격", width: "w-[120px]" }, + { key: "material", label: "재질", width: "w-[100px]" }, + { key: "weight", label: "중량", width: "w-[80px]", align: "right" }, + { key: "remark", label: "비고", minWidth: "min-w-[140px]" }, + ]; + }, [maxLevel]); + + // 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움 + const gridData = useMemo( + () => rows.map((r) => { + const expanded: any = { ...r }; + for (let i = 1; i <= Math.max(1, maxLevel); i++) { + expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : ""; + } + return expanded; + }), + [rows, maxLevel], + ); + + return ( +
+
+
+ + setFilter({ ...filter, project_name: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, unit_code: e.target.value })} + placeholder="pms_wbs_task.objid" /> + + + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="part_name LIKE" /> + +
+
+
+ 모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} +
+
+ + + + + +
+
+ {direction === "descending" && ( +
+ 역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다. +
+ )} +
+ +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx new file mode 100644 index 00000000..4717b1ed --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/part-regist/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +// 개발관리 > PART 등록 (M1) — wace partMngTempList.jsp 1:1 +// 그리드: status != 'release' 인 PART 23셀 +// 액션: 등록 / 수정 / 삭제 / 확정 / 조회 +// 참조: docs/migration/development/01-part.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +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, 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[] = [ + { key: "part_no", label: "품번", width: "w-[140px]", frozen: true }, + { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, + { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "material", label: "재료", width: "w-[100px]" }, + { key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" }, + { key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" }, + { key: "surface_treatment", label: "표면처리", width: "w-[100px]" }, + { key: "maker", label: "메이커", width: "w-[100px]" }, + { key: "part_type_title", label: "범주", width: "w-[100px]" }, + { key: "spec", label: "규격", width: "w-[140px]" }, + { key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" }, + { key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" }, + { key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" }, + { key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" }, + { key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" }, + { key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" }, + { key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" }, + { key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" }, + { key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" }, + { key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" }, + { key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" }, + // M1 부속 + { key: "partner_title", label: "공급업체(시퀀스)", minWidth: "min-w-[180px]" }, + { key: "parent_part_info", label: "상위 품번", width: "w-[120px]" }, + { key: "q_qty", label: "수량(BOM)", width: "w-[90px]", align: "right" }, + { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, + { key: "status", label: "상태", width: "w-[80px]", align: "center" }, +]; + +const EMPTY_FILTER: PartListFilter = { + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function PartRegistPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + // 다이얼로그 상태 + const [formOpen, setFormOpen] = useState(false); + const [formMode, setFormMode] = useState<"create" | "edit">("create"); + const [formObjid, setFormObjid] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + const [excelOpen, setExcelOpen] = useState(false); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devPartApi.listTemp(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + // 행 더블클릭 → 상세 다이얼로그 + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "part_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + // 등록 + const handleCreate = () => { + setFormMode("create"); + setFormObjid(null); + setFormOpen(true); + }; + + // 수정 (단일 선택 필요) + const handleEdit = () => { + if (checkedIds.length !== 1) return toast.error("수정할 행 1개를 선택하세요."); + setFormMode("edit"); + setFormObjid(checkedIds[0]); + setFormOpen(true); + }; + + // 삭제 (다중) + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까?`)) return; + try { + const res = await devPartApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + // 확정 (M1 → M2): EO_NO 채번 + part_mng_history 이력 + const handleDeploy = async () => { + if (checkedIds.length === 0) return toast.error("확정할 행을 선택하세요."); + if (!confirm(`${checkedIds.length}건을 확정하시겠습니까? (M1 → M2)`)) return; + try { + const res = await devPartApi.deploy(checkedIds); + toast.success(`${res.deployed}건이 확정되었습니다.`); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "확정 실패"); + } + }; + + // 상세 → 수정 전환 + const handleEditFromDetail = (objid: string) => { + setDetailOpen(false); + setFormMode("edit"); + setFormObjid(objid); + setFormOpen(true); + }; + + return ( +
+ {/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */} +
+
+
+ + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> +
+
+ + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> +
+
+ + + + + + + +
+
+
+ 총 {total.toLocaleString()}건 (M1: status ≠ 'release') +
+
+ +
+ +
+ + + + +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx new file mode 100644 index 00000000..4bde720d --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/part-search/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +// 개발관리 > PART 조회 (M2) — wace partMngList.jsp 1:1 +// 그리드: status = 'release' 인 PART 23셀 + BOM_QTY +// 액션: 등록 / 수정 / 삭제 / 조회 (도면연동/ERP/Excel은 별 PR) +// 참조: docs/migration/development/01-part.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +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, 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 }, + { key: "part_name", label: "품명", minWidth: "min-w-[220px]" }, + { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "material", label: "재료", width: "w-[100px]" }, + { key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" }, + { key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" }, + { key: "surface_treatment", label: "표면처리", width: "w-[100px]" }, + { key: "maker", label: "메이커", width: "w-[100px]" }, + { key: "part_type_title", label: "범주", width: "w-[100px]" }, + { key: "spec", label: "규격", width: "w-[140px]" }, + { key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" }, + { key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" }, + { key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" }, + { key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" }, + { key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" }, + { key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" }, + { key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" }, + { key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" }, + { key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" }, + { key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" }, + { key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" }, + // M2 추가 + { key: "bom_qty", label: "BOM 수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, + { key: "eo_no", label: "EO_NO", width: "w-[120px]" }, +]; + +const EMPTY_FILTER: PartListFilter = { + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function PartSearchPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [formOpen, setFormOpen] = useState(false); + const [formMode, setFormMode] = useState<"create" | "edit">("create"); + const [formObjid, setFormObjid] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + const [excelOpen, setExcelOpen] = useState(false); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devPartApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "part_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + const handleCreate = () => { + setFormMode("create"); setFormObjid(null); setFormOpen(true); + }; + const handleEdit = () => { + if (checkedIds.length !== 1) return toast.error("수정할 행 1개를 선택하세요."); + setFormMode("edit"); setFormObjid(checkedIds[0]); setFormOpen(true); + }; + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까?`)) return; + try { + const res = await devPartApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + const handleEditFromDetail = (objid: string) => { + setDetailOpen(false); + setFormMode("edit"); setFormObjid(objid); setFormOpen(true); + }; + + return ( +
+
+
+
+ + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> +
+
+ + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> +
+
+ + + + + + +
+
+
+ 총 {total.toLocaleString()}건 (M2: status = 'release') +
+
+ +
+ +
+ + + + +
+ ); +} diff --git a/frontend/components/development/BomReportExcelImportDialog.tsx b/frontend/components/development/BomReportExcelImportDialog.tsx new file mode 100644 index 00000000..fae60795 --- /dev/null +++ b/frontend/components/development/BomReportExcelImportDialog.tsx @@ -0,0 +1,394 @@ +"use client"; + +// 개발관리 E-BOM 등록 CSV Import 다이얼로그 +// wace partMng/openBomReportExcelImportPopUp.jsp 1:1 +// +// 운영판 흐름: +// · Drop Zone: Drag & Drop CSV 템플릿 (fnc_setFileDropZone(..., "csv")) +// · 파싱: parsingExcelFile.do 의 .csv 분기 → parsingCsvFile (수준 기반 부모 자동 매핑) +// · 저장: partBomApplySave.do → savePartBomMaster +// +// CSV 컬럼 (11개, 헤더 1줄 후 데이터): +// 0:수준 1:품번 2:품명 3:수량 4:항목수량 5:재료 6:열처리경도 7:열처리방법 +// 8:표면처리 9:공급업체(MAKER) 10:범주이름(PART_TYPE) + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + 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, BomCsvRow, BomCopySourceRow } from "@/lib/api/devBom"; + +const PRODUCT_GROUP = "0000001"; +const TEMPLATE_DOWNLOAD_URL = "/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + editObjid?: string | null; + initialProductCd?: string; + onSaved: () => void; +} + +interface Column { + key: keyof BomCsvRow; + label: string; + width: string; + align?: "left" | "center" | "right"; + showNameFor?: keyof BomCsvRow; +} + +// 그리드 컬럼: 화면 표시는 핵심 + 자동 채움 컬럼 (운영 그리드 25컬럼 중 CSV 11컬럼 + 자동 ACCTFG/ODRFG) +const COLUMNS: Column[] = [ + { key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" }, + { key: "LEVEL", label: "수준", width: "min-w-[60px]", align: "center" }, + { key: "PARENT_PART_NO", label: "모품번 (자동)", width: "min-w-[140px]", align: "center" }, + { key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" }, + { key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" }, + { key: "QTY", label: "수량", width: "min-w-[70px]", align: "right" }, + { key: "ITEM_QTY", label: "항목수량", width: "min-w-[80px]", align: "right" }, + { key: "MATERIAL", label: "재료", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[100px]" }, + { key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[100px]" }, + { key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" }, + { key: "MAKER", label: "공급업체", width: "min-w-[110px]" }, + { key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" }, + { key: "ACCTFG", label: "계정구분(자동)", width: "min-w-[90px]", align: "center" }, + { key: "ODRFG", label: "조달구분(자동)", width: "min-w-[90px]", align: "center" }, +]; + +const ACCTFG_LABEL: Record = { "4": "반제품", "7": "비용" }; +const ODRFG_LABEL: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; + +function displayValue(r: BomCsvRow, col: Column): string { + if (col.key === "ACCTFG") { + const v = String(r.ACCTFG ?? ""); + return ACCTFG_LABEL[v] ?? v; + } + if (col.key === "ODRFG") { + const v = String(r.ODRFG ?? ""); + return ODRFG_LABEL[v] ?? v; + } + if (col.showNameFor) { + const name = r[col.showNameFor]; + if (name) return String(name); + } + return String(r[col.key] ?? ""); +} + +export function BomReportExcelImportDialog({ open, onOpenChange, editObjid, initialProductCd, onSaved }: Props) { + const fileInputRef = useRef(null); + + const [productCd, setProductCd] = useState(""); + const [bomPartNo, setBomPartNo] = useState(""); + const [bomPartName, setBomPartName] = useState(""); + const [version, setVersion] = useState(""); + + const [copyOptions, setCopyOptions] = useState([]); + const [copySelect, setCopySelect] = useState(""); + + const [rows, setRows] = useState([]); + const [hasError, setHasError] = useState(false); + const [fileName, setFileName] = useState(""); + const [encoding, setEncoding] = useState(""); + 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(""); + setEncoding(""); + setCopySelect(""); + }, [initialProductCd]); + + useEffect(() => { + if (!open) return; + reset(); + devBomApi.excelCopySource().then(setCopyOptions).catch(() => setCopyOptions([])); + }, [open, reset]); + + const handleDialogChange = (v: boolean) => { + if (!v) reset(); + onOpenChange(v); + }; + + 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 (!/\.csv$/i.test(file.name)) { + toast.error("CSV(.csv) 파일만 업로드 가능합니다."); + return; + } + setParsing(true); + setFileName(file.name); + try { + const data = await devBomApi.excelParse(file); + setRows(data.rows ?? []); + setHasError(!!data.hasError); + setEncoding(data.encoding ?? ""); + applyFirstLevelToHeader(data.firstLevel); + if (!data.rows || data.rows.length === 0) { + toast.warning("파싱된 데이터가 없습니다. CSV 형식을 확인해 주세요."); + } else if (data.hasError) { + toast.error("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요."); + } else { + toast.success(`${data.rows.length}건 파싱 완료 (인코딩: ${data.encoding})`); + } + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "CSV 파싱 실패"); + setRows([]); setHasError(false); setFileName(""); setEncoding(""); + } finally { + setParsing(false); + } + }, []); + + const handleFileInput = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) parseFile(f); + e.target.value = ""; + }; + const handleDrop = (e: React.DragEvent) => { + 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("CSV 파일 로딩결과가 유효하지 않습니다. 결과 메시지를 확인해 주세요."); + return; + } + 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 ( + + + + PART 및 구조등록 CSV upload + + + {/* 헤더 */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + setVersion(e.target.value)} placeholder="REV 등" /> +
+
+ + {/* E-BOM 복사 + 액션 버튼 */} +
+
+ + + +
+
+ + + + {rows.length > 0 && ( + + )} +
+
+ +
+ {fileName && {fileName}} + {encoding && 인코딩: {encoding}} + 총 {rows.length}건 + {errorCount > 0 && 에러 {errorCount}건} +
+ + {/* Drop Zone */} + {rows.length === 0 && !parsing && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +
+ Drag & Drop 또는 클릭하여 CSV 템플릿 업로드 (.csv) +
+
+ 컬럼: 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체 / 범주이름 +
+
+ )} + + {parsing && ( +
+ 파싱 중... +
+ )} + + {/* 결과 그리드 */} + {rows.length > 0 && !parsing && ( +
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {rows.map((r, i) => ( + + + {COLUMNS.map((c) => { + const value = displayValue(r, c); + const isNote = c.key === "NOTE"; + return ( + + ); + })} + + ))} + +
# + {c.label} +
{i + 1} + {value} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/development/BomReportStatusDialog.tsx b/frontend/components/development/BomReportStatusDialog.tsx new file mode 100644 index 00000000..c783b765 --- /dev/null +++ b/frontend/components/development/BomReportStatusDialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +// 개발관리 > E-BOM 상태 변경 다이얼로그. +// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드. + +import React, { useEffect, 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 { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { devBomApi, BomReportRow } from "@/lib/api/devBom"; + +const STATUS_OPTIONS = [ + { code: "create", label: "등록중" }, + { code: "changeDesign", label: "설계변경미배포" }, + { code: "deploy", label: "배포완료" }, +]; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; + onSaved: () => void; +} + +export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) { + const [row, setRow] = useState(null); + const [status, setStatus] = useState(""); + const [version, setVersion] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devBomApi.detail(objid) + .then((data) => { + if (!alive) return; + if (!data) { + toast.error("E-BOM 보고서를 찾을 수 없습니다."); + onOpenChange(false); + return; + } + setRow(data); + setStatus(data.status ?? ""); + setVersion(data.revision ?? ""); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + const handleSave = async () => { + if (!objid) return; + if (!status) return toast.error("상태를 선택하세요."); + setSaving(true); + try { + await devBomApi.updateStatus(objid, { + status, + version: version || undefined, + }); + toast.success("상태가 변경되었습니다."); + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + + + + E-BOM 상태 변경 + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+
제품구분: {row.product_name ?? row.product_cd ?? "—"}
+
품번: {row.part_no ?? "—"}
+
품명: {row.part_name ?? "—"}
+
현재상태: {row.status_title ?? row.status ?? "—"}
+
+
+ + +
+
+ + setVersion(e.target.value)} placeholder="예: RE, A, B..." /> +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/development/BomReportTreeDialog.tsx b/frontend/components/development/BomReportTreeDialog.tsx new file mode 100644 index 00000000..12af65e7 --- /dev/null +++ b/frontend/components/development/BomReportTreeDialog.tsx @@ -0,0 +1,194 @@ +"use client"; + +// 개발관리 E-BOM 트리 다이얼로그 — wace setStructurePopupMainFS 1:1 (단일 다이얼로그로 통합) +// +// 운영판은 frameset(헤더/좌측 트리/우측 디테일/하단 버튼) 4-Frame 팝업이지만 RPS 는 단일 +// 다이얼로그에 헤더(BOM Report 메타) + 동적 LEVEL 컬럼 트리 그리드 + 엑셀 다운로드 버튼. +// +// 컬럼 (운영 structureAscendingListExcel.jsp 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량 / 항목수량 / 3D / 2D / PDF / +// 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 + +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2, FileSpreadsheet } from "lucide-react"; +import { toast } from "sonner"; +import { devBomApi, BomReportRow, BomTreeFullRow } from "@/lib/api/devBom"; +import { cn } from "@/lib/utils"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + bomReport: BomReportRow | null; // 헤더 정보 표시용 +} + +export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) { + const [rows, setRows] = useState([]); + const [maxLevel, setMaxLevel] = useState(0); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + + useEffect(() => { + if (!open || !bomReport?.objid) { + setRows([]); setMaxLevel(0); + return; + } + let alive = true; + setLoading(true); + devBomApi.treeFull({ bom_report_objid: bomReport.objid }) + .then((data) => { + if (!alive) return; + setRows(data.rows ?? []); + setMaxLevel(Number(data.max_level) || 0); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "트리 조회 실패"); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, bomReport?.objid]); + + const handleExcel = async () => { + if (!bomReport?.objid) return; + setExporting(true); + try { + const { blob, fileName } = await devBomApi.excelAscending({ bom_report_objid: bomReport.objid }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = fileName; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + toast.success(`${fileName} 다운로드`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패"); + } finally { + setExporting(false); + } + }; + + const effectiveMax = Math.max(1, maxLevel); + + // 동적 LEVEL 컬럼 헤더 (1..maxLevel) + const levelHeaders = useMemo(() => { + const h: number[] = []; + for (let i = 1; i <= effectiveMax; i++) h.push(i); + return h; + }, [effectiveMax]); + + return ( + + + + BOM 구조 조회 + + + {/* 헤더 메타 */} + {bomReport && ( +
+ + + + + + + + +
+ )} + +
+
+ 총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} +
+ +
+ +
+ {loading ? ( +
+ +
+ ) : ( + + + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((r, idx) => { + const lev = Number(r.lev ?? 1); + return ( + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + ); + })} + +
{i}품번품명수량항목수량3D2DPDF재료열처리경도열처리방법표면처리메이커범주 이름비고
+ BOM 구조가 없습니다. +
+ {i === lev ? "*" : ""} + {r.pm_part_no}{r.pm_part_name}{r.qty}{r.p_qty}{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}{r.material}{r.heat_treatment_hardness}{r.heat_treatment_method}{r.surface_treatment}{r.maker}{r.part_type_title}{r.remark}
+ )} +
+ + + + +
+
+ ); +} + +function MetaRow({ label, value }: { label: string; value: any }) { + return ( +
+ {label} + {value != null && value !== "" ? value : "—"} +
+ ); +} diff --git a/frontend/components/development/PartDetailDialog.tsx b/frontend/components/development/PartDetailDialog.tsx new file mode 100644 index 00000000..720d83af --- /dev/null +++ b/frontend/components/development/PartDetailDialog.tsx @@ -0,0 +1,225 @@ +"use client"; + +// 개발관리 > PART 상세 다이얼로그 — wace partMng/partMngDetailPopUp.jsp 1:1 +// +// 운영판은 form 과 동일 화면을 disabled 로 표시 후 "수정" 클릭 시 활성화. +// RPS 에서는 PartFormDialog 와 분리 유지 (호환). 본 다이얼로그는 Form 레이아웃 readonly + +// 부속 정보 행 추가 (EO_NO / EO_DATE / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION)) + +// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) 표시. +// +// "수정" 버튼: 부모가 본 다이얼로그를 닫고 PartFormDialog(mode='edit') 오픈하도록 onEdit 콜백 호출. + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2, Pencil, FileText } from "lucide-react"; +import { toast } from "sonner"; +import { devPartApi, PartRow } from "@/lib/api/devPart"; +import { cn } from "@/lib/utils"; + +const LABEL_ODRFG: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; +const LABEL_LOT_FG: Record = { "0": "미사용", "1": "사용" }; +const LABEL_USE_YN: Record = { "0": "미사용", "1": "사용" }; +const LABEL_QC_FG: Record = { "0": "무검사", "1": "검사" }; +const LABEL_YESNO: Record = { "0": "부", "1": "여" }; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; + /** "수정" 버튼 클릭 시 호출 — 부모는 본 다이얼로그 닫고 PartFormDialog(mode='edit') 오픈 */ + onEdit?: (objid: string) => void; +} + +export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) { + const [row, setRow] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devPartApi.detail(objid) + .then((data) => { if (alive) setRow(data); }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + if (!open) return null; + + return ( + + + + 품목 상세 + + + {loading || !row ? ( +
+ +
+ ) : ( +
+ {/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *) */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 부속 — wace detail 만 표시 */} + + + + + + + + + + {/* CAD Data */} + + + + + + + + + + + + + + +
품번{row.part_no}품명{row.part_name}
재료{row.material}열처리경도{row.heat_treatment_hardness}
열처리방법{row.heat_treatment_method}표면처리{row.surface_treatment}
메이커{row.maker}범주 이름{row.part_type_title}
규격{row.spec}
계정구분{row.acctfg_nm}조달구분{LABEL_ODRFG[row.odrfg ?? ""] ?? row.odrfg_nm ?? ""}
재고단위{row.unit_dc_nm}관리단위{row.unitmang_dc_nm}
환산수량{row.unitchng_nb != null && row.unitchng_nb !== "" ? String(row.unitchng_nb) : ""}LOT구분{LABEL_LOT_FG[row.lot_fg ?? ""] ?? row.lot_fg_nm ?? ""}
사용여부{LABEL_USE_YN[row.use_yn ?? ""] ?? row.use_yn_nm ?? ""}검사여부{LABEL_QC_FG[row.qc_fg ?? ""] ?? row.qc_fg_nm ?? ""}
SET품여부{LABEL_YESNO[row.setitem_fg ?? ""] ?? row.setitem_fg_nm ?? ""}의뢰여부{LABEL_YESNO[row.req_fg ?? ""] ?? row.req_fg_nm ?? ""}
개당길이{row.unit_length}개당소요량{row.unit_qty}
비고{row.remark}
EO No{row.eo_no}EO Date{row.eo_date}
EO구분{row.change_type}EO사유{row.change_option_name ?? row.change_option}
+ CAD Data + 3D + +
2D(Drawing) + +
2D(PDF) + +
+ +
+ CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. +
+
+ )} + + + {row && onEdit && ( + + )} + + +
+
+ ); +} + +// ─── 보조 컴포넌트 ────────────────────────────────────────── + +function Tr({ children }: { children: React.ReactNode }) { + return {children}; +} +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) { + return {children}; +} + +// 운영판 disabled input 의 readonly 박스 +function Ro({ children, align }: { children: React.ReactNode; align?: "left" | "center" | "right" }) { + const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"; + const empty = children == null || children === ""; + return ( +
+ {empty ? : children} +
+ ); +} + +function CadCount({ label, count }: { label: string; count: number }) { + if (count > 0) { + return ( +
+ + {label} 첨부 {count.toLocaleString()}건 +
+ ); + } + return ( +
+ 첨부된 {label} 파일 없음 +
+ ); +} diff --git a/frontend/components/development/PartExcelImportDialog.tsx b/frontend/components/development/PartExcelImportDialog.tsx new file mode 100644 index 00000000..df943cee --- /dev/null +++ b/frontend/components/development/PartExcelImportDialog.tsx @@ -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; +const LABEL_USE = { "0": "미사용", "1": "사용" } as Record; +const LABEL_QC = { "0": "무검사", "1": "검사" } as Record; +const LABEL_YN = { "0": "부", "1": "여" } as Record; + +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(null); + const [parsedRows, setParsedRows] = useState([]); + const [hasError, setHasError] = useState(false); + const [fileName, setFileName] = useState(""); + 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) => { + const f = e.target.files?.[0]; + if (f) parseFile(f); + e.target.value = ""; + }; + const handleDrop = (e: React.DragEvent) => { + 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 ( + + + + PART 등록 Excel upload + + +
+ + + + {fileName && ( + + {fileName} + + )} + {parsedRows.length > 0 && ( + + )} +
+ 총 {parsedRows.length}건 + {errorCount > 0 && 에러 {errorCount}건} +
+
+ + {/* Drop Zone — 파싱 전에만 노출 */} + {parsedRows.length === 0 && !parsing && ( +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + +
+ Drag & Drop 또는 클릭하여 엑셀 템플릿 업로드 (.xlsx, .xls) +
+
+ )} + + {parsing && ( +
+ 파싱 중... +
+ )} + + {/* 결과 그리드 */} + {parsedRows.length > 0 && !parsing && ( +
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {parsedRows.map((r, i) => ( + + + {COLUMNS.map((c) => { + const value = displayValue(r, c); + const isNote = c.key === "NOTE"; + return ( + + ); + })} + + ))} + +
# + {c.label} +
{i + 1} + {value} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/development/PartFormDialog.tsx b/frontend/components/development/PartFormDialog.tsx new file mode 100644 index 00000000..d7110842 --- /dev/null +++ b/frontend/components/development/PartFormDialog.tsx @@ -0,0 +1,486 @@ +"use client"; + +// 개발관리 > PART 등록/수정 다이얼로그 — wace partMng/partMngFormPopUp.jsp 1:1 +// +// 폼 필드 (운영판 그대로, 그 외 추가 없음): +// ① 품번 | 품명 +// ② 재료 | 열처리경도 +// ③ 열처리방법 | 표면처리 +// ④ 메이커 | 범주이름* (PART_TYPE, comm_code 0000062) +// ⑤ 규격 (1행) +// ⑥ 계정구분* (ACCTFG, comm_code 0900213) | 조달구분* (ODRFG, 0/1/8 하드) +// ⑦ 재고단위* (UNIT_DC, comm_code 0001399) | 관리단위* (UNITMANG_DC, 동일) +// ⑧ 환산수량* (UNITCHNG_NB, 숫자) | LOT구분* (0=미사용, 1=사용) +// ⑨ 사용여부* (0=미사용, 1=사용) | 검사여부* (0=무검사, 1=검사) +// ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여) +// ⑪ 개당길이 | 개당소요량 +// ⑫ 비고 (1행) +// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder +// +// 신규: POST /api/development/part (운영 폼 22컬럼) +// 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1) + +import React, { useCallback, useEffect, 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 { Loader2, Save, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart"; +import { cn } from "@/lib/utils"; + +// comm_code group ids (vexplor_rps DB) +const GROUP_PART_TYPE = "0000062"; +const GROUP_UNIT_DC = "0001399"; +const GROUP_ACCTFG = "0900213"; + +// 운영 1:1 하드코딩 옵션 +const OPT_ODRFG = [{ v: "0", t: "구매" }, { v: "1", t: "생산" }, { v: "8", t: "Phantom" }]; +const OPT_LOT_FG = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }]; +const OPT_USE_YN = [{ v: "0", t: "미사용" }, { v: "1", t: "사용" }]; +const OPT_QC_FG = [{ v: "0", t: "무검사" }, { v: "1", t: "검사" }]; +const OPT_YESNO = [{ v: "0", t: "부" }, { v: "1", t: "여" }]; + +interface FormState { + 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; + lot_fg: string; + use_yn: string; + qc_fg: string; + setitem_fg: string; + req_fg: string; + unit_length: string; + unit_qty: string; + remark: string; +} + +const EMPTY_FORM: FormState = { + part_no: "", part_name: "", + material: "", heat_treatment_hardness: "", heat_treatment_method: "", surface_treatment: "", + maker: "", part_type: "", spec: "", + acctfg: "", odrfg: "", unit_dc: "", unitmang_dc: "", unitchng_nb: "", + lot_fg: "", use_yn: "", qc_fg: "", setitem_fg: "", req_fg: "", + unit_length: "", unit_qty: "", remark: "", +}; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "edit"; + editObjid?: string | null; + onSaved: () => void; +} + +export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: Props) { + const isEdit = mode === "edit"; + const [form, setForm] = useState(EMPTY_FORM); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const setField = useCallback( + (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })), + [] + ); + + useEffect(() => { + if (!open) return; + if (isEdit && editObjid) loadDetail(editObjid); + else setForm(EMPTY_FORM); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const loadDetail = async (objid: string) => { + setLoading(true); + try { + const row = await devPartApi.detail(objid); + if (!row) { + toast.error("PART를 찾을 수 없습니다."); + onOpenChange(false); + return; + } + setForm(rowToForm(row)); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + // wace fn_save 1:1 — 모든 required 검증 + const validate = (): string | null => { + if (!form.part_no.trim()) return "품번은 필수입니다."; + if (!form.part_name.trim()) return "품명은 필수입니다."; + if (!form.part_type) return "범주이름은 필수입니다."; + if (!form.acctfg) return "계정구분은 필수입니다."; + if (!form.odrfg) return "조달구분은 필수입니다."; + if (!form.unit_dc) return "재고단위는 필수입니다."; + if (!form.unitmang_dc) return "관리단위는 필수입니다."; + if (!form.unitchng_nb) return "환산수량은 필수입니다."; + if (!form.lot_fg) return "LOT구분은 필수입니다."; + if (!form.use_yn) return "사용여부는 필수입니다."; + if (!form.qc_fg) return "검사여부는 필수입니다."; + if (!form.setitem_fg) return "SET품여부는 필수입니다."; + if (!form.req_fg) return "의뢰여부는 필수입니다."; + return null; + }; + + const handleSave = async () => { + const err = validate(); + if (err) return toast.error(err); + + setSaving(true); + try { + if (isEdit && editObjid) { + const body: PartUpdateBody = { + part_name: form.part_name, + material: form.material, + heat_treatment_hardness: form.heat_treatment_hardness, + heat_treatment_method: form.heat_treatment_method, + surface_treatment: form.surface_treatment, + maker: form.maker, + part_type: form.part_type, + acctfg: form.acctfg, + odrfg: form.odrfg, + spec: form.spec, + unit_dc: form.unit_dc, + unitmang_dc: form.unitmang_dc, + unitchng_nb: form.unitchng_nb, + lot_fg: form.lot_fg, + use_yn: form.use_yn, + qc_fg: form.qc_fg, + setitem_fg: form.setitem_fg, + req_fg: form.req_fg, + unit_length: form.unit_length, + unit_qty: form.unit_qty, + remark: form.remark, + }; + await devPartApi.update(editObjid, body); + toast.success("PART가 수정되었습니다."); + } else { + const body: PartCreateBody = { + part_no: form.part_no, + part_name: form.part_name, + part_type: form.part_type, + material: form.material, + heat_treatment_hardness: form.heat_treatment_hardness, + heat_treatment_method: form.heat_treatment_method, + surface_treatment: form.surface_treatment, + maker: form.maker, + spec: form.spec, + acctfg: form.acctfg, + odrfg: form.odrfg, + unit_dc: form.unit_dc, + unitmang_dc: form.unitmang_dc, + unitchng_nb: form.unitchng_nb, + lot_fg: form.lot_fg, + use_yn: form.use_yn, + qc_fg: form.qc_fg, + setitem_fg: form.setitem_fg, + req_fg: form.req_fg, + unit_length: form.unit_length, + unit_qty: form.unit_qty, + remark: form.remark, + }; + await devPartApi.create(body); + toast.success("PART가 등록되었습니다."); + } + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + const titleText = isEdit ? "품목 수정" : "품목 등록"; + + return ( + + + + {titleText} + + + {loading ? ( +
+ +
+ ) : ( +
+ {/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *) + 각 행 첫 input 은 colSpan=2 로 양쪽 input 비율 맞춤 */} + + + + + + + + + + {/* ① */} + + + + + + + + {/* ② */} + + + + + + + + {/* ③ */} + + + + + + + + {/* ④ */} + + + + + + + + {/* ⑤ 규격 (colspan=4 — 운영판 colspan=4 1:1) */} + + + + + + {/* ⑥ */} + + + + + + + + {/* ⑦ */} + + + + + + + + {/* ⑧ */} + + + + + + + + {/* ⑨ */} + + + + + + + + {/* ⑩ */} + + + + + + + + {/* ⑪ */} + + + + + + + + {/* ⑫ 비고 (colspan=4) */} + + + + + + {/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */} + + + + + + + + + + + + + + +
품번 setField("part_no", e.target.value)} />품명 setField("part_name", e.target.value)} />
재료 setField("material", e.target.value)} />열처리경도 setField("heat_treatment_hardness", e.target.value)} />
열처리방법 setField("heat_treatment_method", e.target.value)} />표면처리 setField("surface_treatment", e.target.value)} />
메이커 setField("maker", e.target.value)} />범주이름 + setField("part_type", v)} /> +
규격 setField("spec", e.target.value)} />
계정구분 + setField("acctfg", v)} /> + 조달구분 setField("odrfg", v)} />
재고단위 + setField("unit_dc", v)} /> + 관리단위 + setField("unitmang_dc", v)} /> +
환산수량 setField("unitchng_nb", e.target.value.replace(/[^0-9.]/g, ""))} />LOT구분 setField("lot_fg", v)} />
사용여부 setField("use_yn", v)} />검사여부 setField("qc_fg", v)} />
SET품여부 setField("setitem_fg", v)} />의뢰여부 setField("req_fg", v)} />
개당길이 setField("unit_length", e.target.value.replace(/[^0-9.]/g, ""))} />개당소요량 setField("unit_qty", e.target.value.replace(/[^0-9.]/g, ""))} />
비고 setField("remark", e.target.value)} />
+ CAD Data + 3D + +
2D(Drawing) + +
2D(PDF) + +
+ +
+ CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. +
+
+ )} + + + + + +
+
+ ); +} + +// ─── 보조 컴포넌트 ────────────────────────────────────────── + +function Tr({ children }: { children: React.ReactNode }) { + return {children}; +} +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) { + return {children}; +} +function Req() { + return *; +} +function BasicSelect({ + value, options, onChange, +}: { + value: string; + options: { v: string; t: string }[]; + onChange: (v: string) => void; +}) { + return ( + + ); +} +function DropPlaceholder({ label }: { label: string }) { + return ( +
+ + Drag & Drop Files Here ({label}) +
+ ); +} + +// ─── PartRow → FormState ──────────────────────────────────── + +function rowToForm(r: PartRow): FormState { + return { + part_no: r.part_no ?? "", + part_name: r.part_name ?? "", + material: r.material ?? "", + heat_treatment_hardness: r.heat_treatment_hardness ?? "", + heat_treatment_method: r.heat_treatment_method ?? "", + surface_treatment: r.surface_treatment ?? "", + maker: r.maker ?? "", + part_type: r.part_type ?? "", + spec: r.spec ?? "", + acctfg: r.acctfg ?? "", + odrfg: r.odrfg ?? "", + unit_dc: r.unit_dc ?? "", + unitmang_dc: r.unitmang_dc ?? "", + unitchng_nb: r.unitchng_nb != null ? String(r.unitchng_nb) : "", + lot_fg: r.lot_fg ?? "", + use_yn: r.use_yn ?? "", + qc_fg: r.qc_fg ?? "", + setitem_fg: r.setitem_fg ?? "", + req_fg: r.req_fg ?? "", + unit_length: r.unit_length ?? "", + unit_qty: r.unit_qty ?? "", + remark: r.remark ?? "", + }; +} diff --git a/frontend/components/development/PartHisDetailDialog.tsx b/frontend/components/development/PartHisDetailDialog.tsx new file mode 100644 index 00000000..9323fb6a --- /dev/null +++ b/frontend/components/development/PartHisDetailDialog.tsx @@ -0,0 +1,148 @@ +"use client"; + +// 개발관리 > 설계변경 리스트 상세 다이얼로그 (read-only). +// wace partMngHisDetailPopUp.jsp 1:1 — 모든 필드 disabled. + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { devEoHistoryApi, EoHistoryDetail } from "@/lib/api/devEoHistory"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; +} + +export function PartHisDetailDialog({ open, onOpenChange, objid }: Props) { + const [row, setRow] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devEoHistoryApi.detail(objid) + .then((data) => { if (alive) setRow(data); }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + return ( + + + + 설계변경 상세 정보 (PART_MNG_HISTORY) + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + +
+ +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
+ )} + + + + +
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Row({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) { + const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : ""; + return ( +
+ {label && } +
+ {value != null && value !== "" ? value : } +
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 6156d405..3418f123 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -107,6 +107,11 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/part-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-regist/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/ebom-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-regist/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/ebom-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-search/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/change-list": dynamic(() => import("@/app/(main)/COMPANY_16/development/change-list/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts new file mode 100644 index 00000000..6da36456 --- /dev/null +++ b/frontend/lib/api/devBom.ts @@ -0,0 +1,303 @@ +import { apiClient } from "./client"; + +// Content-Disposition 의 filename / filename* 파싱 (UTF-8 인코딩 우선) +function extractFileName(cd: string | undefined): string | null { + if (!cd) return null; + const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(cd); + if (utf8 && utf8[1]) { + try { return decodeURIComponent(utf8[1]); } catch { /* fallthrough */ } + } + const plain = /filename="?([^";]+)"?/i.exec(cd); + if (plain && plain[1]) { + try { return decodeURIComponent(plain[1]); } catch { return plain[1]; } + } + return null; +} + +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 +// 라우트: /api/development/ebom/*, /api/development/ebom-tree/* +// ============================================================ + +export interface BomReportListFilter { + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_unit_name?: string; + search_writer?: string; + product_cd?: string; + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + status?: string; + page?: number; + page_size?: number; +} + +export interface BomReportRow { + num: number | string; + objid: string; + customer_objid: string | null; + customer_name: string | null; + contract_objid: string | null; + customer_project_name: string | null; + project_no: string | null; + unit_code: string | null; + unit_name: string | null; + status: string | null; + status_title: string | null; + writer: string | null; + dept_name: string | null; + user_name: string | null; + dept_user_name: string | null; + regdate: string | null; + reg_date: string | null; + deploy_date: string | null; + revision: string | null; + eo_no: string | null; + eo_date: string | null; + note: string | null; + multi_yn: string | null; + multi_master_yn: string | null; + multi_break_yn: string | null; + multi_master_objid: string | null; + bom_cnt: number | string | null; + product_cd: string | null; + product_name: string | null; + part_no: string | null; + part_name: string | null; +} + +export interface BomReportListResponse { + rows: BomReportRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface BomReportStatusBody { + product_cd?: string; + part_no?: string; + part_name?: string; + version?: string; + status: string; +} + +export interface BomTreeFilter { + bom_report_objid?: string; + project_name?: string; + unit_code?: string; + search_part_no?: string; + search_part_name?: string; +} + +export interface BomTreeRow { + bom_report_objid: string | null; + objid: string; + parent_objid: string | null; + child_objid: string | null; + part_no: string | null; // bom_part_qty.part_no (= part_mng.objid) + qty: string | null; + seq: number | string | null; + status: string | null; + lev: number; + path: string[] | null; + // part_mng JOIN + pm_part_no: string | null; + pm_part_name: string | null; + spec: string | null; + material: string | null; + weight: string | null; + remark: string | null; + edit_date: string | null; + eo_no: string | null; + revision: string | null; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; + max_level: number | string | null; +} + +export interface BomTreeResponse { + rows: BomTreeRow[]; + max_level: number; +} + +// 트리 풀 컬럼 (ascendingForExcel 1:1) — BomReportTreeDialog 용 +export interface BomTreeFullRow { + lev: number | string; + pm_part_no: string | null; + pm_part_name: string | null; + qty: string | number | null; + p_qty: string | number | null; + material: string | null; + remark: string | null; + heat_treatment_hardness: string | null; + heat_treatment_method: string | null; + surface_treatment: string | null; + maker: string | null; + part_type: string | null; + part_type_title: string | null; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; +} +export interface BomTreeFullResponse { + rows: BomTreeFullRow[]; + max_level: number; +} + +// ─── API ───────────────────────────────────────────────── + +export const devBomApi = { + // M3 그리드 + async list(filter: BomReportListFilter = {}): Promise { + const res = await apiClient.get("/development/ebom/list", { params: filter }); + return res.data?.data as BomReportListResponse; + }, + + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/ebom/${objid}`); + return res.data?.data ?? null; + }, + + async updateStatus(objid: string, body: BomReportStatusBody) { + return (await apiClient.put(`/development/ebom/${objid}/status`, body)).data; + }, + + async remove(objids: string[]) { + const res = await apiClient.delete("/development/ebom", { data: { objids } }); + return res.data; + }, + + // M4 + async ascending(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/ascending", { params: filter }); + return res.data?.data as BomTreeResponse; + }, + + async descending(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/descending", { params: filter }); + return res.data?.data as BomTreeResponse; + }, + + // E-BOM 트리 (풀 컬럼) — M3 그리드 행 클릭 → BomReportTreeDialog + async treeFull(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/full", { params: filter }); + return res.data?.data as BomTreeFullResponse; + }, + + // M4 엑셀 다운로드 (정/역전개) — wace 1:1 + async excelAscending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { + const res = await apiClient.get("/development/ebom-tree/ascending/excel", { + params: filter, responseType: "blob", + }); + return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_ascending.xlsx" }; + }, + async excelDescending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { + const res = await apiClient.get("/development/ebom-tree/descending/excel", { + params: filter, responseType: "blob", + }); + return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_descending.xlsx" }; + }, + + // Excel Import + async excelParse(file: File): Promise { + 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 { + const res = await apiClient.get("/development/ebom/excel-check-duplicate", { + params: { partNo, exclude }, + }); + return !!res.data?.data?.isDuplicate; + }, + + async excelCopySource(productCd?: string): Promise { + 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 { + const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`); + return ((res.data?.data?.rows as BomCsvRow[]) ?? []); + }, + + async excelSave(input: BomExcelSaveInput): Promise { + const res = await apiClient.post("/development/ebom/excel-save", input); + return res.data?.data as BomExcelSaveResult; + }, +}; + +// ─── CSV Import 타입 (wace parsingCsvFile 1:1) ───────────── + +export interface BomCsvRow { + NOTE: string; + LEVEL: string; + PARENT_PART_NO: string; + PART_NO: string; + PART_NAME: string; + QTY: string; + ITEM_QTY: string; + MATERIAL: string; + HEAT_TREATMENT_HARDNESS: string; + HEAT_TREATMENT_METHOD: string; + SURFACE_TREATMENT: string; + MAKER: string; + PART_TYPE: string; + PART_TYPE_NAME: string; + ACCTFG: string; + ODRFG: string; + UNIT_DC: string; + UNITMANG_DC: string; + UNITCHNG_NB: string; + LOT_FG: string; + USE_YN: string; + QC_FG: string; + SETITEM_FG: string; + REQ_FG: string; +} + +// 기존 코드 호환용 별칭 (필요 시 마이그레이션) +export type BomExcelRow = BomCsvRow; + +export interface BomExcelParseResponse { + rows: BomCsvRow[]; + hasError: boolean; + firstLevel: { part_no: string; part_name: string } | null; + encoding: string; +} + +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: BomCsvRow[]; +} + +export interface BomExcelSaveResult { + bomReportObjid: string; + insertedParts: number; + updatedParts: number; + bomRows: number; + mode: "create" | "update"; +} diff --git a/frontend/lib/api/devEoHistory.ts b/frontend/lib/api/devEoHistory.ts new file mode 100644 index 00000000..f1f77d5b --- /dev/null +++ b/frontend/lib/api/devEoHistory.ts @@ -0,0 +1,106 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 설계변경 리스트 (M5, read-only) — wace partMngHistList 1:1 +// 라우트: /api/development/eo-history/* +// ============================================================ + +export interface EoHistoryListFilter { + Year?: string; + contract_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + change_option?: string; + eo_start_date?: string; + eo_end_date?: string; + change_type?: string; + part_type?: string; + writer_id?: string; + page?: number; + page_size?: number; +} + +export interface EoHistoryRow { + objid: string; + eo_no: string | null; + year: string | null; + project_no: string | null; + project_name: string | null; + unit_name: string | null; + parent_part_info: string | null; + part_no_disp: string | null; + part_name_disp: string | null; + part_no: string | null; + part_name: string | null; + bom_qty_status: string | null; + qty: string | null; + qty_temp: string | null; + change_type: string | null; + change_type_name: string | null; + change_option: string | null; + change_option_name: string | null; + revision_disp: string | null; + revision: string | null; + eo_date: string | null; + part_type: string | null; + part_type_name: string | null; + writer: string | null; + writer_name: string | null; + his_reg_date_title: string | null; + bom_deploy_date: string | null; + bom_deploy_date_title: string | null; +} + +export interface EoHistoryListResponse { + rows: EoHistoryRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface EoHistoryDetail extends EoHistoryRow { + // raw PART_MNG_HISTORY 추가 필드 + product_mgmt_objid?: string | null; + upg_no?: string | null; + unit?: string | null; + spec?: string | null; + material?: string | null; + weight?: string | null; + remark?: string | null; + es_spec?: string | null; + ms_spec?: string | null; + design_apply_point?: string | null; + management_flag?: string | null; + status?: string | null; + reg_date?: string | null; + is_last?: string | null; + sourcing_code?: string | null; + sub_material?: string | null; + thickness?: string | null; + width?: string | null; + height?: string | null; + out_diameter?: string | null; + in_diameter?: string | null; + length?: string | null; + supply_code?: string | null; + contract_objid?: string | null; + maker?: string | null; + his_status?: string | null; + bom_status?: string | null; + heat_treatment_hardness?: string | null; + heat_treatment_method?: string | null; + surface_treatment?: string | null; + customer_project_name?: string | null; +} + +export const devEoHistoryApi = { + async list(filter: EoHistoryListFilter = {}): Promise { + const res = await apiClient.get("/development/eo-history/list", { params: filter }); + return res.data?.data as EoHistoryListResponse; + }, + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/eo-history/${objid}`); + return res.data?.data ?? null; + }, +}; diff --git a/frontend/lib/api/devPart.ts b/frontend/lib/api/devPart.ts new file mode 100644 index 00000000..caea8991 --- /dev/null +++ b/frontend/lib/api/devPart.ts @@ -0,0 +1,309 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 PART (M1 등록 / M2 조회) — wace partMng.xml 1:1 +// 라우트: /api/development/part-temp/*, /api/development/part/* +// ============================================================ + +export interface PartListFilter { + search_part_no?: string; + search_part_name?: string; + search_material?: string; + search_spec?: string; + search_part_type?: string; + writer?: string; + status?: string; + status_arr?: string[]; + product_code?: string; + upg_no?: string; + page?: number; + page_size?: number; + + // M2 추가 필터 + search_year?: string; + search_design_date_from?: string; + search_design_date_to?: string; + customer_objid?: string; + customer_cd?: string; + project_name?: string; + unit_code?: string; + is_last?: string; + eo?: string; +} + +/** partMngBaseSimple + M1/M2 추가 컬럼 평탄화 — Postgres는 컬럼명을 소문자로 반환 */ +export interface PartRow { + objid: string; + part_no: string | null; + part_name: string | null; + product_mgmt_objid: string | null; + upg_no: string | null; + unit: string | null; + unit_title: string | null; + qty: string | null; + spec: string | null; + post_processing: string | null; + material: string | null; + weight: string | null; + part_type: string | null; + part_type_title: string | null; + remark: string | null; + es_spec: string | null; + ms_spec: string | null; + change_type: string | null; + design_apply_point: string | null; + change_option: string | null; + change_option_name: string | null; + management_flag: string | null; + revision: string | null; + status: string | null; + reg_date: string | null; + part_regdate_title: string | null; + edit_date: string | null; + writer: string | null; + is_last: string | null; + is_longd: string | null; + eo_date: string | null; + eo_no: string | null; + eo_temp: string | null; + maker: string | null; + contract_objid: string | null; + thickness: string | null; + width: string | null; + height: string | null; + out_diameter: string | null; + in_diameter: string | null; + length: string | null; + sourcing_code: string | null; + supply_code: string | null; + supply_name: string | null; + sub_material: string | null; + parent_part_no: string | null; + design_date: string | null; + deploy_date: string | null; + excel_upload_seq: string | number | null; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; + cu_total_cnt: number | string | null; + + // 추가 15컬럼 + 라벨 + heat_treatment_hardness: string | null; + heat_treatment_method: string | null; + surface_treatment: string | null; + acctfg: string | null; + acctfg_nm: string | null; + odrfg: string | null; + odrfg_nm: string | null; + unit_dc: string | null; + unit_dc_nm: string | null; + unitmang_dc: string | null; + unitmang_dc_nm: string | null; + unitchng_nb: string | number | null; + lot_fg: string | null; + lot_fg_nm: string | null; + use_yn: string | null; + use_yn_nm: string | null; + qc_fg: string | null; + qc_fg_nm: string | null; + setitem_fg: string | null; + setitem_fg_nm: string | null; + req_fg: string | null; + req_fg_nm: string | null; + unit_length: string | null; + unit_qty: string | null; + + // M1 전용 부속 + partner_title?: string | null; + parent_part_info?: string | null; + bom_report_objid?: string | null; + objid_qty?: string | null; + child_objid?: string | null; + q_qty?: string | null; + q_qty_raw?: string | null; + qty_temp?: string | null; + sort?: string | null; + + // M2 전용 부속 + num?: number | null; + bom_qty?: string | null; +} + +export interface PartListResponse { + rows: PartRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface PartCreateBody { + part_objid?: string; + part_no: string; + part_name: string; + unit?: string; + qty?: string; + spec?: string; + material?: string; + thickness?: string; + width?: string; + height?: string; + out_diameter?: string; + in_diameter?: string; + length?: string; + remark?: string; + part_type: string; + product_mgmt_objid?: string; + supply_code?: string; + maker?: string; + contract_objid?: string; + post_processing?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + acctfg?: string; + odrfg?: string; + unit_dc?: string; + unitmang_dc?: string; + unitchng_nb?: string; + lot_fg?: string; + use_yn?: string; + qc_fg?: string; + setitem_fg?: string; + req_fg?: string; + unit_length?: string; + unit_qty?: string; +} + +export interface PartUpdateBody { + part_name?: string; + material?: string; + heat_treatment_hardness?: string; + heat_treatment_method?: string; + surface_treatment?: string; + maker?: string; + part_type?: string; + acctfg?: string; + odrfg?: string; + spec?: string; + unit_dc?: string; + unitmang_dc?: string; + unitchng_nb?: string; + lot_fg?: string; + use_yn?: string; + qc_fg?: string; + setitem_fg?: string; + req_fg?: string; + unit_length?: string; + unit_qty?: string; + remark?: string; +} + +export interface DeployResult { + deployed: number; + eo_nos: Record; +} + +// ─── 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 = { + // M1 그리드 + async listTemp(filter: PartListFilter = {}): Promise { + const res = await apiClient.get("/development/part-temp/list", { params: filter }); + return res.data?.data as PartListResponse; + }, + + // M2 그리드 + async list(filter: PartListFilter = {}): Promise { + const res = await apiClient.get("/development/part/list", { params: filter }); + return res.data?.data as PartListResponse; + }, + + // 단건 상세 + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/part/${objid}`); + return res.data?.data ?? null; + }, + + // 신규 등록 (38 컬럼) + async create(body: PartCreateBody): Promise<{ objid: string }> { + const res = await apiClient.post("/development/part", body); + return res.data?.data; + }, + + // 상세 수정 (21 컬럼) + async update(objid: string, body: PartUpdateBody) { + return (await apiClient.put(`/development/part/${objid}`, body)).data; + }, + + // 확정 (M1→M2): EO_NO 채번 + part_mng_history 이력 + async deploy(objids: string[]): Promise { + const res = await apiClient.post("/development/part-temp/deploy", { objids }); + return res.data?.data as DeployResult; + }, + + // 다중 삭제 + async remove(objids: string[]) { + const res = await apiClient.delete("/development/part", { data: { objids } }); + return res.data; + }, + + // Excel Import — 파싱 + 검증 + async excelParse(file: File): Promise { + 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 { + const res = await apiClient.post("/development/part/excel-save", { rows }); + return res.data?.data as ExcelSaveResponse; + }, +}; diff --git a/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv b/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv new file mode 100644 index 00000000..3d9d21c1 --- /dev/null +++ b/frontend/public/templates/BOM_REPORT_CSV_IMPORT_TEMPLATE.csv @@ -0,0 +1,6 @@ +수준,품번,품명,수량,항목수량,재료,열처리경도,열처리방법,표면처리,공급업체,범주이름 +1,RFX-140,Peak 소성로,1,1,재질1,,,,(주)배관랜드,구매품 +2,RFX-140-010,치환실 앗세이,1,1,재질2,,,,(주)네온테크,조립품 +3,RFX-140-010-001,치환실 앗세이_001,1,1,재질3,,,,(주)우리전열,구매품 +3,RFX-140-010-002,AC전원,1,1,,,,,ABB,구매품 +2,RFX-140-020,전원부 앗세이,1,1,,,,,,부품 diff --git a/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx b/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx new file mode 100644 index 00000000..ebf7d495 Binary files /dev/null and b/frontend/public/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx differ