생산관리>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:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- ============================================================
|
||||
-- M-BOM 메뉴 양쪽 노출 (PR-A1)
|
||||
-- 운영판 wace 분류는 "생산관리_M-BOM관리" 단독이지만,
|
||||
-- 사용자 요청 (2026-05-13): 구매관리 메뉴 트리에도 동일 M-BOM 메뉴 노출.
|
||||
--
|
||||
-- menu_info 100016 (/COMPANY_16/purchase/mbom) — 구매관리 하위
|
||||
-- menu_info 100032 (/COMPANY_16/production/mbom) — 생산관리 하위
|
||||
-- 두 URL 은 동일한 page.tsx 화면 (production/mbom 의 re-export).
|
||||
--
|
||||
-- 본 스크립트는 menu_info 두 항목 모두 active 보장만 한다.
|
||||
--
|
||||
-- 실행:
|
||||
-- PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \
|
||||
-- -f 03_mbom_menu_dedup.sql
|
||||
-- ============================================================
|
||||
|
||||
UPDATE menu_info
|
||||
SET status = 'active'
|
||||
WHERE objid IN (100016, 100032)
|
||||
AND status <> 'active';
|
||||
|
||||
-- 확인
|
||||
SELECT objid, parent_obj_id, menu_name_kor, menu_url, status
|
||||
FROM menu_info
|
||||
WHERE objid IN (100016, 100032)
|
||||
ORDER BY objid;
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning/mBomMgmtList.jsp 1:1
|
||||
// 그리드: PROJECT_MGMT × CONTRACT_ITEM 펼침 (1 프로젝트 = 1+ 행) + M-BOM 상태/저장일
|
||||
// 검색: 주문유형 / 제품구분 / 국내해외 / 고객사 / 유무상 / S/N / 품번 / 품명 / 접수일 / 요청납기
|
||||
// 액션 (PR-A1): 조회 / 초기화 / 페이지
|
||||
// ※ BOM 복사 / 구매리스트 생성 / M-BOM 편집 트리 다이얼로그는 PR-A2 이후 분리.
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Loader2, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
|
||||
|
||||
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
|
||||
const PARENT_PRODUCT = "0000001"; // 제품구분 comm_code parent_code_id
|
||||
const PARENT_PAID = "0001782"; // 유/무상 (참고: 빈 결과여도 raw paid/free 매칭으로 fallback)
|
||||
|
||||
interface CodeOpt { code: string; label: string; sort?: number | null }
|
||||
interface CustomerOpt { id: number | string; customer_name: string | null; customer_code: string | null }
|
||||
|
||||
const EMPTY_FILTER: MbomListFilter = {
|
||||
search_category_cd: "",
|
||||
search_product_cd: "",
|
||||
search_area_cd: "",
|
||||
search_customer_objid: "",
|
||||
search_paid_type: "",
|
||||
search_serial_no: "",
|
||||
search_part_no: "",
|
||||
search_part_name: "",
|
||||
search_receipt_date_from: "",
|
||||
search_receipt_date_to: "",
|
||||
search_req_del_date_from: "",
|
||||
search_req_del_date_to: "",
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
};
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "part_no", label: "품번", width: "w-[150px]" },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
|
||||
{ key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" },
|
||||
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
|
||||
{ key: "mbom_status", label: "M-BOM", width: "w-[80px]", align: "center" },
|
||||
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
|
||||
];
|
||||
|
||||
export default function MbomMgmtPage() {
|
||||
const [rows, setRows] = useState<MbomRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<MbomListFilter>(EMPTY_FILTER);
|
||||
|
||||
const [categoryOpts, setCategoryOpts] = useState<CodeOpt[]>([]);
|
||||
const [productOpts, setProductOpts] = useState<CodeOpt[]>([]);
|
||||
const [paidOpts, setPaidOpts] = useState<CodeOpt[]>([]);
|
||||
const [customerOpts, setCustomerOpts] = useState<CustomerOpt[]>([]);
|
||||
|
||||
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const f = { ...filter, ...override };
|
||||
const res = await mbomApi.list(f);
|
||||
setRows(res.rows ?? []);
|
||||
setTotal(res.totalCount ?? 0);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
// 초기 옵션 + 첫 조회
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [c1, c2, c3, cust] = await Promise.all([
|
||||
apiClient.get(`/sales/codes/${PARENT_CATEGORY}`),
|
||||
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
|
||||
apiClient.get(`/sales/codes/${PARENT_PAID}`),
|
||||
apiClient.get(`/sales/customers`),
|
||||
]);
|
||||
if (dead) return;
|
||||
setCategoryOpts(c1.data?.data ?? []);
|
||||
setProductOpts(c2.data?.data ?? []);
|
||||
setPaidOpts(c3.data?.data ?? []);
|
||||
setCustomerOpts(cust.data?.data ?? []);
|
||||
} catch {
|
||||
/* 옵션 로드 실패는 무시 — 그리드는 조회 가능 */
|
||||
}
|
||||
})();
|
||||
fetchList(EMPTY_FILTER);
|
||||
return () => { dead = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique)
|
||||
const gridRows = useMemo(
|
||||
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilter((f) => ({ ...f, page: 1 }));
|
||||
fetchList({ page: 1 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilter(EMPTY_FILTER);
|
||||
fetchList(EMPTY_FILTER);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
{/* 운영판 wace mBomMgmtList.jsp 1:1 — 2행 검색 폼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 md:grid-cols-4 xl:grid-cols-6">
|
||||
<Field label="주문유형">
|
||||
<SelectBox
|
||||
value={filter.search_category_cd ?? ""}
|
||||
options={categoryOpts}
|
||||
onChange={(v) => setFilter({ ...filter, search_category_cd: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="제품구분">
|
||||
<SelectBox
|
||||
value={filter.search_product_cd ?? ""}
|
||||
options={productOpts}
|
||||
onChange={(v) => setFilter({ ...filter, search_product_cd: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="국내/해외">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.search_area_cd ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_area_cd: e.target.value })}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="국내">국내</option>
|
||||
<option value="해외">해외</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="고객사">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.search_customer_objid ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_customer_objid: e.target.value })}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{customerOpts.map((c) => (
|
||||
<option key={`${c.id}`} value={c.customer_code ?? `${c.id}`}>
|
||||
{c.customer_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="유/무상">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.search_paid_type ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_paid_type: e.target.value })}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{/* 운영판 1:1 — paid/free raw + comm_code 라벨 fallback */}
|
||||
<option value="paid">유상</option>
|
||||
<option value="free">무상</option>
|
||||
{paidOpts.map((p) => (
|
||||
<option key={p.code} value={p.code}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="S/N">
|
||||
<Input
|
||||
value={filter.search_serial_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_serial_no: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 md:grid-cols-4 xl:grid-cols-6">
|
||||
<Field label="품번">
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="접수일 (시작)">
|
||||
<Input
|
||||
type="date"
|
||||
value={filter.search_receipt_date_from ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_receipt_date_from: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="접수일 (종료)">
|
||||
<Input
|
||||
type="date"
|
||||
value={filter.search_receipt_date_to ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_receipt_date_to: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="요청납기 (시작)">
|
||||
<Input
|
||||
type="date"
|
||||
value={filter.search_req_del_date_from ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_req_del_date_from: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="요청납기 (종료)">
|
||||
<Input
|
||||
type="date"
|
||||
value={filter.search_req_del_date_to ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_req_del_date_to: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
총 {total.toLocaleString()}건 · PROJECT_MGMT × CONTRACT_ITEM 펼침
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSearch} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={GRID_COLUMNS}
|
||||
data={gridRows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 프로젝트가 없습니다."
|
||||
gridId="production-mbom-mgmt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectBox({
|
||||
value, options, onChange,
|
||||
}: { value: string; options: CodeOpt[]; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.code} value={o.code}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 구매관리 > M-BOM 관리 — production/mbom 페이지 re-export.
|
||||
// 사용자 요청 (2026-05-13): wace 운영판은 "생산관리_M-BOM관리" 1:1 이지만,
|
||||
// 구매관리 메뉴 트리에서도 동일 화면 진입을 허용한다.
|
||||
//
|
||||
// menu_info: 100016 (purchase/mbom) + 100032 (production/mbom) — 둘 다 active.
|
||||
|
||||
export { default } from "../../production/mbom/page";
|
||||
@@ -0,0 +1,74 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// ============================================================
|
||||
// 생산관리 > M-BOM 관리 (PR-A1) — wace productionplanning.xml 1:1
|
||||
// 라우트: /api/production/mbom/*
|
||||
// ============================================================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface MbomRow {
|
||||
objid: string;
|
||||
contract_objid: string | null;
|
||||
project_no: string | null;
|
||||
category_cd: string | null;
|
||||
category_name: string | null;
|
||||
product: string | null;
|
||||
product_name: string | null;
|
||||
area_cd: string | null;
|
||||
area_name: string | null;
|
||||
receipt_date: string | null;
|
||||
writer_name: string | null;
|
||||
customer_objid: string | null;
|
||||
customer_name: string | null;
|
||||
paid_type: string | null;
|
||||
paid_type_name: string | null;
|
||||
part_no: string | null;
|
||||
part_name: string | null;
|
||||
part_objid: string | null;
|
||||
serial_no: string | null;
|
||||
serial_no_list: string | null;
|
||||
quantity: string | number | null;
|
||||
req_del_date: string | null;
|
||||
customer_request: string | null;
|
||||
bom_report_objid: string | null;
|
||||
ebom_status: string | null;
|
||||
ebom_regdate: string | null;
|
||||
mbom_header_objid: string | null;
|
||||
purchase_list_objid: string | null;
|
||||
purchase_list_date: string | null;
|
||||
mbom_status: string | null;
|
||||
mbom_part_no: string | null;
|
||||
mbom_regdate: string | null;
|
||||
mbom_editor: string | null;
|
||||
mbom_version: number | null;
|
||||
}
|
||||
|
||||
export interface MbomListResponse {
|
||||
rows: MbomRow[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const mbomApi = {
|
||||
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
|
||||
const res = await apiClient.get("/production/mbom/list", { params: filter });
|
||||
return res.data?.data as MbomListResponse;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user