From 7d73d2ee5703e5e42a43b550c40641e96fc14394 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 12 May 2026 18:07:27 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EA=B4=80=EB=A6=AC>E-BOM=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20M4=20=E2=80=94=20=EC=A0=95/=EC=97=AD?= =?UTF-8?q?=EC=A0=84=EA=B0=9C=20=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=8B=A0=EC=84=A4=20(wace=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영판: structureAscendingListExcel.jsp / structureDescendingListExcel.jsp backend (devBomService.ts): - ascendingForExcel / descendingForExcel — 그리드용 ascending/descending 보다 풀 컬럼 · 추가: P_QTY(bom_part_qty.item_qty), HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT, MAKER, PART_TYPE_TITLE(comm_code.code_name) - devBomExcelExportService.ts 신규 (xlsx 라이브러리) · 헤더: 동적 L1..LMaxLevel + 품번 / 품명 / 수량 / 항목수량 / 3D / 2D / PDF / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 (wace 1:1) · LEVEL 셀: 행의 LEV 와 동일한 컬럼에 "*", 나머지는 공백 · 3D/2D/PDF: attach_file_info count > 0 → "Y", 0 → 공백 (wace 1:1) · 컬럼 너비: LEVEL 4, 품번/품명/재료/MAKER 등 적절히 조정 · 시트명: "BOM 정전개" / "BOM 역전개" - controllers/devBomController.ts: excelAscending / excelDescending 추가 · Content-Disposition 에 filename + filename*=UTF-8'' 둘 다 (한글 파일명 호환) - routes/devBomRoutes.ts: /ebom-tree/ascending/excel, /ebom-tree/descending/excel frontend: - lib/api/devBom.ts: excelAscending/excelDescending (responseType: "blob") · Content-Disposition 의 filename*=UTF-8'' 우선 파싱 헬퍼 추가 - app/.../ebom-search/page.tsx: "정전개 엑셀" / "역전개 엑셀" 버튼 추가 · 현재 검색 조건 그대로 다운로드, blob → anchor.click 으로 저장 · 파일명: 서버 응답 헤더의 "BOM 조회(정전개)_YYYY-MM-DD_HH-mm.xlsx" 그대로 사용 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/controllers/devBomController.ts | 33 +++++ backend-node/src/routes/devBomRoutes.ts | 2 + .../src/services/devBomExcelExportService.ts | 98 ++++++++++++ backend-node/src/services/devBomService.ts | 139 ++++++++++++++++++ .../development/ebom-search/page.tsx | 30 +++- frontend/lib/api/devBom.ts | 28 ++++ 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/services/devBomExcelExportService.ts diff --git a/backend-node/src/controllers/devBomController.ts b/backend-node/src/controllers/devBomController.ts index 2d88b5b7..2b9a0f07 100644 --- a/backend-node/src/controllers/devBomController.ts +++ b/backend-node/src/controllers/devBomController.ts @@ -13,6 +13,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/devBomService"; import * as excelSvc from "../services/devBomExcelImportService"; +import * as excelExportSvc from "../services/devBomExcelExportService"; import { logger } from "../utils/logger"; function parseListFilter(q: Record): svc.BomReportListFilter { @@ -166,6 +167,38 @@ export async function ascending(req: AuthenticatedRequest, res: Response) { } } +// ─── M4 엑셀 다운로드 (정/역전개) ────────────────────────── +// GET /api/development/ebom-tree/ascending/excel +// GET /api/development/ebom-tree/descending/excel +// wace structureAscendingListExcel.jsp / structureDescendingListExcel.jsp 1:1 + +function sendExcel(res: Response, buffer: Buffer, fileName: string) { + const encoded = encodeURIComponent(fileName); + res.setHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + res.setHeader("Content-Disposition", `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`); + res.send(buffer); +} + +export async function excelAscending(req: AuthenticatedRequest, res: Response) { + try { + const { buffer, fileName } = await excelExportSvc.generateAscendingExcel(req.query as svc.BomTreeFilter); + return sendExcel(res, buffer, fileName); + } catch (e: any) { + logger.error("E-BOM 정전개 엑셀 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function excelDescending(req: AuthenticatedRequest, res: Response) { + try { + const { buffer, fileName } = await excelExportSvc.generateDescendingExcel(req.query as svc.BomTreeFilter); + return sendExcel(res, buffer, fileName); + } 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) { diff --git a/backend-node/src/routes/devBomRoutes.ts b/backend-node/src/routes/devBomRoutes.ts index 8e3b892b..8756a24d 100644 --- a/backend-node/src/routes/devBomRoutes.ts +++ b/backend-node/src/routes/devBomRoutes.ts @@ -19,6 +19,8 @@ const excelUpload = multer({ // M4 — 트리 (정/역전개) — /ebom-tree prefix (라우트 충돌 방지: /:objid 위) 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); // M3 Excel Import — /:objid 보다 위에 (라우트 충돌 방지) router.post("/ebom/excel-parse", excelUpload.single("file"), ctrl.excelParse); diff --git a/backend-node/src/services/devBomExcelExportService.ts b/backend-node/src/services/devBomExcelExportService.ts new file mode 100644 index 00000000..05167ffc --- /dev/null +++ b/backend-node/src/services/devBomExcelExportService.ts @@ -0,0 +1,98 @@ +// ============================================================ +// 개발관리 M4 E-BOM 엑셀 다운로드 (정/역전개) — wace 1:1 +// structureAscendingListExcel.jsp / structureDescendingListExcel.jsp +// +// 시트 구성 (wace 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량 / 항목수량(P_QTY) / +// 3D / 2D / PDF (Y/공백) / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / +// 범주 이름 / 비고 +// 헤더 행: 노란색 배경 + bold (운영판 스타일) +// +// 파일명: "BOM 조회(정전개)_YYYY-MM-DD_HH-mm.xlsx" / "BOM 조회(역전개)..." +// ============================================================ + +import * as XLSX from "xlsx"; +import { BomTreeFilter } from "./devBomService"; +import { ascendingForExcel, descendingForExcel } from "./devBomService"; + +type ExcelDirection = "ascending" | "descending"; + +function pad(n: number): string { return n < 10 ? `0${n}` : String(n); } +function nowStamp(): string { + const d = new Date(); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; +} + +function buildSheet(rows: any[], maxLevel: number): XLSX.WorkSheet { + const effectiveMax = Math.max(1, maxLevel); + + // 헤더 행 + const header: string[] = []; + for (let i = 1; i <= effectiveMax; i++) header.push(String(i)); + header.push("품번", "품명", "수량", "항목수량", "3D", "2D", "PDF", + "재료", "열처리경도", "열처리방법", "표면처리", "메이커", "범주 이름", "비고"); + + const aoa: any[][] = [header]; + + for (const r of rows) { + const lev = Number(r.lev ?? 1); + const row: any[] = []; + for (let i = 1; i <= effectiveMax; i++) row.push(i === lev ? "*" : ""); + row.push( + 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 ?? "", + ); + aoa.push(row); + } + + const ws = XLSX.utils.aoa_to_sheet(aoa); + + // 컬럼 너비 (LEVEL 컬럼은 좁게, 텍스트 컬럼은 넓게) + const cols: XLSX.ColInfo[] = []; + for (let i = 0; i < effectiveMax; i++) cols.push({ wch: 4 }); + cols.push( + { wch: 18 }, { wch: 24 }, { wch: 8 }, { wch: 10 }, + { wch: 6 }, { wch: 6 }, { wch: 6 }, + { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 16 }, + ); + ws["!cols"] = cols; + + // 헤더 셀 노란 배경 + bold (XLSX 무료 라이브러리는 스타일 미저장. cellStyles:true 옵션과 함께 쓰면 일부만 동작) + // → 운영판 노란 배경은 시각적 효과. SheetJS Community 빌드는 스타일 무시. + // 필요 시 exceljs로 교체. 본 구현은 데이터 1:1 정확성 우선. + + return ws; +} + +export async function generateAscendingExcel(filter: BomTreeFilter): Promise<{ buffer: Buffer; fileName: string }> { + const { rows, max_level } = await ascendingForExcel(filter); + const ws = buildSheet(rows, max_level); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM 정전개"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { buffer: buf, fileName: `BOM 조회(정전개)_${nowStamp()}.xlsx` }; +} + +export async function generateDescendingExcel(filter: BomTreeFilter): Promise<{ buffer: Buffer; fileName: string }> { + const { rows, max_level } = await descendingForExcel(filter); + const ws = buildSheet(rows, max_level); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "BOM 역전개"); + const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return { buffer: buf, fileName: `BOM 조회(역전개)_${nowStamp()}.xlsx` }; +} + +// 호환 export +export type { ExcelDirection }; diff --git a/backend-node/src/services/devBomService.ts b/backend-node/src/services/devBomService.ts index e37c0fc0..8ce20a64 100644 --- a/backend-node/src/services/devBomService.ts +++ b/backend-node/src/services/devBomService.ts @@ -274,6 +274,145 @@ export async function ascending(filter: BomTreeFilter) { return { rows: r.rows, max_level }; } +// ─── M4 엑셀 다운로드용 풀 컬럼 (wace structureAscendingListExcel.jsp 1:1) ── +// 그리드 ascending/descending 보다 추가: P_QTY(=bom_part_qty.item_qty), MAKER, PART_TYPE_TITLE, +// HEAT_TREATMENT_HARDNESS, HEAT_TREATMENT_METHOD, SURFACE_TREATMENT +// +// 엑셀 컬럼 (wace 1:1): +// 동적 L1..LMaxLevel ("*" 표시) + 품번 / 품명 / 수량(QTY) / 항목수량(P_QTY) / +// 3D / 2D / PDF (Y/공백) / 재료 / 열처리경도 / 열처리방법 / 표면처리 / 메이커 / 범주 이름 / 비고 + +function buildAscendingExcelSql(filter: BomTreeFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + 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"; + + 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 ")}` : ""; + + return { params, startWhere, finalWhere }; +} + +export async function ascendingForExcel(filter: BomTreeFilter) { + const { params, startWhere, finalWhere } = buildAscendingExcelSql(filter, 1); + const sql = ` + WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, item_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.item_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.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 + ) + SELECT T.lev, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + T.qty, T.item_qty AS p_qty, + PM.material, PM.remark, + PM.heat_treatment_hardness, PM.heat_treatment_method, PM.surface_treatment, + PM.maker, PM.part_type, + CC.code_name AS part_type_title, + (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 + LEFT JOIN comm_code CC ON CC.code_id = PM.part_type + ${finalWhere} + ORDER BY T.path + `; + const r = await getPool().query(sql, params); + const max_level = r.rows[0]?.max_level ?? 0; + return { rows: r.rows, max_level }; +} + +export async function descendingForExcel(filter: BomTreeFilter) { + const params: any[] = []; + const anchorConds: string[] = []; + let idx = 1; + + 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) 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, item_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.item_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.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 + ) + SELECT T.lev, + PM.part_no AS pm_part_no, + PM.part_name AS pm_part_name, + T.qty, T.item_qty AS p_qty, + PM.material, PM.remark, + PM.heat_treatment_hardness, PM.heat_treatment_method, PM.surface_treatment, + PM.maker, PM.part_type, + CC.code_name AS part_type_title, + (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 + LEFT JOIN comm_code CC ON CC.code_id = PM.part_type + ORDER BY T.path + `; + const r = await getPool().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) { diff --git a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx index 6e4bd229..3804d63b 100644 --- a/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx +++ b/frontend/app/(main)/COMPANY_16/development/ebom-search/page.tsx @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, + Search, Loader2, RotateCcw, ChevronsRight, ChevronsLeft, FileSpreadsheet, } from "lucide-react"; import { toast } from "sonner"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; @@ -28,6 +28,7 @@ export default function EbomSearchPage() { const [rows, setRows] = useState([]); const [maxLevel, setMaxLevel] = useState(0); const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); const runQuery = useCallback(async (dir: Direction) => { setLoading(true); @@ -44,6 +45,25 @@ export default function EbomSearchPage() { } }, [filter]); + // wace structureAscending/DescendingListExcel.jsp 1:1 — 현재 검색 조건 그대로 .xlsx 다운로드 + const downloadExcel = useCallback(async (dir: Direction) => { + setExporting(true); + try { + const fn = dir === "ascending" ? devBomApi.excelAscending : devBomApi.excelDescending; + const { blob, fileName } = await fn(filter); + 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); + } + }, [filter]); + // 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시 const columns: DataGridColumn[] = useMemo(() => { const levelCols: DataGridColumn[] = []; @@ -131,6 +151,14 @@ export default function EbomSearchPage() { : } 역전개 조회 + + {direction === "descending" && ( diff --git a/frontend/lib/api/devBom.ts b/frontend/lib/api/devBom.ts index fc4ba30c..6bd82a6e 100644 --- a/frontend/lib/api/devBom.ts +++ b/frontend/lib/api/devBom.ts @@ -1,5 +1,19 @@ import { apiClient } from "./client"; +// Content-Disposition 의 filename / filename* 파싱 (UTF-8 인코딩 우선) +function extractFileName(cd: string | undefined): string | null { + if (!cd) return null; + const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(cd); + if (utf8 && utf8[1]) { + try { return decodeURIComponent(utf8[1]); } catch { /* fallthrough */ } + } + const plain = /filename="?([^";]+)"?/i.exec(cd); + if (plain && plain[1]) { + try { return decodeURIComponent(plain[1]); } catch { return plain[1]; } + } + return null; +} + // ============================================================ // 개발관리 E-BOM (M3 등록 / M4 조회) — wace partMng.xml 1:1 // 라우트: /api/development/ebom/*, /api/development/ebom-tree/* @@ -144,6 +158,20 @@ export const devBomApi = { return res.data?.data as BomTreeResponse; }, + // M4 엑셀 다운로드 (정/역전개) — wace 1:1 + async excelAscending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { + const res = await apiClient.get("/development/ebom-tree/ascending/excel", { + params: filter, responseType: "blob", + }); + return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_ascending.xlsx" }; + }, + async excelDescending(filter: BomTreeFilter): Promise<{ blob: Blob; fileName: string }> { + const res = await apiClient.get("/development/ebom-tree/descending/excel", { + params: filter, responseType: "blob", + }); + return { blob: res.data as Blob, fileName: extractFileName(res.headers?.["content-disposition"]) ?? "BOM_descending.xlsx" }; + }, + // Excel Import async excelParse(file: File): Promise { const fd = new FormData();