diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9781ae35..9c164e37 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -178,6 +178,7 @@ import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 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 erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -425,6 +426,7 @@ app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/p 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", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts new file mode 100644 index 00000000..50281618 --- /dev/null +++ b/backend-node/src/controllers/devBomController.ts @@ -0,0 +1,103 @@ +// ============================================================ +// 개발관리 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 { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.BomReportListFilter { + const filter: svc.BomReportListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +// ─── M3 그리드 ────────────────────────────────────────────── + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM(M3) 목록 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M3 단건 ──────────────────────────────────────────────── + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("E-BOM 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── M3 상태 변경 ────────────────────────────────────────── + +export async function updateStatus(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const { objid } = req.params; + const rowCount = await svc.updateStatus(userId, objid, req.body); + if (rowCount === 0) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, message: "상태가 변경되었습니다." }); + } catch (e: any) { + logger.error("E-BOM 상태 변경 실패", { error: e.message }); + return res.status(400).json({ success: false, message: e.message }); + } +} + +// ─── M3 다중 삭제 ────────────────────────────────────────── + +export async function removeMany(req: AuthenticatedRequest, res: Response) { + try { + const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : []; + if (objids.length === 0) { + return res.status(400).json({ success: false, message: "objids가 비어있습니다." }); + } + const removed = await svc.removeMany(objids); + return res.json({ success: true, data: { removed }, message: `${removed}건이 삭제되었습니다.` }); + } catch (e: any) { + logger.error("E-BOM 삭제 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +// ─── 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 }); + } +} + +// ─── M4 역전개 ───────────────────────────────────────────── + +export async function descending(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.descending(req.query as svc.BomTreeFilter); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("E-BOM 역전개 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts new file mode 100644 index 00000000..9e0676a5 --- /dev/null +++ b/backend-node/src/routes/devBomRoutes.ts @@ -0,0 +1,23 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) 라우트. +// app.ts: app.use("/api/development", devBomRoutes) — devPart 라우터와 prefix 공유. +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/devBomController"; + +const router = Router(); +router.use(authenticateToken); + +// M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위) +router.get("/ebom-tree/ascending", ctrl.ascending); +router.get("/ebom-tree/descending", ctrl.descending); + +// M3 — 그리드 + CRUD +router.get("/ebom/list", ctrl.getList); +router.delete("/ebom", ctrl.removeMany); +router.put("/ebom/:objid/status", ctrl.updateStatus); +router.get("/ebom/:objid", ctrl.getByObjid); + +export default router; diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts new file mode 100644 index 00000000..e37c0fc0 --- /dev/null +++ b/backend-node/src/services/devBomService.ts @@ -0,0 +1,341 @@ +// ============================================================ +// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 이식. +// +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/partMng.xml): +// getBOMStandardStructureGridList → list() (M3 그리드, part_bom_report) +// updateStructureStatus → updateStatus() (M3 상태변경) +// deleteBomReport + deleteBomQty → removeMany() (M3 다중 삭제 트랜잭션) +// structureAscendingList → ascending() (M4 정전개, 재귀 CTE) +// selectStructureDescendingList → descending() (M4 역전개, 재귀 CTE) +// +// vexplor_rps 적응: +// · customer_mng 매핑: wace SUPPLY_MNG.OBJID → vexplor customer_mng.customer_code +// · PRODUCT_NAME: wace CODE_NAME() → vexplor LEFT JOIN comm_code CC_PRD +// · M4 product_mgmt_spec/upg/vc 분기 제거 (vexplor part_bom_report 단순화) +// ============================================================ + +import { PoolClient } from "pg"; +import { getPool, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 필터/바디 타입 ────────────────────────────────────────── + +export interface BomReportListFilter { + customer_cd?: string; + project_name?: string; + unit_code?: string; + search_unit_name?: string; + search_writer?: string; + product_cd?: string; + search_part_no?: string; + search_part_name?: string; + search_from_date?: string; + search_to_date?: string; + status?: string; + page?: number; + page_size?: number; +} + +export interface BomReportStatusBody { + product_cd?: string; + part_no?: string; + part_name?: string; + version?: string; + status: string; +} + +export interface BomTreeFilter { + bom_report_objid?: string; + project_name?: string; // part_bom_report.contract_objid + unit_code?: string; + search_part_no?: string; + search_part_name?: string; +} + +// ─── 공용 파라미터 빌더 ──────────────────────────────────── + +function buildListWhere(filter: BomReportListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.customer_cd) { conds.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_cd); } + if (filter.project_name) { conds.push(`T.CONTRACT_OBJID = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { conds.push(`T.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); } + if (filter.search_unit_name) { + conds.push(`EXISTS (SELECT 1 FROM PMS_WBS_TASK W + WHERE W.OBJID = T.UNIT_CODE + AND (UPPER(W.TASK_NAME) LIKE UPPER($${idx}) OR UPPER(W.UNIT_NO) LIKE UPPER($${idx})))`); + params.push(`%${filter.search_unit_name}%`); idx++; + } + if (filter.search_writer) { conds.push(`T.WRITER = $${idx++}`); params.push(filter.search_writer); } + if (filter.product_cd) { conds.push(`T.PRODUCT_CD = $${idx++}`); params.push(filter.product_cd); } + if (filter.search_part_no) { conds.push(`UPPER(T.PART_NO) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_no}%`); } + if (filter.search_part_name) { conds.push(`UPPER(T.PART_NAME) LIKE UPPER($${idx++})`); params.push(`%${filter.search_part_name}%`); } + if (filter.search_from_date) { conds.push(`T.REGDATE::date >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_from_date); } + if (filter.search_to_date) { conds.push(`T.REGDATE::date <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_to_date); } + if (filter.status) { conds.push(`T.STATUS = $${idx++}`); params.push(filter.status); } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── M3 그리드 ────────────────────────────────────────────── + +export async function list(filter: BomReportListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildListWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + SELECT + ROW_NUMBER() OVER (ORDER BY T.REGDATE DESC) AS NUM, + T.OBJID, T.CUSTOMER_OBJID, SM.customer_name AS CUSTOMER_NAME, + T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO, + T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME, + T.STATUS, + CASE UPPER(T.STATUS) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' END AS STATUS_TITLE, + T.WRITER, UI.dept_name AS DEPT_NAME, UI.user_name AS USER_NAME, + COALESCE(UI.dept_name || '/' || UI.user_name, '') AS DEPT_USER_NAME, + T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE, + T.DEPLOY_DATE, T.REVISION, + EO_DATA.EO_NO, EO_DATA.EO_DATE, + T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID, + COALESCE(EO_DATA.BOM_CNT, 0)::int AS BOM_CNT, + T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME, + T.PART_NO, T.PART_NAME + FROM PART_BOM_REPORT T + LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = T.WRITER + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active' + LEFT JOIN ( + SELECT BP.BOM_REPORT_OBJID, + MAX(PM2.EO_NO) AS EO_NO, + MAX(PM2.EO_DATE) AS EO_DATE, + COUNT(*) AS BOM_CNT + FROM BOM_PART_QTY BP + LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar + GROUP BY BP.BOM_REPORT_OBJID + ) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID + WHERE ${where.sql} + `; + + const dataSql = `${baseSql} ORDER BY T.REGDATE DESC NULLS LAST LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize }; +} + +// ─── M3 단건 ──────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = ` + SELECT T.*, + CC_PRD.code_name AS PRODUCT_NAME, + (SELECT COUNT(*) FROM BOM_PART_QTY Q WHERE Q.BOM_REPORT_OBJID = T.OBJID) AS BOM_CNT + FROM PART_BOM_REPORT T + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status='active' + WHERE T.OBJID = $1 + `; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── M3 상태 변경 ────────────────────────────────────────── + +export async function updateStatus(userId: string, objid: string, body: BomReportStatusBody) { + if (!body.status) throw new Error("status는 필수입니다."); + const sql = ` + UPDATE PART_BOM_REPORT + SET PRODUCT_CD = COALESCE($1, PRODUCT_CD), + PART_NO = COALESCE($2, PART_NO), + PART_NAME = COALESCE($3, PART_NAME), + REVISION = COALESCE($4, REVISION), + STATUS = $5, + editer = $6, + edit_date = NOW() + WHERE OBJID = $7 + `; + const r = await getPool().query(sql, [ + body.product_cd ?? null, + body.part_no ?? null, + body.part_name ?? null, + body.version ?? null, + body.status, + userId, + objid, + ]); + return r.rowCount ?? 0; +} + +// ─── M3 다중 삭제 (트랜잭션) ─────────────────────────────── + +export async function removeMany(objids: string[]): Promise { + if (!objids || objids.length === 0) return 0; + let removed = 0; + await transaction(async (client: PoolClient) => { + // 1) 자식 트리 + await client.query( + `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1::varchar[])`, + [objids] + ); + // 2) 메인 + const r = await client.query( + `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1::varchar[])`, + [objids] + ); + removed = r.rowCount ?? 0; + }); + logger.info("E-BOM 삭제 완료", { removed }); + return removed; +} + +// ─── M4 정전개 (재귀 CTE) ────────────────────────────────── + +export async function ascending(filter: BomTreeFilter) { + const pool = getPool(); + const params: any[] = []; + const conds: string[] = []; + let idx = 1; + + // 시작점 필터: 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘 + if (filter.bom_report_objid) { + conds.push(`BP.bom_report_objid = $${idx++}`); + params.push(filter.bom_report_objid); + } else if (filter.project_name || filter.unit_code) { + const subConds: string[] = []; + if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); } + if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); } + conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`); + } + const startWhere = conds.length ? conds.join(" AND ") : "1=1"; + + // PART 검색 필터는 결과 단계 적용 + const finalConds: string[] = []; + if (filter.search_part_no) { + finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_no}%`); + } + if (filter.search_part_name) { + finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`); + params.push(`%${filter.search_part_name}%`); + } + const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : ""; + + const sql = ` + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, BP.seq, BP.status, + 1, ARRAY[BP.objid::varchar], FALSE + FROM bom_part_qty BP + WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') + AND ${startWhere} + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, B.seq, B.status, + T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.parent_objid = T.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 역전개 (재귀 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.objid = T.parent_objid AND NOT T.cycle + ) + SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status, + T.lev, T.path, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + PM.edit_date, + PM.eo_no, PM.revision, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ORDER BY T.path + `; + const r = await pool.query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} diff --git a/docs/migration/development/02-ebom.md b/docs/migration/development/02-ebom.md new file mode 100644 index 00000000..d50acd14 --- /dev/null +++ b/docs/migration/development/02-ebom.md @@ -0,0 +1,282 @@ +# PR-B : E-BOM 등록·조회 묶음 구현 명세 (M3+M4) + +> 작성: 2026-05-12 / 범위: 개발관리 M3(E-BOM 등록) + M4(E-BOM 조회) — `part_bom_report` 메인, `bom_part_qty` 트리. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +원본 `wace_plm/src/com/pms/mapper/partMng.xml`: + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `getBOMStandardStructureGridList` | 2,859 | `GET /api/development/ebom/list` | M3 그리드 (PART_BOM_REPORT + 집계) | +| `updateStructureStatus` | 8,027 | `PUT /api/development/ebom/status` | M3 상태변경 (PRODUCT_CD/PART_NO/NAME/VERSION/STATUS) | +| `deleteBomReport` | 6,838 | `DELETE /api/development/ebom` (body `objids`) | M3 다중 삭제 + BOM_PART_QTY CASCADE | +| `deleteBomQty` | 6,847 | (deleteBomReport 내부) | M3 삭제 시 자식 트리 동시 삭제 | +| `structureAscendingList` | 7,361 | `GET /api/development/ebom/ascending` | M4 정전개 (root → leaf) | +| `selectStructureDescendingList` | 6,582 | `GET /api/development/ebom/descending` | M4 역전개 (leaf → root) | + +--- + +## 2. API 엔드포인트 명세 + +### 2.1 M3 그리드 — `GET /api/development/ebom/list` + +**Query**: +``` +customer_cd?: string // part_bom_report.customer_objid +project_name?: string // part_bom_report.contract_objid (project_mgmt.objid) +unit_code?: string // pms_wbs_task.objid +search_unit_name?: string // pms_wbs_task.unit_no/task_name LIKE +search_writer?: string // part_bom_report.writer +product_cd?: string // wace 'product_code' 검색 (part_bom_report.product_cd) +search_part_no?: string // part_bom_report.part_no LIKE +search_part_name?: string // part_bom_report.part_name LIKE +search_from_date?: string // regdate from +search_to_date?: string // regdate to +status?: string // part_bom_report.status (create/changeDesign/deploy) +page?, page_size? +``` + +**SQL** (vexplor_rps part_bom_report 스키마 적응 — wace `getBOMStandardStructureGridList` 의 PRODUCT_CD/PART_NO/PART_NAME 분기 활성, MULTI_* 컬럼 그대로): +```sql +SELECT + ROW_NUMBER() OVER(ORDER BY T.REGDATE DESC) AS NUM, + T.OBJID, T.CUSTOMER_OBJID, SM.SUPPLY_NAME AS CUSTOMER_NAME, + T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO, + T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME, + T.STATUS, + CASE UPPER(T.STATUS) + WHEN 'CREATE' THEN '등록중' + WHEN 'CHANGEDESIGN' THEN '설계변경미배포' + WHEN 'DEPLOY' THEN '배포완료' + ELSE '' END AS STATUS_TITLE, + T.WRITER, UI.DEPT_NAME, UI.USER_NAME, + COALESCE(UI.DEPT_NAME || '/' || UI.USER_NAME, '') AS DEPT_USER_NAME, + T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE, + T.DEPLOY_DATE, T.REVISION, + EO_DATA.EO_NO, EO_DATA.EO_DATE, + T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID, + COALESCE(EO_DATA.BOM_CNT, 0) AS BOM_CNT, + T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME, + T.PART_NO, T.PART_NAME + FROM PART_BOM_REPORT T + LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID -- vexplor 매핑 + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE + LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER + LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active' + LEFT JOIN ( + SELECT BP.BOM_REPORT_OBJID, + MAX(PM2.EO_NO) AS EO_NO, + MAX(PM2.EO_DATE) AS EO_DATE, + COUNT(*) AS BOM_CNT + FROM BOM_PART_QTY BP + LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar + GROUP BY BP.BOM_REPORT_OBJID + ) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID + WHERE 1=1 + 동적 (위 11 필터) +``` + +**Response**: `{ rows: BomReportRow[], total, page, pageSize }` + +### 2.2 M3 단건 상세 — `GET /api/development/ebom/:objid` + +`SELECT T.* FROM PART_BOM_REPORT T WHERE T.OBJID = $1` + (옵션) BOM_PART_QTY 카운트. +편집 다이얼로그 진입용. + +### 2.3 M3 상태 변경 — `PUT /api/development/ebom/:objid/status` + +**Body**: `{ product_cd?, part_no?, part_name?, version?, status }` — wace `updateStructureStatus` 1:1. +`STATUS` 만 변경하는 단순 케이스도 지원 (다른 필드 NULL 허용). + +```sql +UPDATE PART_BOM_REPORT + SET PRODUCT_CD = COALESCE($1, PRODUCT_CD), + PART_NO = COALESCE($2, PART_NO), + PART_NAME = COALESCE($3, PART_NAME), + REVISION = COALESCE($4, REVISION), + STATUS = $5, + EDITER = $6, + EDIT_DATE = NOW() + WHERE OBJID = $7 +``` + +### 2.4 M3 다중 삭제 — `DELETE /api/development/ebom` (body: `{ objids: string[] }`) + +**트랜잭션**: +1. `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1)` (자식 트리) +2. `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1)` (메인) + +wace는 part_mng도 정리(`deleteBomQtyPart`, status='create'만)하지만 본 PR에서는 part_mng 보존 (M1·M2와 결합 안 됨). + +### 2.5 M4 정전개 — `GET /api/development/ebom/ascending` + +**Query**: +``` +bom_report_objid?: string // 단일 BOM 한정 조회 +project_name?: string // PART_BOM_REPORT.contract_objid +unit_code?: string +search_part_no?: string +search_part_name?: string +``` + +**SQL** (재귀 CTE — wace `structureAscendingList` 의 BOM_PART_QTY 트리 1:1): +```sql +WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, lev, path, cycle) AS ( + SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid, + BP.part_no, BP.qty, 1, ARRAY[BP.objid], FALSE + FROM bom_part_qty BP + WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '') + AND BP.bom_report_objid = $bom_report_objid /* 또는 필터 적용된 PART_BOM_REPORT 서브쿼리 */ + UNION ALL + SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid, + B.part_no, B.qty, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.parent_objid = T.objid AND NOT T.cycle +) +SELECT T.*, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + PM.spec, PM.material, PM.weight, PM.remark, + PM.edit_date, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt, + (SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt, + (SELECT MAX(lev) FROM TREE) AS max_level + FROM TREE T + LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar + ORDER BY T.path +``` + +**Response**: `{ rows: AscRow[], max_level: number }` + +### 2.6 M4 역전개 — `GET /api/development/ebom/descending` + +같은 Query 파라미터. 재귀 방향 반대: +- 시작점: 리프(`child_objid` 가 다른 행의 `parent_objid` 가 아닌 행) 또는 사용자가 지정한 PART +- 트리 부모 방향으로 traverse + +**SQL** (역전개 — wace `selectStructureDescendingList` 단순 매핑): +```sql +WITH RECURSIVE TREE(...) AS ( + /* 1. anchor: 조건 매칭 BOM 또는 leaf part */ + SELECT BP.* , 1 AS lev, ARRAY[BP.objid] AS path, FALSE AS cycle + FROM bom_part_qty BP + WHERE ... /* PART_NO 매칭 등 */ + UNION ALL + /* 2. parent 방향 traverse */ + SELECT B.*, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path) + FROM bom_part_qty B + JOIN TREE T ON B.objid = T.parent_objid AND NOT T.cycle +) +SELECT ... (정전개와 동일 part_mng / attach_file_info JOIN) +``` + +**Response**: `{ rows: DescRow[], max_level: number }` + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/ + devBomRoutes.ts // 6 endpoint + controllers/ + devBomController.ts + services/ + devBomService.ts // list/getById/updateStatus/removeMany/ascending/descending +``` + +`app.ts`: `app.use("/api/development", devBomRoutes)` (devPart 라우터와 prefix 공유 — Express 중복 등록 안전, 경로 충돌 없음). + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/ + ebom-regist/page.tsx // M3 그리드 + 검색 + 액션 + ebom-search/page.tsx // M4 정/역전개 (동적 LEVEL 컬럼) + components/development/ + BomReportStatusDialog.tsx // M3 상태 변경 다이얼로그 (status select) + lib/api/ + devBom.ts // 6 endpoint 호출 + 타입 +``` + +### 4.1 M3 그리드 (9 셀, wace structureList.jsp:185~215 1:1) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| product_name | 제품구분 | center | 160 | +| part_no | 품번 | left | 210 | +| part_name | 품명 | left | flex | +| bom_cnt | E-BOM (folder click) | center | 150 | +| dept_user_name | 등록자 | center | 120 | +| reg_date | 등록일 | center | 130 | +| deploy_date | 확정일 | center | 100 | +| revision | Version | center | 110 | +| status_title | 상태 | center | 110 | + +### 4.2 M3 검색 폼 (wace 1:1 — 9 필드) + +customer_cd · project_name · unit_code · SEARCH_UNIT_NAME · SEARCH_WRITER · product_cd · SEARCH_PART_NO · SEARCH_PART_NAME · search_fromDate~toDate · status + +본 PR 1차: PRODUCT_CD · SEARCH_PART_NO · SEARCH_PART_NAME · STATUS 4필드로 시작. 나머지 추후 보강. + +### 4.3 M3 액션 버튼 (wace 1:1) + +- 조회 / 삭제 / E-BOM등록(Excel Import — 별 PR) / 상태변경 + +본 PR 포함: 조회 · 삭제 · 상태변경. **E-BOM등록(Excel Import)은 별 PR**. + +### 4.4 M4 동적 LEVEL 컬럼 + +backend response의 `max_level` 값에 따라 컬럼을 동적 생성: +- LEVEL 1..max_level: 각 레벨 컬럼은 `row.lev === i` 인 행의 `part_no` 표시 (트리 들여쓰기 효과) +- 고정 컬럼: 품번 · 품명 · 3D/2D/PDF · 수량 · 변경일 · 규격 · 재질 · 중량 · 비고 + +DataGrid의 컬럼 배열을 fetch 결과 도착 시 동적 생성 (state). + +### 4.5 M4 액션 + +- 정전개 조회 (default) +- 역전개 조회 +- (Excel Download — 별 PR) + +--- + +## 5. 본 PR 제외 항목 + +| 항목 | 사유 / 후속 | +|---|---| +| E-BOM 등록 (Excel Import) | `openBomReportExcelImportPopUp.jsp` — 별 PR | +| 정/역전개 Excel Download | `structureAscendingListExcel`/`structureDescendingExcelList` — 별 PR | +| `BOM_PART_QTY` 직접 편집 (수량 변경) | `structureQtySave` — wace 운영판에서도 별 화면 | +| 다중 BOM(MULTI_*) 분기 처리 | 현재 vexplor 데이터 없음 — 기본 1:1만 | +| wace_plm `product_mgmt_spec/upg/vc` 분기 | vexplor 스키마는 product_cd 단순 — 운영판 1:1 적응 | + +--- + +## 6. 검증 시나리오 (verify.md 기준) + +1. M3 페이지 진입 → part_bom_report 그리드 (현재 0건, schema/UI 동작 확인) +2. M3 상태변경 다이얼로그 → status='deploy' → DB 반영 확인 +3. M3 다중 삭제 → bom_part_qty CASCADE 확인 +4. M4 페이지 진입 → 정전개 (`/ascending`) 0건 응답 → 페이지 정상 표시 +5. M4 역전개 토글 → `/descending` 응답 +6. (시드 후) MAX_LEVEL=3 트리에서 동적 컬럼 3개 생성 확인 + +--- + +## 7. 적응 사항 (운영판 대비 변경점) + +| # | 항목 | 변경 | +|---|---|---| +| 1 | `customer_mng` 매핑 | wace `SUPPLY_MNG.OBJID::VARCHAR = T.CUSTOMER_OBJID` → vexplor `customer_mng.customer_code = T.CUSTOMER_OBJID` | +| 2 | `PRODUCT_NAME` lookup | wace `CODE_NAME(PRODUCT_CD)` → vexplor `LEFT JOIN comm_code` (`CC_PRD`) | +| 3 | M4 `PRODUCT_MGMT_*` 분기 제거 | vexplor part_bom_report 는 product_cd/version 단순화 — wace `product_mgmt_spec/upg/vc` 컬럼 없음 → JOIN 생략, 정전개는 BOM_PART_QTY 트리만 | +| 4 | 다중 삭제 트랜잭션 | wace 두 매퍼(`deleteBomQty` + `deleteBomReport`) 호출 → backend `transaction()` 한 번 | diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx new file mode 100644 index 00000000..52f17273 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -0,0 +1,183 @@ +"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, 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, +} 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"; + +const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용) + +const STATUS_OPTIONS = [ + { code: "create", label: "등록중" }, + { code: "changeDesign", label: "설계변경미배포" }, + { code: "deploy", label: "배포완료" }, +]; + +const 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]" }, + { key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "right", formatNumber: true }, + { key: "dept_user_name", label: "등록자", width: "w-[140px]", align: "center" }, + { key: "reg_date", label: "등록일", width: "w-[120px]", align: "center" }, + { key: "deploy_date", label: "확정일", width: "w-[120px]", align: "center" }, + { key: "revision", label: "Version", width: "w-[100px]", align: "center" }, + { key: "status_title", label: "상태", width: "w-[120px]", align: "center" }, +]; + +const EMPTY_FILTER: BomReportListFilter = { + product_cd: "", status: "", + search_part_no: "", search_part_name: "", + page: 1, page_size: 50, +}; + +export default function EbomRegistPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [checkedIds, setCheckedIds] = useState([]); + + const [statusOpen, setStatusOpen] = useState(false); + const [statusObjid, setStatusObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devBomApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + setCheckedIds([]); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + const handleDelete = async () => { + if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다."); + if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까? (자식 BOM 트리도 함께 삭제됨)`)) return; + try { + const res = await devBomApi.remove(checkedIds); + toast.success(res?.message ?? "삭제되었습니다."); + fetchList(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패"); + } + }; + + const handleStatusChange = () => { + if (checkedIds.length !== 1) return toast.error("상태 변경할 행 1개를 선택하세요."); + setStatusObjid(checkedIds[0]); + setStatusOpen(true); + }; + + return ( +
+
+
+ + setFilter({ ...filter, product_cd: v })} + /> + + + + + + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="품번 LIKE" + /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="품명 LIKE" + /> + +
+
+
총 {total.toLocaleString()}건
+
+ + + + +
+
+
+ +
+ +
+ + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx new file mode 100644 index 00000000..6e4bd229 --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -0,0 +1,164 @@ +"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, +} from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom"; + +type Direction = "ascending" | "descending"; + +const EMPTY_FILTER: BomTreeFilter = { + project_name: "", unit_code: "", + search_part_no: "", search_part_name: "", +}; + +export default function EbomSearchPage() { + const [filter, setFilter] = useState(EMPTY_FILTER); + const [direction, setDirection] = useState("ascending"); + const [rows, setRows] = useState([]); + const [maxLevel, setMaxLevel] = useState(0); + const [loading, setLoading] = useState(false); + + const 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]); + + // 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시 + const columns: DataGridColumn[] = useMemo(() => { + const levelCols: DataGridColumn[] = []; + for (let i = 1; i <= Math.max(1, maxLevel); i++) { + levelCols.push({ + key: `__lev_${i}`, + label: `L${i}`, + width: "w-[140px]", + }); + } + return [ + ...levelCols, + { key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false }, + { key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" }, + { key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true }, + { key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" }, + { key: "revision", label: "REV", width: "w-[60px]", align: "center" }, + { key: "spec", label: "규격", width: "w-[120px]" }, + { key: "material", label: "재질", width: "w-[100px]" }, + { key: "weight", label: "중량", width: "w-[80px]", align: "right" }, + { key: "remark", label: "비고", minWidth: "min-w-[140px]" }, + ]; + }, [maxLevel]); + + // 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움 + const gridData = useMemo( + () => rows.map((r) => { + const expanded: any = { ...r }; + for (let i = 1; i <= Math.max(1, maxLevel); i++) { + expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : ""; + } + return expanded; + }), + [rows, maxLevel], + ); + + return ( +
+
+
+ + setFilter({ ...filter, project_name: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, unit_code: e.target.value })} + placeholder="pms_wbs_task.objid" /> + + + setFilter({ ...filter, search_part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, search_part_name: e.target.value })} + placeholder="part_name LIKE" /> + +
+
+
+ 모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} +
+
+ + + +
+
+ {direction === "descending" && ( +
+ 역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다. +
+ )} +
+ +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/components/development/BomReportStatusDialog.tsx b/frontend/components/development/BomReportStatusDialog.tsx new file mode 100644 index 00000000..c783b765 --- /dev/null +++ b/frontend/components/development/BomReportStatusDialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +// 개발관리 > E-BOM 상태 변경 다이얼로그. +// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드. + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { devBomApi, BomReportRow } from "@/lib/api/devBom"; + +const STATUS_OPTIONS = [ + { code: "create", label: "등록중" }, + { code: "changeDesign", label: "설계변경미배포" }, + { code: "deploy", label: "배포완료" }, +]; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; + onSaved: () => void; +} + +export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) { + const [row, setRow] = useState(null); + const [status, setStatus] = useState(""); + const [version, setVersion] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devBomApi.detail(objid) + .then((data) => { + if (!alive) return; + if (!data) { + toast.error("E-BOM 보고서를 찾을 수 없습니다."); + onOpenChange(false); + return; + } + setRow(data); + setStatus(data.status ?? ""); + setVersion(data.revision ?? ""); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + const handleSave = async () => { + if (!objid) return; + if (!status) return toast.error("상태를 선택하세요."); + setSaving(true); + try { + await devBomApi.updateStatus(objid, { + status, + version: version || undefined, + }); + toast.success("상태가 변경되었습니다."); + onSaved(); + onOpenChange(false); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + + + + E-BOM 상태 변경 + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+
제품구분: {row.product_name ?? row.product_cd ?? "—"}
+
품번: {row.part_no ?? "—"}
+
품명: {row.part_name ?? "—"}
+
현재상태: {row.status_title ?? row.status ?? "—"}
+
+
+ + +
+
+ + setVersion(e.target.value)} placeholder="예: RE, A, B..." /> +
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 23eddf88..25684a8d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -109,6 +109,8 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/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/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts new file mode 100644 index 00000000..fff3363b --- /dev/null +++ b/frontend/lib/api/devBom.ts @@ -0,0 +1,146 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 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; +} + +// ─── API ───────────────────────────────────────────────── + +export const devBomApi = { + // M3 그리드 + async list(filter: BomReportListFilter = {}): Promise { + const res = await apiClient.get("/development/ebom/list", { params: filter }); + return res.data?.data as BomReportListResponse; + }, + + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/ebom/${objid}`); + return res.data?.data ?? null; + }, + + async updateStatus(objid: string, body: BomReportStatusBody) { + return (await apiClient.put(`/development/ebom/${objid}/status`, body)).data; + }, + + async remove(objids: string[]) { + const res = await apiClient.delete("/development/ebom", { data: { objids } }); + return res.data; + }, + + // M4 + async ascending(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/ascending", { params: filter }); + return res.data?.data as BomTreeResponse; + }, + + async descending(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/descending", { params: filter }); + return res.data?.data as BomTreeResponse; + }, +};