Merge pull request 'hjjeong' (#10) from hjjeong into main
Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/10
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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); // 차량 운행 이력 관리
|
||||
|
||||
@@ -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<string, any>): 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<string, any>));
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>): 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<string, any>));
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>): 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<string, any>));
|
||||
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<string, any>));
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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<Map<string, { code_id: string; code_name: string }>> {
|
||||
const r = await client.query(
|
||||
`SELECT CODE_ID AS code_id, CODE_NAME AS code_name
|
||||
FROM COMM_CODE WHERE PARENT_CODE_ID = $1`,
|
||||
[CODE_PARENT_PART_TYPE]
|
||||
);
|
||||
const m = new Map<string, { code_id: string; code_name: string }>();
|
||||
for (const row of r.rows) if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ─── 1) 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<string>();
|
||||
const levelToPartNoMap = new Map<string, string>();
|
||||
|
||||
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<number, string>(); // 숫자 수준용 (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<boolean> {
|
||||
if (!partNo) return false;
|
||||
const sql = excludeObjid
|
||||
? `SELECT 1 FROM PART_BOM_REPORT WHERE PART_NO = $1 AND OBJID <> $2 LIMIT 1`
|
||||
: `SELECT 1 FROM PART_BOM_REPORT WHERE PART_NO = $1 LIMIT 1`;
|
||||
const params = excludeObjid ? [partNo.trim(), excludeObjid] : [partNo.trim()];
|
||||
const r = await getPool().query(sql, params);
|
||||
return (r.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ─── 3) E-BOM 복사: 기존 BOM_PART_QTY → BomCsvRow[] ────────
|
||||
|
||||
export async function copyBomForGrid(sourceObjid: string): Promise<BomCsvRow[]> {
|
||||
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<BomSaveResult> {
|
||||
if (!input.productCd) throw new Error("제품구분은 필수입니다.");
|
||||
if (!input.partNo) throw new Error("품번은 필수입니다.");
|
||||
if (!input.partName) throw new Error("품명은 필수입니다.");
|
||||
|
||||
let insertedParts = 0;
|
||||
let updatedParts = 0;
|
||||
let bomRows = 0;
|
||||
let bomReportObjid = (input.bomReportObjid && input.bomReportObjid.trim()) ? input.bomReportObjid.trim() : "";
|
||||
const mode: "create" | "update" = bomReportObjid ? "update" : "create";
|
||||
|
||||
await transaction(async (client: PoolClient) => {
|
||||
if (mode === "update") {
|
||||
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<string, string>(); // PART_NO → part_mng.objid (자식·부모 공용 캐시)
|
||||
const childBomObjIdByPartNo = new Map<string, string>(); // PART_NO → bom_part_qty.child_objid
|
||||
|
||||
// 자식 PART: 있으면 updatePartInfoFromCsv UPDATE, 없으면 insertpartInfo INSERT (wace 1:1)
|
||||
async function upsertChildPart(r: BomCsvRow): Promise<string> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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<number> {
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// ============================================================
|
||||
// 개발관리 PART Excel Import 서비스 — wace_plm PartMngService 1:1
|
||||
//
|
||||
// 원본 흐름 (wace partMng/openPartExcelImportPopUp.jsp):
|
||||
// 1) /partMng/partParsingExcelFile.do → 업로드 파일 파싱 + 검증
|
||||
// 엑셀 row[2..]부터 22컬럼 추출. 검증 결과는 행마다 NOTE 컬럼에 누적.
|
||||
// 코드명 → 코드값 매핑 (PART_TYPE/ACCTFG/UNIT_DC/UNITMANG_DC, 한글 → 0/1/8/Y/N).
|
||||
// 품번 중복 (PART_MNG 기존 + 엑셀 내 중복) → "품번중복" NOTE.
|
||||
// 2) /partMng/partUploadSave.do → 그리드 데이터 INSERT
|
||||
// partMng.getPartObjid 로 part_no 존재 여부 확인. 없으면 createObjId + mergePartMng INSERT.
|
||||
// (NOTE에 에러 있는 행은 클라이언트가 차단)
|
||||
//
|
||||
// vexplor_rps 차이:
|
||||
// · 운영판은 파일을 서버에 저장 → /partParsingExcelFile.do 가 그 파일을 읽음.
|
||||
// · RPS는 multer memoryStorage 로 받은 Buffer 를 그 자리에서 파싱 (WbsTemplate parseExcel 패턴).
|
||||
// · 결과 그리드는 클라이언트 메모리에만 유지 → 저장 호출 시 다시 검증 한 번 더 수행.
|
||||
// ============================================================
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import { PoolClient } from "pg";
|
||||
import { getPool, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { createObjId } from "../utils/objidUtil";
|
||||
|
||||
// ─── wace 운영판 PARENT_CODE_ID (commCode) ──────────────────
|
||||
const CODE_PARENT_PART_TYPE = "0000062"; // 범주 (PART_TYPE)
|
||||
const CODE_PARENT_ACCTFG = "0900213"; // 계정구분
|
||||
const CODE_PARENT_UNIT_DC = "0001399"; // 단위 (UNIT_DC / UNITMANG_DC)
|
||||
|
||||
// 엑셀 22컬럼 (1행 헤더 — wace JSP colModel 순서)
|
||||
// 0:품번 1:품명 2:재료 3:열처리경도 4:열처리방법 5:표면처리 6:메이커 7:범주 이름
|
||||
// 8:규격 9:계정구분 10:조달구분 11:재고단위 12:관리단위 13:환산수량
|
||||
// 14:LOT구분 15:사용여부 16:검사여부 17:SET품여부 18:의뢰여부 19:개당길이 20:개당소요량 21:비고
|
||||
|
||||
export interface PartExcelRow {
|
||||
NOTE: string;
|
||||
PART_NO: string;
|
||||
PART_NAME: string;
|
||||
MATERIAL: string;
|
||||
HEAT_TREATMENT_HARDNESS: string;
|
||||
HEAT_TREATMENT_METHOD: string;
|
||||
SURFACE_TREATMENT: string;
|
||||
MAKER: string;
|
||||
PART_TYPE: string; // 코드값(`code_id`) — 매핑 실패 시 원본 한글
|
||||
PART_TYPE_NAME?: string; // 화면용 라벨
|
||||
SPEC: string;
|
||||
ACCTFG: string;
|
||||
ACCTFG_NAME?: string;
|
||||
ODRFG: string;
|
||||
ODRFG_NAME?: string;
|
||||
UNIT_DC: string;
|
||||
UNIT_DC_NAME?: string;
|
||||
UNITMANG_DC: string;
|
||||
UNITMANG_DC_NAME?: string;
|
||||
UNITCHNG_NB: string;
|
||||
LOT_FG: string;
|
||||
USE_YN: string;
|
||||
QC_FG: string;
|
||||
SETITEM_FG: string;
|
||||
REQ_FG: string;
|
||||
UNIT_LENGTH: string;
|
||||
UNIT_QTY: string;
|
||||
REMARK: string;
|
||||
}
|
||||
|
||||
function appendNote(r: PartExcelRow, msg: string) {
|
||||
r.NOTE = r.NOTE ? `${r.NOTE} / ${msg}` : msg;
|
||||
}
|
||||
|
||||
function getCell(row: any[], idx: number): string {
|
||||
const v = row?.[idx];
|
||||
if (v === undefined || v === null) return "";
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
// ─── 코드명 → 코드값 매핑 (commCode 1:1) ────────────────────
|
||||
|
||||
async function fetchCodeMap(
|
||||
client: PoolClient,
|
||||
parentCodeId: string
|
||||
): Promise<Map<string, { code_id: string; code_name: string }>> {
|
||||
const r = await client.query(
|
||||
`SELECT CODE_ID AS code_id, CODE_NAME AS code_name
|
||||
FROM COMM_CODE
|
||||
WHERE PARENT_CODE_ID = $1`,
|
||||
[parentCodeId]
|
||||
);
|
||||
const m = new Map<string, { code_id: string; code_name: string }>();
|
||||
for (const row of r.rows) {
|
||||
if (row.code_name) m.set(String(row.code_name).trim().toUpperCase(), row);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function mapKr01(value: string, t: string, f: string, defaultVal: string): string {
|
||||
const v = value.trim();
|
||||
if (!v) return defaultVal;
|
||||
if (v === t) return "1";
|
||||
if (v === f) return "0";
|
||||
return v; // 숫자 입력 시 그대로
|
||||
}
|
||||
function mapOdrfg(value: string): string {
|
||||
const v = value.trim();
|
||||
if (!v) return "";
|
||||
if (v === "구매") return "0";
|
||||
if (v === "생산") return "1";
|
||||
if (v.toUpperCase() === "PHANTOM") return "8";
|
||||
return v;
|
||||
}
|
||||
|
||||
// ─── 1) 파싱 + 검증 ─────────────────────────────────────────
|
||||
|
||||
export async function parseAndValidate(buffer: Buffer): Promise<{
|
||||
rows: PartExcelRow[];
|
||||
hasError: boolean;
|
||||
}> {
|
||||
const wb = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
const raw: any[][] = XLSX.utils.sheet_to_json(sheet, {
|
||||
header: 1, raw: false, defval: "",
|
||||
});
|
||||
|
||||
const result: PartExcelRow[] = [];
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
const [partTypeMap, acctfgMap, unitDcMap] = await Promise.all([
|
||||
fetchCodeMap(client, CODE_PARENT_PART_TYPE),
|
||||
fetchCodeMap(client, CODE_PARENT_ACCTFG),
|
||||
fetchCodeMap(client, CODE_PARENT_UNIT_DC),
|
||||
]);
|
||||
|
||||
const partNoSeenInFile = new Map<string, number>();
|
||||
let emptyRowCnt = 0;
|
||||
|
||||
// wace: rowIndex = 2 부터 (0=안내라벨 "입력", 1=헤더)
|
||||
for (let i = 2; i < raw.length; i++) {
|
||||
const row = raw[i];
|
||||
if (!row) continue;
|
||||
|
||||
const cur: PartExcelRow = {
|
||||
NOTE: "",
|
||||
PART_NO: getCell(row, 0),
|
||||
PART_NAME: getCell(row, 1),
|
||||
MATERIAL: getCell(row, 2),
|
||||
HEAT_TREATMENT_HARDNESS: getCell(row, 3),
|
||||
HEAT_TREATMENT_METHOD: getCell(row, 4),
|
||||
SURFACE_TREATMENT: getCell(row, 5),
|
||||
MAKER: getCell(row, 6),
|
||||
PART_TYPE: "",
|
||||
SPEC: getCell(row, 8),
|
||||
ACCTFG: "",
|
||||
ODRFG: "",
|
||||
UNIT_DC: "",
|
||||
UNITMANG_DC: "",
|
||||
UNITCHNG_NB: getCell(row, 13),
|
||||
LOT_FG: "",
|
||||
USE_YN: "",
|
||||
QC_FG: "",
|
||||
SETITEM_FG: "",
|
||||
REQ_FG: "",
|
||||
UNIT_LENGTH: getCell(row, 19),
|
||||
UNIT_QTY: getCell(row, 20),
|
||||
REMARK: getCell(row, 21),
|
||||
};
|
||||
|
||||
// wace getCellValue 의 emptyColCnt(8 미만)로 행 채택. 단순화: 모든 컬럼 빈값이면 빈 행.
|
||||
const nonEmptyCount =
|
||||
(cur.PART_NO ? 1 : 0) + (cur.PART_NAME ? 1 : 0) + (cur.MATERIAL ? 1 : 0) +
|
||||
(cur.HEAT_TREATMENT_HARDNESS ? 1 : 0) + (cur.HEAT_TREATMENT_METHOD ? 1 : 0) +
|
||||
(cur.SURFACE_TREATMENT ? 1 : 0) + (cur.MAKER ? 1 : 0) +
|
||||
(getCell(row, 7) ? 1 : 0) + (cur.SPEC ? 1 : 0) +
|
||||
(getCell(row, 9) ? 1 : 0) + (getCell(row, 10) ? 1 : 0) +
|
||||
(getCell(row, 11) ? 1 : 0) + (getCell(row, 12) ? 1 : 0) + (cur.UNITCHNG_NB ? 1 : 0) +
|
||||
(getCell(row, 14) ? 1 : 0) + (getCell(row, 15) ? 1 : 0) +
|
||||
(getCell(row, 16) ? 1 : 0) + (getCell(row, 17) ? 1 : 0) +
|
||||
(getCell(row, 18) ? 1 : 0) + (cur.UNIT_LENGTH ? 1 : 0) +
|
||||
(cur.UNIT_QTY ? 1 : 0) + (cur.REMARK ? 1 : 0);
|
||||
|
||||
if (nonEmptyCount === 0) {
|
||||
emptyRowCnt++;
|
||||
if (emptyRowCnt > 3) break; // wace 동일
|
||||
continue;
|
||||
}
|
||||
|
||||
// 품번 필수 + 중복 검사 (DB + 엑셀 내)
|
||||
if (!cur.PART_NO) {
|
||||
appendNote(cur, "필수입력 - 품번");
|
||||
} else {
|
||||
const dbRes = await client.query(
|
||||
`SELECT 1 FROM PART_MNG WHERE PART_NO = $1 LIMIT 1`,
|
||||
[cur.PART_NO]
|
||||
);
|
||||
if ((dbRes.rowCount ?? 0) > 0) {
|
||||
appendNote(cur, "품번중복");
|
||||
}
|
||||
if (partNoSeenInFile.has(cur.PART_NO)) {
|
||||
appendNote(cur, `엑셀 내 품번중복(row ${partNoSeenInFile.get(cur.PART_NO)})`);
|
||||
} else {
|
||||
partNoSeenInFile.set(cur.PART_NO, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cur.PART_NAME) appendNote(cur, "필수입력 - 품명");
|
||||
|
||||
// PART_TYPE (범주 이름) — 코드명 → code_id
|
||||
const partTypeIn = getCell(row, 7);
|
||||
if (partTypeIn) {
|
||||
const hit = partTypeMap.get(partTypeIn.toUpperCase());
|
||||
if (hit) {
|
||||
cur.PART_TYPE = hit.code_id;
|
||||
cur.PART_TYPE_NAME = hit.code_name;
|
||||
} else {
|
||||
cur.PART_TYPE = partTypeIn;
|
||||
appendNote(cur, `범주 이름 확인:${partTypeIn}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ACCTFG (계정구분)
|
||||
const acctfgIn = getCell(row, 9);
|
||||
if (acctfgIn) {
|
||||
if (/^\d+$/.test(acctfgIn)) {
|
||||
cur.ACCTFG = acctfgIn;
|
||||
} else {
|
||||
const hit = acctfgMap.get(acctfgIn.toUpperCase());
|
||||
if (hit) { cur.ACCTFG = hit.code_id; cur.ACCTFG_NAME = hit.code_name; }
|
||||
else cur.ACCTFG = acctfgIn;
|
||||
}
|
||||
}
|
||||
|
||||
// ODRFG (조달구분)
|
||||
const odrfgIn = getCell(row, 10);
|
||||
cur.ODRFG = mapOdrfg(odrfgIn);
|
||||
if (odrfgIn && cur.ODRFG === odrfgIn && !/^[018]$/.test(odrfgIn)) {
|
||||
appendNote(cur, `조달구분 확인:${odrfgIn}`);
|
||||
}
|
||||
cur.ODRFG_NAME = cur.ODRFG === "0" ? "구매"
|
||||
: cur.ODRFG === "1" ? "생산"
|
||||
: cur.ODRFG === "8" ? "Phantom" : odrfgIn;
|
||||
|
||||
// UNIT_DC (재고단위)
|
||||
const unitDcIn = getCell(row, 11);
|
||||
if (unitDcIn) {
|
||||
if (/^\d+$/.test(unitDcIn)) cur.UNIT_DC = unitDcIn;
|
||||
else {
|
||||
const hit = unitDcMap.get(unitDcIn.toUpperCase());
|
||||
if (hit) { cur.UNIT_DC = hit.code_id; cur.UNIT_DC_NAME = hit.code_name; }
|
||||
else cur.UNIT_DC = unitDcIn;
|
||||
}
|
||||
}
|
||||
|
||||
// UNITMANG_DC (관리단위) — 동일 매핑
|
||||
const unitmangIn = getCell(row, 12);
|
||||
if (unitmangIn) {
|
||||
if (/^\d+$/.test(unitmangIn)) cur.UNITMANG_DC = unitmangIn;
|
||||
else {
|
||||
const hit = unitDcMap.get(unitmangIn.toUpperCase());
|
||||
if (hit) { cur.UNITMANG_DC = hit.code_id; cur.UNITMANG_DC_NAME = hit.code_name; }
|
||||
else cur.UNITMANG_DC = unitmangIn;
|
||||
}
|
||||
}
|
||||
|
||||
cur.LOT_FG = mapKr01(getCell(row, 14), "사용", "미사용", "0");
|
||||
cur.USE_YN = mapKr01(getCell(row, 15), "사용", "미사용", "1");
|
||||
cur.QC_FG = mapKr01(getCell(row, 16), "검사", "무검사", "0");
|
||||
cur.SETITEM_FG = mapKr01(getCell(row, 17), "여", "부", "0");
|
||||
cur.REQ_FG = mapKr01(getCell(row, 18), "여", "부", "0");
|
||||
|
||||
result.push(cur);
|
||||
emptyRowCnt = 0;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
const hasError = result.some((r) => r.NOTE);
|
||||
return { rows: result, hasError };
|
||||
}
|
||||
|
||||
// ─── 2) 저장 (mergePartMng 1:1, 신규만 INSERT) ──────────────
|
||||
|
||||
export interface SavePartExcelInput {
|
||||
PART_NO: string;
|
||||
PART_NAME: string;
|
||||
MATERIAL?: string;
|
||||
HEAT_TREATMENT_HARDNESS?: string;
|
||||
HEAT_TREATMENT_METHOD?: string;
|
||||
SURFACE_TREATMENT?: string;
|
||||
MAKER?: string;
|
||||
PART_TYPE?: string;
|
||||
SPEC?: string;
|
||||
ACCTFG?: string;
|
||||
ODRFG?: string;
|
||||
UNIT_DC?: string;
|
||||
UNITMANG_DC?: string;
|
||||
UNITCHNG_NB?: string | number;
|
||||
LOT_FG?: string;
|
||||
USE_YN?: string;
|
||||
QC_FG?: string;
|
||||
SETITEM_FG?: string;
|
||||
REQ_FG?: string;
|
||||
UNIT_LENGTH?: string;
|
||||
UNIT_QTY?: string;
|
||||
REMARK?: string;
|
||||
}
|
||||
|
||||
export async function saveExcelRows(
|
||||
userId: string,
|
||||
rows: SavePartExcelInput[]
|
||||
): Promise<{ inserted: number; skipped: number; skippedPartNos: string[] }> {
|
||||
if (!rows || rows.length === 0) return { inserted: 0, skipped: 0, skippedPartNos: [] };
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
const skippedPartNos: string[] = [];
|
||||
|
||||
await transaction(async (client: PoolClient) => {
|
||||
for (const r of rows) {
|
||||
const partNo = (r.PART_NO ?? "").trim();
|
||||
if (!partNo) { skipped++; continue; }
|
||||
|
||||
// wace partMng.getPartObjid — IS_LAST='1' 인 동일 part_no 가 있으면 INSERT 스킵
|
||||
const existRes = await client.query(
|
||||
`SELECT OBJID FROM PART_MNG WHERE PART_NO = $1 AND IS_LAST = '1' LIMIT 1`,
|
||||
[partNo]
|
||||
);
|
||||
if ((existRes.rowCount ?? 0) > 0) {
|
||||
skipped++;
|
||||
skippedPartNos.push(partNo);
|
||||
continue;
|
||||
}
|
||||
|
||||
const objid = createObjId();
|
||||
// wace mergePartMng 1:1 (엑셀 임포트가 채우는 컬럼만)
|
||||
await client.query(
|
||||
`INSERT INTO PART_MNG (
|
||||
OBJID, PART_NO, PART_NAME, SPEC, MATERIAL, PART_TYPE, REMARK,
|
||||
STATUS, REG_DATE, WRITER, IS_LAST,
|
||||
MAKER,
|
||||
HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT,
|
||||
ACCTFG, ODRFG, UNIT_DC, UNITMANG_DC, UNITCHNG_NB,
|
||||
LOT_FG, USE_YN, QC_FG, SETITEM_FG, REQ_FG,
|
||||
UNIT_LENGTH, UNIT_QTY
|
||||
) VALUES (
|
||||
$1::numeric, $2, $3, $4, $5, $6, $7,
|
||||
'create', NOW(), $8, '1',
|
||||
$9,
|
||||
$10, $11, $12,
|
||||
$13, $14, $15, $16,
|
||||
CASE WHEN $17 = '' OR $17 IS NULL THEN NULL ELSE $17::numeric END,
|
||||
COALESCE($18, '0'), COALESCE($19, '1'), COALESCE($20, '0'),
|
||||
COALESCE($21, '0'), COALESCE($22, '0'),
|
||||
$23, $24
|
||||
)`,
|
||||
[
|
||||
objid, partNo, r.PART_NAME ?? "", r.SPEC ?? null, r.MATERIAL ?? null,
|
||||
r.PART_TYPE ?? null, r.REMARK ?? null,
|
||||
userId,
|
||||
r.MAKER ?? null,
|
||||
r.HEAT_TREATMENT_HARDNESS ?? null, r.HEAT_TREATMENT_METHOD ?? null, r.SURFACE_TREATMENT ?? null,
|
||||
r.ACCTFG ?? null, r.ODRFG ?? null, r.UNIT_DC ?? null, r.UNITMANG_DC ?? null,
|
||||
r.UNITCHNG_NB === undefined || r.UNITCHNG_NB === null ? "" : String(r.UNITCHNG_NB),
|
||||
r.LOT_FG ?? null, r.USE_YN ?? null, r.QC_FG ?? null, r.SETITEM_FG ?? null, r.REQ_FG ?? null,
|
||||
r.UNIT_LENGTH ?? null, r.UNIT_QTY ?? null,
|
||||
]
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("PART 엑셀 임포트 저장 완료", { userId, inserted, skipped });
|
||||
return { inserted, skipped, skippedPartNos };
|
||||
}
|
||||
@@ -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<PartListFilter>,
|
||||
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<string> {
|
||||
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<number> {
|
||||
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<string, string> }> {
|
||||
if (!objids || objids.length === 0) return { deployed: 0, eo_nos: {} };
|
||||
|
||||
const eoNos: Record<string, string> = {};
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
`;
|
||||
@@ -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, "")));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <partMngBaseSimple> 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 <partMngBaseSimple> 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 <partMngBaseSimple> 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<objid, eo_no> }`
|
||||
|
||||
### 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 우선 `<Input>` 2개로 시작, 추후 보강)
|
||||
|
||||
### 4.3 액션 버튼 (각 page 상단)
|
||||
|
||||
**M1**: 등록 · 수정 · 삭제 · 확정 · 조회
|
||||
**M2**: 등록 · 수정 · 삭제 · 조회 (도면연동/ERP업로드/Excel은 본 PR 제외)
|
||||
|
||||
### 4.4 PartFormDialog (신규/수정 통합)
|
||||
|
||||
- mode: `'create' | 'edit'`
|
||||
- 38 필드 — `<Input>` + `<CommCodeSelect>` 조합
|
||||
- 검증: 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)
|
||||
@@ -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()` 한 번 |
|
||||
@@ -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 메뉴는 변경리스트 한 화면 — 필터 단순화 |
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 만 일어남.
|
||||
@@ -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
|
||||
-- ============================================================
|
||||
@@ -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;
|
||||
@@ -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<EoHistoryRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<EoHistoryListFilter>(EMPTY_FILTER);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailObjid, setDetailObjid] = useState<string | null>(null);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<EoHistoryListFilter>) => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<Field label="년도">
|
||||
<select className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.Year ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}>
|
||||
<option value="">전체</option>
|
||||
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="프로젝트 OBJID">
|
||||
<Input value={filter.contract_objid ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, contract_objid: e.target.value })}
|
||||
placeholder="project_mgmt.objid" />
|
||||
</Field>
|
||||
<Field label="품번">
|
||||
<Input value={filter.part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })}
|
||||
placeholder="part_no LIKE" />
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input value={filter.part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })}
|
||||
placeholder="part_name LIKE" />
|
||||
</Field>
|
||||
|
||||
<Field label="EO Date 시작">
|
||||
<Input type="date" value={filter.eo_start_date ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, eo_start_date: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="EO Date 종료">
|
||||
<Input type="date" value={filter.eo_end_date ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, eo_end_date: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="PART구분">
|
||||
<CommCodeSelect groupId={GROUP_PART_TYPE}
|
||||
value={filter.part_type ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
|
||||
</Field>
|
||||
<Field label="EO구분 / EO사유 (code_id)">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input value={filter.change_type ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, change_type: e.target.value })}
|
||||
placeholder="EO구분 code_id" />
|
||||
<Input value={filter.change_option ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, change_option: e.target.value })}
|
||||
placeholder="EO사유 code_id" />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">총 {total.toLocaleString()}건 (read-only)</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
emptyMessage="설계변경 이력이 없습니다."
|
||||
gridId="development-eo-history"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartHisDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
objid={detailObjid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<BomReportRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<BomReportListFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [statusObjid, setStatusObjid] = useState<string | null>(null);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
const [treeReport, setTreeReport] = useState<BomReportRow | null>(null);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<BomReportListFilter>) => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<Field label="제품구분">
|
||||
<CommCodeSelect
|
||||
groupId={PRODUCT_GROUP}
|
||||
value={filter.product_cd ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, product_cd: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="상태">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.status ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, status: e.target.value })}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{STATUS_OPTIONS.map((o) =>
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="품번">
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">총 {total.toLocaleString()}건</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setExcelOpen(true)}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">E-BOM 등록(Excel)</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleStatusChange}
|
||||
disabled={checkedIds.length !== 1}>
|
||||
<Settings className="h-4 w-4" /><span className="ml-1">상태변경</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||
disabled={checkedIds.length === 0}>
|
||||
<Trash2 className="h-4 w-4" /><span className="ml-1">삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="등록된 E-BOM이 없습니다."
|
||||
gridId="development-ebom-regist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BomReportStatusDialog
|
||||
open={statusOpen}
|
||||
onOpenChange={setStatusOpen}
|
||||
objid={statusObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<BomReportExcelImportDialog
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
initialProductCd={filter.product_cd ?? ""}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<BomReportTreeDialog
|
||||
open={treeOpen}
|
||||
onOpenChange={setTreeOpen}
|
||||
bomReport={treeReport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<BomTreeFilter>(EMPTY_FILTER);
|
||||
const [direction, setDirection] = useState<Direction>("ascending");
|
||||
const [rows, setRows] = useState<BomTreeRow[]>([]);
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<Field label="프로젝트 OBJID">
|
||||
<Input value={filter.project_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, project_name: e.target.value })}
|
||||
placeholder="project_mgmt.objid" />
|
||||
</Field>
|
||||
<Field label="UNIT_CODE">
|
||||
<Input value={filter.unit_code ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, unit_code: e.target.value })}
|
||||
placeholder="pms_wbs_task.objid" />
|
||||
</Field>
|
||||
<Field label="품번">
|
||||
<Input value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="part_no LIKE" />
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="part_name LIKE" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => runQuery("ascending")} disabled={loading}
|
||||
variant={direction === "ascending" ? "default" : "secondary"}>
|
||||
{loading && direction === "ascending"
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <ChevronsRight className="h-4 w-4" />}
|
||||
<span className="ml-1">정전개 조회</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => runQuery("descending")} disabled={loading}
|
||||
variant={direction === "descending" ? "default" : "secondary"}>
|
||||
{loading && direction === "descending"
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <ChevronsLeft className="h-4 w-4" />}
|
||||
<span className="ml-1">역전개 조회</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadExcel("ascending")} disabled={exporting}>
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1">정전개 엑셀</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => downloadExcel("descending")} disabled={exporting}>
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1">역전개 엑셀</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{direction === "descending" && (
|
||||
<div className="mt-2 text-xs text-amber-600">
|
||||
역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={gridData}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 BOM이 없습니다."
|
||||
gridId={`development-ebom-search-${direction}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PartRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<PartListFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [formObjid, setFormObjid] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailObjid, setDetailObjid] = useState<string | null>(null);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
|
||||
setLoading(true);
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */}
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번</Label>
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명</Label>
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="default" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4" /><span className="ml-1">등록</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleEdit}
|
||||
disabled={checkedIds.length !== 1}>
|
||||
<Pencil className="h-4 w-4" /><span className="ml-1">수정</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||
disabled={checkedIds.length === 0}>
|
||||
<Trash2 className="h-4 w-4" /><span className="ml-1">삭제</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDeploy}
|
||||
disabled={checkedIds.length === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
<CheckSquare className="h-4 w-4" /><span className="ml-1">확정</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
총 {total.toLocaleString()}건 (M1: status ≠ 'release')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="조건에 맞는 PART가 없습니다."
|
||||
gridId="development-part-regist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
mode={formMode}
|
||||
editObjid={formObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<PartDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
objid={detailObjid}
|
||||
onEdit={handleEditFromDetail}
|
||||
/>
|
||||
<PartExcelImportDialog
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PartRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<PartListFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [formObjid, setFormObjid] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailObjid, setDetailObjid] = useState<string | null>(null);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<PartListFilter>) => {
|
||||
setLoading(true);
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번</Label>
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명</Label>
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="default" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4" /><span className="ml-1">등록</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleEdit}
|
||||
disabled={checkedIds.length !== 1}>
|
||||
<Pencil className="h-4 w-4" /><span className="ml-1">수정</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||
disabled={checkedIds.length === 0}>
|
||||
<Trash2 className="h-4 w-4" /><span className="ml-1">삭제</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
총 {total.toLocaleString()}건 (M2: status = 'release')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="조건에 맞는 PART가 없습니다."
|
||||
gridId="development-part-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
mode={formMode}
|
||||
editObjid={formObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
<PartDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
objid={detailObjid}
|
||||
onEdit={handleEditFromDetail}
|
||||
/>
|
||||
<PartExcelImportDialog
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = { "4": "반제품", "7": "비용" };
|
||||
const ODRFG_LABEL: Record<string, string> = { "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<HTMLInputElement>(null);
|
||||
|
||||
const [productCd, setProductCd] = useState<string>("");
|
||||
const [bomPartNo, setBomPartNo] = useState<string>("");
|
||||
const [bomPartName, setBomPartName] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
|
||||
const [copyOptions, setCopyOptions] = useState<BomCopySourceRow[]>([]);
|
||||
const [copySelect, setCopySelect] = useState<string>("");
|
||||
|
||||
const [rows, setRows] = useState<BomCsvRow[]>([]);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [encoding, setEncoding] = useState<string>("");
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setProductCd(initialProductCd ?? "");
|
||||
setBomPartNo("");
|
||||
setBomPartName("");
|
||||
setVersion("");
|
||||
setRows([]);
|
||||
setHasError(false);
|
||||
setFileName("");
|
||||
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<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
e.target.value = "";
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!copySelect) { toast.error("복사할 BOM을 선택하세요."); return; }
|
||||
setCopying(true);
|
||||
try {
|
||||
const copied = await devBomApi.excelCopy(copySelect);
|
||||
setRows(copied);
|
||||
setHasError(false);
|
||||
const first = copied.find((r) => !r.PARENT_PART_NO);
|
||||
if (first) applyFirstLevelToHeader({ part_no: first.PART_NO, part_name: first.PART_NAME });
|
||||
toast.success(`BOM 데이터 ${copied.length}건 불러왔습니다.`);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 복사 실패");
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!productCd) { toast.error("제품구분을 선택해 주세요."); return; }
|
||||
if (!bomPartNo) { toast.error("품번을 입력해 주세요."); return; }
|
||||
if (!bomPartName){ toast.error("품명을 입력해 주세요."); return; }
|
||||
if (hasError) {
|
||||
toast.error("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 (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PART 및 구조등록 CSV upload</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-4 gap-3 border-b pb-3">
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">제품구분 *</Label>
|
||||
<CommCodeSelect
|
||||
groupId={PRODUCT_GROUP}
|
||||
value={productCd}
|
||||
onValueChange={setProductCd}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번 * (1레벨 자동)</Label>
|
||||
<Input value={bomPartNo} readOnly placeholder="CSV 1레벨에서 자동 채움" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명 * (1레벨 자동)</Label>
|
||||
<Input value={bomPartName} readOnly placeholder="CSV 1레벨에서 자동 채움" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="REV 등" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* E-BOM 복사 + 액션 버튼 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">E-BOM 복사</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border bg-background px-2 text-sm min-w-[280px]"
|
||||
value={copySelect}
|
||||
onChange={(e) => setCopySelect(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{copyOptions.map((o) => (
|
||||
<option key={o.objid} value={o.objid}>
|
||||
{o.part_no} / {o.part_name} {o.revision ? `(v${o.revision})` : ""} - {o.regdate ?? ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} disabled={copying || !copySelect}>
|
||||
{copying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Copy className="h-4 w-4" />}
|
||||
<span className="ml-1">복사</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={TEMPLATE_DOWNLOAD_URL} download>
|
||||
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
|
||||
<Upload className="h-4 w-4" /><span className="ml-1">CSV 파일 선택</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
{rows.length > 0 && (
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setRows([]); setHasError(false); setFileName(""); setEncoding(""); }}>
|
||||
<FileX className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-3">
|
||||
{fileName && <span className="truncate max-w-[400px]">{fileName}</span>}
|
||||
{encoding && <span>인코딩: <b>{encoding}</b></span>}
|
||||
<span>총 {rows.length}건</span>
|
||||
{errorCount > 0 && <span className="text-destructive font-semibold">에러 {errorCount}건</span>}
|
||||
</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
{rows.length === 0 && !parsing && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded p-8 text-center transition-colors cursor-pointer",
|
||||
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-9 w-9 mx-auto text-muted-foreground mb-2" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Drag & Drop 또는 클릭하여 CSV 템플릿 업로드 (.csv)
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
컬럼: 수준 / 품번 / 품명 / 수량 / 항목수량 / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 공급업체 / 범주이름
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsing && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2">파싱 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 그리드 */}
|
||||
{rows.length > 0 && !parsing && (
|
||||
<div className="flex-1 min-h-0 overflow-auto border rounded">
|
||||
<table className="text-xs border-collapse w-max min-w-full">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="border px-2 py-1 w-[40px] text-center">#</th>
|
||||
{COLUMNS.map((c) => (
|
||||
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
|
||||
<td className="border px-2 py-1 text-center">{i + 1}</td>
|
||||
{COLUMNS.map((c) => {
|
||||
const value = displayValue(r, c);
|
||||
const isNote = c.key === "NOTE";
|
||||
return (
|
||||
<td
|
||||
key={c.key as string}
|
||||
className={cn(
|
||||
"border px-2 py-1 whitespace-nowrap",
|
||||
c.align === "right" && "text-right",
|
||||
c.align === "center" && "text-center",
|
||||
isNote && r.NOTE && "text-destructive font-semibold"
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleDialogChange(false)}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || hasError}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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<BomReportRow | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-BOM 상태 변경</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1">
|
||||
<div><span className="text-muted-foreground">제품구분:</span> {row.product_name ?? row.product_cd ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">품번:</span> {row.part_no ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">품명:</span> {row.part_name ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">현재상태:</span> {row.status_title ?? row.status ?? "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">변경 상태 *</Label>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{STATUS_OPTIONS.map((o) =>
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="예: RE, A, B..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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<BomTreeFullRow[]>([]);
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">BOM 구조 조회</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 헤더 메타 */}
|
||||
{bomReport && (
|
||||
<div className="grid grid-cols-4 gap-x-6 gap-y-1.5 border-b px-4 py-3 text-xs">
|
||||
<MetaRow label="제품구분" value={bomReport.product_name ?? bomReport.product_cd} />
|
||||
<MetaRow label="품번" value={bomReport.part_no} />
|
||||
<MetaRow label="품명" value={bomReport.part_name} />
|
||||
<MetaRow label="Version" value={bomReport.revision} />
|
||||
<MetaRow label="상태" value={bomReport.status_title} />
|
||||
<MetaRow label="등록자" value={bomReport.dept_user_name} />
|
||||
<MetaRow label="등록일" value={bomReport.reg_date} />
|
||||
<MetaRow label="확정일" value={bomReport.deploy_date} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleExcel} disabled={exporting || rows.length === 0}>
|
||||
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
|
||||
<span className="ml-1">엑셀 다운로드</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="text-xs border-collapse w-max min-w-full">
|
||||
<thead className="bg-yellow-100 dark:bg-yellow-900/30 sticky top-0">
|
||||
<tr>
|
||||
{levelHeaders.map((i) => (
|
||||
<th key={`l${i}`} className="border px-2 py-1 w-[36px] text-center font-bold">{i}</th>
|
||||
))}
|
||||
<th className="border px-2 py-1 min-w-[150px] text-left">품번</th>
|
||||
<th className="border px-2 py-1 min-w-[180px] text-left">품명</th>
|
||||
<th className="border px-2 py-1 min-w-[60px] text-right">수량</th>
|
||||
<th className="border px-2 py-1 min-w-[70px] text-right">항목수량</th>
|
||||
<th className="border px-2 py-1 min-w-[40px] text-center">3D</th>
|
||||
<th className="border px-2 py-1 min-w-[40px] text-center">2D</th>
|
||||
<th className="border px-2 py-1 min-w-[40px] text-center">PDF</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">재료</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">열처리경도</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">열처리방법</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-left">표면처리</th>
|
||||
<th className="border px-2 py-1 min-w-[110px] text-left">메이커</th>
|
||||
<th className="border px-2 py-1 min-w-[100px] text-center">범주 이름</th>
|
||||
<th className="border px-2 py-1 min-w-[130px] text-left">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={levelHeaders.length + 14} className="py-8 text-center text-muted-foreground">
|
||||
BOM 구조가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((r, idx) => {
|
||||
const lev = Number(r.lev ?? 1);
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-muted/30">
|
||||
{levelHeaders.map((i) => (
|
||||
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lev && "font-bold")}>
|
||||
{i === lev ? "*" : ""}
|
||||
</td>
|
||||
))}
|
||||
<td className="border px-2 py-0.5 whitespace-nowrap">{r.pm_part_no}</td>
|
||||
<td className="border px-2 py-0.5">{r.pm_part_name}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{r.qty}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{r.p_qty}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}</td>
|
||||
<td className="border px-2 py-0.5">{r.material}</td>
|
||||
<td className="border px-2 py-0.5">{r.heat_treatment_hardness}</td>
|
||||
<td className="border px-2 py-0.5">{r.heat_treatment_method}</td>
|
||||
<td className="border px-2 py-0.5">{r.surface_treatment}</td>
|
||||
<td className="border px-2 py-0.5">{r.maker}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.part_type_title}</td>
|
||||
<td className="border px-2 py-0.5">{r.remark}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: any }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-muted-foreground w-[60px] shrink-0">{label}</span>
|
||||
<span className="font-medium">{value != null && value !== "" ? value : "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = { "0": "구매", "1": "생산", "8": "Phantom" };
|
||||
const LABEL_LOT_FG: Record<string, string> = { "0": "미사용", "1": "사용" };
|
||||
const LABEL_USE_YN: Record<string, string> = { "0": "미사용", "1": "사용" };
|
||||
const LABEL_QC_FG: Record<string, string> = { "0": "무검사", "1": "검사" };
|
||||
const LABEL_YESNO: Record<string, string> = { "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<PartRow | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">품목 상세</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *) */}
|
||||
<table className="w-full border-collapse text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "25%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<Tr>
|
||||
<Th>품번</Th><Td colSpan={2}><Ro>{row.part_no}</Ro></Td>
|
||||
<Th>품명</Th><Td><Ro>{row.part_name}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>재료</Th><Td colSpan={2}><Ro>{row.material}</Ro></Td>
|
||||
<Th>열처리경도</Th><Td><Ro>{row.heat_treatment_hardness}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>열처리방법</Th><Td colSpan={2}><Ro>{row.heat_treatment_method}</Ro></Td>
|
||||
<Th>표면처리</Th><Td><Ro>{row.surface_treatment}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>메이커</Th><Td colSpan={2}><Ro>{row.maker}</Ro></Td>
|
||||
<Th>범주 이름</Th><Td><Ro>{row.part_type_title}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>규격</Th><Td colSpan={4}><Ro>{row.spec}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>계정구분</Th><Td colSpan={2}><Ro>{row.acctfg_nm}</Ro></Td>
|
||||
<Th>조달구분</Th><Td><Ro>{LABEL_ODRFG[row.odrfg ?? ""] ?? row.odrfg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>재고단위</Th><Td colSpan={2}><Ro>{row.unit_dc_nm}</Ro></Td>
|
||||
<Th>관리단위</Th><Td><Ro>{row.unitmang_dc_nm}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>환산수량</Th>
|
||||
<Td colSpan={2}><Ro align="right">{row.unitchng_nb != null && row.unitchng_nb !== "" ? String(row.unitchng_nb) : ""}</Ro></Td>
|
||||
<Th>LOT구분</Th><Td><Ro>{LABEL_LOT_FG[row.lot_fg ?? ""] ?? row.lot_fg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>사용여부</Th><Td colSpan={2}><Ro>{LABEL_USE_YN[row.use_yn ?? ""] ?? row.use_yn_nm ?? ""}</Ro></Td>
|
||||
<Th>검사여부</Th><Td><Ro>{LABEL_QC_FG[row.qc_fg ?? ""] ?? row.qc_fg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>SET품여부</Th><Td colSpan={2}><Ro>{LABEL_YESNO[row.setitem_fg ?? ""] ?? row.setitem_fg_nm ?? ""}</Ro></Td>
|
||||
<Th>의뢰여부</Th><Td><Ro>{LABEL_YESNO[row.req_fg ?? ""] ?? row.req_fg_nm ?? ""}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>개당길이</Th><Td colSpan={2}><Ro align="right">{row.unit_length}</Ro></Td>
|
||||
<Th>개당소요량</Th><Td><Ro align="right">{row.unit_qty}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>비고</Th><Td colSpan={4}><Ro>{row.remark}</Ro></Td>
|
||||
</Tr>
|
||||
|
||||
{/* 부속 — wace detail 만 표시 */}
|
||||
<Tr>
|
||||
<Th>EO No</Th><Td colSpan={2}><Ro>{row.eo_no}</Ro></Td>
|
||||
<Th>EO Date</Th><Td><Ro>{row.eo_date}</Ro></Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th>EO구분</Th><Td colSpan={2}><Ro>{row.change_type}</Ro></Td>
|
||||
<Th>EO사유</Th><Td><Ro>{row.change_option_name ?? row.change_option}</Ro></Td>
|
||||
</Tr>
|
||||
|
||||
{/* CAD Data */}
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
||||
CAD Data
|
||||
</th>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="3D" count={Number(row.cu01_cnt ?? 0)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="2D(Drawing)" count={Number(row.cu02_cnt ?? 0)} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="2D(PDF)" count={Number(row.cu03_cnt ?? 0)} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
{row && onEdit && (
|
||||
<Button onClick={() => onEdit(row.objid)}>
|
||||
<Pencil className="h-4 w-4" /><span className="ml-1">수정</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 보조 컴포넌트 ──────────────────────────────────────────
|
||||
|
||||
function Tr({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>;
|
||||
}
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-middle font-medium">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) {
|
||||
return <td className="border px-3 py-1.5" colSpan={colSpan}>{children}</td>;
|
||||
}
|
||||
|
||||
// 운영판 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 (
|
||||
<div className={cn(
|
||||
"min-h-9 rounded border bg-muted/30 px-2 py-1.5",
|
||||
cls,
|
||||
)}>
|
||||
{empty ? <span className="text-muted-foreground">—</span> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CadCount({ label, count }: { label: string; count: number }) {
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<span>{label} 첨부 {count.toLocaleString()}건</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-3 text-center text-xs">
|
||||
첨부된 {label} 파일 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 PART Excel Import 다이얼로그
|
||||
// wace partMng/openPartExcelImportPopUp.jsp 1:1
|
||||
// - Template Download / Drag & Drop / 파일선택 → 백엔드 파싱 + 검증
|
||||
// - 검증 그리드 22컬럼 + NOTE (에러는 빨강) — wace expenseDetailGrid 1:1
|
||||
// - NOTE 있는 행이 하나라도 있으면 저장 차단 (wace fn_save 1:1)
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Upload, Save, Loader2, FileX } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devPartApi, PartExcelRow } from "@/lib/api/devPart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TEMPLATE_DOWNLOAD_URL = "/templates/PART_EXCEL_IMPORT_TEMPLATE.xlsx";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: keyof PartExcelRow;
|
||||
label: string;
|
||||
width: string;
|
||||
align?: "left" | "center" | "right";
|
||||
showNameFor?: keyof PartExcelRow;
|
||||
}
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{ key: "NOTE", label: "결과", width: "min-w-[200px]", align: "left" },
|
||||
{ key: "PART_NO", label: "품번", width: "min-w-[140px]", align: "center" },
|
||||
{ key: "PART_NAME", label: "품명", width: "min-w-[200px]", align: "left" },
|
||||
{ key: "MATERIAL", label: "재료", width: "min-w-[100px]" },
|
||||
{ key: "HEAT_TREATMENT_HARDNESS", label: "열처리경도", width: "min-w-[110px]" },
|
||||
{ key: "HEAT_TREATMENT_METHOD", label: "열처리방법", width: "min-w-[110px]" },
|
||||
{ key: "SURFACE_TREATMENT", label: "표면처리", width: "min-w-[100px]" },
|
||||
{ key: "MAKER", label: "메이커", width: "min-w-[110px]" },
|
||||
{ key: "PART_TYPE", label: "범주", width: "min-w-[90px]", align: "center", showNameFor: "PART_TYPE_NAME" },
|
||||
{ key: "SPEC", label: "규격", width: "min-w-[100px]" },
|
||||
{ key: "ACCTFG", label: "계정구분", width: "min-w-[90px]", align: "center", showNameFor: "ACCTFG_NAME" },
|
||||
{ key: "ODRFG", label: "조달구분", width: "min-w-[90px]", align: "center", showNameFor: "ODRFG_NAME" },
|
||||
{ key: "UNIT_DC", label: "재고단위", width: "min-w-[80px]", align: "center", showNameFor: "UNIT_DC_NAME" },
|
||||
{ key: "UNITMANG_DC", label: "관리단위", width: "min-w-[80px]", align: "center", showNameFor: "UNITMANG_DC_NAME" },
|
||||
{ key: "UNITCHNG_NB", label: "환산수량", width: "min-w-[80px]", align: "right" },
|
||||
{ key: "LOT_FG", label: "LOT구분", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "USE_YN", label: "사용여부", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "QC_FG", label: "검사여부", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "SETITEM_FG", label: "SET품여부", width: "min-w-[90px]", align: "center" },
|
||||
{ key: "REQ_FG", label: "의뢰여부", width: "min-w-[80px]", align: "center" },
|
||||
{ key: "UNIT_LENGTH", label: "개당길이", width: "min-w-[80px]", align: "right" },
|
||||
{ key: "UNIT_QTY", label: "개당소요량", width: "min-w-[90px]", align: "right" },
|
||||
{ key: "REMARK", label: "비고", width: "min-w-[130px]", align: "left" },
|
||||
];
|
||||
|
||||
const LABEL_LOT = { "0": "미사용", "1": "사용" } as Record<string, string>;
|
||||
const LABEL_USE = { "0": "미사용", "1": "사용" } as Record<string, string>;
|
||||
const LABEL_QC = { "0": "무검사", "1": "검사" } as Record<string, string>;
|
||||
const LABEL_YN = { "0": "부", "1": "여" } as Record<string, string>;
|
||||
|
||||
function displayValue(r: PartExcelRow, col: Column): string {
|
||||
if (col.showNameFor) {
|
||||
const name = r[col.showNameFor];
|
||||
if (name) return String(name);
|
||||
return String(r[col.key] ?? "");
|
||||
}
|
||||
const v = String(r[col.key] ?? "");
|
||||
if (col.key === "LOT_FG") return LABEL_LOT[v] ?? v;
|
||||
if (col.key === "USE_YN") return LABEL_USE[v] ?? v;
|
||||
if (col.key === "QC_FG") return LABEL_QC[v] ?? v;
|
||||
if (col.key === "SETITEM_FG" || col.key === "REQ_FG") return LABEL_YN[v] ?? v;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function PartExcelImportDialog({ open, onOpenChange, onSaved }: Props) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [parsedRows, setParsedRows] = useState<PartExcelRow[]>([]);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setParsedRows([]);
|
||||
setHasError(false);
|
||||
setFileName("");
|
||||
}, []);
|
||||
|
||||
const handleDialogChange = (v: boolean) => {
|
||||
if (!v) reset();
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const parseFile = useCallback(async (file: File) => {
|
||||
if (!/\.xlsx?$/i.test(file.name)) {
|
||||
toast.error("xlsx 또는 xls 파일만 업로드 가능합니다.");
|
||||
return;
|
||||
}
|
||||
setParsing(true);
|
||||
setFileName(file.name);
|
||||
try {
|
||||
const data = await devPartApi.excelParse(file);
|
||||
setParsedRows(data.rows ?? []);
|
||||
setHasError(!!data.hasError);
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
toast.warning("파싱된 데이터가 없습니다. 템플릿 형식을 확인해 주세요.");
|
||||
} else if (data.hasError) {
|
||||
toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요.");
|
||||
} else {
|
||||
toast.success(`${data.rows.length}건 파싱 완료`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패");
|
||||
reset();
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
}, [reset]);
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
e.target.value = "";
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) parseFile(f);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (parsedRows.length === 0) { toast.error("저장할 데이터가 없습니다."); return; }
|
||||
if (hasError) {
|
||||
toast.error("엑셀파일 로딩결과가 유효하지 않습니다. 결과메세지를 확인해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("저장 하시겠습니까?")) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await devPartApi.excelSave(parsedRows);
|
||||
toast.success(`${res.inserted}건이 저장되었습니다.${res.skipped > 0 ? ` (중복 ${res.skipped}건 건너뜀)` : ""}`);
|
||||
onSaved();
|
||||
handleDialogChange(false);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorCount = useMemo(() => parsedRows.filter((r) => r.NOTE).length, [parsedRows]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent className="max-w-[1500px] w-[97vw] max-h-[92vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PART 등록 Excel upload</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={TEMPLATE_DOWNLOAD_URL} download>
|
||||
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={parsing}>
|
||||
<Upload className="h-4 w-4" /><span className="ml-1">파일 선택</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
{fileName && (
|
||||
<span className="text-sm text-muted-foreground ml-2 truncate max-w-[300px]">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{parsedRows.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
<FileX className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
총 {parsedRows.length}건
|
||||
{errorCount > 0 && <span className="ml-2 text-destructive">에러 {errorCount}건</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone — 파싱 전에만 노출 */}
|
||||
{parsedRows.length === 0 && !parsing && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded p-10 text-center transition-colors cursor-pointer",
|
||||
dragOver ? "border-primary bg-primary/5" : "border-muted-foreground/30"
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Drag & Drop 또는 클릭하여 엑셀 템플릿 업로드 (.xlsx, .xls)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsing && (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" /><span className="ml-2">파싱 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결과 그리드 */}
|
||||
{parsedRows.length > 0 && !parsing && (
|
||||
<div className="flex-1 min-h-0 overflow-auto border rounded">
|
||||
<table className="text-xs border-collapse w-max min-w-full">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
<th className="border px-2 py-1 w-[40px] text-center">#</th>
|
||||
{COLUMNS.map((c) => (
|
||||
<th key={c.key as string} className={cn("border px-2 py-1", c.width)}>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedRows.map((r, i) => (
|
||||
<tr key={i} className={r.NOTE ? "bg-destructive/5" : ""}>
|
||||
<td className="border px-2 py-1 text-center">{i + 1}</td>
|
||||
{COLUMNS.map((c) => {
|
||||
const value = displayValue(r, c);
|
||||
const isNote = c.key === "NOTE";
|
||||
return (
|
||||
<td
|
||||
key={c.key as string}
|
||||
className={cn(
|
||||
"border px-2 py-1 whitespace-nowrap",
|
||||
c.align === "right" && "text-right",
|
||||
c.align === "center" && "text-center",
|
||||
isNote && r.NOTE && "text-destructive font-semibold"
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleDialogChange(false)}>닫기</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || parsedRows.length === 0 || hasError}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<FormState>(EMPTY_FORM);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof FormState>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">{titleText}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* 운영판 colgroup 1:1 (12% / 12% / 25% / 12% / *)
|
||||
각 행 첫 input 은 colSpan=2 로 양쪽 input 비율 맞춤 */}
|
||||
<table className="w-full border-collapse text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col style={{ width: "25%" }} />
|
||||
<col style={{ width: "12%" }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{/* ① */}
|
||||
<Tr>
|
||||
<Th>품번</Th>
|
||||
<Td colSpan={2}><Input value={form.part_no} disabled={isEdit}
|
||||
onChange={(e) => setField("part_no", e.target.value)} /></Td>
|
||||
<Th>품명</Th>
|
||||
<Td><Input value={form.part_name}
|
||||
onChange={(e) => setField("part_name", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ② */}
|
||||
<Tr>
|
||||
<Th>재료</Th>
|
||||
<Td colSpan={2}><Input value={form.material}
|
||||
onChange={(e) => setField("material", e.target.value)} /></Td>
|
||||
<Th>열처리경도</Th>
|
||||
<Td><Input value={form.heat_treatment_hardness}
|
||||
onChange={(e) => setField("heat_treatment_hardness", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ③ */}
|
||||
<Tr>
|
||||
<Th>열처리방법</Th>
|
||||
<Td colSpan={2}><Input value={form.heat_treatment_method}
|
||||
onChange={(e) => setField("heat_treatment_method", e.target.value)} /></Td>
|
||||
<Th>표면처리</Th>
|
||||
<Td><Input value={form.surface_treatment}
|
||||
onChange={(e) => setField("surface_treatment", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ④ */}
|
||||
<Tr>
|
||||
<Th>메이커</Th>
|
||||
<Td colSpan={2}><Input value={form.maker}
|
||||
onChange={(e) => setField("maker", e.target.value)} /></Td>
|
||||
<Th>범주이름</Th>
|
||||
<Td>
|
||||
<CommCodeSelect groupId={GROUP_PART_TYPE} withAll={false}
|
||||
value={form.part_type}
|
||||
onValueChange={(v) => setField("part_type", v)} />
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑤ 규격 (colspan=4 — 운영판 colspan=4 1:1) */}
|
||||
<Tr>
|
||||
<Th>규격</Th>
|
||||
<Td colSpan={4}><Input value={form.spec} maxLength={100}
|
||||
onChange={(e) => setField("spec", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑥ */}
|
||||
<Tr>
|
||||
<Th>계정구분<Req /></Th>
|
||||
<Td colSpan={2}>
|
||||
<CommCodeSelect groupId={GROUP_ACCTFG} withAll={false}
|
||||
value={form.acctfg}
|
||||
onValueChange={(v) => setField("acctfg", v)} />
|
||||
</Td>
|
||||
<Th>조달구분<Req /></Th>
|
||||
<Td><BasicSelect value={form.odrfg} options={OPT_ODRFG}
|
||||
onChange={(v) => setField("odrfg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑦ */}
|
||||
<Tr>
|
||||
<Th>재고단위<Req /></Th>
|
||||
<Td colSpan={2}>
|
||||
<CommCodeSelect groupId={GROUP_UNIT_DC} withAll={false}
|
||||
value={form.unit_dc}
|
||||
onValueChange={(v) => setField("unit_dc", v)} />
|
||||
</Td>
|
||||
<Th>관리단위<Req /></Th>
|
||||
<Td>
|
||||
<CommCodeSelect groupId={GROUP_UNIT_DC} withAll={false}
|
||||
value={form.unitmang_dc}
|
||||
onValueChange={(v) => setField("unitmang_dc", v)} />
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑧ */}
|
||||
<Tr>
|
||||
<Th>환산수량<Req /></Th>
|
||||
<Td colSpan={2}><Input value={form.unitchng_nb} className="text-right"
|
||||
inputMode="decimal"
|
||||
onChange={(e) => setField("unitchng_nb", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
||||
<Th>LOT구분<Req /></Th>
|
||||
<Td><BasicSelect value={form.lot_fg} options={OPT_LOT_FG}
|
||||
onChange={(v) => setField("lot_fg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑨ */}
|
||||
<Tr>
|
||||
<Th>사용여부<Req /></Th>
|
||||
<Td colSpan={2}><BasicSelect value={form.use_yn} options={OPT_USE_YN}
|
||||
onChange={(v) => setField("use_yn", v)} /></Td>
|
||||
<Th>검사여부<Req /></Th>
|
||||
<Td><BasicSelect value={form.qc_fg} options={OPT_QC_FG}
|
||||
onChange={(v) => setField("qc_fg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑩ */}
|
||||
<Tr>
|
||||
<Th>SET품여부<Req /></Th>
|
||||
<Td colSpan={2}><BasicSelect value={form.setitem_fg} options={OPT_YESNO}
|
||||
onChange={(v) => setField("setitem_fg", v)} /></Td>
|
||||
<Th>의뢰여부<Req /></Th>
|
||||
<Td><BasicSelect value={form.req_fg} options={OPT_YESNO}
|
||||
onChange={(v) => setField("req_fg", v)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑪ */}
|
||||
<Tr>
|
||||
<Th>개당길이</Th>
|
||||
<Td colSpan={2}><Input value={form.unit_length} className="text-right"
|
||||
inputMode="decimal" maxLength={20}
|
||||
onChange={(e) => setField("unit_length", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
||||
<Th>개당소요량</Th>
|
||||
<Td><Input value={form.unit_qty} className="text-right"
|
||||
inputMode="decimal" maxLength={20}
|
||||
onChange={(e) => setField("unit_qty", e.target.value.replace(/[^0-9.]/g, ""))} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑫ 비고 (colspan=4) */}
|
||||
<Tr>
|
||||
<Th>비고</Th>
|
||||
<Td colSpan={4}><Input value={form.remark}
|
||||
onChange={(e) => setField("remark", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */}
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
||||
CAD Data
|
||||
</th>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="3D" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="2D(Drawing)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="2D(PDF)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 보조 컴포넌트 ──────────────────────────────────────────
|
||||
|
||||
function Tr({ children }: { children: React.ReactNode }) {
|
||||
return <tr>{children}</tr>;
|
||||
}
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-middle font-medium">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
function Td({ children, colSpan }: { children: React.ReactNode; colSpan?: number }) {
|
||||
return <td className="border px-3 py-1.5" colSpan={colSpan}>{children}</td>;
|
||||
}
|
||||
function Req() {
|
||||
return <span className="ml-1 text-red-500">*</span>;
|
||||
}
|
||||
function BasicSelect({
|
||||
value, options, onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: { v: string; t: string }[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className={cn("h-9 w-full rounded-md border bg-background px-2 text-sm")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{options.map((o) => <option key={o.v} value={o.v}>{o.t}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
function DropPlaceholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-6 text-center text-xs">
|
||||
<Upload className="h-5 w-5 mx-auto mb-1" />
|
||||
Drag & Drop Files Here ({label})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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<EoHistoryDetail | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>설계변경 상세 정보 (PART_MNG_HISTORY)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-2 text-sm">
|
||||
<Section title="EO 정보">
|
||||
<Row>
|
||||
<V label="EO_NO" value={row.eo_no} />
|
||||
<V label="EO_DATE" value={row.eo_date} align="center" />
|
||||
<V label="실행일" value={row.his_reg_date_title} align="center" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="EO구분" value={row.change_type_name} align="center" />
|
||||
<V label="EO사유" value={row.change_option_name} align="center" />
|
||||
<V label="담당자" value={row.writer_name} align="center" />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="프로젝트 / 유닛">
|
||||
<Row>
|
||||
<V label="프로젝트번호" value={row.project_no} />
|
||||
<V label="프로젝트명" value={row.project_name} />
|
||||
<V label="유닛명" value={row.unit_name} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="PART 정보">
|
||||
<Row>
|
||||
<V label="모품번" value={row.parent_part_info} />
|
||||
<V label="품번" value={row.part_no_disp ?? row.part_no} />
|
||||
<V label="품명" value={row.part_name_disp ?? row.part_name} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="범주" value={row.part_type_name} align="center" />
|
||||
<V label="Revision" value={row.revision_disp ?? row.revision} align="center" />
|
||||
<V label="규격" value={row.spec} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="재료" value={row.material} />
|
||||
<V label="중량" value={row.weight} align="right" />
|
||||
<V label="메이커" value={row.maker} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="열처리경도" value={row.heat_treatment_hardness} />
|
||||
<V label="열처리방법" value={row.heat_treatment_method} />
|
||||
<V label="표면처리" value={row.surface_treatment} />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="비고" value={row.remark} />
|
||||
<V label="" value="" />
|
||||
<V label="" value="" />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section title="수량 / BOM 상태">
|
||||
<Row>
|
||||
<V label="수량" value={row.qty} align="right" />
|
||||
<V label="변경수량" value={row.qty_temp} align="right" />
|
||||
<V label="BOM 상태" value={row.bom_qty_status} align="center" />
|
||||
</Row>
|
||||
<Row>
|
||||
<V label="BOM 배포일" value={row.bom_deploy_date_title} align="center" />
|
||||
<V label="이력 상태" value={row.his_status} align="center" />
|
||||
<V label="" value="" />
|
||||
</Row>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
||||
<div className="space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-3 gap-3">{children}</div>;
|
||||
}
|
||||
|
||||
function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) {
|
||||
const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : "";
|
||||
return (
|
||||
<div>
|
||||
{label && <Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>}
|
||||
<div className={`min-h-9 rounded-md border bg-muted/30 px-2 py-2 ${cls}`}>
|
||||
{value != null && value !== "" ? value : <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +107,11 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/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 }),
|
||||
|
||||
@@ -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<BomReportListResponse> {
|
||||
const res = await apiClient.get("/development/ebom/list", { params: filter });
|
||||
return res.data?.data as BomReportListResponse;
|
||||
},
|
||||
|
||||
async detail(objid: string): Promise<BomReportRow | null> {
|
||||
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<BomTreeResponse> {
|
||||
const res = await apiClient.get("/development/ebom-tree/ascending", { params: filter });
|
||||
return res.data?.data as BomTreeResponse;
|
||||
},
|
||||
|
||||
async descending(filter: BomTreeFilter): Promise<BomTreeResponse> {
|
||||
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<BomTreeFullResponse> {
|
||||
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<BomExcelParseResponse> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await apiClient.post("/development/ebom/excel-parse", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data as BomExcelParseResponse;
|
||||
},
|
||||
|
||||
async excelCheckDuplicate(partNo: string, exclude?: string): Promise<boolean> {
|
||||
const res = await apiClient.get("/development/ebom/excel-check-duplicate", {
|
||||
params: { partNo, exclude },
|
||||
});
|
||||
return !!res.data?.data?.isDuplicate;
|
||||
},
|
||||
|
||||
async excelCopySource(productCd?: string): Promise<BomCopySourceRow[]> {
|
||||
const res = await apiClient.get("/development/ebom/excel-copy-source", {
|
||||
params: productCd ? { productCd } : undefined,
|
||||
});
|
||||
return (res.data?.data as BomCopySourceRow[]) ?? [];
|
||||
},
|
||||
|
||||
async excelCopy(objid: string): Promise<BomCsvRow[]> {
|
||||
const res = await apiClient.get(`/development/ebom/excel-copy/${objid}`);
|
||||
return ((res.data?.data?.rows as BomCsvRow[]) ?? []);
|
||||
},
|
||||
|
||||
async excelSave(input: BomExcelSaveInput): Promise<BomExcelSaveResult> {
|
||||
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";
|
||||
}
|
||||
@@ -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<EoHistoryListResponse> {
|
||||
const res = await apiClient.get("/development/eo-history/list", { params: filter });
|
||||
return res.data?.data as EoHistoryListResponse;
|
||||
},
|
||||
async detail(objid: string): Promise<EoHistoryDetail | null> {
|
||||
const res = await apiClient.get(`/development/eo-history/${objid}`);
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
// ─── Excel Import ────────────────────────────────────────────
|
||||
|
||||
export interface PartExcelRow {
|
||||
NOTE: string;
|
||||
PART_NO: string;
|
||||
PART_NAME: string;
|
||||
MATERIAL: string;
|
||||
HEAT_TREATMENT_HARDNESS: string;
|
||||
HEAT_TREATMENT_METHOD: string;
|
||||
SURFACE_TREATMENT: string;
|
||||
MAKER: string;
|
||||
PART_TYPE: string;
|
||||
PART_TYPE_NAME?: string;
|
||||
SPEC: string;
|
||||
ACCTFG: string;
|
||||
ACCTFG_NAME?: string;
|
||||
ODRFG: string;
|
||||
ODRFG_NAME?: string;
|
||||
UNIT_DC: string;
|
||||
UNIT_DC_NAME?: string;
|
||||
UNITMANG_DC: string;
|
||||
UNITMANG_DC_NAME?: string;
|
||||
UNITCHNG_NB: string;
|
||||
LOT_FG: string;
|
||||
USE_YN: string;
|
||||
QC_FG: string;
|
||||
SETITEM_FG: string;
|
||||
REQ_FG: string;
|
||||
UNIT_LENGTH: string;
|
||||
UNIT_QTY: string;
|
||||
REMARK: string;
|
||||
}
|
||||
|
||||
export interface ExcelParseResponse {
|
||||
rows: PartExcelRow[];
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export interface ExcelSaveResponse {
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
skippedPartNos: string[];
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────────────
|
||||
|
||||
export const devPartApi = {
|
||||
// M1 그리드
|
||||
async listTemp(filter: PartListFilter = {}): Promise<PartListResponse> {
|
||||
const res = await apiClient.get("/development/part-temp/list", { params: filter });
|
||||
return res.data?.data as PartListResponse;
|
||||
},
|
||||
|
||||
// M2 그리드
|
||||
async list(filter: PartListFilter = {}): Promise<PartListResponse> {
|
||||
const res = await apiClient.get("/development/part/list", { params: filter });
|
||||
return res.data?.data as PartListResponse;
|
||||
},
|
||||
|
||||
// 단건 상세
|
||||
async detail(objid: string): Promise<PartRow | null> {
|
||||
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<DeployResult> {
|
||||
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<ExcelParseResponse> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await apiClient.post("/development/part/excel-parse", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data as ExcelParseResponse;
|
||||
},
|
||||
|
||||
// Excel Import — 저장 (신규 PART_NO 만 INSERT)
|
||||
async excelSave(rows: PartExcelRow[]): Promise<ExcelSaveResponse> {
|
||||
const res = await apiClient.post("/development/part/excel-save", { rows });
|
||||
return res.data?.data as ExcelSaveResponse;
|
||||
},
|
||||
};
|
||||
@@ -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,,,,,,부품
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user