From dd88dc6e8c0d0d0b2fcb4c209d53681e6ba51dcd Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 16:29:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC>M-BOM=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=E2=80=94=20PR-A2=20=EB=8B=A8=EA=B1=B4=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20+=20read-only=20=ED=8A=B8=EB=A6=AC=204?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20(wace=20mBomPopupLeft.do=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행 더블클릭 → MbomDetailDialog (헤더 메타 + 동적 LEVEL × 19컬럼 트리 그리드). 운영판 ProductionPlanningController:1113~1276 의 4분기 자동 판별을 백엔드에서 처리: 1) SAVED mbom_header.status='Y' 우선 → getSavedMbomTreeList CTE 2) ASSIGNED_EBOM source_bom_type='EBOM' → partMng.getBOMTreeList(working) CTE 3) ASSIGNED_MBOM source_bom_type='MBOM' → getMbomStructureOnly CTE 4) TEMPLATE Machine 이외 + 동일 part_no → mbom_header 템플릿 CTE 5) NONE 빈 트리 backend: - mbomService.getDetail (getProjectMgmtDetail 1:1, TOTAL_PROD_QTY = production_plan 우선) - mbomService.getTree (4분기 orchestrator + 매퍼 4종 CTE 1:1) - GET /api/production/mbom/detail/:objid - GET /api/production/mbom/tree/:objid frontend: - lib/api/mbom.ts : MbomDetail / MbomTreeRow / MbomBomDataType / getDetail / getTree - components/production/MbomDetailDialog.tsx (max-w-1600px, 헤더 14필드 + 트리 그리드) - page.tsx 행 더블클릭 핸들러 검증: O-RING (593315995) SAVED 분기 5행 정상. TOTAL_PROD_QTY production_plan=5 / QUANTITY=2 fallback 확인. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/mbomController.ts | 31 +- .../src/routes/productionMbomRoutes.ts | 6 +- backend-node/src/services/mbomService.ts | 605 +++++++++++++++++- .../COMPANY_16/production/mbom/page.tsx | 23 +- .../production/MbomDetailDialog.tsx | 232 +++++++ frontend/lib/api/mbom.ts | 112 +++- 6 files changed, 992 insertions(+), 17 deletions(-) create mode 100644 frontend/components/production/MbomDetailDialog.tsx diff --git a/backend-node/src/controllers/mbomController.ts b/backend-node/src/controllers/mbomController.ts index 3d52ff45..0ad892ff 100644 --- a/backend-node/src/controllers/mbomController.ts +++ b/backend-node/src/controllers/mbomController.ts @@ -1,7 +1,9 @@ // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식. +// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식. // 라우트: -// GET /api/production/mbom/list M-BOM 관리 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침) +// GET /api/production/mbom/list 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침) +// GET /api/production/mbom/detail/:objid 단건 상세 (mBomHeaderPopup.do 1:1) +// GET /api/production/mbom/tree/:objid read-only 트리 4분기 자동 판별 (mBomPopupLeft.do 1:1) // ============================================================ import { Response } from "express"; @@ -25,3 +27,28 @@ export async function getList(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: e.message }); } } + +export async function getDetail(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid 누락" }); + const data = await svc.getDetail(objid); + if (!data) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다" }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 단건 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getTree(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid 누락" }); + const data = await svc.getTree(objid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("M-BOM 트리 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/productionMbomRoutes.ts b/backend-node/src/routes/productionMbomRoutes.ts index 4ec199b2..392cd071 100644 --- a/backend-node/src/routes/productionMbomRoutes.ts +++ b/backend-node/src/routes/productionMbomRoutes.ts @@ -1,5 +1,5 @@ // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) 라우트. +// 생산관리 > M-BOM 관리 라우트. // app.ts: app.use("/api/production/mbom", productionMbomRoutes) // ============================================================ @@ -10,6 +10,8 @@ import * as ctrl from "../controllers/mbomController"; const router = Router(); router.use(authenticateToken); -router.get("/list", ctrl.getList); +router.get("/list", ctrl.getList); +router.get("/detail/:objid", ctrl.getDetail); +router.get("/tree/:objid", ctrl.getTree); export default router; diff --git a/backend-node/src/services/mbomService.ts b/backend-node/src/services/mbomService.ts index f6f52b20..6a1f57f0 100644 --- a/backend-node/src/services/mbomService.ts +++ b/backend-node/src/services/mbomService.ts @@ -1,14 +1,23 @@ // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식. +// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식. // -// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml): -// mBomMgmtGridList → list() (라인 2874~3119, PROJECT_MGMT × CONTRACT_ITEM 펼침) +// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml, +// wace_plm/src/com/pms/mapper/partMng.xml): +// mBomMgmtGridList → list() (PR-A1, ~3119) +// getProjectMgmtDetail → getDetail() (PR-A2, ~3218) +// getLatestMbomByProjectId → getLatestSavedMbom() (PR-A2, ~3570) +// getLatestMbomTemplateByPartNo → getLatestTemplate() (PR-A2, ~3591) +// getSavedMbomTreeList → getSavedTree() (PR-A2, ~4359) +// getMbomStructureOnly → getStructureOnly() (PR-A2, ~4538) +// getMbomTemplateDetails → getTemplateDetails() (PR-A2, ~3794) +// partMng.getBOMTreeList → getEbomWorkingTree() (PR-A2, partMng.xml ~3549) // -// 그리드 베이스: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행). -// 9 검색 필터 + 30+ 출력 컬럼 (M-BOM 상태/저장일/작성자 + 구매리스트 매칭). -// vexplor_rps 의존: project_mgmt / contract_mgmt / contract_item / contract_item_serial -// / mbom_header (PR-A0) / mbom_history / sales_request_master / client_mng -// / supply_mng / part_bom_report / comm_code / user_info / user_name() fn +// 트리 분기 (mBomPopupLeft.do 1:1): +// 1) SAVED — mbom_header 에 status='Y' 가 있으면 그 트리 +// 2) ASSIGNED_EBOM — project_mgmt.source_bom_type='EBOM' + source_ebom_objid +// 3) ASSIGNED_MBOM — project_mgmt.source_bom_type='MBOM' + source_mbom_objid +// 4) TEMPLATE — Machine 이외(product != '0000928') + part_no 있으면 동일 품번 mbom_header +// 5) NONE — 빈 트리 // ============================================================ import { getPool } from "../database/db"; @@ -275,3 +284,583 @@ export async function list(filter: MbomListFilter) { pageSize, }; } + +// ─── 단건 상세 (getProjectMgmtDetail) ────────────────────────── +// +// 매퍼 productionplanning.xml getProjectMgmtDetail (라인 3150~3218) 1:1. +// TOTAL_PROD_QTY = production_plan 우선 → PM.QUANTITY fallback. + +export async function getDetail(objid: string) { + const pool = getPool(); + const sql = ` + SELECT + PM.OBJID::VARCHAR AS objid, + PM.CONTRACT_OBJID AS contract_objid, + PM.PROJECT_NO AS project_no, + PM.BOM_REPORT_OBJID AS bom_report_objid, + PM.PART_OBJID AS part_objid, + PM.PART_NO AS part_no, + PM.PART_NAME AS part_name, + PM.SOURCE_BOM_TYPE AS source_bom_type, + PM.SOURCE_EBOM_OBJID AS source_ebom_objid, + PM.SOURCE_MBOM_OBJID AS source_mbom_objid, + PM.QUANTITY AS quantity, + COALESCE( + (SELECT NULLIF(PP.TOTAL_PROD_QTY, '')::numeric + FROM PRODUCTION_PLAN PP + WHERE PP.PROJECT_OBJID = PM.OBJID::VARCHAR + AND UPPER(PP.STATUS) = 'ACTIVE' + LIMIT 1), + COALESCE(NULLIF(PM.QUANTITY, '')::numeric, 0) + ) AS total_prod_qty, + COALESCE( + (SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = PM.BOM_REPORT_OBJID + AND PM.MBOM_STATUS = 'Y' + LIMIT 1), '' + ) AS mbom_part_no, + CM.CATEGORY_CD AS category_cd, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), '') AS category_name, + CM.PRODUCT AS product, + CM.PRODUCT AS product_code, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS product_name, + CM.AREA_CD AS area_cd, + COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1), '') AS area_name, + CM.CUSTOMER_OBJID AS customer_objid, + COALESCE( + CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG AS C + WHERE 'C_' || C.OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1) + ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG + WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1) END, + '' + ) AS customer_name, + CM.PAID_TYPE AS paid_type, + CM.REQ_DEL_DATE AS req_del_date, + TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') AS receipt_date, + COALESCE( + (SELECT TO_CHAR(PBR.REGDATE, 'YYYY-MM-DD') + FROM PART_BOM_REPORT PBR + WHERE PBR.OBJID::VARCHAR = PM.BOM_REPORT_OBJID + AND PM.MBOM_STATUS = 'Y' + LIMIT 1), + TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') + ) AS mbom_regdate + FROM PROJECT_MGMT PM + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE PM.OBJID::VARCHAR = $1 + LIMIT 1 + `; + const r = await pool.query(sql, [objid]); + return r.rows[0] ?? null; +} + +// ─── 분기 진입점 (mBomPopupLeft.do 1:1) ──────────────────────── +// +// 운영판 ProductionPlanningController:1113~1276 의 분기 로직: +// 1) 저장된 M-BOM (mbom_header.status='Y') 우선 +// 2) 없으면 source_bom_type 으로 EBOM/MBOM 분기 +// 3) 그래도 없으면 Machine 이외 + part_no 매칭으로 템플릿 +// 4) 모두 없으면 빈 트리 + +export type BomDataType = "SAVED" | "ASSIGNED_EBOM" | "ASSIGNED_MBOM" | "TEMPLATE" | "NONE"; + +export interface MbomTreeResult { + bom_data_type: BomDataType; + bom_report_objid: string | null; + max_level: number; + rows: any[]; +} + +export async function getTree(objid: string): Promise { + const detail = await getDetail(objid); + if (!detail) { + return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] }; + } + + // 1) SAVED — getLatestMbomByProjectId + const saved = await getLatestSavedMbom(objid); + if (saved && saved.objid) { + const rows = await getSavedTree(saved.objid); + return finalize("SAVED", saved.objid, rows); + } + + // 2) ASSIGNED_EBOM + if (detail.source_bom_type === "EBOM" && detail.source_ebom_objid) { + const rows = await getEbomWorkingTree(detail.source_ebom_objid); + return finalize("ASSIGNED_EBOM", detail.source_ebom_objid, rows); + } + + // 3) ASSIGNED_MBOM + if (detail.source_bom_type === "MBOM" && detail.source_mbom_objid) { + const rows = await getStructureOnly(detail.source_mbom_objid); + return finalize("ASSIGNED_MBOM", detail.source_mbom_objid, rows); + } + + // 4) TEMPLATE — Machine 이외 + part_no + if (detail.product_code !== "0000928" && detail.part_no) { + const tpl = await getLatestTemplate(detail.part_no); + if (tpl && tpl.template_header_objid) { + const rows = await getTemplateDetails(tpl.template_header_objid); + return finalize("TEMPLATE", tpl.template_header_objid, rows); + } + } + + // 5) NONE + return { bom_data_type: "NONE", bom_report_objid: detail.bom_report_objid ?? null, max_level: 1, rows: [] }; +} + +function finalize(type: BomDataType, bomReportObjid: string, rows: any[]): MbomTreeResult { + let maxLevel = 1; + for (const r of rows) { + const lv = Number(r.level ?? r.LEVEL ?? 1); + if (lv > maxLevel) maxLevel = lv; + } + return { bom_data_type: type, bom_report_objid: bomReportObjid, max_level: maxLevel, rows }; +} + +// ─── 분기 1) SAVED 진입 ────────────────────────────────────── +// +// 매퍼 getLatestMbomByProjectId (productionplanning.xml:3555~3570) 1:1. + +async function getLatestSavedMbom(projectObjId: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT OBJID::VARCHAR AS objid, MBOM_NO AS mbom_no, + SOURCE_BOM_TYPE AS source_bom_type, + SOURCE_EBOM_OBJID AS source_ebom_objid, + SOURCE_MBOM_OBJID AS source_mbom_objid, + PROJECT_OBJID AS project_objid, STATUS AS status, REGDATE AS regdate + FROM MBOM_HEADER + WHERE PROJECT_OBJID = $1 AND STATUS = 'Y' + ORDER BY REGDATE DESC LIMIT 1`, + [projectObjId], + ); + return r.rows[0] ?? null; +} + +// ─── 분기 4) TEMPLATE 진입 ─────────────────────────────────── +// +// 매퍼 getLatestMbomTemplateByPartNo (productionplanning.xml:3573~3591) 1:1. + +async function getLatestTemplate(partNo: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT MH.OBJID::VARCHAR AS template_header_objid, + MH.MBOM_NO AS template_mbom_no, + MH.PART_NO AS part_no, + MH.PART_NAME AS part_name, + MH.SOURCE_BOM_TYPE AS source_bom_type, + MH.SOURCE_EBOM_OBJID AS source_ebom_objid, + MH.SOURCE_MBOM_OBJID AS source_mbom_objid, + TO_CHAR(MH.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate + FROM MBOM_HEADER MH + INNER JOIN PROJECT_MGMT PM ON MH.PROJECT_OBJID = PM.OBJID::VARCHAR + INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID + WHERE MH.PART_NO = $1 + AND MH.STATUS = 'Y' + AND CM.PRODUCT != '0000928' + ORDER BY MH.REGDATE DESC LIMIT 1`, + [partNo], + ); + return r.rows[0] ?? null; +} + +// ─── 분기 1-SAVED 트리 ─────────────────────────────────────── +// +// 매퍼 getSavedMbomTreeList (productionplanning.xml:4114~4359) 1:1. +// RECURSIVE CTE + PART_MNG 조인 + ATTACH_FILE_INFO 카운트 (CU01/02/03_CNT) + 소재소요량. + +async function getSavedTree(mbomHeaderObjid: string) { + const pool = getPool(); + const r = await pool.query(SAVED_TREE_SQL, [mbomHeaderObjid]); + return r.rows; +} + +// ─── 분기 3-ASSIGNED_MBOM 구조만 ───────────────────────────── +// +// 매퍼 getMbomStructureOnly (productionplanning.xml:4362~4538) 1:1. +// 생산 정보는 NULL — 구조만 표시. + +async function getStructureOnly(mbomHeaderObjid: string) { + const pool = getPool(); + const r = await pool.query(STRUCTURE_ONLY_SQL, [mbomHeaderObjid]); + return r.rows; +} + +// ─── 분기 4-TEMPLATE 트리 ──────────────────────────────────── +// +// 매퍼 getMbomTemplateDetails (productionplanning.xml:3594~3794) 1:1. +// ORDER_QTY/PRODUCTION_QTY 가 빠진 점만 SAVED 와 다름. + +async function getTemplateDetails(mbomHeaderObjid: string) { + const pool = getPool(); + const r = await pool.query(TEMPLATE_TREE_SQL, [mbomHeaderObjid]); + return r.rows; +} + +// ─── 분기 2-ASSIGNED_EBOM 트리 ─────────────────────────────── +// +// 매퍼 partMng.getBOMTreeList (partMng.xml:3289~3549) - search_type='working' 1:1. +// bom_part_qty RECURSIVE CTE + PART_MNG 조인. + +async function getEbomWorkingTree(bomReportObjid: string) { + const pool = getPool(); + const r = await pool.query(EBOM_WORKING_TREE_SQL, [bomReportObjid]); + return r.rows; +} + +// ─── 트리 SELECT 본문 (매퍼 4종 1:1, lowercase alias) ────── + +const SAVED_TREE_SQL = ` +WITH RECURSIVE VIEW_BOM( + MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP, + REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE, + UNIT, SUPPLY_TYPE, MAKE_OR_BUY, + RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE, + PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE, + REQUIRED_QTY, ORDER_QTY, PRODUCTION_QTY, STOCK_QTY, SHORTAGE_QTY, + VENDOR, UNIT_PRICE, PROCESSING_UNIT_PRICE, TOTAL_PRICE, CURRENCY, + LEAD_TIME, MIN_ORDER_QTY, WRITER, EDITER, EDIT_DATE, REMARK +) AS ( + SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.ITEM_QTY, A.QTY, + A.REGDATE, A.SEQ, A.STATUS, 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE, + A.UNIT, A.SUPPLY_TYPE, A.MAKE_OR_BUY, + A.RAW_MATERIAL_PART_NO, A.RAW_MATERIAL_SPEC, A.RAW_MATERIAL, A.RAW_MATERIAL_SIZE, + A.PROCESSING_VENDOR, A.PROCESSING_DEADLINE, A.GRINDING_DEADLINE, + A.REQUIRED_QTY, A.ORDER_QTY, A.PRODUCTION_QTY, A.STOCK_QTY, A.SHORTAGE_QTY, + A.VENDOR, A.UNIT_PRICE, A.PROCESSING_UNIT_PRICE, A.TOTAL_PRICE, A.CURRENCY, + A.LEAD_TIME, A.MIN_ORDER_QTY, A.WRITER, A.EDITER, A.EDIT_DATE, A.REMARK + FROM MBOM_DETAIL A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.MBOM_HEADER_OBJID = $1 + AND A.STATUS = 'ACTIVE' + UNION ALL + SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.ITEM_QTY, B.QTY, + B.REGDATE, B.SEQ, B.STATUS, LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH), + B.UNIT, B.SUPPLY_TYPE, B.MAKE_OR_BUY, + B.RAW_MATERIAL_PART_NO, B.RAW_MATERIAL_SPEC, B.RAW_MATERIAL, B.RAW_MATERIAL_SIZE, + B.PROCESSING_VENDOR, B.PROCESSING_DEADLINE, B.GRINDING_DEADLINE, + B.REQUIRED_QTY, B.ORDER_QTY, B.PRODUCTION_QTY, B.STOCK_QTY, B.SHORTAGE_QTY, + B.VENDOR, B.UNIT_PRICE, B.PROCESSING_UNIT_PRICE, B.TOTAL_PRICE, B.CURRENCY, + B.LEAD_TIME, B.MIN_ORDER_QTY, B.WRITER, B.EDITER, B.EDIT_DATE, B.REMARK + FROM MBOM_DETAIL B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID + AND B.STATUS = 'ACTIVE' +) +SELECT + V.MBOM_HEADER_OBJID AS bom_report_objid, + V.OBJID AS objid, + V.PARENT_OBJID AS parent_objid, + V.CHILD_OBJID AS child_objid, + V.PART_OBJID AS part_objid, + V.PART_NO AS part_no, + V.PART_NAME AS part_name, + V.QTY AS qty, + V.ITEM_QTY AS item_qty, + V.QTY_TEMP AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ AS seq, + V.STATUS AS status, + V.UNIT AS unit, + V.SUPPLY_TYPE AS supply_type, + V.MAKE_OR_BUY AS make_or_buy, + V.RAW_MATERIAL_PART_NO AS raw_material_no, + V.RAW_MATERIAL_SPEC AS raw_material_spec, + V.RAW_MATERIAL AS raw_material, + V.RAW_MATERIAL_SIZE AS size, + V.PROCESSING_VENDOR AS processing_vendor, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = V.PROCESSING_VENDOR) AS processing_vendor_name, + V.PROCESSING_DEADLINE AS processing_deadline, + V.GRINDING_DEADLINE AS grinding_deadline, + V.REQUIRED_QTY AS required_qty, + V.ORDER_QTY AS order_qty, + V.PRODUCTION_QTY AS production_qty, + V.STOCK_QTY AS stock_qty, + V.SHORTAGE_QTY AS shortage_qty, + V.VENDOR AS vendor, + (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = V.VENDOR) AS vendor_name, + V.UNIT_PRICE AS unit_price, + V.PROCESSING_UNIT_PRICE AS processing_unit_price, + V.TOTAL_PRICE AS total_price, + V.CURRENCY AS currency, + V.LEAD_TIME AS lead_time, + V.MIN_ORDER_QTY AS min_order_qty, + V.WRITER AS writer, + TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate, + V.EDITER AS editer, + CASE WHEN V.EDIT_DATE IS NOT NULL THEN TO_CHAR(V.EDIT_DATE, 'YYYY-MM-DD HH24:MI:SS') END AS edit_date, + V.REMARK AS remark, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid, + 1 AS leaf, + P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, + COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length +FROM VIEW_BOM V +INNER JOIN PART_MNG P ON P.OBJID = V.PART_OBJID +ORDER BY V.PATH2 +`; + +const STRUCTURE_ONLY_SQL = ` +WITH RECURSIVE VIEW_BOM( + MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP, + REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE, + UNIT, WRITER, RAW_MATERIAL_PART_NO +) AS ( + SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.QTY, A.QTY, + A.REGDATE, A.SEQ, A.STATUS, 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE, + A.UNIT, A.WRITER, A.RAW_MATERIAL_PART_NO + FROM MBOM_DETAIL A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.MBOM_HEADER_OBJID = $1 + AND A.STATUS = 'ACTIVE' + UNION ALL + SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.QTY, B.QTY, + B.REGDATE, B.SEQ, B.STATUS, LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH), + B.UNIT, B.WRITER, B.RAW_MATERIAL_PART_NO + FROM MBOM_DETAIL B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID + AND B.STATUS = 'ACTIVE' +) +SELECT + V.MBOM_HEADER_OBJID AS bom_report_objid, + V.OBJID AS objid, + V.PARENT_OBJID AS parent_objid, + V.CHILD_OBJID AS child_objid, + V.PART_OBJID AS part_objid, + V.PART_NO AS part_no, + V.PART_NAME AS part_name, + V.QTY AS qty, + V.ITEM_QTY AS item_qty, + V.QTY_TEMP AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ, V.STATUS, V.UNIT, V.WRITER, + TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate, + NULL::text AS supply_type, NULL::text AS make_or_buy, + NULL::text AS raw_material_no, NULL::text AS raw_material_spec, + NULL::text AS raw_material, NULL::text AS size, + NULL::text AS processing_vendor, NULL::text AS processing_deadline, NULL::text AS grinding_deadline, + NULL::numeric AS required_qty, NULL::numeric AS order_qty, NULL::numeric AS production_qty, + NULL::numeric AS stock_qty, NULL::numeric AS shortage_qty, + NULL::text AS vendor, NULL::numeric AS unit_price, NULL::numeric AS processing_unit_price, + NULL::numeric AS total_price, NULL::text AS currency, + NULL::int AS lead_time, NULL::numeric AS min_order_qty, + NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid, + 1 AS leaf, + P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, + COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length +FROM VIEW_BOM V +INNER JOIN PART_MNG P ON P.OBJID = V.PART_OBJID +ORDER BY V.PATH2 +`; + +// TEMPLATE 트리는 SAVED 와 동일한 CTE — 운영판도 mbom_detail 한 테이블에서 가져옴. +// 차이: TEMPLATE 은 ORDER_QTY/PRODUCTION_QTY 를 결과에서 표시하지 않음 (재계산 대상). +const TEMPLATE_TREE_SQL = ` +WITH RECURSIVE VIEW_BOM( + MBOM_HEADER_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, QTY_TEMP, + REGDATE, SEQ, STATUS, LEV, PATH, PATH2, CYCLE, + UNIT, SUPPLY_TYPE, MAKE_OR_BUY, + RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE, + PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE, + REQUIRED_QTY, WRITER, EDITER, EDIT_DATE, REMARK +) AS ( + SELECT A.MBOM_HEADER_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PART_OBJID, A.PART_NO, A.PART_NAME, A.QTY, A.QTY, A.QTY, + A.REGDATE, A.SEQ, A.STATUS, 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE, + A.UNIT, A.SUPPLY_TYPE, A.MAKE_OR_BUY, + A.RAW_MATERIAL_PART_NO, A.RAW_MATERIAL_SPEC, A.RAW_MATERIAL, A.RAW_MATERIAL_SIZE, + A.PROCESSING_VENDOR, A.PROCESSING_DEADLINE, A.GRINDING_DEADLINE, + A.REQUIRED_QTY, A.WRITER, A.EDITER, A.EDIT_DATE, A.REMARK + FROM MBOM_DETAIL A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.MBOM_HEADER_OBJID = $1 + AND A.STATUS = 'ACTIVE' + UNION ALL + SELECT B.MBOM_HEADER_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PART_OBJID, B.PART_NO, B.PART_NAME, B.QTY, B.QTY, B.QTY, + B.REGDATE, B.SEQ, B.STATUS, LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH), + B.UNIT, B.SUPPLY_TYPE, B.MAKE_OR_BUY, + B.RAW_MATERIAL_PART_NO, B.RAW_MATERIAL_SPEC, B.RAW_MATERIAL, B.RAW_MATERIAL_SIZE, + B.PROCESSING_VENDOR, B.PROCESSING_DEADLINE, B.GRINDING_DEADLINE, + B.REQUIRED_QTY, B.WRITER, B.EDITER, B.EDIT_DATE, B.REMARK + FROM MBOM_DETAIL B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.MBOM_HEADER_OBJID = B.MBOM_HEADER_OBJID + AND B.STATUS = 'ACTIVE' +) +SELECT + V.MBOM_HEADER_OBJID AS bom_report_objid, + V.OBJID, V.PARENT_OBJID AS parent_objid, V.CHILD_OBJID AS child_objid, + V.PART_OBJID AS part_objid, V.PART_NO AS part_no, V.PART_NAME AS part_name, + V.QTY AS qty, V.ITEM_QTY AS item_qty, V.QTY_TEMP AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM MBOM_DETAIL WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ AS seq, V.STATUS AS status, + V.UNIT, V.SUPPLY_TYPE AS supply_type, V.MAKE_OR_BUY AS make_or_buy, + V.RAW_MATERIAL_PART_NO AS raw_material_no, + V.RAW_MATERIAL_SPEC AS raw_material_spec, + V.RAW_MATERIAL AS raw_material, + V.RAW_MATERIAL_SIZE AS size, + V.PROCESSING_VENDOR AS processing_vendor, + V.PROCESSING_DEADLINE AS processing_deadline, + V.GRINDING_DEADLINE AS grinding_deadline, + V.REQUIRED_QTY AS required_qty, + NULL::numeric AS order_qty, NULL::numeric AS production_qty, + V.WRITER AS writer, + TO_CHAR(V.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS regdate, + V.EDITER AS editer, + CASE WHEN V.EDIT_DATE IS NOT NULL THEN TO_CHAR(V.EDIT_DATE, 'YYYY-MM-DD HH24:MI:SS') END AS edit_date, + V.REMARK AS remark, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS root_objid, + CASE WHEN V.LEV = 1 THEN V.OBJID END AS sub_root_objid, + 1 AS leaf, + P.SPEC, P.MATERIAL, P.WEIGHT, P.PART_TYPE, P.REVISION, P.MAKER, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty, + COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length +FROM VIEW_BOM V +LEFT JOIN PART_MNG P ON V.PART_OBJID = P.OBJID +ORDER BY V.PATH2 +`; + +// 매퍼 partMng.getBOMTreeList search_type='working' 1:1. +// E-BOM 호환 — part_no 컬럼명 충돌 회피 위해 운영판처럼 V.PART_NO 는 PART_OBJID 로 alias. +const EBOM_WORKING_TREE_SQL = ` +WITH RECURSIVE VIEW_BOM( + BOM_REPORT_OBJID, OBJID, PARENT_OBJID, CHILD_OBJID, + PARENT_PART_NO, PART_NO, LAST_PART_OBJID, + QTY, ITEM_QTY, QTY_TEMP, REGDATE, SEQ, STATUS, + PART_MNG_NO, PARENT_PART_MNG_NO, LEV, PATH, PATH2, CYCLE +) AS ( + SELECT A.BOM_REPORT_OBJID, A.OBJID, A.PARENT_OBJID, A.CHILD_OBJID, + A.PARENT_PART_NO, A.PART_NO, A.LAST_PART_OBJID, + A.QTY, A.ITEM_QTY, A.QTY_TEMP, A.REGDATE, A.SEQ, A.STATUS, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = A.PART_NO) AS PART_MNG_NO, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = A.PARENT_PART_NO) AS PARENT_PART_MNG_NO, + 1, + ARRAY [A.CHILD_OBJID::TEXT], + ARRAY [LPAD(A.SEQ::TEXT, 10, '0')], + FALSE + FROM BOM_PART_QTY A + WHERE 1=1 + AND (A.PARENT_OBJID IS NULL OR A.PARENT_OBJID = '') + AND A.BOM_REPORT_OBJID = $1 + AND (A.STATUS NOT IN ('deleting', 'deleted') OR A.STATUS IS NULL) + UNION ALL + SELECT B.BOM_REPORT_OBJID, B.OBJID, B.PARENT_OBJID, B.CHILD_OBJID, + B.PARENT_PART_NO, B.PART_NO, B.LAST_PART_OBJID, + B.QTY, B.ITEM_QTY, B.QTY_TEMP, B.REGDATE, B.SEQ, B.STATUS, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = B.PART_NO) AS PART_MNG_NO, + (SELECT PART_NO FROM PART_MNG P WHERE P.OBJID::varchar = B.PARENT_PART_NO) AS PARENT_PART_MNG_NO, + LEV + 1, + PATH || B.CHILD_OBJID::TEXT, + PATH2 || LPAD(B.SEQ::TEXT, 10, '0'), + B.PARENT_OBJID = ANY(PATH) + FROM BOM_PART_QTY B + JOIN VIEW_BOM ON B.PARENT_OBJID = VIEW_BOM.CHILD_OBJID + AND VIEW_BOM.BOM_REPORT_OBJID = B.BOM_REPORT_OBJID + AND (B.STATUS NOT IN ('deleting', 'deleted') OR B.STATUS IS NULL) +) +SELECT + V.BOM_REPORT_OBJID AS bom_report_objid, + V.OBJID AS objid, + V.PARENT_OBJID AS parent_objid, + V.CHILD_OBJID AS child_objid, + V.PARENT_PART_NO AS parent_part_no, + V.PART_NO AS part_objid, + V.LAST_PART_OBJID AS bom_last_part_objid, + V.QTY AS qty, + V.ITEM_QTY AS item_qty, + (CASE WHEN V.STATUS = 'deploy' THEN V.QTY + WHEN V.STATUS = 'beforeEdit' THEN V.QTY + WHEN V.STATUS != 'editing' AND (V.QTY_TEMP IS NULL OR V.QTY_TEMP = '') THEN V.QTY + ELSE COALESCE(V.QTY_TEMP, V.QTY) END) AS qty_temp, + V.LEV AS level, + (SELECT COUNT(*) FROM BOM_PART_QTY WHERE PARENT_OBJID = V.CHILD_OBJID) AS sub_part_cnt, + V.SEQ AS seq, V.STATUS AS status, + P.OBJID AS last_part_objid, + P.PART_NAME AS part_name, + P.PART_NO AS part_no, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CODE_ID = P.UNIT) AS unit_title, + P.SPEC, P.MATERIAL, P.WEIGHT, P.REVISION, P.MAKER, + (SELECT CODE_NAME FROM COMM_CODE CC WHERE CODE_ID = P.PART_TYPE) AS part_type_title, + P.REMARK AS part_remark, + P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH, + P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt, + (SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt, + -- E-BOM 분기는 생산정보가 없으므로 NULL 로 채워 SAVED 와 동일한 키셋 유지 + NULL::text AS supply_type, NULL::text AS make_or_buy, + NULL::text AS raw_material_no, NULL::text AS raw_material_spec, + NULL::text AS raw_material, NULL::text AS size, + NULL::text AS processing_vendor, NULL::text AS processing_vendor_name, + NULL::text AS processing_deadline, NULL::text AS grinding_deadline, + NULL::numeric AS required_qty, NULL::numeric AS order_qty, NULL::numeric AS production_qty, + NULL::numeric AS stock_qty, NULL::numeric AS shortage_qty, + NULL::text AS vendor, NULL::text AS vendor_name, + NULL::numeric AS unit_price, NULL::numeric AS processing_unit_price, + NULL::numeric AS total_price, NULL::text AS currency, + NULL::int AS lead_time, NULL::numeric AS min_order_qty, + NULL::text AS writer, NULL::text AS regdate, NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark +FROM VIEW_BOM V +INNER JOIN PART_MNG P ON P.OBJID = COALESCE(V.LAST_PART_OBJID, V.PART_NO) +ORDER BY V.PATH2 +`; diff --git a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx index b17f84bc..115d8a66 100644 --- a/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/mbom/page.tsx @@ -1,10 +1,12 @@ "use client"; -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning/mBomMgmtList.jsp 1:1 +// 생산관리 > M-BOM 관리 — wace productionplanning/mBomMgmtList.jsp 1:1 // 그리드: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행) + M-BOM 상태/저장일 // 검색: 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / S/N / 품번 / 품명 / 접수일 / 요청납기 -// 액션 (PR-A1): 조회 / 초기화 / 페이지 -// ※ BOM 복사 / 구매리스트 생성 / M-BOM 편집 트리 다이얼로그는 PR-A2 이후 분리. +// 액션: +// PR-A1: 조회 / 초기화 / 페이지 +// PR-A2: 행 더블클릭 → MbomDetailDialog (헤더 + read-only 트리 4분기) +// ※ BOM 복사 / 구매리스트 생성 / M-BOM 본 편집 — PR-B 분리. import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -15,6 +17,7 @@ import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { apiClient } from "@/lib/api/client"; import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom"; +import { MbomDetailDialog } from "@/components/production/MbomDetailDialog"; const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id @@ -70,6 +73,9 @@ export default function MbomMgmtPage() { const [paidOpts, setPaidOpts] = useState([]); const [customerOpts, setCustomerOpts] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogObjid, setDialogObjid] = useState(null); + const fetchList = useCallback(async (override?: Partial) => { setLoading(true); try { @@ -260,8 +266,19 @@ export default function MbomMgmtPage() { showRowNumber emptyMessage="조건에 맞는 프로젝트가 없습니다." gridId="production-mbom-mgmt" + onRowDoubleClick={(row: any) => { + if (!row?.objid) return; + setDialogObjid(String(row.objid)); + setDialogOpen(true); + }} /> + + ); } diff --git a/frontend/components/production/MbomDetailDialog.tsx b/frontend/components/production/MbomDetailDialog.tsx new file mode 100644 index 00000000..f7ee38a7 --- /dev/null +++ b/frontend/components/production/MbomDetailDialog.tsx @@ -0,0 +1,232 @@ +"use client"; + +// 생산관리 > M-BOM 관리 — 단건 상세 + read-only 트리 다이얼로그. +// +// 운영판 통합: +// wace mBomHeaderPopup.jsp (헤더 메타) +// + wace mBomPopupLeft.jsp (read-only 트리 — 4분기 자동) +// +// 4분기 (운영판 mBomPopupLeft.do): +// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함) +// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 +// ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만 +// TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿 +// NONE 빈 트리 +// +// 본 편집 / BOM 복사 / 구매리스트 생성 / 변경이력 — PR-B 분리. + +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 } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + projectObjid: string | null; +} + +const BOM_DATA_TYPE_LABEL: Record = { + SAVED: { text: "저장된 M-BOM", color: "bg-emerald-600" }, + ASSIGNED_EBOM: { text: "할당된 E-BOM", color: "bg-sky-600" }, + ASSIGNED_MBOM: { text: "할당된 M-BOM", color: "bg-indigo-600" }, + TEMPLATE: { text: "M-BOM 템플릿", color: "bg-amber-600" }, + NONE: { text: "BOM 없음", color: "bg-slate-500" }, +}; + +export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) { + const [detail, setDetail] = useState(null); + const [tree, setTree] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !projectObjid) { + setDetail(null); setTree(null); + return; + } + let alive = true; + setLoading(true); + Promise.all([ + mbomApi.getDetail(projectObjid), + mbomApi.getTree(projectObjid), + ]) + .then(([d, t]) => { + if (!alive) return; + setDetail(d); + setTree(t); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패"); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, projectObjid]); + + const maxLevel = Math.max(1, tree?.max_level ?? 1); + const rows: MbomTreeRow[] = tree?.rows ?? []; + const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE"; + const meta = BOM_DATA_TYPE_LABEL[bomDataType]; + + const levelHeaders = useMemo(() => { + const h: number[] = []; + for (let i = 1; i <= maxLevel; i++) h.push(i); + return h; + }, [maxLevel]); + + return ( + + + + + M-BOM 관리 — 단건 상세 + {meta.text} + + + + {/* 헤더 메타 (운영판 mBomHeaderPopup.jsp 1:1) */} + {detail && ( +
+ + + + + + + + + + + + + + +
+ )} + +
+
+ 총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} + {tree?.bom_report_objid && ( + BOM_OBJID = {tree.bom_report_objid} + )} +
+
+ +
+ {loading ? ( +
+ +
+ ) : ( + + + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((r, idx) => { + const lv = Number(r.level ?? 1); + return ( + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + ); + })} + +
{i}품번품명수량항목수량단위자/사급Make/Buy소재품번소재규격필요수량주문수량생산수량가공업체가공납기연삭납기3D2DPDF비고
+ {bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."} +
+ {i === lv ? "*" : ""} + {r.part_no}{r.part_name}{fmtNum(r.qty)}{fmtNum(r.item_qty)}{r.unit_title ?? r.unit ?? ""}{r.supply_type ?? ""}{r.make_or_buy ?? ""}{r.raw_material_no ?? ""}{r.raw_material ?? ""}{r.size ?? ""}{fmtNum(r.required_qty)}{fmtNum(r.order_qty)}{fmtNum(r.production_qty)}{r.processing_vendor_name ?? r.processing_vendor ?? ""}{r.processing_deadline ?? ""}{r.grinding_deadline ?? ""}{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}{r.remark ?? ""}
+ )} +
+ + + + +
+
+ ); +} + +function MetaRow({ label, value, numeric }: { label: string; value: any; numeric?: boolean }) { + return ( +
+ {label} + + {value != null && value !== "" ? value : "—"} + +
+ ); +} + +function fmtNum(v: any): string { + if (v == null || v === "") return ""; + const n = Number(v); + if (!isFinite(n)) return String(v); + // 정수면 천 단위, 소수가 있으면 그대로 (4자리 까지 표시) + return Number.isInteger(n) + ? n.toLocaleString() + : n.toLocaleString(undefined, { maximumFractionDigits: 4 }); +} + +function paidLabel(v: string | null | undefined): string { + if (v === "paid") return "유상"; + if (v === "free") return "무상"; + return v ?? ""; +} diff --git a/frontend/lib/api/mbom.ts b/frontend/lib/api/mbom.ts index 6f75fd46..5d49098a 100644 --- a/frontend/lib/api/mbom.ts +++ b/frontend/lib/api/mbom.ts @@ -1,8 +1,11 @@ import { apiClient } from "./client"; // ============================================================ -// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 -// 라우트: /api/production/mbom/* +// 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 +// 라우트: +// GET /api/production/mbom/list (PR-A1, 그리드) +// GET /api/production/mbom/detail/:objid (PR-A2, 단건 상세) +// GET /api/production/mbom/tree/:objid (PR-A2, read-only 트리 4분기) // ============================================================ export interface MbomListFilter { @@ -66,9 +69,114 @@ export interface MbomListResponse { pageSize: number; } +// ─── 단건 상세 (PR-A2) ────────────────────────────────────── + +export interface MbomDetail { + objid: string; + contract_objid: string | null; + project_no: string | null; + bom_report_objid: string | null; + part_objid: string | null; + part_no: string | null; + part_name: string | null; + source_bom_type: string | null; + source_ebom_objid: string | null; + source_mbom_objid: string | null; + quantity: string | number | null; + total_prod_qty: string | number | null; + mbom_part_no: string | null; + category_cd: string | null; + category_name: string | null; + product: string | null; + product_code: string | null; + product_name: string | null; + area_cd: string | null; + area_name: string | null; + customer_objid: string | null; + customer_name: string | null; + paid_type: string | null; + req_del_date: string | null; + receipt_date: string | null; + mbom_regdate: string | null; +} + +// ─── read-only 트리 (PR-A2) ───────────────────────────────── +// 운영판 mBomPopupLeft.do 4분기 자동 판별: +// SAVED — mbom_header.status='Y' 최신 +// ASSIGNED_EBOM — source_bom_type='EBOM' + source_ebom_objid +// ASSIGNED_MBOM — source_bom_type='MBOM' + source_mbom_objid +// TEMPLATE — Machine 이외 + 동일 part_no 의 mbom_header +// NONE — 빈 트리 + +export type MbomBomDataType = "SAVED" | "ASSIGNED_EBOM" | "ASSIGNED_MBOM" | "TEMPLATE" | "NONE"; + +export interface MbomTreeRow { + objid: string; + parent_objid: string | null; + child_objid: string | null; + part_objid: string | null; + part_no: string | null; + part_name: string | null; + qty: string | number | null; + item_qty: string | number | null; + qty_temp: string | number | null; + level: number; + sub_part_cnt: number; + seq: number; + status: string | null; + unit: string | null; + unit_title: string | null; + supply_type: string | null; + make_or_buy: string | null; + raw_material_no: string | null; + raw_material_spec: string | null; + raw_material: string | null; + size: string | null; + processing_vendor: string | null; + processing_vendor_name: string | null; + processing_deadline: string | null; + grinding_deadline: string | null; + required_qty: string | number | null; + order_qty: string | number | null; + production_qty: string | number | null; + vendor: string | null; + vendor_name: string | null; + unit_price: string | number | null; + total_price: string | number | null; + currency: string | null; + writer: string | null; + regdate: string | null; + editer: string | null; + edit_date: string | null; + remark: string | null; + spec: string | null; + material: string | null; + weight: string | number | null; + revision: string | null; + cu01_cnt: number; + cu02_cnt: number; + cu03_cnt: number; + [key: string]: any; +} + +export interface MbomTreeResponse { + bom_data_type: MbomBomDataType; + bom_report_objid: string | null; + max_level: number; + rows: MbomTreeRow[]; +} + export const mbomApi = { async list(filter: MbomListFilter = {}): Promise { const res = await apiClient.get("/production/mbom/list", { params: filter }); return res.data?.data as MbomListResponse; }, + async getDetail(objid: string): Promise { + const res = await apiClient.get(`/production/mbom/detail/${encodeURIComponent(objid)}`); + return res.data?.data as MbomDetail; + }, + async getTree(objid: string): Promise { + const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`); + return res.data?.data as MbomTreeResponse; + }, };