개발관리>E-BOM 등록·조회 메뉴 신설 (PR-B) — wace partMng 1:1 이식
backend (M3+M4): - devBomService: list/getByObjid/updateStatus/removeMany/ascending/descending - M3 그리드 SQL (getBOMStandardStructureGridList 1:1) - customer_mng 매핑 (wace SUPPLY_MNG → vexplor customer_mng.customer_code) - PRODUCT_NAME LEFT JOIN comm_code (CODE_NAME 함수 대체) - M3 다중 삭제 트랜잭션 (bom_part_qty + part_bom_report CASCADE) - M4 정/역전개 재귀 CTE (bom_part_qty 트리 + part_mng JOIN) - vexplor 적응: M4 product_mgmt_spec/upg/vc 분기 제거 (스키마 단순화) - PG 재귀 CTE 타입 일치: ARRAY[BP.objid::varchar] 명시 cast frontend (M3+M4): - ebom-regist (M3): 9셀 그리드 (제품구분·품번·품명·E-BOM·등록자·등록일·확정일·Version·상태) - ebom-search (M4): 동적 LEVEL 컬럼 + 정/역전개 토글 - BomReportStatusDialog: 상태 변경 (create/changeDesign/deploy + version) - AdminPageRenderer dynamic 임포트 2건 + menu_info URL spec 정렬 본 PR 제외 (별 PR): E-BOM Excel Import, 정/역전개 Excel Download, BOM_PART_QTY 수량 편집 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,7 @@ import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개
|
|||||||
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
||||||
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
|
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
|
||||||
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식)
|
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식)
|
||||||
|
import devBomRoutes from "./routes/devBomRoutes"; // 개발관리>E-BOM 등록/조회 (wace_plm 도메인 이식)
|
||||||
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
|
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
|
||||||
import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리
|
import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리
|
||||||
import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리
|
import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리
|
||||||
@@ -425,6 +426,7 @@ app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/p
|
|||||||
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
|
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
|
||||||
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
|
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
|
||||||
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인)
|
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인)
|
||||||
|
app.use("/api/development", devBomRoutes); // 개발관리>E-BOM 등록/조회 (wace_plm 도메인)
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 개발관리 E-BOM (M3 등록 / M4 조회) 컨트롤러.
|
||||||
|
// 라우트:
|
||||||
|
// GET /api/development/ebom/list (M3 그리드)
|
||||||
|
// GET /api/development/ebom/:objid (M3 단건)
|
||||||
|
// PUT /api/development/ebom/:objid/status (M3 상태변경)
|
||||||
|
// DELETE /api/development/ebom (M3 다중 삭제, body: { objids })
|
||||||
|
// GET /api/development/ebom-tree/ascending (M4 정전개)
|
||||||
|
// GET /api/development/ebom-tree/descending (M4 역전개)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import * as svc from "../services/devBomService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
function parseListFilter(q: Record<string, any>): svc.BomReportListFilter {
|
||||||
|
const filter: svc.BomReportListFilter = { ...q };
|
||||||
|
if (q.page) filter.page = Number(q.page);
|
||||||
|
if (q.page_size) filter.page_size = Number(q.page_size);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 그리드 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const data = await svc.list(parseListFilter(req.query as Record<string, any>));
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("E-BOM(M3) 목록 실패", { error: e.message });
|
||||||
|
return res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 단건 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getByObjid(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { objid } = req.params;
|
||||||
|
const row = await svc.getByObjid(objid);
|
||||||
|
if (!row) return res.status(404).json({ success: false, message: "not_found" });
|
||||||
|
return res.json({ success: true, data: row });
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("E-BOM 상세 실패", { error: e.message });
|
||||||
|
return res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 상태 변경 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updateStatus(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const { objid } = req.params;
|
||||||
|
const rowCount = await svc.updateStatus(userId, objid, req.body);
|
||||||
|
if (rowCount === 0) return res.status(404).json({ success: false, message: "not_found" });
|
||||||
|
return res.json({ success: true, message: "상태가 변경되었습니다." });
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("E-BOM 상태 변경 실패", { error: e.message });
|
||||||
|
return res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 다중 삭제 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function removeMany(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const objids = Array.isArray(req.body?.objids) ? (req.body.objids as any[]).map(String) : [];
|
||||||
|
if (objids.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "objids가 비어있습니다." });
|
||||||
|
}
|
||||||
|
const removed = await svc.removeMany(objids);
|
||||||
|
return res.json({ success: true, data: { removed }, message: `${removed}건이 삭제되었습니다.` });
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("E-BOM 삭제 실패", { error: e.message });
|
||||||
|
return res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M4 정전개 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function ascending(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const data = await svc.ascending(req.query as svc.BomTreeFilter);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("E-BOM 정전개 실패", { error: e.message });
|
||||||
|
return res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M4 역전개 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function descending(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const data = await svc.descending(req.query as svc.BomTreeFilter);
|
||||||
|
return res.json({ success: true, data });
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error("E-BOM 역전개 실패", { error: e.message });
|
||||||
|
return res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 개발관리 E-BOM (M3 등록 / M4 조회) 라우트.
|
||||||
|
// app.ts: app.use("/api/development", devBomRoutes) — devPart 라우터와 prefix 공유.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as ctrl from "../controllers/devBomController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위)
|
||||||
|
router.get("/ebom-tree/ascending", ctrl.ascending);
|
||||||
|
router.get("/ebom-tree/descending", ctrl.descending);
|
||||||
|
|
||||||
|
// M3 — 그리드 + CRUD
|
||||||
|
router.get("/ebom/list", ctrl.getList);
|
||||||
|
router.delete("/ebom", ctrl.removeMany);
|
||||||
|
router.put("/ebom/:objid/status", ctrl.updateStatus);
|
||||||
|
router.get("/ebom/:objid", ctrl.getByObjid);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 이식.
|
||||||
|
//
|
||||||
|
// 매퍼 매핑 (원본: wace_plm/src/com/pms/mapper/partMng.xml):
|
||||||
|
// getBOMStandardStructureGridList → list() (M3 그리드, part_bom_report)
|
||||||
|
// updateStructureStatus → updateStatus() (M3 상태변경)
|
||||||
|
// deleteBomReport + deleteBomQty → removeMany() (M3 다중 삭제 트랜잭션)
|
||||||
|
// structureAscendingList → ascending() (M4 정전개, 재귀 CTE)
|
||||||
|
// selectStructureDescendingList → descending() (M4 역전개, 재귀 CTE)
|
||||||
|
//
|
||||||
|
// vexplor_rps 적응:
|
||||||
|
// · customer_mng 매핑: wace SUPPLY_MNG.OBJID → vexplor customer_mng.customer_code
|
||||||
|
// · PRODUCT_NAME: wace CODE_NAME() → vexplor LEFT JOIN comm_code CC_PRD
|
||||||
|
// · M4 product_mgmt_spec/upg/vc 분기 제거 (vexplor part_bom_report 단순화)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { PoolClient } from "pg";
|
||||||
|
import { getPool, transaction } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// ─── 필터/바디 타입 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BomReportListFilter {
|
||||||
|
customer_cd?: string;
|
||||||
|
project_name?: string;
|
||||||
|
unit_code?: string;
|
||||||
|
search_unit_name?: string;
|
||||||
|
search_writer?: string;
|
||||||
|
product_cd?: string;
|
||||||
|
search_part_no?: string;
|
||||||
|
search_part_name?: string;
|
||||||
|
search_from_date?: string;
|
||||||
|
search_to_date?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomReportStatusBody {
|
||||||
|
product_cd?: string;
|
||||||
|
part_no?: string;
|
||||||
|
part_name?: string;
|
||||||
|
version?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomTreeFilter {
|
||||||
|
bom_report_objid?: string;
|
||||||
|
project_name?: string; // part_bom_report.contract_objid
|
||||||
|
unit_code?: string;
|
||||||
|
search_part_no?: string;
|
||||||
|
search_part_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 공용 파라미터 빌더 ────────────────────────────────────
|
||||||
|
|
||||||
|
function buildListWhere(filter: BomReportListFilter, startIdx: number) {
|
||||||
|
const params: any[] = [];
|
||||||
|
const conds: string[] = [];
|
||||||
|
let idx = startIdx;
|
||||||
|
|
||||||
|
if (filter.customer_cd) { conds.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_cd); }
|
||||||
|
if (filter.project_name) { conds.push(`T.CONTRACT_OBJID = $${idx++}`); params.push(filter.project_name); }
|
||||||
|
if (filter.unit_code) { conds.push(`T.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); }
|
||||||
|
if (filter.search_unit_name) {
|
||||||
|
conds.push(`EXISTS (SELECT 1 FROM PMS_WBS_TASK W
|
||||||
|
WHERE W.OBJID = T.UNIT_CODE
|
||||||
|
AND (UPPER(W.TASK_NAME) LIKE UPPER($${idx}) OR UPPER(W.UNIT_NO) LIKE UPPER($${idx})))`);
|
||||||
|
params.push(`%${filter.search_unit_name}%`); idx++;
|
||||||
|
}
|
||||||
|
if (filter.search_writer) { conds.push(`T.WRITER = $${idx++}`); params.push(filter.search_writer); }
|
||||||
|
if (filter.product_cd) { conds.push(`T.PRODUCT_CD = $${idx++}`); params.push(filter.product_cd); }
|
||||||
|
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_from_date) { conds.push(`T.REGDATE::date >= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_from_date); }
|
||||||
|
if (filter.search_to_date) { conds.push(`T.REGDATE::date <= TO_DATE($${idx++}, 'YYYY-MM-DD')`); params.push(filter.search_to_date); }
|
||||||
|
if (filter.status) { conds.push(`T.STATUS = $${idx++}`); params.push(filter.status); }
|
||||||
|
|
||||||
|
return { sql: conds.length ? conds.join(" AND ") : "1=1", params };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 그리드 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function list(filter: BomReportListFilter) {
|
||||||
|
const { limit, offset, page, pageSize } = paginate(filter);
|
||||||
|
const where = buildListWhere(filter, 1);
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const baseSql = `
|
||||||
|
SELECT
|
||||||
|
ROW_NUMBER() OVER (ORDER BY T.REGDATE DESC) AS NUM,
|
||||||
|
T.OBJID, T.CUSTOMER_OBJID, SM.customer_name AS CUSTOMER_NAME,
|
||||||
|
T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO,
|
||||||
|
T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME,
|
||||||
|
T.STATUS,
|
||||||
|
CASE UPPER(T.STATUS)
|
||||||
|
WHEN 'CREATE' THEN '등록중'
|
||||||
|
WHEN 'CHANGEDESIGN' THEN '설계변경미배포'
|
||||||
|
WHEN 'DEPLOY' THEN '배포완료'
|
||||||
|
ELSE '' END AS STATUS_TITLE,
|
||||||
|
T.WRITER, UI.dept_name AS DEPT_NAME, UI.user_name AS USER_NAME,
|
||||||
|
COALESCE(UI.dept_name || '/' || UI.user_name, '') AS DEPT_USER_NAME,
|
||||||
|
T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE,
|
||||||
|
T.DEPLOY_DATE, T.REVISION,
|
||||||
|
EO_DATA.EO_NO, EO_DATA.EO_DATE,
|
||||||
|
T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID,
|
||||||
|
COALESCE(EO_DATA.BOM_CNT, 0)::int AS BOM_CNT,
|
||||||
|
T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME,
|
||||||
|
T.PART_NO, T.PART_NAME
|
||||||
|
FROM PART_BOM_REPORT T
|
||||||
|
LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID
|
||||||
|
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID
|
||||||
|
LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE
|
||||||
|
LEFT JOIN user_info UI ON UI.user_id = T.WRITER
|
||||||
|
LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active'
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT BP.BOM_REPORT_OBJID,
|
||||||
|
MAX(PM2.EO_NO) AS EO_NO,
|
||||||
|
MAX(PM2.EO_DATE) AS EO_DATE,
|
||||||
|
COUNT(*) AS BOM_CNT
|
||||||
|
FROM BOM_PART_QTY BP
|
||||||
|
LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar
|
||||||
|
GROUP BY BP.BOM_REPORT_OBJID
|
||||||
|
) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID
|
||||||
|
WHERE ${where.sql}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dataSql = `${baseSql} ORDER BY T.REGDATE DESC NULLS LAST LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`;
|
||||||
|
const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`;
|
||||||
|
|
||||||
|
const [dataRes, countRes] = await Promise.all([
|
||||||
|
pool.query(dataSql, [...where.params, limit, offset]),
|
||||||
|
pool.query(countSql, where.params),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 단건 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getByObjid(objid: string) {
|
||||||
|
const sql = `
|
||||||
|
SELECT T.*,
|
||||||
|
CC_PRD.code_name AS PRODUCT_NAME,
|
||||||
|
(SELECT COUNT(*) FROM BOM_PART_QTY Q WHERE Q.BOM_REPORT_OBJID = T.OBJID) AS BOM_CNT
|
||||||
|
FROM PART_BOM_REPORT T
|
||||||
|
LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status='active'
|
||||||
|
WHERE T.OBJID = $1
|
||||||
|
`;
|
||||||
|
const r = await getPool().query(sql, [objid]);
|
||||||
|
return r.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 상태 변경 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updateStatus(userId: string, objid: string, body: BomReportStatusBody) {
|
||||||
|
if (!body.status) throw new Error("status는 필수입니다.");
|
||||||
|
const sql = `
|
||||||
|
UPDATE PART_BOM_REPORT
|
||||||
|
SET PRODUCT_CD = COALESCE($1, PRODUCT_CD),
|
||||||
|
PART_NO = COALESCE($2, PART_NO),
|
||||||
|
PART_NAME = COALESCE($3, PART_NAME),
|
||||||
|
REVISION = COALESCE($4, REVISION),
|
||||||
|
STATUS = $5,
|
||||||
|
editer = $6,
|
||||||
|
edit_date = NOW()
|
||||||
|
WHERE OBJID = $7
|
||||||
|
`;
|
||||||
|
const r = await getPool().query(sql, [
|
||||||
|
body.product_cd ?? null,
|
||||||
|
body.part_no ?? null,
|
||||||
|
body.part_name ?? null,
|
||||||
|
body.version ?? null,
|
||||||
|
body.status,
|
||||||
|
userId,
|
||||||
|
objid,
|
||||||
|
]);
|
||||||
|
return r.rowCount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M3 다중 삭제 (트랜잭션) ───────────────────────────────
|
||||||
|
|
||||||
|
export async function removeMany(objids: string[]): Promise<number> {
|
||||||
|
if (!objids || objids.length === 0) return 0;
|
||||||
|
let removed = 0;
|
||||||
|
await transaction(async (client: PoolClient) => {
|
||||||
|
// 1) 자식 트리
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1::varchar[])`,
|
||||||
|
[objids]
|
||||||
|
);
|
||||||
|
// 2) 메인
|
||||||
|
const r = await client.query(
|
||||||
|
`DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1::varchar[])`,
|
||||||
|
[objids]
|
||||||
|
);
|
||||||
|
removed = r.rowCount ?? 0;
|
||||||
|
});
|
||||||
|
logger.info("E-BOM 삭제 완료", { removed });
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M4 정전개 (재귀 CTE) ──────────────────────────────────
|
||||||
|
|
||||||
|
export async function ascending(filter: BomTreeFilter) {
|
||||||
|
const pool = getPool();
|
||||||
|
const params: any[] = [];
|
||||||
|
const conds: string[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
// 시작점 필터: 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘
|
||||||
|
if (filter.bom_report_objid) {
|
||||||
|
conds.push(`BP.bom_report_objid = $${idx++}`);
|
||||||
|
params.push(filter.bom_report_objid);
|
||||||
|
} else if (filter.project_name || filter.unit_code) {
|
||||||
|
const subConds: string[] = [];
|
||||||
|
if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); }
|
||||||
|
if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); }
|
||||||
|
conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`);
|
||||||
|
}
|
||||||
|
const startWhere = conds.length ? conds.join(" AND ") : "1=1";
|
||||||
|
|
||||||
|
// PART 검색 필터는 결과 단계 적용
|
||||||
|
const finalConds: string[] = [];
|
||||||
|
if (filter.search_part_no) {
|
||||||
|
finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`);
|
||||||
|
params.push(`%${filter.search_part_no}%`);
|
||||||
|
}
|
||||||
|
if (filter.search_part_name) {
|
||||||
|
finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`);
|
||||||
|
params.push(`%${filter.search_part_name}%`);
|
||||||
|
}
|
||||||
|
const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS (
|
||||||
|
SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid,
|
||||||
|
BP.part_no, BP.qty, BP.seq, BP.status,
|
||||||
|
1, ARRAY[BP.objid::varchar], FALSE
|
||||||
|
FROM bom_part_qty BP
|
||||||
|
WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '')
|
||||||
|
AND ${startWhere}
|
||||||
|
UNION ALL
|
||||||
|
SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid,
|
||||||
|
B.part_no, B.qty, B.seq, B.status,
|
||||||
|
T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path)
|
||||||
|
FROM bom_part_qty B
|
||||||
|
JOIN TREE T ON B.parent_objid = T.objid AND NOT T.cycle
|
||||||
|
)
|
||||||
|
SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status,
|
||||||
|
T.lev, T.path,
|
||||||
|
PM.part_no AS pm_part_no,
|
||||||
|
PM.part_name AS pm_part_name,
|
||||||
|
PM.spec, PM.material, PM.weight, PM.remark,
|
||||||
|
PM.edit_date,
|
||||||
|
PM.eo_no, PM.revision,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt,
|
||||||
|
(SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level
|
||||||
|
FROM TREE T
|
||||||
|
LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar
|
||||||
|
${finalWhere}
|
||||||
|
ORDER BY T.path
|
||||||
|
`;
|
||||||
|
const r = await pool.query(sql, params);
|
||||||
|
const max_level = r.rows[0]?.max_level ?? 0;
|
||||||
|
return { rows: r.rows, max_level };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── M4 역전개 (재귀 CTE — parent 방향) ────────────────────
|
||||||
|
|
||||||
|
export async function descending(filter: BomTreeFilter) {
|
||||||
|
const pool = getPool();
|
||||||
|
const params: any[] = [];
|
||||||
|
const anchorConds: string[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
// anchor: PART 검색 필터로 leaf 후보 선택. 없으면 전체 (잎-가지가 자식 없는 행)
|
||||||
|
if (filter.search_part_no) {
|
||||||
|
anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_no) LIKE UPPER($${idx++}))`);
|
||||||
|
params.push(`%${filter.search_part_no}%`);
|
||||||
|
}
|
||||||
|
if (filter.search_part_name) {
|
||||||
|
anchorConds.push(`EXISTS (SELECT 1 FROM part_mng PMA WHERE PMA.objid::varchar = BP.part_no AND UPPER(PMA.part_name) LIKE UPPER($${idx++}))`);
|
||||||
|
params.push(`%${filter.search_part_name}%`);
|
||||||
|
}
|
||||||
|
if (filter.bom_report_objid) {
|
||||||
|
anchorConds.push(`BP.bom_report_objid = $${idx++}`);
|
||||||
|
params.push(filter.bom_report_objid);
|
||||||
|
} else if (filter.project_name || filter.unit_code) {
|
||||||
|
const subConds: string[] = [];
|
||||||
|
if (filter.project_name) { subConds.push(`contract_objid = $${idx++}`); params.push(filter.project_name); }
|
||||||
|
if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); }
|
||||||
|
anchorConds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`);
|
||||||
|
}
|
||||||
|
if (anchorConds.length === 0) {
|
||||||
|
// 검색 조건이 전혀 없으면 빈 결과 반환 (역전개는 통상 PART 한정 조회)
|
||||||
|
return { rows: [], max_level: 0 };
|
||||||
|
}
|
||||||
|
const anchorWhere = anchorConds.join(" AND ");
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS (
|
||||||
|
SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid,
|
||||||
|
BP.part_no, BP.qty, BP.seq, BP.status,
|
||||||
|
1, ARRAY[BP.objid::varchar], FALSE
|
||||||
|
FROM bom_part_qty BP
|
||||||
|
WHERE ${anchorWhere}
|
||||||
|
UNION ALL
|
||||||
|
SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid,
|
||||||
|
B.part_no, B.qty, B.seq, B.status,
|
||||||
|
T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path)
|
||||||
|
FROM bom_part_qty B
|
||||||
|
JOIN TREE T ON B.objid = T.parent_objid AND NOT T.cycle
|
||||||
|
)
|
||||||
|
SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status,
|
||||||
|
T.lev, T.path,
|
||||||
|
PM.part_no AS pm_part_no,
|
||||||
|
PM.part_name AS pm_part_name,
|
||||||
|
PM.spec, PM.material, PM.weight, PM.remark,
|
||||||
|
PM.edit_date,
|
||||||
|
PM.eo_no, PM.revision,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt,
|
||||||
|
(SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level
|
||||||
|
FROM TREE T
|
||||||
|
LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar
|
||||||
|
ORDER BY T.path
|
||||||
|
`;
|
||||||
|
const r = await pool.query(sql, params);
|
||||||
|
const max_level = r.rows[0]?.max_level ?? 0;
|
||||||
|
return { rows: r.rows, max_level };
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# PR-B : E-BOM 등록·조회 묶음 구현 명세 (M3+M4)
|
||||||
|
|
||||||
|
> 작성: 2026-05-12 / 범위: 개발관리 M3(E-BOM 등록) + M4(E-BOM 조회) — `part_bom_report` 메인, `bom_part_qty` 트리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 매퍼 쿼리 1:1 매핑
|
||||||
|
|
||||||
|
원본 `wace_plm/src/com/pms/mapper/partMng.xml`:
|
||||||
|
|
||||||
|
| Query id | Line | 본 PR 매핑 | 용도 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `getBOMStandardStructureGridList` | 2,859 | `GET /api/development/ebom/list` | M3 그리드 (PART_BOM_REPORT + 집계) |
|
||||||
|
| `updateStructureStatus` | 8,027 | `PUT /api/development/ebom/status` | M3 상태변경 (PRODUCT_CD/PART_NO/NAME/VERSION/STATUS) |
|
||||||
|
| `deleteBomReport` | 6,838 | `DELETE /api/development/ebom` (body `objids`) | M3 다중 삭제 + BOM_PART_QTY CASCADE |
|
||||||
|
| `deleteBomQty` | 6,847 | (deleteBomReport 내부) | M3 삭제 시 자식 트리 동시 삭제 |
|
||||||
|
| `structureAscendingList` | 7,361 | `GET /api/development/ebom/ascending` | M4 정전개 (root → leaf) |
|
||||||
|
| `selectStructureDescendingList` | 6,582 | `GET /api/development/ebom/descending` | M4 역전개 (leaf → root) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 엔드포인트 명세
|
||||||
|
|
||||||
|
### 2.1 M3 그리드 — `GET /api/development/ebom/list`
|
||||||
|
|
||||||
|
**Query**:
|
||||||
|
```
|
||||||
|
customer_cd?: string // part_bom_report.customer_objid
|
||||||
|
project_name?: string // part_bom_report.contract_objid (project_mgmt.objid)
|
||||||
|
unit_code?: string // pms_wbs_task.objid
|
||||||
|
search_unit_name?: string // pms_wbs_task.unit_no/task_name LIKE
|
||||||
|
search_writer?: string // part_bom_report.writer
|
||||||
|
product_cd?: string // wace 'product_code' 검색 (part_bom_report.product_cd)
|
||||||
|
search_part_no?: string // part_bom_report.part_no LIKE
|
||||||
|
search_part_name?: string // part_bom_report.part_name LIKE
|
||||||
|
search_from_date?: string // regdate from
|
||||||
|
search_to_date?: string // regdate to
|
||||||
|
status?: string // part_bom_report.status (create/changeDesign/deploy)
|
||||||
|
page?, page_size?
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL** (vexplor_rps part_bom_report 스키마 적응 — wace `getBOMStandardStructureGridList` 의 PRODUCT_CD/PART_NO/PART_NAME 분기 활성, MULTI_* 컬럼 그대로):
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
ROW_NUMBER() OVER(ORDER BY T.REGDATE DESC) AS NUM,
|
||||||
|
T.OBJID, T.CUSTOMER_OBJID, SM.SUPPLY_NAME AS CUSTOMER_NAME,
|
||||||
|
T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO,
|
||||||
|
T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME,
|
||||||
|
T.STATUS,
|
||||||
|
CASE UPPER(T.STATUS)
|
||||||
|
WHEN 'CREATE' THEN '등록중'
|
||||||
|
WHEN 'CHANGEDESIGN' THEN '설계변경미배포'
|
||||||
|
WHEN 'DEPLOY' THEN '배포완료'
|
||||||
|
ELSE '' END AS STATUS_TITLE,
|
||||||
|
T.WRITER, UI.DEPT_NAME, UI.USER_NAME,
|
||||||
|
COALESCE(UI.DEPT_NAME || '/' || UI.USER_NAME, '') AS DEPT_USER_NAME,
|
||||||
|
T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE,
|
||||||
|
T.DEPLOY_DATE, T.REVISION,
|
||||||
|
EO_DATA.EO_NO, EO_DATA.EO_DATE,
|
||||||
|
T.NOTE, T.MULTI_YN, T.MULTI_MASTER_YN, T.MULTI_BREAK_YN, T.MULTI_MASTER_OBJID,
|
||||||
|
COALESCE(EO_DATA.BOM_CNT, 0) AS BOM_CNT,
|
||||||
|
T.PRODUCT_CD, CC_PRD.code_name AS PRODUCT_NAME,
|
||||||
|
T.PART_NO, T.PART_NAME
|
||||||
|
FROM PART_BOM_REPORT T
|
||||||
|
LEFT JOIN customer_mng SM ON SM.customer_code = T.CUSTOMER_OBJID -- vexplor 매핑
|
||||||
|
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = T.CONTRACT_OBJID
|
||||||
|
LEFT JOIN PMS_WBS_TASK WT ON WT.OBJID = T.UNIT_CODE
|
||||||
|
LEFT JOIN USER_INFO UI ON UI.USER_ID = T.WRITER
|
||||||
|
LEFT JOIN COMM_CODE CC_PRD ON CC_PRD.code_id = T.PRODUCT_CD AND CC_PRD.status = 'active'
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT BP.BOM_REPORT_OBJID,
|
||||||
|
MAX(PM2.EO_NO) AS EO_NO,
|
||||||
|
MAX(PM2.EO_DATE) AS EO_DATE,
|
||||||
|
COUNT(*) AS BOM_CNT
|
||||||
|
FROM BOM_PART_QTY BP
|
||||||
|
LEFT JOIN PART_MNG PM2 ON BP.PART_NO = PM2.OBJID::varchar
|
||||||
|
GROUP BY BP.BOM_REPORT_OBJID
|
||||||
|
) EO_DATA ON EO_DATA.BOM_REPORT_OBJID = T.OBJID
|
||||||
|
WHERE 1=1 + 동적 (위 11 필터)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `{ rows: BomReportRow[], total, page, pageSize }`
|
||||||
|
|
||||||
|
### 2.2 M3 단건 상세 — `GET /api/development/ebom/:objid`
|
||||||
|
|
||||||
|
`SELECT T.* FROM PART_BOM_REPORT T WHERE T.OBJID = $1` + (옵션) BOM_PART_QTY 카운트.
|
||||||
|
편집 다이얼로그 진입용.
|
||||||
|
|
||||||
|
### 2.3 M3 상태 변경 — `PUT /api/development/ebom/:objid/status`
|
||||||
|
|
||||||
|
**Body**: `{ product_cd?, part_no?, part_name?, version?, status }` — wace `updateStructureStatus` 1:1.
|
||||||
|
`STATUS` 만 변경하는 단순 케이스도 지원 (다른 필드 NULL 허용).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE PART_BOM_REPORT
|
||||||
|
SET PRODUCT_CD = COALESCE($1, PRODUCT_CD),
|
||||||
|
PART_NO = COALESCE($2, PART_NO),
|
||||||
|
PART_NAME = COALESCE($3, PART_NAME),
|
||||||
|
REVISION = COALESCE($4, REVISION),
|
||||||
|
STATUS = $5,
|
||||||
|
EDITER = $6,
|
||||||
|
EDIT_DATE = NOW()
|
||||||
|
WHERE OBJID = $7
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 M3 다중 삭제 — `DELETE /api/development/ebom` (body: `{ objids: string[] }`)
|
||||||
|
|
||||||
|
**트랜잭션**:
|
||||||
|
1. `DELETE FROM BOM_PART_QTY WHERE BOM_REPORT_OBJID = ANY($1)` (자식 트리)
|
||||||
|
2. `DELETE FROM PART_BOM_REPORT WHERE OBJID = ANY($1)` (메인)
|
||||||
|
|
||||||
|
wace는 part_mng도 정리(`deleteBomQtyPart`, status='create'만)하지만 본 PR에서는 part_mng 보존 (M1·M2와 결합 안 됨).
|
||||||
|
|
||||||
|
### 2.5 M4 정전개 — `GET /api/development/ebom/ascending`
|
||||||
|
|
||||||
|
**Query**:
|
||||||
|
```
|
||||||
|
bom_report_objid?: string // 단일 BOM 한정 조회
|
||||||
|
project_name?: string // PART_BOM_REPORT.contract_objid
|
||||||
|
unit_code?: string
|
||||||
|
search_part_no?: string
|
||||||
|
search_part_name?: string
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL** (재귀 CTE — wace `structureAscendingList` 의 BOM_PART_QTY 트리 1:1):
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, lev, path, cycle) AS (
|
||||||
|
SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid,
|
||||||
|
BP.part_no, BP.qty, 1, ARRAY[BP.objid], FALSE
|
||||||
|
FROM bom_part_qty BP
|
||||||
|
WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '')
|
||||||
|
AND BP.bom_report_objid = $bom_report_objid /* 또는 필터 적용된 PART_BOM_REPORT 서브쿼리 */
|
||||||
|
UNION ALL
|
||||||
|
SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid,
|
||||||
|
B.part_no, B.qty, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path)
|
||||||
|
FROM bom_part_qty B
|
||||||
|
JOIN TREE T ON B.parent_objid = T.objid AND NOT T.cycle
|
||||||
|
)
|
||||||
|
SELECT T.*,
|
||||||
|
PM.part_no AS pm_part_no,
|
||||||
|
PM.part_name AS pm_part_name,
|
||||||
|
PM.spec, PM.material, PM.weight, PM.remark,
|
||||||
|
PM.edit_date,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_DRAWING_CAD') AS cu02_cnt,
|
||||||
|
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt,
|
||||||
|
(SELECT MAX(lev) FROM TREE) AS max_level
|
||||||
|
FROM TREE T
|
||||||
|
LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar
|
||||||
|
ORDER BY T.path
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `{ rows: AscRow[], max_level: number }`
|
||||||
|
|
||||||
|
### 2.6 M4 역전개 — `GET /api/development/ebom/descending`
|
||||||
|
|
||||||
|
같은 Query 파라미터. 재귀 방향 반대:
|
||||||
|
- 시작점: 리프(`child_objid` 가 다른 행의 `parent_objid` 가 아닌 행) 또는 사용자가 지정한 PART
|
||||||
|
- 트리 부모 방향으로 traverse
|
||||||
|
|
||||||
|
**SQL** (역전개 — wace `selectStructureDescendingList` 단순 매핑):
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE TREE(...) AS (
|
||||||
|
/* 1. anchor: 조건 매칭 BOM 또는 leaf part */
|
||||||
|
SELECT BP.* , 1 AS lev, ARRAY[BP.objid] AS path, FALSE AS cycle
|
||||||
|
FROM bom_part_qty BP
|
||||||
|
WHERE ... /* PART_NO 매칭 등 */
|
||||||
|
UNION ALL
|
||||||
|
/* 2. parent 방향 traverse */
|
||||||
|
SELECT B.*, T.lev + 1, T.path || B.objid, B.objid = ANY(T.path)
|
||||||
|
FROM bom_part_qty B
|
||||||
|
JOIN TREE T ON B.objid = T.parent_objid AND NOT T.cycle
|
||||||
|
)
|
||||||
|
SELECT ... (정전개와 동일 part_mng / attach_file_info JOIN)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `{ rows: DescRow[], max_level: number }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backend 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
backend-node/src/
|
||||||
|
routes/
|
||||||
|
devBomRoutes.ts // 6 endpoint
|
||||||
|
controllers/
|
||||||
|
devBomController.ts
|
||||||
|
services/
|
||||||
|
devBomService.ts // list/getById/updateStatus/removeMany/ascending/descending
|
||||||
|
```
|
||||||
|
|
||||||
|
`app.ts`: `app.use("/api/development", devBomRoutes)` (devPart 라우터와 prefix 공유 — Express 중복 등록 안전, 경로 충돌 없음).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Frontend 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
app/(main)/COMPANY_16/development/
|
||||||
|
ebom-regist/page.tsx // M3 그리드 + 검색 + 액션
|
||||||
|
ebom-search/page.tsx // M4 정/역전개 (동적 LEVEL 컬럼)
|
||||||
|
components/development/
|
||||||
|
BomReportStatusDialog.tsx // M3 상태 변경 다이얼로그 (status select)
|
||||||
|
lib/api/
|
||||||
|
devBom.ts // 6 endpoint 호출 + 타입
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 M3 그리드 (9 셀, wace structureList.jsp:185~215 1:1)
|
||||||
|
|
||||||
|
| key | 라벨 | 정렬 | 너비 |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| product_name | 제품구분 | center | 160 |
|
||||||
|
| part_no | 품번 | left | 210 |
|
||||||
|
| part_name | 품명 | left | flex |
|
||||||
|
| bom_cnt | E-BOM (folder click) | center | 150 |
|
||||||
|
| dept_user_name | 등록자 | center | 120 |
|
||||||
|
| reg_date | 등록일 | center | 130 |
|
||||||
|
| deploy_date | 확정일 | center | 100 |
|
||||||
|
| revision | Version | center | 110 |
|
||||||
|
| status_title | 상태 | center | 110 |
|
||||||
|
|
||||||
|
### 4.2 M3 검색 폼 (wace 1:1 — 9 필드)
|
||||||
|
|
||||||
|
customer_cd · project_name · unit_code · SEARCH_UNIT_NAME · SEARCH_WRITER · product_cd · SEARCH_PART_NO · SEARCH_PART_NAME · search_fromDate~toDate · status
|
||||||
|
|
||||||
|
본 PR 1차: PRODUCT_CD · SEARCH_PART_NO · SEARCH_PART_NAME · STATUS 4필드로 시작. 나머지 추후 보강.
|
||||||
|
|
||||||
|
### 4.3 M3 액션 버튼 (wace 1:1)
|
||||||
|
|
||||||
|
- 조회 / 삭제 / E-BOM등록(Excel Import — 별 PR) / 상태변경
|
||||||
|
|
||||||
|
본 PR 포함: 조회 · 삭제 · 상태변경. **E-BOM등록(Excel Import)은 별 PR**.
|
||||||
|
|
||||||
|
### 4.4 M4 동적 LEVEL 컬럼
|
||||||
|
|
||||||
|
backend response의 `max_level` 값에 따라 컬럼을 동적 생성:
|
||||||
|
- LEVEL 1..max_level: 각 레벨 컬럼은 `row.lev === i` 인 행의 `part_no` 표시 (트리 들여쓰기 효과)
|
||||||
|
- 고정 컬럼: 품번 · 품명 · 3D/2D/PDF · 수량 · 변경일 · 규격 · 재질 · 중량 · 비고
|
||||||
|
|
||||||
|
DataGrid의 컬럼 배열을 fetch 결과 도착 시 동적 생성 (state).
|
||||||
|
|
||||||
|
### 4.5 M4 액션
|
||||||
|
|
||||||
|
- 정전개 조회 (default)
|
||||||
|
- 역전개 조회
|
||||||
|
- (Excel Download — 별 PR)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 본 PR 제외 항목
|
||||||
|
|
||||||
|
| 항목 | 사유 / 후속 |
|
||||||
|
|---|---|
|
||||||
|
| E-BOM 등록 (Excel Import) | `openBomReportExcelImportPopUp.jsp` — 별 PR |
|
||||||
|
| 정/역전개 Excel Download | `structureAscendingListExcel`/`structureDescendingExcelList` — 별 PR |
|
||||||
|
| `BOM_PART_QTY` 직접 편집 (수량 변경) | `structureQtySave` — wace 운영판에서도 별 화면 |
|
||||||
|
| 다중 BOM(MULTI_*) 분기 처리 | 현재 vexplor 데이터 없음 — 기본 1:1만 |
|
||||||
|
| wace_plm `product_mgmt_spec/upg/vc` 분기 | vexplor 스키마는 product_cd 단순 — 운영판 1:1 적응 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 검증 시나리오 (verify.md 기준)
|
||||||
|
|
||||||
|
1. M3 페이지 진입 → part_bom_report 그리드 (현재 0건, schema/UI 동작 확인)
|
||||||
|
2. M3 상태변경 다이얼로그 → status='deploy' → DB 반영 확인
|
||||||
|
3. M3 다중 삭제 → bom_part_qty CASCADE 확인
|
||||||
|
4. M4 페이지 진입 → 정전개 (`/ascending`) 0건 응답 → 페이지 정상 표시
|
||||||
|
5. M4 역전개 토글 → `/descending` 응답
|
||||||
|
6. (시드 후) MAX_LEVEL=3 트리에서 동적 컬럼 3개 생성 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 적응 사항 (운영판 대비 변경점)
|
||||||
|
|
||||||
|
| # | 항목 | 변경 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `customer_mng` 매핑 | wace `SUPPLY_MNG.OBJID::VARCHAR = T.CUSTOMER_OBJID` → vexplor `customer_mng.customer_code = T.CUSTOMER_OBJID` |
|
||||||
|
| 2 | `PRODUCT_NAME` lookup | wace `CODE_NAME(PRODUCT_CD)` → vexplor `LEFT JOIN comm_code` (`CC_PRD`) |
|
||||||
|
| 3 | M4 `PRODUCT_MGMT_*` 분기 제거 | vexplor part_bom_report 는 product_cd/version 단순화 — wace `product_mgmt_spec/upg/vc` 컬럼 없음 → JOIN 생략, 정전개는 BOM_PART_QTY 트리만 |
|
||||||
|
| 4 | 다중 삭제 트랜잭션 | wace 두 매퍼(`deleteBomQty` + `deleteBomReport`) 호출 → backend `transaction()` 한 번 |
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 개발관리 > E-BOM 등록 (M3) — wace structureList.jsp 1:1
|
||||||
|
// 그리드: part_bom_report 9셀
|
||||||
|
// 액션: 조회 / 삭제 / 상태변경 (E-BOM등록 Excel Import는 별 PR)
|
||||||
|
// 참조: docs/migration/development/02-ebom.md
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, 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, Trash2, Settings,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||||
|
import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom";
|
||||||
|
import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog";
|
||||||
|
|
||||||
|
const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용)
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ code: "create", label: "등록중" },
|
||||||
|
{ code: "changeDesign", label: "설계변경미배포" },
|
||||||
|
{ code: "deploy", label: "배포완료" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GRID_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "product_name", label: "제품구분", width: "w-[160px]", align: "center", frozen: true },
|
||||||
|
{ key: "part_no", label: "품번", width: "w-[210px]" },
|
||||||
|
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||||
|
{ key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "dept_user_name", label: "등록자", width: "w-[140px]", align: "center" },
|
||||||
|
{ key: "reg_date", label: "등록일", width: "w-[120px]", align: "center" },
|
||||||
|
{ key: "deploy_date", label: "확정일", width: "w-[120px]", align: "center" },
|
||||||
|
{ key: "revision", label: "Version", width: "w-[100px]", align: "center" },
|
||||||
|
{ key: "status_title", label: "상태", width: "w-[120px]", align: "center" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FILTER: BomReportListFilter = {
|
||||||
|
product_cd: "", status: "",
|
||||||
|
search_part_no: "", search_part_name: "",
|
||||||
|
page: 1, page_size: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EbomRegistPage() {
|
||||||
|
const [rows, setRows] = useState<BomReportRow[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<BomReportListFilter>(EMPTY_FILTER);
|
||||||
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
const [statusObjid, setStatusObjid] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async (override?: Partial<BomReportListFilter>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const f = { ...filter, ...override };
|
||||||
|
const res = await devBomApi.list(f);
|
||||||
|
setRows(res.rows ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
setCheckedIds([]);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (checkedIds.length === 0) return toast.error("선택된 행이 없습니다.");
|
||||||
|
if (!confirm(`${checkedIds.length}건을 삭제하시겠습니까? (자식 BOM 트리도 함께 삭제됨)`)) return;
|
||||||
|
try {
|
||||||
|
const res = await devBomApi.remove(checkedIds);
|
||||||
|
toast.success(res?.message ?? "삭제되었습니다.");
|
||||||
|
fetchList();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = () => {
|
||||||
|
if (checkedIds.length !== 1) return toast.error("상태 변경할 행 1개를 선택하세요.");
|
||||||
|
setStatusObjid(checkedIds[0]);
|
||||||
|
setStatusOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b bg-card px-4 py-3">
|
||||||
|
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||||
|
<Field label="제품구분">
|
||||||
|
<CommCodeSelect
|
||||||
|
groupId={PRODUCT_GROUP}
|
||||||
|
value={filter.product_cd ?? ""}
|
||||||
|
onValueChange={(v) => setFilter({ ...filter, product_cd: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="상태">
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||||
|
value={filter.status ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
{STATUS_OPTIONS.map((o) =>
|
||||||
|
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="품번">
|
||||||
|
<Input
|
||||||
|
value={filter.search_part_no ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||||
|
placeholder="품번 LIKE"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="품명">
|
||||||
|
<Input
|
||||||
|
value={filter.search_part_name ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||||
|
placeholder="품명 LIKE"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">총 {total.toLocaleString()}건</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }}>
|
||||||
|
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => fetchList()} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
<span className="ml-1">조회</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleStatusChange}
|
||||||
|
disabled={checkedIds.length !== 1}>
|
||||||
|
<Settings className="h-4 w-4" /><span className="ml-1">상태변경</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={handleDelete}
|
||||||
|
disabled={checkedIds.length === 0}>
|
||||||
|
<Trash2 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={rows}
|
||||||
|
loading={loading}
|
||||||
|
showRowNumber
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
emptyMessage="등록된 E-BOM이 없습니다."
|
||||||
|
gridId="development-ebom-regist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BomReportStatusDialog
|
||||||
|
open={statusOpen}
|
||||||
|
onOpenChange={setStatusOpen}
|
||||||
|
objid={statusObjid}
|
||||||
|
onSaved={fetchList}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 개발관리 > E-BOM 조회 (M4) — wace structureAscendingList.jsp 1:1
|
||||||
|
// 정전개(루트→리프) / 역전개(리프→부모) 토글. 동적 LEVEL 컬럼.
|
||||||
|
// 참조: docs/migration/development/02-ebom.md
|
||||||
|
|
||||||
|
import React, { useCallback, 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, ChevronsRight, ChevronsLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom";
|
||||||
|
|
||||||
|
type Direction = "ascending" | "descending";
|
||||||
|
|
||||||
|
const EMPTY_FILTER: BomTreeFilter = {
|
||||||
|
project_name: "", unit_code: "",
|
||||||
|
search_part_no: "", search_part_name: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EbomSearchPage() {
|
||||||
|
const [filter, setFilter] = useState<BomTreeFilter>(EMPTY_FILTER);
|
||||||
|
const [direction, setDirection] = useState<Direction>("ascending");
|
||||||
|
const [rows, setRows] = useState<BomTreeRow[]>([]);
|
||||||
|
const [maxLevel, setMaxLevel] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const runQuery = useCallback(async (dir: Direction) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const fn = dir === "ascending" ? devBomApi.ascending : devBomApi.descending;
|
||||||
|
const res = await fn(filter);
|
||||||
|
setRows(res.rows ?? []);
|
||||||
|
setMaxLevel(Number(res.max_level) || 0);
|
||||||
|
setDirection(dir);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
// 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시
|
||||||
|
const columns: DataGridColumn[] = useMemo(() => {
|
||||||
|
const levelCols: DataGridColumn[] = [];
|
||||||
|
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||||
|
levelCols.push({
|
||||||
|
key: `__lev_${i}`,
|
||||||
|
label: `L${i}`,
|
||||||
|
width: "w-[140px]",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...levelCols,
|
||||||
|
{ key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false },
|
||||||
|
{ key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" },
|
||||||
|
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" },
|
||||||
|
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||||
|
{ key: "spec", label: "규격", width: "w-[120px]" },
|
||||||
|
{ key: "material", label: "재질", width: "w-[100px]" },
|
||||||
|
{ key: "weight", label: "중량", width: "w-[80px]", align: "right" },
|
||||||
|
{ key: "remark", label: "비고", minWidth: "min-w-[140px]" },
|
||||||
|
];
|
||||||
|
}, [maxLevel]);
|
||||||
|
|
||||||
|
// 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움
|
||||||
|
const gridData = useMemo(
|
||||||
|
() => rows.map((r) => {
|
||||||
|
const expanded: any = { ...r };
|
||||||
|
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||||
|
expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : "";
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
}),
|
||||||
|
[rows, maxLevel],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b bg-card px-4 py-3">
|
||||||
|
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||||
|
<Field label="프로젝트 OBJID">
|
||||||
|
<Input value={filter.project_name ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, project_name: e.target.value })}
|
||||||
|
placeholder="project_mgmt.objid" />
|
||||||
|
</Field>
|
||||||
|
<Field label="UNIT_CODE">
|
||||||
|
<Input value={filter.unit_code ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, unit_code: e.target.value })}
|
||||||
|
placeholder="pms_wbs_task.objid" />
|
||||||
|
</Field>
|
||||||
|
<Field label="품번">
|
||||||
|
<Input value={filter.search_part_no ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||||
|
placeholder="part_no LIKE" />
|
||||||
|
</Field>
|
||||||
|
<Field label="품명">
|
||||||
|
<Input value={filter.search_part_name ?? ""}
|
||||||
|
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||||
|
placeholder="part_name LIKE" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
모드: {direction === "ascending" ? "정전개 (루트 → 리프)" : "역전개 (리프 → 부모)"} · {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}>
|
||||||
|
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => runQuery("ascending")} disabled={loading}
|
||||||
|
variant={direction === "ascending" ? "default" : "secondary"}>
|
||||||
|
{loading && direction === "ascending"
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <ChevronsRight className="h-4 w-4" />}
|
||||||
|
<span className="ml-1">정전개 조회</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => runQuery("descending")} disabled={loading}
|
||||||
|
variant={direction === "descending" ? "default" : "secondary"}>
|
||||||
|
{loading && direction === "descending"
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <ChevronsLeft className="h-4 w-4" />}
|
||||||
|
<span className="ml-1">역전개 조회</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{direction === "descending" && (
|
||||||
|
<div className="mt-2 text-xs text-amber-600">
|
||||||
|
역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 p-2">
|
||||||
|
<DataGrid
|
||||||
|
columns={columns}
|
||||||
|
data={gridData}
|
||||||
|
loading={loading}
|
||||||
|
showRowNumber
|
||||||
|
emptyMessage="조건에 맞는 BOM이 없습니다."
|
||||||
|
gridId={`development-ebom-search-${direction}`}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 개발관리 > E-BOM 상태 변경 다이얼로그.
|
||||||
|
// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드.
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Loader2, Save } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { devBomApi, BomReportRow } from "@/lib/api/devBom";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ code: "create", label: "등록중" },
|
||||||
|
{ code: "changeDesign", label: "설계변경미배포" },
|
||||||
|
{ code: "deploy", label: "배포완료" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
objid: string | null;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) {
|
||||||
|
const [row, setRow] = useState<BomReportRow | null>(null);
|
||||||
|
const [status, setStatus] = useState<string>("");
|
||||||
|
const [version, setVersion] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !objid) return;
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
devBomApi.detail(objid)
|
||||||
|
.then((data) => {
|
||||||
|
if (!alive) return;
|
||||||
|
if (!data) {
|
||||||
|
toast.error("E-BOM 보고서를 찾을 수 없습니다.");
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRow(data);
|
||||||
|
setStatus(data.status ?? "");
|
||||||
|
setVersion(data.revision ?? "");
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||||
|
onOpenChange(false);
|
||||||
|
})
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [open, objid, onOpenChange]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!objid) return;
|
||||||
|
if (!status) return toast.error("상태를 선택하세요.");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await devBomApi.updateStatus(objid, {
|
||||||
|
status,
|
||||||
|
version: version || undefined,
|
||||||
|
});
|
||||||
|
toast.success("상태가 변경되었습니다.");
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>E-BOM 상태 변경</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading || !row ? (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1">
|
||||||
|
<div><span className="text-muted-foreground">제품구분:</span> {row.product_name ?? row.product_cd ?? "—"}</div>
|
||||||
|
<div><span className="text-muted-foreground">품번:</span> {row.part_no ?? "—"}</div>
|
||||||
|
<div><span className="text-muted-foreground">품명:</span> {row.part_name ?? "—"}</div>
|
||||||
|
<div><span className="text-muted-foreground">현재상태:</span> {row.status_title ?? row.status ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-xs text-muted-foreground">변경 상태 *</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">선택</option>
|
||||||
|
{STATUS_OPTIONS.map((o) =>
|
||||||
|
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||||
|
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="예: RE, A, B..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving || loading}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
<span className="ml-1">저장</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -109,6 +109,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|||||||
"/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/development/part-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-regist/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/development/part-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-regist/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_16/development/ebom-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-regist/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/COMPANY_16/development/ebom-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-search/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1
|
||||||
|
// 라우트: /api/development/ebom/*, /api/development/ebom-tree/*
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface BomReportListFilter {
|
||||||
|
customer_cd?: string;
|
||||||
|
project_name?: string;
|
||||||
|
unit_code?: string;
|
||||||
|
search_unit_name?: string;
|
||||||
|
search_writer?: string;
|
||||||
|
product_cd?: string;
|
||||||
|
search_part_no?: string;
|
||||||
|
search_part_name?: string;
|
||||||
|
search_from_date?: string;
|
||||||
|
search_to_date?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomReportRow {
|
||||||
|
num: number | string;
|
||||||
|
objid: string;
|
||||||
|
customer_objid: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
contract_objid: string | null;
|
||||||
|
customer_project_name: string | null;
|
||||||
|
project_no: string | null;
|
||||||
|
unit_code: string | null;
|
||||||
|
unit_name: string | null;
|
||||||
|
status: string | null;
|
||||||
|
status_title: string | null;
|
||||||
|
writer: string | null;
|
||||||
|
dept_name: string | null;
|
||||||
|
user_name: string | null;
|
||||||
|
dept_user_name: string | null;
|
||||||
|
regdate: string | null;
|
||||||
|
reg_date: string | null;
|
||||||
|
deploy_date: string | null;
|
||||||
|
revision: string | null;
|
||||||
|
eo_no: string | null;
|
||||||
|
eo_date: string | null;
|
||||||
|
note: string | null;
|
||||||
|
multi_yn: string | null;
|
||||||
|
multi_master_yn: string | null;
|
||||||
|
multi_break_yn: string | null;
|
||||||
|
multi_master_objid: string | null;
|
||||||
|
bom_cnt: number | string | null;
|
||||||
|
product_cd: string | null;
|
||||||
|
product_name: string | null;
|
||||||
|
part_no: string | null;
|
||||||
|
part_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomReportListResponse {
|
||||||
|
rows: BomReportRow[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomReportStatusBody {
|
||||||
|
product_cd?: string;
|
||||||
|
part_no?: string;
|
||||||
|
part_name?: string;
|
||||||
|
version?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomTreeFilter {
|
||||||
|
bom_report_objid?: string;
|
||||||
|
project_name?: string;
|
||||||
|
unit_code?: string;
|
||||||
|
search_part_no?: string;
|
||||||
|
search_part_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomTreeRow {
|
||||||
|
bom_report_objid: string | null;
|
||||||
|
objid: string;
|
||||||
|
parent_objid: string | null;
|
||||||
|
child_objid: string | null;
|
||||||
|
part_no: string | null; // bom_part_qty.part_no (= part_mng.objid)
|
||||||
|
qty: string | null;
|
||||||
|
seq: number | string | null;
|
||||||
|
status: string | null;
|
||||||
|
lev: number;
|
||||||
|
path: string[] | null;
|
||||||
|
// part_mng JOIN
|
||||||
|
pm_part_no: string | null;
|
||||||
|
pm_part_name: string | null;
|
||||||
|
spec: string | null;
|
||||||
|
material: string | null;
|
||||||
|
weight: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
edit_date: string | null;
|
||||||
|
eo_no: string | null;
|
||||||
|
revision: string | null;
|
||||||
|
cu01_cnt: number | string | null;
|
||||||
|
cu02_cnt: number | string | null;
|
||||||
|
cu03_cnt: number | string | null;
|
||||||
|
max_level: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BomTreeResponse {
|
||||||
|
rows: BomTreeRow[];
|
||||||
|
max_level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const devBomApi = {
|
||||||
|
// M3 그리드
|
||||||
|
async list(filter: BomReportListFilter = {}): Promise<BomReportListResponse> {
|
||||||
|
const res = await apiClient.get("/development/ebom/list", { params: filter });
|
||||||
|
return res.data?.data as BomReportListResponse;
|
||||||
|
},
|
||||||
|
|
||||||
|
async detail(objid: string): Promise<BomReportRow | null> {
|
||||||
|
const res = await apiClient.get(`/development/ebom/${objid}`);
|
||||||
|
return res.data?.data ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(objid: string, body: BomReportStatusBody) {
|
||||||
|
return (await apiClient.put(`/development/ebom/${objid}/status`, body)).data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(objids: string[]) {
|
||||||
|
const res = await apiClient.delete("/development/ebom", { data: { objids } });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// M4
|
||||||
|
async ascending(filter: BomTreeFilter): Promise<BomTreeResponse> {
|
||||||
|
const res = await apiClient.get("/development/ebom-tree/ascending", { params: filter });
|
||||||
|
return res.data?.data as BomTreeResponse;
|
||||||
|
},
|
||||||
|
|
||||||
|
async descending(filter: BomTreeFilter): Promise<BomTreeResponse> {
|
||||||
|
const res = await apiClient.get("/development/ebom-tree/descending", { params: filter });
|
||||||
|
return res.data?.data as BomTreeResponse;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user