개발관리>E-BOM 조회 M4 — 정/역전개 엑셀 다운로드 신설 (wace 1:1)

운영판: 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) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-12 18:07:27 +09:00
parent 0c791d21d6
commit 7d73d2ee57
6 changed files with 329 additions and 1 deletions
@@ -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<string, any>): 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) {
+2
View File
@@ -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);
@@ -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 };
+139
View File
@@ -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) {
@@ -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<BomTreeRow[]>([]);
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() {
: <ChevronsLeft className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
<Button size="sm" variant="outline" onClick={() => downloadExcel("ascending")} disabled={exporting}>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
<Button size="sm" variant="outline" onClick={() => downloadExcel("descending")} disabled={exporting}>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileSpreadsheet className="h-4 w-4" />}
<span className="ml-1"> </span>
</Button>
</div>
</div>
{direction === "descending" && (
+28
View File
@@ -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<BomExcelParseResponse> {
const fd = new FormData();