From 429b1d1e8ad0faceef57f2ce1563729b70533a3c Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 10:08:39 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BOM=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=E2=80=94=20BOM=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20+=20=ED=8A=B8=EB=A6=AC=20=EC=A1=B0=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 검증 중 두 가지 문제 발견: 1) 트리가 1레벨만 표시되고 자식들이 안 풀림 — ascending/descending CTE 의 재귀 조인 조건 버그. 잘못된 조인: B.parent_objid = T.objid 올바른 조인: B.parent_objid = T.child_objid (ascending), B.child_objid = T.parent_objid (descending) wace relatePartInfo 1:1 패턴 — BOM_PART_QTY INSERT 시 OBJID 와 별도로 createObjId() 따로 발급되는 CHILD_OBJID 가 부모 식별자. 자식 행의 PARENT_OBJID 는 부모 행의 CHILD_OBJID 와 매칭됨 (PARENT_OBJID = T.OBJID 가 아님). 4 함수 일괄 정정 : ascending / descending / ascendingForExcel / descendingForExcel 검증 : test-20003-0082 BOM → Level 1 루트 + Level 2 자식 2건 정상 트리 출력. 2) E-BOM 컬럼이 숫자(bom_cnt)로 표시되어 어디를 눌러 BOM 구조를 봐야 할지 불명확. 운영판 wace 는 fnc_getFolderIcon 으로 폴더 아이콘 + 클릭 → setStructurePopupMainFS. backend: - GET /api/development/ebom-tree/full → ascendingForExcel 1:1, 풀 컬럼 JSON 노출 (HEAT_TREATMENT_HARDNESS/METHOD/SURFACE_TREATMENT/MAKER/PART_TYPE_TITLE/CU_파일 카운트 포함) frontend: - BomReportTreeDialog.tsx 신설 (wace 4-Frame 팝업 → 단일 다이얼로그 통합) · 헤더 메타 8필드 (제품구분/품번/품명/Version/상태/등록자/등록일/확정일) · 동적 LEVEL 컬럼 (L1..LMaxLevel, "*" 표시) + 운영판 14컬럼 · 노란 배경 헤더 (운영판 스타일 1:1) · 엑셀 다운로드 버튼 (해당 BOM 만 ascendingForExcel 호출) - lib/api/devBom.ts : treeFull() API + BomTreeFullRow 타입 - app/.../ebom-regist/page.tsx : · bom_cnt 컬럼 formatNumber → renderType: "folder" (wace fnc_getFolderIcon 1:1) · 클릭 핸들러를 품번 → E-BOM 폴더 셀로 이동 (운영판 fn_openSetStructure 동작) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/devBomController.ts | 12 ++ backend-node/src/routes/devBomRoutes.ts | 1 + backend-node/src/services/devBomService.ts | 8 +- .../development/ebom-regist/page.tsx | 32 ++- .../development/BomReportTreeDialog.tsx | 194 ++++++++++++++++++ frontend/lib/api/devBom.ts | 30 +++ 6 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 frontend/components/development/BomReportTreeDialog.tsx diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts index 2b9a0f07..383acaf6 100644 --- a/backend-node/src/controllers/devBomController.ts +++ b/backend-node/src/controllers/devBomController.ts @@ -167,6 +167,18 @@ export async function ascending(req: AuthenticatedRequest, res: Response) { } } +// ─── E-BOM 트리 보기 (M3 행 클릭 → 다이얼로그) ───────────── +// GET /api/development/ebom-tree/full?bom_report_objid=... — ascendingForExcel 1:1, 풀 컬럼 JSON +export async function treeFull(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.ascendingForExcel(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 엑셀 다운로드 (정/역전개) ────────────────────────── // GET /api/development/ebom-tree/ascending/excel // GET /api/development/ebom-tree/descending/excel diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts index 8756a24d..b3fbf792 100644 --- a/backend-node/src/routes/devBomRoutes.ts +++ b/backend-node/src/routes/devBomRoutes.ts @@ -21,6 +21,7 @@ router.get("/ebom-tree/ascending", ctrl.ascending); router.get("/ebom-tree/descending", ctrl.descending); router.get("/ebom-tree/ascending/excel", ctrl.excelAscending); router.get("/ebom-tree/descending/excel", ctrl.excelDescending); +router.get("/ebom-tree/full", ctrl.treeFull); // M3 Excel Import — /:objid 보다 위에 (라우트 충돌 방지) router.post("/ebom/excel-parse", excelUpload.single("file"), ctrl.excelParse); diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts index 8ce20a64..dfcf0d20 100644 --- a/backend-node/src/services/devBomService.ts +++ b/backend-node/src/services/devBomService.ts @@ -251,7 +251,7 @@ export async function ascending(filter: BomTreeFilter) { 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 + JOIN TREE T ON B.parent_objid = T.child_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, @@ -327,7 +327,7 @@ export async function ascendingForExcel(filter: BomTreeFilter) { B.part_no, B.qty, B.item_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 + JOIN TREE T ON B.parent_objid = T.child_objid AND NOT T.cycle ) SELECT T.lev, PM.part_no AS pm_part_no, @@ -389,7 +389,7 @@ export async function descendingForExcel(filter: BomTreeFilter) { B.part_no, B.qty, B.item_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 + JOIN TREE T ON B.child_objid = T.parent_objid AND NOT T.cycle ) SELECT T.lev, PM.part_no AS pm_part_no, @@ -457,7 +457,7 @@ export async function descending(filter: BomTreeFilter) { 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 + JOIN TREE T ON B.child_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, diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx index c6ea0fd1..2b8340f9 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-regist/page.tsx @@ -5,7 +5,7 @@ // 액션: 조회 / 삭제 / 상태변경 (E-BOM등록 Excel Import는 별 PR) // 참조: docs/migration/development/02-ebom.md -import React, { useCallback, useEffect, useState } from "react"; +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"; @@ -18,6 +18,7 @@ import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom"; import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog"; import { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog"; +import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialog"; const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용) @@ -27,11 +28,12 @@ const STATUS_OPTIONS = [ { code: "deploy", label: "배포완료" }, ]; -const GRID_COLUMNS: DataGridColumn[] = [ +const BASE_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 }, + // wace fnc_getFolderIcon 1:1 — 폴더 아이콘 클릭 시 BOM 구조 다이얼로그 + { key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "center", renderType: "folder" }, { 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" }, @@ -55,6 +57,8 @@ export default function EbomRegistPage() { const [statusOpen, setStatusOpen] = useState(false); const [statusObjid, setStatusObjid] = useState(null); const [excelOpen, setExcelOpen] = useState(false); + const [treeOpen, setTreeOpen] = useState(false); + const [treeReport, setTreeReport] = useState(null); const fetchList = useCallback(async (override?: Partial) => { setLoading(true); @@ -91,6 +95,21 @@ export default function EbomRegistPage() { setStatusOpen(true); }; + // 품번 셀 클릭 → BOM 트리 다이얼로그 (wace fn_openSetStructure 1:1) + const openTree = useCallback((row: BomReportRow) => { + setTreeReport(row); + setTreeOpen(true); + }, []); + + const columns: DataGridColumn[] = useMemo( + () => BASE_GRID_COLUMNS.map((c) => + c.key === "bom_cnt" + ? { ...c, onClick: (row: any) => openTree(row as BomReportRow) } + : c, + ), + [openTree], + ); + return (
@@ -157,7 +176,7 @@ export default function EbomRegistPage() {
+
); } diff --git a/frontend/components/development/BomReportTreeDialog.tsx b/frontend/components/development/BomReportTreeDialog.tsx new file mode 100644 index 00000000..12af65e7 --- /dev/null +++ b/frontend/components/development/BomReportTreeDialog.tsx @@ -0,0 +1,194 @@ +"use client"; + +// 개발관리 E-BOM 트리 다이얼로그 — wace setStructurePopupMainFS 1:1 (단일 다이얼로그로 통합) +// +// 운영판은 frameset(헤더/좌측 트리/우측 디테일/하단 버튼) 4-Frame 팝업이지만 RPS 는 단일 +// 다이얼로그에 헤더(BOM Report 메타) + 동적 LEVEL 컬럼 트리 그리드 + 엑셀 다운로드 버튼. +// +// 컬럼 (운영 structureAscendingListExcel.jsp 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량 / 항목수량 / 3D / 2D / PDF / +// 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 + +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2, FileSpreadsheet } from "lucide-react"; +import { toast } from "sonner"; +import { devBomApi, BomReportRow, BomTreeFullRow } from "@/lib/api/devBom"; +import { cn } from "@/lib/utils"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + bomReport: BomReportRow | null; // 헤더 정보 표시용 +} + +export function BomReportTreeDialog({ open, onOpenChange, bomReport }: Props) { + const [rows, setRows] = useState([]); + const [maxLevel, setMaxLevel] = useState(0); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + + useEffect(() => { + if (!open || !bomReport?.objid) { + setRows([]); setMaxLevel(0); + return; + } + let alive = true; + setLoading(true); + devBomApi.treeFull({ bom_report_objid: bomReport.objid }) + .then((data) => { + if (!alive) return; + setRows(data.rows ?? []); + setMaxLevel(Number(data.max_level) || 0); + }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "트리 조회 실패"); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, bomReport?.objid]); + + const handleExcel = async () => { + if (!bomReport?.objid) return; + setExporting(true); + try { + const { blob, fileName } = await devBomApi.excelAscending({ bom_report_objid: bomReport.objid }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = fileName; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + toast.success(`${fileName} 다운로드`); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 다운로드 실패"); + } finally { + setExporting(false); + } + }; + + const effectiveMax = Math.max(1, maxLevel); + + // 동적 LEVEL 컬럼 헤더 (1..maxLevel) + const levelHeaders = useMemo(() => { + const h: number[] = []; + for (let i = 1; i <= effectiveMax; i++) h.push(i); + return h; + }, [effectiveMax]); + + return ( + + + + BOM 구조 조회 + + + {/* 헤더 메타 */} + {bomReport && ( +
+ + + + + + + + +
+ )} + +
+
+ 총 {rows.length.toLocaleString()}행 · MAX_LEVEL = {maxLevel} +
+ +
+ +
+ {loading ? ( +
+ +
+ ) : ( + + + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((r, idx) => { + const lev = Number(r.lev ?? 1); + return ( + + {levelHeaders.map((i) => ( + + ))} + + + + + + + + + + + + + + + + ); + })} + +
{i}품번품명수량항목수량3D2DPDF재료열처리경도열처리방법표면처리메이커범주 이름비고
+ BOM 구조가 없습니다. +
+ {i === lev ? "*" : ""} + {r.pm_part_no}{r.pm_part_name}{r.qty}{r.p_qty}{Number(r.cu01_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu02_cnt ?? 0) > 0 ? "Y" : ""}{Number(r.cu03_cnt ?? 0) > 0 ? "Y" : ""}{r.material}{r.heat_treatment_hardness}{r.heat_treatment_method}{r.surface_treatment}{r.maker}{r.part_type_title}{r.remark}
+ )} +
+ + + + +
+
+ ); +} + +function MetaRow({ label, value }: { label: string; value: any }) { + return ( +
+ {label} + {value != null && value !== "" ? value : "—"} +
+ ); +} diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index 6bd82a6e..6da36456 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -124,6 +124,30 @@ export interface BomTreeResponse { max_level: number; } +// 트리 풀 컬럼 (ascendingForExcel 1:1) — BomReportTreeDialog 용 +export interface BomTreeFullRow { + lev: number | string; + pm_part_no: string | null; + pm_part_name: string | null; + qty: string | number | null; + p_qty: string | number | null; + material: string | null; + remark: string | null; + heat_treatment_hardness: string | null; + heat_treatment_method: string | null; + surface_treatment: string | null; + maker: string | null; + part_type: string | null; + part_type_title: string | null; + cu01_cnt: number | string | null; + cu02_cnt: number | string | null; + cu03_cnt: number | string | null; +} +export interface BomTreeFullResponse { + rows: BomTreeFullRow[]; + max_level: number; +} + // ─── API ───────────────────────────────────────────────── export const devBomApi = { @@ -158,6 +182,12 @@ export const devBomApi = { return res.data?.data as BomTreeResponse; }, + // E-BOM 트리 (풀 컬럼) — M3 그리드 행 클릭 → BomReportTreeDialog + async treeFull(filter: BomTreeFilter): Promise { + const res = await apiClient.get("/development/ebom-tree/full", { params: filter }); + return res.data?.data as BomTreeFullResponse; + }, + // M4 엑셀 다운로드 (정/역전개) — wace 1:1 async excelAscending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { const res = await apiClient.get("/development/ebom-tree/ascending/excel", {