생산관리>M-BOM 관리 — PR-A1 그리드/검색 (mBomMgmtGridList 1:1 이식)

운영 wace productionplanning/mBomMgmtList.jsp + productionplanning.xml:2874-3119
mBomMgmtGridList 매퍼 1:1 이식. PROJECT_MGMT × CONTRACT_ITEM 펼침 그리드
+ M-BOM 헤더/히스토리/구매리스트 상태 표시 + 9 검색 필터.

백엔드 (3 파일 + app.ts 마운트):
- services/mbomService.ts — list() : 9 검색 필터 + 30+ 컬럼 SELECT
  · 주문유형/제품구분/국내해외(CODE_NAME 비교)/고객사(C_ 3-way)/유무상/SN(EXISTS)
  · 품번/품명(PM·CI 양쪽 LIKE)/접수일·요청납기 범위
  · WRITER_NAME/MBOM_EDITOR : user_name() PL/pgSQL (PR-A0 신설)
  · MBOM_STATUS/MBOM_PART_NO/MBOM_REGDATE/MBOM_VERSION : mbom_header+history 서브쿼리
  · PURCHASE_LIST_OBJID/_DATE : sales_request_master.mbom_header_objid 매칭
  · CUSTOMER_NAME : CASE C_% → client_mng / ELSE → supply_mng
- controllers/mbomController.ts — getList
- routes/productionMbomRoutes.ts — GET /list
- app.ts — /api/production/mbom 마운트 (productionRoutes 다음)

프론트 (3 파일):
- lib/api/mbom.ts — MbomListFilter / MbomRow / mbomApi.list
- app/(main)/COMPANY_16/production/mbom/page.tsx — 검색 폼 2행(12 필드) + 16 컬럼 DataGrid
  · comm_code 옵션 로드: /api/sales/codes/0000167 (주문유형) /0000001 (제품구분) /0001782 (유무상)
  · 고객사: /api/sales/customers 재사용 (customer_mng)
  · 국내/해외 + 유상/무상 raw 옵션
- app/(main)/COMPANY_16/purchase/mbom/page.tsx — production/mbom 페이지 re-export
  (사용자 요청: 구매관리 메뉴 트리에도 동일 화면 노출)

메뉴 (data-sync):
- 03_mbom_menu_dedup.sql — menu_info 100016(purchase/mbom) + 100032(production/mbom)
  양쪽 active 보장 (이미 DB에 등록되어 있던 entry)

PR-A2 이후 분리:
- 단건 상세 다이얼로그, read-only mbom_detail 트리 표시
- BOM 복사 (E-BOM→M-BOM 트리 복사)
- 구매리스트 생성 액션 (M-BOM→PURCHASE)
- M-BOM 본 편집 (4프레임 팝업)

검증:
- backend nodemon hot-load OK (401 TOKEN_MISSING 응답으로 라우터 등록 확인)
- 매퍼 SQL 직접 실행: PROJECT_MGMT × CONTRACT_ITEM 5건 + CUSTOMER/M-BOM 매칭 정상
- typecheck: 신규 코드 0 에러 (pre-existing 에러만 잔존)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 15:57:23 +09:00
parent 7af366c595
commit 66cee22be3
8 changed files with 721 additions and 0 deletions
+2
View File
@@ -119,6 +119,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
@@ -380,6 +381,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관리 (wace_plm 도메인)
app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징)
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
app.use("/api/material-status", materialStatusRoutes); // 자재현황
@@ -0,0 +1,27 @@
// ============================================================
// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식.
// 라우트:
// GET /api/production/mbom/list M-BOM 관리 그리드 (PROJECT_MGMT × CONTRACT_ITEM 펼침)
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/mbomService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.MbomListFilter {
const filter: svc.MbomListFilter = { ...q };
if (q.page) filter.page = Number(q.page);
if (q.page_size) filter.page_size = Number(q.page_size);
return filter;
}
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.list(parseFilter(req.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("M-BOM 관리 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,15 @@
// ============================================================
// 생산관리 > M-BOM 관리 (PR-A1) 라우트.
// app.ts: app.use("/api/production/mbom", productionMbomRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/mbomController";
const router = Router();
router.use(authenticateToken);
router.get("/list", ctrl.getList);
export default router;
+277
View File
@@ -0,0 +1,277 @@
// ============================================================
// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1 이식.
//
// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/productionplanning.xml):
// mBomMgmtGridList → list() (라인 2874~3119, PROJECT_MGMT × CONTRACT_ITEM 펼침)
//
// 그리드 베이스: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행).
// 9 검색 필터 + 30+ 출력 컬럼 (M-BOM 상태/저장일/작성자 + 구매리스트 매칭).
// vexplor_rps 의존: project_mgmt / contract_mgmt / contract_item / contract_item_serial
// / mbom_header (PR-A0) / mbom_history / sales_request_master / client_mng
// / supply_mng / part_bom_report / comm_code / user_info / user_name() fn
// ============================================================
import { getPool } from "../database/db";
// ─── 필터/페이지 타입 ──────────────────────────────────────────
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,
};
}