Files
wace_rps/backend-node/src/services/mbomService.ts
T
hjjeong b38f5957f2 구매관리 7메뉴 신규 + M-BOM PR-B3·B5 + 발주관리 DataGrid 통일 + 생산계획&실적 라우트
구매관리 (wace 1:1)
- backend: services/purchaseService.ts (7 list + 옵션 3종) + controllers/purchaseController.ts + routes/purchaseRoutes.ts (/api/purchase 마운트)
- frontend: lib/api/purchase.ts + 7 page.tsx (list/quote-request/proposal/inbound/inbound-by-item/inbound-by-date/project-status)
- 영업관리 4메뉴 DataGrid 패턴 통일 — pageSizeOptions=[10,15,20,50,100], emptyMessage, showColumnSettings/summaryStats/onRefresh/onDownload/showChart
- 마스터단독 데이터(sales_request_master, project_mgmt+mbom_detail) 노출, detail/part 누락 테이블 의존은 빈 그리드 + UI

발주관리 (purchase/order/page.tsx)
- EDataTable → DataGrid 교체 + logicstudio 6종 props + 날짜/숫자 pre-format

M-BOM PR-B3 — 구매리스트 생성 (wace createPurchaseListFromMBom.do 1:1)
- mbomService.createSalesRequest + controller + route POST /api/production/mbom/sales-request
- 단건 체크 + 1:1 강제 + R-YYYYMMDD-NNN 채번 + sales_request_master 단건 INSERT
- production/mbom/page.tsx 에 [구매리스트 생성] 버튼

M-BOM PR-B5 — BOM 할당 (mBomEbomSelectPopup.do)
- mbomService.searchAssignableEboms/assignBom + controller + routes
- MbomAssignDialog 신규, MbomDetailDialog 통합

생산관리 4메뉴 라우트 (생산계획&실적, 소요량)
- prodPlanResultService/Controller + productionPlanResultRoutes (planResult/mbomReq)
- mbomRequirementService + 4 page.tsx (prod-plan-result, prod-plan-result-equip, raw-material-requirement, semi-product-requirement)
- lib/api/prodPlanResult.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:31:12 +09:00

1567 lines
64 KiB
TypeScript

// ============================================================
// 생산관리 > 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 <if> 조건 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<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::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<string> {
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<MbomSaveResult> {
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<string>(existRes.rows.map((r: any) => r.objid));
const incomingIds = new Set<string>();
// 신규 행의 client temp- child_objid → 서버 발급 createObjId 매핑
// (객체 ID 안정성 + DB 에 temp- 잔존 방지)
const tempChildMap = new Map<string, string>();
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<string, string>();
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<AssignableEbomRow[]> {
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<MbomHistoryRow[]> {
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<CreateSalesRequestResult> {
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
`;