생산관리>M-BOM 관리 — PR-A2 단건 상세 + read-only 트리 4분기 (wace mBomPopupLeft.do 1:1)

행 더블클릭 → 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) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 16:29:52 +09:00
parent 55239547d6
commit dd88dc6e8c
6 changed files with 992 additions and 17 deletions
+29 -2
View File
@@ -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 });
}
}
@@ -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;
+597 -8
View File
@@ -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<MbomTreeResult> {
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
`;
@@ -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<CodeOpt[]>([]);
const [customerOpts, setCustomerOpts] = useState<CustomerOpt[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogObjid, setDialogObjid] = useState<string | null>(null);
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
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);
}}
/>
</div>
<MbomDetailDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
projectObjid={dialogObjid}
/>
</div>
);
}
@@ -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<MbomBomDataType, { text: string; color: string }> = {
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<MbomDetail | null>(null);
const [tree, setTree] = useState<MbomTreeResponse | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[1600px] 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 flex items-center gap-3">
<span>M-BOM </span>
<span className={cn("rounded px-2 py-0.5 text-xs font-semibold", meta.color)}>{meta.text}</span>
</DialogTitle>
</DialogHeader>
{/* 헤더 메타 (운영판 mBomHeaderPopup.jsp 1:1) */}
{detail && (
<div className="grid grid-cols-4 gap-x-6 gap-y-1.5 border-b px-4 py-3 text-xs">
<MetaRow label="프로젝트번호" value={detail.project_no} />
<MetaRow label="주문유형" value={detail.category_name} />
<MetaRow label="제품구분" value={detail.product_name} />
<MetaRow label="국내/해외" value={detail.area_name} />
<MetaRow label="고객사" value={detail.customer_name} />
<MetaRow label="유/무상" value={paidLabel(detail.paid_type)} />
<MetaRow label="품번" value={detail.part_no} />
<MetaRow label="품명" value={detail.part_name} />
<MetaRow label="수주수량" value={fmtNum(detail.quantity)} numeric />
<MetaRow label="총생산수량" value={fmtNum(detail.total_prod_qty)} numeric />
<MetaRow label="요청납기" value={detail.req_del_date} />
<MetaRow label="접수일" value={detail.receipt_date} />
<MetaRow label="M-BOM 품번" value={detail.mbom_part_no || "—"} />
<MetaRow label="M-BOM 저장일" value={detail.mbom_regdate || "—"} />
</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}
{tree?.bom_report_objid && (
<span className="ml-2 text-muted-foreground/70">BOM_OBJID = {tree.bom_report_objid}</span>
)}
</div>
</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-[60px] text-center"></th>
<th className="border px-2 py-1 min-w-[70px] text-center">/</th>
<th className="border px-2 py-1 min-w-[80px] text-center">Make/Buy</th>
<th className="border px-2 py-1 min-w-[120px] text-left"></th>
<th className="border px-2 py-1 min-w-[120px] 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-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></th>
<th className="border px-2 py-1 min-w-[80px] text-right"></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-[100px] text-center"></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-[120px] text-left"></th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={levelHeaders.length + 19} className="py-8 text-center text-muted-foreground">
{bomDataType === "NONE" ? "표시할 BOM이 없습니다." : "트리가 비어있습니다."}
</td>
</tr>
)}
{rows.map((r, idx) => {
const lv = Number(r.level ?? 1);
return (
<tr key={`${r.objid}_${idx}`} className="hover:bg-muted/30">
{levelHeaders.map((i) => (
<td key={`lc${i}`} className={cn("border px-1 py-0.5 text-center", i === lv && "font-bold")}>
{i === lv ? "*" : ""}
</td>
))}
<td className="border px-2 py-0.5 whitespace-nowrap">{r.part_no}</td>
<td className="border px-2 py-0.5">{r.part_name}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.qty)}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.item_qty)}</td>
<td className="border px-2 py-0.5 text-center">{r.unit_title ?? r.unit ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.supply_type ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.make_or_buy ?? ""}</td>
<td className="border px-2 py-0.5">{r.raw_material_no ?? ""}</td>
<td className="border px-2 py-0.5">{r.raw_material ?? ""}</td>
<td className="border px-2 py-0.5">{r.size ?? ""}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.required_qty)}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.order_qty)}</td>
<td className="border px-2 py-0.5 text-right">{fmtNum(r.production_qty)}</td>
<td className="border px-2 py-0.5">{r.processing_vendor_name ?? r.processing_vendor ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.processing_deadline ?? ""}</td>
<td className="border px-2 py-0.5 text-center">{r.grinding_deadline ?? ""}</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.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, numeric }: { label: string; value: any; numeric?: boolean }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground w-[80px] shrink-0">{label}</span>
<span className={cn("font-medium", numeric && "tabular-nums")}>
{value != null && value !== "" ? value : "—"}
</span>
</div>
);
}
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 ?? "";
}
+110 -2
View File
@@ -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<MbomListResponse> {
const res = await apiClient.get("/production/mbom/list", { params: filter });
return res.data?.data as MbomListResponse;
},
async getDetail(objid: string): Promise<MbomDetail> {
const res = await apiClient.get(`/production/mbom/detail/${encodeURIComponent(objid)}`);
return res.data?.data as MbomDetail;
},
async getTree(objid: string): Promise<MbomTreeResponse> {
const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`);
return res.data?.data as MbomTreeResponse;
},
};