// ============================================================ // 생산관리 > M-BOM 관리 — wace productionplanning.xml 1:1 이식. // // 매퍼 매핑 (원본: 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) // // 트리 분기 (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"; import { createObjId } from "../utils/objidUtil"; // ─── 필터/페이지 타입 ────────────────────────────────────────── export interface MbomListFilter { search_category_cd?: string; search_product_cd?: string; search_area_cd?: string; search_customer_objid?: string; search_paid_type?: string; search_serial_no?: string; search_part_no?: string; search_part_name?: string; search_receipt_date_from?: string; search_receipt_date_to?: string; search_req_del_date_from?: string; search_req_del_date_to?: string; page?: number; page_size?: number; } 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 }; } // ─── WHERE 절 빌더 (매퍼 mBomMgmtGridList 조건 1:1) ────── function buildWhere(filter: MbomListFilter, startIdx: number) { const params: any[] = []; const conds: string[] = []; let idx = startIdx; if (filter.search_category_cd) { conds.push(`CM.CATEGORY_CD = $${idx++}`); params.push(filter.search_category_cd); } if (filter.search_product_cd) { conds.push(`CM.PRODUCT = $${idx++}`); params.push(filter.search_product_cd); } if (filter.search_area_cd) { // 운영판: CODE_NAME(CM.AREA_CD) = '국내'/'해외' (사람이 보는 값으로 검색) conds.push(`(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1) = $${idx++}`); params.push(filter.search_area_cd); } if (filter.search_receipt_date_from) { conds.push(`PM.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_receipt_date_from); } if (filter.search_receipt_date_to) { conds.push(`PM.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`); params.push(filter.search_receipt_date_to); } if (filter.search_customer_objid) { // 운영판 3-way 매칭 (C_xxx 양방향) 1:1 conds.push( `(CM.CUSTOMER_OBJID = $${idx} OR CM.CUSTOMER_OBJID = REPLACE($${idx}, 'C_', '') OR 'C_' || CM.CUSTOMER_OBJID = $${idx})` ); params.push(filter.search_customer_objid); idx++; } if (filter.search_paid_type) { conds.push(`CM.PAID_TYPE = $${idx++}`); params.push(filter.search_paid_type); } if (filter.search_serial_no) { conds.push( `EXISTS (SELECT 1 FROM CONTRACT_ITEM_SERIAL CIS WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID AND UPPER(CIS.STATUS) = 'ACTIVE' AND UPPER(CIS.SERIAL_NO) LIKE '%' || UPPER($${idx++}) || '%')` ); params.push(filter.search_serial_no); } if (filter.search_req_del_date_from) { conds.push(`COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) >= $${idx++}`); params.push(filter.search_req_del_date_from); } if (filter.search_req_del_date_to) { conds.push(`COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) <= $${idx++}`); params.push(filter.search_req_del_date_to); } if (filter.search_part_no) { conds.push( `(UPPER(PM.PART_NO) LIKE '%' || UPPER($${idx}) || '%' OR UPPER(CI.PART_NO) LIKE '%' || UPPER($${idx}) || '%')` ); params.push(filter.search_part_no); idx++; } if (filter.search_part_name) { conds.push( `(UPPER(PM.PART_NAME) LIKE '%' || UPPER($${idx}) || '%' OR UPPER(CI.PART_NAME) LIKE '%' || UPPER($${idx}) || '%')` ); params.push(filter.search_part_name); idx++; } return { sql: conds.length ? "AND " + conds.join(" AND ") : "", params }; } // ─── M-BOM 관리 그리드 ────────────────────────────────────────── // // 매퍼 productionplanning.xml mBomMgmtGridList (라인 2874~3119) 1:1 이식. export async function list(filter: MbomListFilter) { const { limit, offset, page, pageSize } = paginate(filter); const where = buildWhere(filter, 1); const pool = getPool(); const baseSql = ` FROM PROJECT_MGMT PM LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID LEFT OUTER JOIN CONTRACT_ITEM CI ON ( CASE WHEN PM.CONTRACT_ITEM_OBJID IS NOT NULL THEN CI.OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID ELSE CI.CONTRACT_OBJID = PM.CONTRACT_OBJID AND CI.PART_OBJID = PM.PART_OBJID END ) AND CI.STATUS = 'ACTIVE' WHERE 1=1 AND PM.PROJECT_NO IS NOT NULL AND PM.PROJECT_NO != '' ${where.sql} `; const dataSql = ` SELECT PM.OBJID, PM.CONTRACT_OBJID, PM.PROJECT_NO, CM.CATEGORY_CD, COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD LIMIT 1), '') AS CATEGORY_NAME, CM.PRODUCT, COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT LIMIT 1), '') AS PRODUCT_NAME, CM.AREA_CD, COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.AREA_CD LIMIT 1), '') AS AREA_NAME, TO_CHAR(PM.REGDATE, 'YYYY-MM-DD') AS RECEIPT_DATE, (SELECT user_name(MH.WRITER) FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY MH.REGDATE DESC LIMIT 1) AS WRITER_NAME, CM.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, COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PAID_TYPE LIMIT 1), CASE WHEN CM.PAID_TYPE = 'paid' THEN '유상' WHEN CM.PAID_TYPE = 'free' THEN '무상' ELSE '' END ) AS PAID_TYPE_NAME, -- 품목 정보: CONTRACT_ITEM 우선, 없으면 PM COALESCE(CI.PART_NO, PM.PART_NO, '') AS PART_NO, COALESCE(CI.PART_NAME, PM.PART_NAME, '') AS PART_NAME, CI.PART_OBJID, -- S/N 표시: "첫S/N 외 N건" (SELECT CASE WHEN COUNT(*) = 0 THEN '' WHEN COUNT(*) = 1 THEN MIN(CIS.SERIAL_NO) ELSE MIN(CIS.SERIAL_NO) || ' 외 ' || (COUNT(*) - 1)::TEXT || '건' END FROM CONTRACT_ITEM_SERIAL CIS WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID AND UPPER(CIS.STATUS) = 'ACTIVE' AND CIS.SERIAL_NO IS NOT NULL AND CIS.SERIAL_NO != '' ) AS SERIAL_NO, -- S/N 전체 (콤마 리스트) — 팝업용 (SELECT STRING_AGG(CIS.SERIAL_NO, ', ' ORDER BY CIS.SERIAL_NO) FROM CONTRACT_ITEM_SERIAL CIS WHERE CIS.ITEM_OBJID::VARCHAR = PM.CONTRACT_ITEM_OBJID AND UPPER(CIS.STATUS) = 'ACTIVE' AND CIS.SERIAL_NO IS NOT NULL AND CIS.SERIAL_NO != '' ) AS SERIAL_NO_LIST, COALESCE(NULLIF(PM.QUANTITY, '')::numeric, NULLIF(CI.ORDER_QUANTITY, '')::numeric, 0) AS QUANTITY, COALESCE(CI.DUE_DATE, PM.DUE_DATE, CM.REQ_DEL_DATE) AS REQ_DEL_DATE, COALESCE(CI.CUSTOMER_REQUEST, '') AS CUSTOMER_REQUEST, -- E-BOM 정보 COALESCE(CI.PART_OBJID, PM.PART_OBJID) AS BOM_REPORT_OBJID, COALESCE((SELECT PBR.STATUS FROM PART_BOM_REPORT PBR WHERE PBR.OBJID::VARCHAR = COALESCE(CI.PART_OBJID, PM.PART_OBJID) LIMIT 1), '') AS EBOM_STATUS, COALESCE((SELECT TO_CHAR(PBR.REGDATE, 'YYYY-MM-DD') FROM PART_BOM_REPORT PBR WHERE PBR.OBJID::VARCHAR = COALESCE(CI.PART_OBJID, PM.PART_OBJID) LIMIT 1), '') AS EBOM_REGDATE, -- M-BOM HEADER OBJID (SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY MH.REGDATE DESC LIMIT 1) AS MBOM_HEADER_OBJID, -- 구매리스트 OBJID + 생성일 (SELECT SRM.OBJID::VARCHAR FROM SALES_REQUEST_MASTER SRM WHERE SRM.MBOM_HEADER_OBJID = ( SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY MH.REGDATE DESC LIMIT 1 ) LIMIT 1) AS PURCHASE_LIST_OBJID, (SELECT TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') FROM SALES_REQUEST_MASTER SRM WHERE SRM.MBOM_HEADER_OBJID = ( SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY MH.REGDATE DESC LIMIT 1 ) LIMIT 1) AS PURCHASE_LIST_DATE, -- M-BOM 상태 ('Y' 있으면 Y, 아니면 PM.mbom_status raw) COALESCE( (SELECT CASE WHEN COUNT(*) > 0 THEN 'Y' ELSE COALESCE(PM.MBOM_STATUS, '') END FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' LIMIT 1), COALESCE(PM.MBOM_STATUS, '') ) AS MBOM_STATUS, -- M-BOM 품번 COALESCE( (SELECT MH.MBOM_NO FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY MH.REGDATE DESC LIMIT 1), '' ) AS MBOM_PART_NO, -- M-BOM 저장일 (수정일 우선, 없으면 등록일) (SELECT TO_CHAR(COALESCE(MH.EDIT_DATE, MH.REGDATE), 'YYYY-MM-DD') FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY COALESCE(MH.EDIT_DATE, MH.REGDATE) DESC LIMIT 1) AS MBOM_REGDATE, -- M-BOM 작성자/수정자 (SELECT user_name(COALESCE(MH.EDITER, MH.WRITER)) FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY COALESCE(MH.EDITER, MH.WRITER) DESC LIMIT 1) AS MBOM_EDITOR, -- M-BOM 변경이력 카운트 (0이면 NULL) NULLIF( (SELECT COUNT(1)::INTEGER FROM MBOM_HISTORY MHI WHERE MHI.MBOM_HEADER_OBJID = ( SELECT MH.OBJID::VARCHAR FROM MBOM_HEADER MH WHERE MH.PROJECT_OBJID = PM.OBJID::VARCHAR AND MH.STATUS = 'Y' ORDER BY MH.REGDATE DESC LIMIT 1 )), 0 ) AS MBOM_VERSION ${baseSql} ORDER BY PM.REGDATE DESC, CI.PART_NO LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2} `; const countSql = `SELECT COUNT(*)::int AS cnt ${baseSql}`; const dataParams = [...where.params, limit, offset]; const [dataRes, countRes] = await Promise.all([ pool.query(dataSql, dataParams), pool.query(countSql, where.params), ]); return { rows: dataRes.rows, totalCount: countRes.rows[0]?.cnt ?? 0, page, 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::varchar = 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::varchar = 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::varchar = 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::varchar = 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::varchar = 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::varchar = 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::varchar = 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::varchar = 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::varchar = 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 `; // ─── 저장 (PR-B1) ─────────────────────────────────────────── // // 운영판 ProductionPlanningController.saveMbom (1549~1645) + 서비스 saveMbom (1192~1574) 1:1. // 매퍼: insertMbomHeader / updateMbomHeader / insertMbomDetail / updateMbomDetail // / deleteMbomDetailByObjid / insertMbomHistory / updateProjectMbomStatus. // // 분기 처리: // isUpdate=false (최초 저장) — 새 mbom_header 생성 + child_objid 재매핑 후 detail 일괄 insert // + history(CREATE) + project_mgmt.mbom_status='Y' // isUpdate=true (수정 저장) — 기존 mbom_header.objid 조회 → updateMbomHeader // → mbom_data 의 objid 기준 UPSERT(insert/update) + 누락분 delete // + history(UPDATE, 변경 행수 description) export interface MbomSaveRow { objid?: string | null; parent_objid?: string | null; child_objid?: string | null; seq?: number | string | null; level?: number | string | null; part_objid?: string | number | null; part_no?: string | null; part_name?: string | null; qty?: number | string | null; item_qty?: number | string | null; unit?: 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; // tree row alias → raw_material_size raw_material_size?: string | null; processing_vendor?: string | null; processing_deadline?: string | null; grinding_deadline?: string | null; required_qty?: number | string | null; order_qty?: number | string | null; production_qty?: number | string | null; stock_qty?: number | string | null; shortage_qty?: number | string | null; vendor?: string | null; unit_price?: number | string | null; processing_unit_price?: number | string | null; total_price?: number | string | null; currency?: string | null; lead_time?: number | string | null; min_order_qty?: number | string | null; remark?: string | null; } export interface MbomSavePayload { project_obj_id: string; is_update: boolean; mbom_part_no?: string | null; // 최상위 제품 변경 시 (PR-B1 에서는 신규/유지만) rows: MbomSaveRow[]; } export interface MbomSaveResult { mode: "CREATE" | "UPDATE"; mbom_header_objid: string; mbom_no: string; inserted: number; updated: number; deleted: number; } // generateMbomNo — wace generateMbomNo(EBOM/TEMPLATE) 1:1. // 패턴: M-{cleanPartNo}-{YYMMDD}-{NN} (NN = 동일 prefix 마지막+1, 미존재 시 01) async function generateMbomNo( client: any, sourceBomType: string, basePartNo: string, ): Promise { let cleanPartNo = (basePartNo || "").trim(); if (cleanPartNo.startsWith("M-")) cleanPartNo = cleanPartNo.substring(2); const now = new Date(); const yy = String(now.getFullYear()).slice(-2); const mm = String(now.getMonth() + 1).padStart(2, "0"); const dd = String(now.getDate()).padStart(2, "0"); const dateStr = `${yy}${mm}${dd}`; const prefix = `M-${cleanPartNo}-${dateStr}`; const r = await client.query( `SELECT MBOM_NO FROM MBOM_HEADER WHERE MBOM_NO LIKE $1 || '-%' ORDER BY MBOM_NO DESC LIMIT 1`, [prefix], ); let seq = 1; if (r.rows[0]?.mbom_no) { const m = String(r.rows[0].mbom_no).match(/-(\d{2})$/); if (m) seq = Math.min(99, Number(m[1]) + 1); } return `${prefix}-${String(seq).padStart(2, "0")}`; } const DETAIL_INSERT_SQL = ` INSERT INTO MBOM_DETAIL ( OBJID, MBOM_HEADER_OBJID, PARENT_OBJID, CHILD_OBJID, SEQ, LEVEL, PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, 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, STATUS, WRITER, REGDATE, EDITER, EDIT_DATE, REMARK ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, $15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26, $27,$28,$29,$30,$31,$32,$33, 'ACTIVE', $34, NOW(), $34, NOW(), $35 )`; const DETAIL_UPDATE_SQL = ` UPDATE MBOM_DETAIL SET PARENT_OBJID = NULLIF($1, ''), SEQ = $2, LEVEL = $3, PART_OBJID = $4, PART_NO = NULLIF($5, ''), PART_NAME = NULLIF($6, ''), QTY = $7, ITEM_QTY = $8, UNIT = NULLIF($9, ''), SUPPLY_TYPE = NULLIF($10, ''), MAKE_OR_BUY = NULLIF($11, ''), RAW_MATERIAL_PART_NO = NULLIF($12, ''), RAW_MATERIAL_SPEC = NULLIF($13, ''), RAW_MATERIAL = NULLIF($14, ''), RAW_MATERIAL_SIZE = NULLIF($15, ''), PROCESSING_VENDOR = NULLIF($16, ''), PROCESSING_DEADLINE = NULLIF($17, ''), GRINDING_DEADLINE = NULLIF($18, ''), REQUIRED_QTY = $19, ORDER_QTY = $20, PRODUCTION_QTY = $21, STOCK_QTY = $22, SHORTAGE_QTY = $23, EDITER = $24, EDIT_DATE = NOW(), REMARK = NULLIF($25, '') WHERE OBJID = $26`; function n(v: any): number | null { if (v == null || v === "") return null; const num = Number(v); return Number.isFinite(num) ? num : null; } function s(v: any): string | null { if (v == null) return null; const str = String(v).trim(); return str === "" ? null : str; } function bi(v: any): string | null { // part_objid 는 bigint — 숫자/문자열 모두 수용, 빈값/NaN 은 null if (v == null || v === "") return null; const num = Number(v); return Number.isFinite(num) ? String(Math.trunc(num)) : null; } function detailInsertParams( row: MbomSaveRow, objid: string, mbomHeaderObjid: string, childObjid: string, parentObjid: string | null, userId: string, ): any[] { return [ objid, mbomHeaderObjid, parentObjid, childObjid, n(row.seq) ?? 999, n(row.level) ?? 1, bi(row.part_objid), s(row.part_no), s(row.part_name), n(row.qty), n(row.item_qty), s(row.unit), s(row.supply_type), s(row.make_or_buy), s(row.raw_material_no), s(row.raw_material_spec), s(row.raw_material), s(row.raw_material_size ?? row.size), s(row.processing_vendor), s(row.processing_deadline), s(row.grinding_deadline), n(row.required_qty), n(row.order_qty), n(row.production_qty), n(row.stock_qty), n(row.shortage_qty), s(row.vendor), n(row.unit_price), n(row.processing_unit_price), n(row.total_price), s(row.currency) ?? "KRW", n(row.lead_time), n(row.min_order_qty), userId, s(row.remark), ]; } function detailUpdateParams(row: MbomSaveRow, objid: string, userId: string): any[] { return [ s(row.parent_objid) ?? "", n(row.seq) ?? 999, n(row.level) ?? 1, bi(row.part_objid), s(row.part_no) ?? "", s(row.part_name) ?? "", n(row.qty), n(row.item_qty), s(row.unit) ?? "", s(row.supply_type) ?? "", s(row.make_or_buy) ?? "", s(row.raw_material_no) ?? "", s(row.raw_material_spec) ?? "", s(row.raw_material) ?? "", s(row.raw_material_size ?? row.size) ?? "", s(row.processing_vendor) ?? "", s(row.processing_deadline) ?? "", s(row.grinding_deadline) ?? "", n(row.required_qty), n(row.order_qty), n(row.production_qty), n(row.stock_qty), n(row.shortage_qty), userId, s(row.remark) ?? "", objid, ]; } export async function save(payload: MbomSavePayload, sessionUserId: string): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const projectObjId = String(payload.project_obj_id ?? "").trim(); if (!projectObjId) throw new Error("project_obj_id 누락"); const userId = String(sessionUserId ?? "").trim() || "system"; // 1) 프로젝트 + 할당 정보 조회 (sourceBomType + basePartNo) const proj = await client.query( `SELECT PM.OBJID::VARCHAR AS 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, CM.PRODUCT AS product_code, (SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR WHERE PBR.OBJID::VARCHAR = PM.SOURCE_EBOM_OBJID LIMIT 1) AS ebom_part_no, (SELECT MH.MBOM_NO FROM MBOM_HEADER MH WHERE MH.OBJID = PM.SOURCE_MBOM_OBJID LIMIT 1) AS source_mbom_no FROM PROJECT_MGMT PM INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID WHERE PM.OBJID::VARCHAR = $1 LIMIT 1`, [projectObjId], ); if (!proj.rows[0]) throw new Error("프로젝트 정보를 찾을 수 없습니다"); const p = proj.rows[0]; let sourceBomType: string = p.source_bom_type ?? ""; let sourceEbomObjid: string | null = null; let sourceMbomObjid: string | null = null; let basePartNo = ""; if (sourceBomType === "EBOM") { sourceEbomObjid = p.source_ebom_objid ?? null; basePartNo = p.ebom_part_no ?? p.part_no ?? ""; } else if (sourceBomType === "MBOM") { sourceMbomObjid = p.source_mbom_objid ?? null; basePartNo = p.source_mbom_no ?? p.part_no ?? ""; } else { // Machine 이외(product != 0000928) + part_no 가 있으면 TEMPLATE if (p.product_code !== "0000928" && p.part_no) { sourceBomType = "TEMPLATE"; basePartNo = p.part_no; } else { throw new Error("M-BOM 기준 정보가 없습니다. BOM 복사 팝업에서 먼저 기준을 설정해주세요."); } } let mbomHeaderObjid: string; let mbomNo: string; let inserted = 0, updated = 0, deleted = 0; let mode: "CREATE" | "UPDATE"; if (payload.is_update) { // ── UPDATE 분기 ── const exist = await client.query( `SELECT OBJID AS objid, MBOM_NO AS mbom_no FROM MBOM_HEADER WHERE PROJECT_OBJID = $1 AND STATUS = 'Y' ORDER BY REGDATE DESC LIMIT 1`, [projectObjId], ); if (!exist.rows[0]) throw new Error("수정 대상 M-BOM 헤더를 찾을 수 없습니다"); mbomHeaderObjid = exist.rows[0].objid; mbomNo = exist.rows[0].mbom_no; mode = "UPDATE"; // 헤더 update await client.query( `UPDATE MBOM_HEADER SET PART_NO = $1, PART_NAME = $2, EDITER = $3, EDIT_DATE = NOW() WHERE OBJID = $4`, [p.part_no ?? "", p.part_name ?? "", userId, mbomHeaderObjid], ); // 기존 detail objid 수집 const existRes = await client.query( `SELECT OBJID AS objid FROM MBOM_DETAIL WHERE MBOM_HEADER_OBJID = $1`, [mbomHeaderObjid], ); const existIds = new Set(existRes.rows.map((r: any) => r.objid)); const incomingIds = new Set(); // 신규 행의 client temp- child_objid → 서버 발급 createObjId 매핑 // (객체 ID 안정성 + DB 에 temp- 잔존 방지) const tempChildMap = new Map(); for (const row of payload.rows ?? []) { if (!s(row.objid)) { const newId = createObjId(); const tempChild = s(row.child_objid); if (tempChild) tempChildMap.set(tempChild, newId); // 임시로 row 자체에 새 objid 부여 — 이어지는 루프에서 다시 읽지 않도록 (row as any).__newObjid = newId; } } // UPSERT for (const row of payload.rows ?? []) { let objid = s(row.objid) ?? ""; const isNew = !objid; if (isNew) objid = (row as any).__newObjid as string; // 새 행이면 child_objid 도 새 ID 로 통일, 기존 행이면 그대로 let childObjid = isNew ? objid : (s(row.child_objid) ?? objid); // parent_objid: temp- 참조면 신규 발급 ID 로 remap, 아니면 그대로 const rawParent = s(row.parent_objid); const parentObjid = rawParent && tempChildMap.has(rawParent) ? tempChildMap.get(rawParent)! : rawParent; incomingIds.add(objid); if (existIds.has(objid)) { await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, parent_objid: parentObjid }, objid, userId)); updated++; } else { await client.query( DETAIL_INSERT_SQL, detailInsertParams(row, objid, mbomHeaderObjid, childObjid, parentObjid, userId), ); inserted++; } } // 누락 행 delete for (const oldId of existIds) { if (!incomingIds.has(oldId)) { await client.query(`DELETE FROM MBOM_DETAIL WHERE OBJID = $1`, [oldId]); deleted++; } } // history UPDATE await insertHistory( client, mbomHeaderObjid, "UPDATE", `${inserted + updated + deleted}개 항목 처리 (insert=${inserted}, update=${updated}, delete=${deleted})`, userId, ); } else { // ── CREATE 분기 ── mbomHeaderObjid = createObjId(); mbomNo = await generateMbomNo(client, sourceBomType, basePartNo); mode = "CREATE"; await client.query( `INSERT INTO MBOM_HEADER ( OBJID, MBOM_NO, SOURCE_BOM_TYPE, SOURCE_EBOM_OBJID, SOURCE_MBOM_OBJID, PROJECT_OBJID, PART_NO, PART_NAME, STATUS, MBOM_STATUS, WRITER, REGDATE ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'Y','DRAFT',$9,NOW())`, [ mbomHeaderObjid, mbomNo, sourceBomType, sourceEbomObjid, sourceMbomObjid, projectObjId, p.part_no ?? "", p.part_name ?? "", userId, ], ); // child_objid 재매핑 (E-BOM/MBOM 의 기존 child_objid → 새 child_objid) const childMap = new Map(); for (const row of payload.rows ?? []) { const oldChild = s(row.child_objid); if (oldChild && !childMap.has(oldChild)) childMap.set(oldChild, createObjId()); } for (const row of payload.rows ?? []) { const oldChild = s(row.child_objid); const oldParent = s(row.parent_objid); const newChild = (oldChild && childMap.get(oldChild)) || createObjId(); const newParent = oldParent ? (childMap.get(oldParent) ?? oldParent) : null; await client.query( DETAIL_INSERT_SQL, detailInsertParams(row, newChild, mbomHeaderObjid, newChild, newParent, userId), ); inserted++; } // history CREATE await insertHistory( client, mbomHeaderObjid, "CREATE", `M-BOM 신규 저장 (${inserted}개 항목)`, userId, ); // PROJECT_MGMT.MBOM_STATUS = 'Y' await client.query( `UPDATE PROJECT_MGMT SET MBOM_STATUS = 'Y', MBOM_WRITER = $1, MBOM_REGDATE = NOW() WHERE OBJID::VARCHAR = $2`, [userId, projectObjId], ); } await client.query("COMMIT"); return { mode, mbom_header_objid: mbomHeaderObjid, mbom_no: mbomNo, inserted, updated, deleted }; } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } // ─── BOM 할당 (PR-B5) ─────────────────────────────────────── // // 매퍼 productionplanning.getEbomList (3221~3265) 1:1 — 할당 가능한 E-BOM 검색. // 매퍼 productionplanning.saveBomAssignment (3545~3553) 1:1 — project_mgmt.source_bom_type/source_*_objid 업데이트. // // 운영판 흐름 (mBomEbomSelectPopup.do + assignEbomToMbom.do): // 1) 사용자가 BOM 할당 다이얼로그 오픈 → E-BOM 검색 (part_bom_report) // 2) 한 건 선택 → assign(projectObjid, 'EBOM', bomReportObjid) 호출 // 3) project_mgmt.source_bom_type='EBOM' + source_ebom_objid 저장 // 4) M-BOM 다이얼로그 재조회 → ASSIGNED_EBOM 트리 자동 표시 // (M-BOM 할당은 PR-B5 v2 — 우선 E-BOM 만) export interface AssignableEbomFilter { search_part_no?: string; search_part_name?: string; search_material?: string; search_supplier?: string; limit?: number; } export interface AssignableEbomRow { objid: string; product_cd: string | null; product_name: string | null; part_no: string | null; part_name: string | null; status: string | null; revision: string | null; reg_date: string | null; writer_name: string | null; dept_name: string | null; material: string | null; supplier: string | null; } export async function searchAssignableEboms(filter: AssignableEbomFilter): Promise { const pool = getPool(); const conds: string[] = []; const params: any[] = []; let idx = 1; if (filter.search_part_no) { conds.push(`UPPER(T.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`); params.push(filter.search_part_no); } if (filter.search_part_name) { conds.push(`UPPER(T.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`); params.push(filter.search_part_name); } if (filter.search_material) { conds.push(`UPPER(PM.MATERIAL) LIKE '%' || UPPER($${idx++}) || '%'`); params.push(filter.search_material); } if (filter.search_supplier) { conds.push(`UPPER(PM.MAKER) LIKE '%' || UPPER($${idx++}) || '%'`); params.push(filter.search_supplier); } const limit = Math.min(500, Math.max(1, Number(filter.limit) || 100)); const sql = ` SELECT T.OBJID::VARCHAR AS objid, T.PRODUCT_CD AS product_cd, COALESCE((SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = T.PRODUCT_CD LIMIT 1), '') AS product_name, T.PART_NO AS part_no, T.PART_NAME AS part_name, T.STATUS AS status, T.REVISION AS revision, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS reg_date, UI.USER_NAME AS writer_name, UI.DEPT_NAME AS dept_name, COALESCE(PM.MATERIAL, '') AS material, COALESCE(PM.MAKER, '') AS supplier FROM PART_BOM_REPORT T LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER LEFT JOIN PART_MNG PM ON PM.PART_NO = T.PART_NO AND PM.STATUS = 'release' WHERE 1=1 AND T.PART_NO IS NOT NULL AND TRIM(T.PART_NO) != '' AND T.PART_NAME IS NOT NULL AND TRIM(T.PART_NAME) != '' ${conds.length ? "AND " + conds.join(" AND ") : ""} ORDER BY T.REGDATE DESC LIMIT ${limit} `; const r = await pool.query(sql, params); return r.rows; } export type AssignSourceType = "EBOM" | "MBOM"; export async function assignBom( projectObjId: string, sourceBomType: AssignSourceType, sourceBomObjId: string, _userId: string, ): Promise<{ success: boolean; source_bom_type: string; source_obj_id: string }> { const pool = getPool(); // 매퍼 saveBomAssignment 1:1. // EBOM 이면 source_ebom_objid, MBOM 이면 source_mbom_objid 만 세팅 (다른 쪽 NULL). const sql = ` UPDATE PROJECT_MGMT SET SOURCE_BOM_TYPE = $1, SOURCE_EBOM_OBJID = CASE WHEN $1 = 'EBOM' THEN $2 ELSE NULL END, SOURCE_MBOM_OBJID = CASE WHEN $1 = 'MBOM' THEN $2 ELSE NULL END WHERE OBJID::VARCHAR = $3 `; const r = await pool.query(sql, [sourceBomType, sourceBomObjId, projectObjId]); if (r.rowCount === 0) throw new Error("프로젝트를 찾을 수 없습니다"); return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId }; } // ─── 변경이력 조회 (PR-B4) ────────────────────────────────── // // 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1. // project_objid 로 그 프로젝트의 모든 mbom_header 변경이력 시간순 (최신 우선). export interface MbomHistoryRow { objid: string; mbom_header_objid: string; change_type: string; change_description: string | null; change_user: string | null; change_user_name: string | null; change_date: string; mbom_part_no: string | null; mbom_regdate: string | null; } export async function getHistory(projectObjid: string): Promise { const pool = getPool(); const sql = ` SELECT MH.OBJID AS objid, MH.MBOM_HEADER_OBJID AS mbom_header_objid, MH.CHANGE_TYPE AS change_type, MH.CHANGE_DESCRIPTION AS change_description, MH.CHANGE_USER AS change_user, COALESCE( (SELECT USER_NAME FROM USER_INFO WHERE USER_ID = MH.CHANGE_USER LIMIT 1), MH.CHANGE_USER ) AS change_user_name, TO_CHAR(MH.CHANGE_DATE, 'YYYY-MM-DD HH24:MI:SS') AS change_date, MHD.MBOM_NO AS mbom_part_no, TO_CHAR(MHD.REGDATE, 'YYYY-MM-DD HH24:MI:SS') AS mbom_regdate FROM MBOM_HISTORY MH INNER JOIN MBOM_HEADER MHD ON MH.MBOM_HEADER_OBJID = MHD.OBJID WHERE MHD.PROJECT_OBJID = $1 ORDER BY MH.CHANGE_DATE DESC `; const r = await pool.query(sql, [projectObjid]); return r.rows; } // ─── 구매리스트 생성 (PR-B3) ──────────────────────────────── // // wace 매퍼 salesMng.xml `getNextRequestMngNo` + `insertSalesRequestMasterFromMBom` 1:1 // (서비스 SalesMngService.createPurchaseListFromMBom, 매퍼 라인 3960~3997). // // 검증: M-BOM 헤더 존재 + 동일 mbom_header_objid 로 이미 생성된 sales_request_master 없음. // 입력: mbomHeaderObjid (mbom_header.objid), projectMgmtObjid (project_mgmt.objid). // 처리: 단일 INSERT, BEGIN/COMMIT 트랜잭션. mbom_header 역링크 컬럼 없음 (운영 동일). export interface CreateSalesRequestResult { objid: string; request_mng_no: string; mbom_header_objid: string; } export async function createSalesRequest( mbomHeaderObjid: string, projectMgmtObjid: string, sessionUserId: string, ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const mbom = String(mbomHeaderObjid ?? "").trim(); const proj = String(projectMgmtObjid ?? "").trim(); if (!mbom) throw new Error("MBOM_HEADER_OBJID 누락"); if (!proj) throw new Error("PROJECT_MGMT_OBJID 누락"); const userId = String(sessionUserId ?? "").trim() || "system"; // 1) M-BOM 헤더 존재 확인 const mh = await client.query( `SELECT OBJID FROM MBOM_HEADER WHERE OBJID::VARCHAR = $1 AND STATUS = 'Y' LIMIT 1`, [mbom], ); if (!mh.rows[0]) throw new Error("저장된 M-BOM 헤더를 찾을 수 없습니다"); // 2) 동일 MBOM_HEADER 로 이미 생성된 구매리스트 차단 (운영판 sweetalert 가드 1:1) const dup = await client.query( `SELECT OBJID, REQUEST_MNG_NO FROM SALES_REQUEST_MASTER WHERE MBOM_HEADER_OBJID = $1 LIMIT 1`, [mbom], ); if (dup.rows[0]) { throw new Error(`이미 생성된 구매리스트가 있습니다 (${dup.rows[0].request_mng_no})`); } // 3) 채번: R + YYYYMMDD + - + 3자리 (운영 getNextRequestMngNo 1:1) const seqRes = await client.query( `SELECT 'R' || TO_CHAR(NOW(), 'YYYYMMDD') || '-' || LPAD( (COALESCE(MAX(SUBSTR(REQUEST_MNG_NO, 11, 13)), '0')::INTEGER + 1)::TEXT, 3, '0' ) AS request_mng_no FROM SALES_REQUEST_MASTER WHERE DOC_TYPE IN ('PURCHASE_REQUEST', 'PURCHASE_REG') OR DOC_TYPE IS NULL`, ); const requestMngNo: string = seqRes.rows[0]?.request_mng_no; if (!requestMngNo) throw new Error("REQUEST_MNG_NO 채번 실패"); // 4) INSERT (운영 insertSalesRequestMasterFromMBom 1:1, PROJECT_NO 컬럼에 PROJECT_MGMT.OBJID 저장) const newObjid = createObjId(); await client.query( `INSERT INTO SALES_REQUEST_MASTER ( OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID, REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE ) VALUES ($1, $2, $3, $4, $5, 'create', $6, NOW(), 'PURCHASE_REQUEST')`, [newObjid, requestMngNo, proj, mbom, userId, userId], ); await client.query("COMMIT"); return { objid: newObjid, request_mng_no: requestMngNo, mbom_header_objid: mbom, }; } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } async function insertHistory( client: any, mbomHeaderObjid: string, changeType: "CREATE" | "UPDATE", description: string, userId: string, ) { await client.query( `INSERT INTO MBOM_HISTORY ( OBJID, MBOM_HEADER_OBJID, CHANGE_TYPE, CHANGE_DESCRIPTION, BEFORE_DATA, AFTER_DATA, CHANGE_USER, CHANGE_DATE ) VALUES ($1, $2, $3, $4, NULL, NULL, $5, NOW())`, [createObjId(), mbomHeaderObjid, changeType, description, userId], ); } // 매퍼 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::varchar = 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::varchar = 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::varchar = 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::varchar = COALESCE(V.LAST_PART_OBJID, V.PART_NO) ORDER BY V.PATH2 `;