import { Response, NextFunction } from "express"; import reportService from "../services/reportService"; import { CreateReportRequest, UpdateReportRequest, SaveLayoutRequest, CreateTemplateRequest, GetReportsParams, } from "../types/report"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; import path from "path"; import fs from "fs"; import { Document, Packer, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, WidthType, AlignmentType, VerticalAlign, BorderStyle, PageOrientation, convertMillimetersToTwip, Header, Footer, HeadingLevel, TableLayoutType, } from "docx"; import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; function getUserInfo(req: AuthenticatedRequest) { return { userId: req.user?.userId || "SYSTEM", companyCode: req.user?.companyCode || "*", }; } export class ReportController { async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { companyCode } = getUserInfo(req); const { page = "1", limit = "20", searchText = "", searchField, startDate, endDate, reportType = "", useYn = "Y", sortBy = "created_at", sortOrder = "DESC", } = req.query; const result = await reportService.getReports({ page: parseInt(page as string, 10), limit: parseInt(limit as string, 10), searchText: searchText as string, searchField: searchField as GetReportsParams["searchField"], startDate: startDate as string | undefined, endDate: endDate as string | undefined, reportType: reportType as string, useYn: useYn as string, sortBy: sortBy as string, sortOrder: sortOrder as "ASC" | "DESC", }, companyCode); return res.json({ success: true, data: result }); } catch (error) { return next(error); } } async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { companyCode } = getUserInfo(req); const { reportId } = req.params; const report = await reportService.getReportById(reportId, companyCode); if (!report) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." }); } return res.json({ success: true, data: report }); } catch (error) { return next(error); } } async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { companyCode } = getUserInfo(req); const { menuObjid } = req.params; const menuObjidNum = parseInt(menuObjid, 10); if (isNaN(menuObjidNum)) { return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." }); } const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode); return res.json({ success: true, data: result }); } catch (error) { return next(error); } } async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId, companyCode } = getUserInfo(req); const data: CreateReportRequest = req.body; if (!data.reportNameKor || !data.reportType) { return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." }); } data.companyCode = companyCode; const reportId = await reportService.createReport(data, userId); return res.status(201).json({ success: true, data: { reportId }, message: "리포트가 생성되었습니다.", }); } catch (error) { return next(error); } } async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId, companyCode } = getUserInfo(req); const { reportId } = req.params; const data: UpdateReportRequest = req.body; const success = await reportService.updateReport(reportId, data, userId, companyCode); if (!success) { return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." }); } return res.json({ success: true, message: "리포트가 수정되었습니다." }); } catch (error) { return next(error); } } async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { companyCode } = getUserInfo(req); const { reportId } = req.params; const success = await reportService.deleteReport(reportId, companyCode); if (!success) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." }); } return res.json({ success: true, message: "리포트가 삭제되었습니다." }); } catch (error) { return next(error); } } async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId, companyCode } = getUserInfo(req); const { reportId } = req.params; const { newName } = req.body; const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName); if (!newReportId) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." }); } return res.status(201).json({ success: true, data: { reportId: newReportId }, message: "리포트가 복사되었습니다.", }); } catch (error) { return next(error); } } async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { companyCode } = getUserInfo(req); const { reportId } = req.params; const layout = await reportService.getLayout(reportId, companyCode); if (!layout) { return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." }); } const storedData = layout.components; let layoutData; if ( storedData && typeof storedData === "object" && !Array.isArray(storedData) && Array.isArray((storedData as Record).pages) ) { const parsed = storedData as Record; layoutData = { ...layout, pages: parsed.pages, watermark: parsed.watermark, components: storedData, }; } else { layoutData = { ...layout, components: storedData || [] }; } return res.json({ success: true, data: layoutData }); } catch (error) { return next(error); } } async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId, companyCode } = getUserInfo(req); const { reportId } = req.params; const data: SaveLayoutRequest = req.body; if (!data.layoutConfig?.pages?.length) { return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." }); } await reportService.saveLayout(reportId, data, userId, companyCode); return res.json({ success: true, message: "레이아웃이 저장되었습니다." }); } catch (error) { return next(error); } } async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const templates = await reportService.getTemplates(); return res.json({ success: true, data: templates }); } catch (error) { return next(error); } } async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const categories = await reportService.getCategories(); return res.json({ success: true, data: categories }); } catch (error) { return next(error); } } async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId } = getUserInfo(req); const data: CreateTemplateRequest = req.body; if (!data.templateNameKor || !data.templateType) { return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." }); } const templateId = await reportService.createTemplate(data, userId); return res.status(201).json({ success: true, data: { templateId }, message: "템플릿이 생성되었습니다.", }); } catch (error) { return next(error); } } async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId } = getUserInfo(req); const { reportId } = req.params; const { templateNameKor, templateNameEng, description } = req.body; if (!templateNameKor) { return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); } const templateId = await reportService.saveAsTemplate( reportId, templateNameKor, templateNameEng, description, userId ); return res.status(201).json({ success: true, data: { templateId }, message: "템플릿이 저장되었습니다.", }); } catch (error) { return next(error); } } async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { userId } = getUserInfo(req); const { templateNameKor, templateNameEng, templateType, description, layoutConfig, defaultQueries = [], } = req.body; if (!templateNameKor) { return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); } if (!layoutConfig) { return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." }); } const templateId = await reportService.createTemplateFromLayout( templateNameKor, templateNameEng, templateType || "GENERAL", description, layoutConfig, defaultQueries, userId ); return res.status(201).json({ success: true, data: { templateId, }, message: "템플릿이 생성되었습니다.", }); } catch (error) { return next(error); } } async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { templateId } = req.params; const success = await reportService.deleteTemplate(templateId); if (!success) { return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." }); } return res.json({ success: true, message: "템플릿이 삭제되었습니다." }); } catch (error) { return next(error); } } async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { reportId, queryId } = req.params; const { parameters = {}, sqlQuery, externalConnectionId } = req.body; const result = await reportService.executeQuery( reportId, queryId, parameters, sqlQuery, externalConnectionId ); return res.json({ success: true, data: result }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다."; return res.status(400).json({ success: false, message }); } } async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { companyCode } = getUserInfo(req); const { ExternalDbConnectionService } = await import( "../services/externalDbConnectionService" ); const result = await ExternalDbConnectionService.getConnections({ is_active: "Y", company_code: companyCode, }); return res.json(result); } catch (error) { return next(error); } } async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { if (!req.file) { return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." }); } const { companyCode } = getUserInfo(req); const file = req.file; const uploadDir = path.join(process.cwd(), "uploads", `company_${companyCode}`, "reports"); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const timestamp = Date.now(); const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_"); const fileName = `${timestamp}_${safeFileName}`; const filePath = path.join(uploadDir, fileName); fs.writeFileSync(filePath, file.buffer); const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`; return res.json({ success: true, data: { fileName, fileUrl, originalName: file.originalname, size: file.size, mimeType: file.mimetype, }, }); } catch (error) { return next(error); } } async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { layoutConfig, queryResults, fileName = "리포트" } = req.body; if (!layoutConfig || !layoutConfig.pages) { return res.status(400).json({ success: false, message: "레이아웃 데이터가 필요합니다.", }); } const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx) const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386); const queryResultsMap: Record< string, { fields: string[]; rows: Record[] } > = queryResults || {}; const getComponentValue = (component: any): string => { if (component.queryId && component.fieldName) { const queryResult = queryResultsMap[component.queryId]; if (queryResult && queryResult.rows && queryResult.rows.length > 0) { const value = queryResult.rows[0][component.fieldName]; if (value !== null && value !== undefined) { return String(value); } } return `{${component.fieldName}}`; } return component.defaultValue || ""; }; // px → half-point (1px = 0.75pt, px * 1.5) const pxToHalfPt = (px: number) => Math.round(px * 1.5); const createCellContent = ( component: any, displayValue: string, pxToHalfPtFn: (px: number) => number, pxToTwipFn: (px: number) => number, queryResultsMapRef: Record< string, { fields: string[]; rows: Record[] } >, AlignmentTypeRef: typeof AlignmentType, VerticalAlignRef: typeof VerticalAlign, BorderStyleRef: typeof BorderStyle, ParagraphRef: typeof Paragraph, TextRunRef: typeof TextRun, ImageRunRef: typeof ImageRun, TableRef: typeof Table, TableRowRef: typeof TableRow, TableCellRef: typeof TableCell, pageIndex: number = 0, totalPages: number = 1 ): (Paragraph | Table)[] => { const result: (Paragraph | Table)[] = []; // Text/Label if (component.type === "text" || component.type === "label") { const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentTypeRef.CENTER : component.textAlign === "right" ? AlignmentTypeRef.RIGHT : AlignmentTypeRef.LEFT; // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 const lines = displayValue.split("\n"); const textChildren: TextRun[] = []; lines.forEach((line: string, index: number) => { if (index > 0) { // 줄바꿈 추가 (break: 1은 줄바꿈 1개) textChildren.push(new TextRunRef({ break: 1 })); } textChildren.push( new TextRunRef({ text: line, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), bold: component.fontWeight === "bold" || component.fontWeight === "600", font: "맑은 고딕", }) ); }); result.push( new ParagraphRef({ alignment, children: textChildren, }) ); } // Image else if (component.type === "image" && component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ children: [ new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }) ); } catch (e) { result.push(new ParagraphRef({ children: [] })); } } // Signature else if (component.type === "signature") { const sigFontSize = pxToHalfPtFn(component.fontSize || 12); const textRuns: TextRun[] = []; if (component.showLabel !== false) { textRuns.push( new TextRunRef({ text: (component.labelText || "서명:") + " ", size: sigFontSize, font: "맑은 고딕", }) ); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); // 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정 const sigImageHeight = 30; // 고정 높이 (약 40px) const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80; result.push( new ParagraphRef({ children: [ ...textRuns, new ImageRunRef({ data: imageBuffer, transformation: { width: sigImageWidth, height: sigImageHeight, }, type: "png", }), ], }) ); } catch (e) { textRuns.push( new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕", }) ); result.push(new ParagraphRef({ children: textRuns })); } } else { textRuns.push( new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕", }) ); result.push(new ParagraphRef({ children: textRuns })); } } // Stamp else if (component.type === "stamp") { const stampFontSize = pxToHalfPtFn(component.fontSize || 12); const textRuns: TextRun[] = []; if (component.personName) { textRuns.push( new TextRunRef({ text: component.personName + " ", size: stampFontSize, font: "맑은 고딕", }) ); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ children: [ ...textRuns, new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round( Math.min(component.width, component.height) * 0.75 ), height: Math.round( Math.min(component.width, component.height) * 0.75 ), }, type: "png", }), ], }) ); } catch (e) { textRuns.push( new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕", }) ); result.push(new ParagraphRef({ children: textRuns })); } } else { textRuns.push( new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕", }) ); result.push(new ParagraphRef({ children: textRuns })); } } // PageNumber else if (component.type === "pageNumber") { const format = component.pageNumberFormat || "number"; const currentPageNum = pageIndex + 1; let pageNumberText = ""; switch (format) { case "number": pageNumberText = `${currentPageNum}`; break; case "numberTotal": pageNumberText = `${currentPageNum} / ${totalPages}`; break; case "koreanNumber": pageNumberText = `${currentPageNum} 페이지`; break; default: pageNumberText = `${currentPageNum}`; } const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentTypeRef.CENTER : component.textAlign === "right" ? AlignmentTypeRef.RIGHT : AlignmentTypeRef.LEFT; result.push( new ParagraphRef({ alignment, children: [ new TextRunRef({ text: pageNumberText, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), font: "맑은 고딕", }), ], }) ); } // Card 컴포넌트 else if (component.type === "card") { const cardTitle = component.cardTitle || "정보 카드"; const cardItems = component.cardItems || []; const labelWidth = component.labelWidth || 80; const showCardTitle = component.showCardTitle !== false; const titleFontSize = pxToHalfPtFn(component.titleFontSize || 14); const labelFontSize = pxToHalfPtFn(component.labelFontSize || 13); const valueFontSize = pxToHalfPtFn(component.valueFontSize || 13); const titleColor = (component.titleColor || "#1e40af").replace( "#", "" ); const labelColor = (component.labelColor || "#374151").replace( "#", "" ); const valueColor = (component.valueColor || "#000000").replace( "#", "" ); const borderColor = (component.borderColor || "#e5e7eb").replace( "#", "" ); // 쿼리 바인딩된 값 가져오기 const getCardValueFn = (item: { label: string; value: string; fieldName?: string; }) => { if ( item.fieldName && component.queryId && queryResultsMapRef[component.queryId] ) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value; } } return item.value; }; // 제목 if (showCardTitle) { result.push( new ParagraphRef({ children: [ new TextRunRef({ text: cardTitle, size: titleFontSize, color: titleColor, bold: true, font: "맑은 고딕", }), ], }) ); // 구분선 result.push( new ParagraphRef({ border: { bottom: { color: borderColor, space: 1, style: BorderStyleRef.SINGLE, size: 8, }, }, children: [], }) ); } // 항목들 for (const item of cardItems) { const itemValue = getCardValueFn( item as { label: string; value: string; fieldName?: string } ); result.push( new ParagraphRef({ children: [ new TextRunRef({ text: item.label, size: labelFontSize, color: labelColor, bold: true, font: "맑은 고딕", }), new TextRunRef({ text: " ", size: labelFontSize, font: "맑은 고딕", }), new TextRunRef({ text: itemValue, size: valueFontSize, color: valueColor, font: "맑은 고딕", }), ], }) ); } } // 계산 컴포넌트 else if (component.type === "calculation") { const calcItems = component.calcItems || []; const resultLabel = component.resultLabel || "합계"; const calcLabelWidth = component.labelWidth || 120; const calcLabelFontSize = pxToHalfPtFn(component.labelFontSize || 13); const calcValueFontSize = pxToHalfPtFn(component.valueFontSize || 13); const calcResultFontSize = pxToHalfPtFn( component.resultFontSize || 16 ); const calcLabelColor = (component.labelColor || "#374151").replace( "#", "" ); const calcValueColor = (component.valueColor || "#000000").replace( "#", "" ); const calcResultColor = (component.resultColor || "#2563eb").replace( "#", "" ); const numberFormat = component.numberFormat || "currency"; const currencySuffix = component.currencySuffix || "원"; const borderColor = (component.borderColor || "#374151").replace( "#", "" ); // 숫자 포맷팅 함수 const formatNumberFn = (num: number): string => { if (numberFormat === "none") return String(num); if (numberFormat === "comma") return num.toLocaleString(); if (numberFormat === "currency") return num.toLocaleString() + currencySuffix; return String(num); }; // 쿼리 바인딩된 값 가져오기 const getCalcItemValueFn = (item: { label: string; value: number | string; operator: string; fieldName?: string; }): number => { if ( item.fieldName && component.queryId && queryResultsMapRef[component.queryId] ) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; const val = row[item.fieldName]; return typeof val === "number" ? val : parseFloat(String(val)) || 0; } } return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0; }; // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) let calcResult = 0; if (calcItems.length > 0) { // 첫 번째 항목은 기준값 calcResult = getCalcItemValueFn( calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string; } ); // 두 번째 항목부터 연산자 적용 for (let i = 1; i < calcItems.length; i++) { const item = calcItems[i]; const val = getCalcItemValueFn( item as { label: string; value: number | string; operator: string; fieldName?: string; } ); switch ((item as { operator: string }).operator) { case "+": calcResult += val; break; case "-": calcResult -= val; break; case "x": calcResult *= val; break; case "÷": calcResult = val !== 0 ? calcResult / val : calcResult; break; } } } // 테이블로 계산 항목 렌더링 const calcTableRows = []; // 각 항목 for (const item of calcItems) { const itemValue = getCalcItemValueFn( item as { label: string; value: number | string; operator: string; fieldName?: string; } ); calcTableRows.push( new TableRowRef({ children: [ new TableCellRef({ children: [ new ParagraphRef({ children: [ new TextRunRef({ text: item.label, size: calcLabelFontSize, color: calcLabelColor, font: "맑은 고딕", }), ], }), ], width: { size: pxToTwipFn(calcLabelWidth), type: WidthType.DXA, }, borders: { top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), new TableCellRef({ children: [ new ParagraphRef({ alignment: AlignmentTypeRef.RIGHT, children: [ new TextRunRef({ text: formatNumberFn(itemValue), size: calcValueFontSize, color: calcValueColor, font: "맑은 고딕", }), ], }), ], borders: { top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), ], }) ); } // 구분선 행 calcTableRows.push( new TableRowRef({ children: [ new TableCellRef({ columnSpan: 2, children: [new ParagraphRef({ children: [] })], borders: { top: { style: BorderStyleRef.SINGLE, size: 8, color: borderColor, }, bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, }, }), ], }) ); // 결과 행 calcTableRows.push( new TableRowRef({ children: [ new TableCellRef({ children: [ new ParagraphRef({ children: [ new TextRunRef({ text: resultLabel, size: calcResultFontSize, color: calcLabelColor, bold: true, font: "맑은 고딕", }), ], }), ], width: { size: pxToTwipFn(calcLabelWidth), type: WidthType.DXA, }, borders: { top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), new TableCellRef({ children: [ new ParagraphRef({ alignment: AlignmentTypeRef.RIGHT, children: [ new TextRunRef({ text: formatNumberFn(calcResult), size: calcResultFontSize, color: calcResultColor, bold: true, font: "맑은 고딕", }), ], }), ], borders: { top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), ], }) ); result.push( new TableRef({ rows: calcTableRows, width: { size: pxToTwipFn(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF", }, }, }) ); } // Barcode 컴포넌트 (바코드 이미지가 미리 생성되어 전달된 경우) else if (component.type === "barcode" && component.barcodeImageBase64) { try { const base64Data = component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); result.push( new ParagraphRef({ children: [ new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }) ); } catch (e) { // 바코드 이미지 생성 실패 시 텍스트로 대체 const barcodeValue = component.barcodeValue || "BARCODE"; result.push( new ParagraphRef({ children: [ new TextRunRef({ text: `[${barcodeValue}]`, size: pxToHalfPtFn(12), font: "맑은 고딕", }), ], }) ); } } // Checkbox 컴포넌트 else if (component.type === "checkbox") { // 체크 상태 결정 (쿼리 바인딩 또는 고정값) let isChecked = component.checkboxChecked === true; if (component.checkboxFieldName && component.queryId && queryResultsMapRef[component.queryId]) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; const val = row[component.checkboxFieldName]; // truthy/falsy 값 판정 if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { isChecked = true; } else { isChecked = false; } } } const checkboxSymbol = isChecked ? "☑" : "☐"; const checkboxLabel = component.checkboxLabel || ""; const labelPosition = component.checkboxLabelPosition || "right"; const displayText = labelPosition === "left" ? `${checkboxLabel} ${checkboxSymbol}` : `${checkboxSymbol} ${checkboxLabel}`; result.push( new ParagraphRef({ children: [ new TextRunRef({ text: displayText.trim(), size: pxToHalfPtFn(component.fontSize || 14), font: "맑은 고딕", color: (component.fontColor || "#374151").replace("#", ""), }), ], }) ); } // Divider - 테이블 셀로 감싸서 정확한 너비 적용 else if ( component.type === "divider" && component.orientation === "horizontal" ) { result.push( new ParagraphRef({ border: { bottom: { color: (component.lineColor || "#000000").replace("#", ""), space: 1, style: BorderStyleRef.SINGLE, size: (component.lineWidth || 1) * 8, }, }, children: [], }) ); } // 기타 (빈 paragraph) else { result.push(new ParagraphRef({ children: [] })); } return result; }; // 바코드 이미지 생성 헬퍼 함수 const generateBarcodeImage = async ( component: any, queryResultsMapRef: Record[] }> ): Promise => { try { const barcodeType = component.barcodeType || "CODE128"; const barcodeColor = (component.barcodeColor || "#000000").replace("#", ""); // transparent는 bwip-js에서 지원하지 않으므로 흰색으로 변환 let barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); if (barcodeBackground === "transparent" || barcodeBackground === "") { barcodeBackground = "ffffff"; } // 바코드 값 결정 (쿼리 바인딩 또는 고정값) let barcodeValue = component.barcodeValue || "SAMPLE123"; // QR코드 다중 필드 모드 if ( barcodeType === "QR" && component.qrUseMultiField && component.qrDataFields && component.qrDataFields.length > 0 && component.queryId && queryResultsMapRef[component.queryId] ) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { // 모든 행 포함 모드 if (component.qrIncludeAllRows) { const allRowsData: Record[] = []; qResult.rows.forEach((row) => { const rowData: Record = {}; component.qrDataFields!.forEach((field: { fieldName: string; label: string }) => { if (field.fieldName && field.label) { const val = row[field.fieldName]; rowData[field.label] = val !== null && val !== undefined ? String(val) : ""; } }); allRowsData.push(rowData); }); barcodeValue = JSON.stringify(allRowsData); } else { // 단일 행 (첫 번째 행만) const row = qResult.rows[0]; const jsonData: Record = {}; component.qrDataFields.forEach((field: { fieldName: string; label: string }) => { if (field.fieldName && field.label) { const val = row[field.fieldName]; jsonData[field.label] = val !== null && val !== undefined ? String(val) : ""; } }); barcodeValue = JSON.stringify(jsonData); } } } // 단일 필드 바인딩 else if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) { const qResult = queryResultsMapRef[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { // QR코드 + 모든 행 포함 if (barcodeType === "QR" && component.qrIncludeAllRows) { const allValues = qResult.rows .map((row) => { const val = row[component.barcodeFieldName!]; return val !== null && val !== undefined ? String(val) : ""; }) .filter((v) => v !== ""); barcodeValue = JSON.stringify(allValues); } else { // 단일 행 (첫 번째 행만) const row = qResult.rows[0]; const val = row[component.barcodeFieldName]; if (val !== null && val !== undefined) { barcodeValue = String(val); } } } } // bwip-js 바코드 타입 매핑 const bcidMap: Record = { "CODE128": "code128", "CODE39": "code39", "EAN13": "ean13", "EAN8": "ean8", "UPC": "upca", "QR": "qrcode", }; const bcid = bcidMap[barcodeType] || "code128"; const isQR = barcodeType === "QR"; // 바코드 옵션 설정 const options: any = { bcid: bcid, text: barcodeValue, scale: 3, includetext: !isQR && component.showBarcodeText !== false, textxalign: "center", barcolor: barcodeColor, backgroundcolor: barcodeBackground, }; // QR 코드 옵션 if (isQR) { options.eclevel = component.qrErrorCorrectionLevel || "M"; } // 바코드 이미지 생성 const png = await bwipjs.toBuffer(options); const base64 = png.toString("base64"); return `data:image/png;base64,${base64}`; } catch (error) { logger.error("바코드 생성 오류:", error); return null; } }; // 모든 페이지의 바코드 컴포넌트에 대해 이미지 생성 for (const page of layoutConfig.pages) { if (page.components) { for (const component of page.components) { if (component.type === "barcode") { const barcodeImage = await generateBarcodeImage(component, queryResultsMap); if (barcodeImage) { component.barcodeImageBase64 = barcodeImage; } } } } } // 섹션 생성 (페이지별) const sortedPages = layoutConfig.pages.sort( (a: any, b: any) => a.page_order - b.page_order ); const totalPagesCount = sortedPages.length; const sections = sortedPages.map((page: any, pageIndex: number) => { const pageWidthTwip = mmToTwip(page.width); const pageHeightTwip = mmToTwip(page.height); const marginTopMm = page.margins?.top || 10; const marginBottomMm = page.margins?.bottom || 10; const marginLeftMm = page.margins?.left || 10; const marginRightMm = page.margins?.right || 10; const marginTop = mmToTwip(marginTopMm); const marginBottom = mmToTwip(marginBottomMm); const marginLeft = mmToTwip(marginLeftMm); const marginRight = mmToTwip(marginRightMm); // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) const marginLeftPx = marginLeftMm * 3.78; const marginTopPx = marginTopMm * 3.78; // 컴포넌트를 Y좌표순으로 정렬 const sortedComponents = [...(page.components || [])].sort( (a: any, b: any) => a.y - b.y ); // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 const Y_GROUP_THRESHOLD = 30; // px const componentGroups: any[][] = []; let currentGroup: any[] = []; let groupBaseY = -Infinity; for (const comp of sortedComponents) { const compY = comp.y - marginTopPx; if (currentGroup.length === 0) { currentGroup.push(comp); groupBaseY = compY; } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { currentGroup.push(comp); } else { componentGroups.push(currentGroup); currentGroup = [comp]; groupBaseY = compY; } } if (currentGroup.length > 0) { componentGroups.push(currentGroup); } // 컴포넌트를 Paragraph/Table로 변환 const children: (Paragraph | Table)[] = []; // Y좌표를 spacing으로 변환하기 위한 추적 변수 let lastBottomY = 0; // 각 그룹 처리 for (const group of componentGroups) { // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); const groupHeight = Math.max( ...sortedGroup.map((c: any) => c.height) ); // spacing 계산 const gapFromPrevious = Math.max(0, groupY - lastBottomY); const spacingBefore = pxToTwip(gapFromPrevious); // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 if (sortedGroup.length > 1) { // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } // 각 컴포넌트를 셀로 변환 const cells: TableCell[] = []; let prevEndX = 0; for (const component of sortedGroup) { const adjustedX = Math.max(0, component.x - marginLeftPx); const displayValue = getComponentValue(component); // 이전 셀과의 간격을 위한 빈 셀 추가 if (adjustedX > prevEndX + 5) { const gapWidth = adjustedX - prevEndX; cells.push( new TableCell({ children: [new Paragraph({ children: [] })], width: { size: pxToTwip(gapWidth), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }) ); } // 컴포넌트 셀 생성 const cellContent = createCellContent( component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell, pageIndex, totalPagesCount ); cells.push( new TableCell({ children: cellContent, width: { size: pxToTwip(component.width), type: WidthType.DXA, }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, verticalAlign: VerticalAlign.TOP, }) ); prevEndX = adjustedX + component.width; } // 테이블 행 생성 const rowTable = new Table({ rows: [new TableRow({ children: cells })], width: { size: 100, type: WidthType.PERCENTAGE }, layout: TableLayoutType.FIXED, // 셀 너비 고정 borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); children.push(rowTable); lastBottomY = groupY + groupHeight; continue; } // 단일 컴포넌트 처리 (기존 로직) const component = sortedGroup[0]; const displayValue = getComponentValue(component); const adjustedX = Math.max(0, component.x - marginLeftPx); const adjustedY = groupY; // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) const indentLeft = pxToTwip(adjustedX); // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 if (component.type === "text" || component.type === "label") { const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentType.CENTER : component.textAlign === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT; // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 const lines = displayValue.split("\n"); const textChildren: TextRun[] = []; lines.forEach((line: string, index: number) => { if (index > 0) { textChildren.push(new TextRun({ break: 1 })); } textChildren.push( new TextRun({ text: line, size: fontSizeHalfPt, color: (component.fontColor || "#000000").replace("#", ""), bold: component.fontWeight === "bold" || component.fontWeight === "600", font: "맑은 고딕", }) ); }); // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 const textCell = new TableCell({ children: [ new Paragraph({ alignment, children: textChildren, }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, verticalAlign: VerticalAlign.TOP, }); const textTable = new Table({ rows: [new TableRow({ children: [textCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, layout: TableLayoutType.FIXED, // 셀 너비 고정 indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } children.push(textTable); lastBottomY = adjustedY + component.height; } // Image 컴포넌트 else if (component.type === "image" && component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ new ImageRun({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }); children.push(paragraph); lastBottomY = adjustedY + component.height; } catch (imgError) { logger.error("이미지 처리 오류:", imgError); } } // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 else if (component.type === "divider") { if (component.orientation === "horizontal") { // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } // 테이블 셀로 감싸서 너비 제한 const dividerCell = new TableCell({ children: [ new Paragraph({ border: { bottom: { color: (component.lineColor || "#000000").replace( "#", "" ), space: 1, style: BorderStyle.SINGLE, size: (component.lineWidth || 1) * 8, }, }, children: [], }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, }); const dividerTable = new Table({ rows: [new TableRow({ children: [dividerCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); children.push(dividerTable); lastBottomY = adjustedY + component.height; } } // Signature 컴포넌트 else if (component.type === "signature") { const labelText = component.labelText || "서명:"; const showLabel = component.showLabel !== false; const sigFontSize = pxToHalfPt(component.fontSize || 12); const textRuns: TextRun[] = []; if (showLabel) { textRuns.push( new TextRun({ text: labelText + " ", size: sigFontSize, font: "맑은 고딕", }) ); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); // 서명 이미지 크기: 라벨 옆에 인라인으로 표시될 수 있도록 적절한 크기로 조정 const sigImageHeight = 30; // 고정 높이 const sigImageWidth = Math.round((component.width / component.height) * sigImageHeight) || 80; const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ ...textRuns, new ImageRun({ data: imageBuffer, transformation: { width: sigImageWidth, height: sigImageHeight, }, type: "png", }), ], }); children.push(paragraph); } catch (imgError) { logger.error("서명 이미지 오류:", imgError); textRuns.push( new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕", }) ); children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns, }) ); } } else { textRuns.push( new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕", }) ); children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns, }) ); } lastBottomY = adjustedY + component.height; } // Stamp 컴포넌트 else if (component.type === "stamp") { const personName = component.personName || ""; const stampFontSize = pxToHalfPt(component.fontSize || 12); const textRuns: TextRun[] = []; if (personName) { textRuns.push( new TextRun({ text: personName + " ", size: stampFontSize, font: "맑은 고딕", }) ); } if (component.imageBase64) { try { const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); const paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ ...textRuns, new ImageRun({ data: imageBuffer, transformation: { width: Math.round( Math.min(component.width, component.height) * 0.75 ), height: Math.round( Math.min(component.width, component.height) * 0.75 ), }, type: "png", }), ], }); children.push(paragraph); } catch (imgError) { logger.error("도장 이미지 오류:", imgError); textRuns.push( new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕", }) ); children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns, }) ); } } else { textRuns.push( new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕", }) ); children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns, }) ); } lastBottomY = adjustedY + component.height; } // PageNumber 컴포넌트 - 테이블 셀로 감싸서 정확한 위치 적용 else if (component.type === "pageNumber") { const format = component.pageNumberFormat || "number"; const currentPageNum = pageIndex + 1; let pageNumberText = ""; switch (format) { case "number": pageNumberText = `${currentPageNum}`; break; case "numberTotal": pageNumberText = `${currentPageNum} / ${totalPagesCount}`; break; case "koreanNumber": pageNumberText = `${currentPageNum} 페이지`; break; default: pageNumberText = `${currentPageNum}`; } const pageNumFontSize = pxToHalfPt(component.fontSize || 13); const alignment = component.textAlign === "center" ? AlignmentType.CENTER : component.textAlign === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT; // 테이블 셀로 감싸서 width와 indent 정확히 적용 const pageNumCell = new TableCell({ children: [ new Paragraph({ alignment, children: [ new TextRun({ text: pageNumberText, size: pageNumFontSize, color: (component.fontColor || "#000000").replace( "#", "" ), font: "맑은 고딕", }), ], }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, }, verticalAlign: VerticalAlign.TOP, }); const pageNumTable = new Table({ rows: [new TableRow({ children: [pageNumCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } children.push(pageNumTable); lastBottomY = adjustedY + component.height; } // Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 else if (component.type === "card") { const cardTitle = component.cardTitle || "정보 카드"; const cardItems = component.cardItems || []; const labelWidthPx = component.labelWidth || 80; const showCardTitle = component.showCardTitle !== false; const titleFontSize = pxToHalfPt(component.titleFontSize || 14); const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13); const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13); const titleColorCard = (component.titleColor || "#1e40af").replace( "#", "" ); const labelColorCard = (component.labelColor || "#374151").replace( "#", "" ); const valueColorCard = (component.valueColor || "#000000").replace( "#", "" ); const borderColorCard = ( component.borderColor || "#e5e7eb" ).replace("#", ""); // 쿼리 바인딩된 값 가져오기 const getCardValueLocal = (item: { label: string; value: string; fieldName?: string; }) => { if ( item.fieldName && component.queryId && queryResultsMap[component.queryId] ) { const qResult = queryResultsMap[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value; } } return item.value; }; const cardParagraphs: Paragraph[] = []; // 제목 if (showCardTitle) { cardParagraphs.push( new Paragraph({ children: [ new TextRun({ text: cardTitle, size: titleFontSize, color: titleColorCard, bold: true, font: "맑은 고딕", }), ], }) ); // 구분선 cardParagraphs.push( new Paragraph({ border: { bottom: { color: borderColorCard, space: 1, style: BorderStyle.SINGLE, size: 8, }, }, children: [], }) ); } // 항목들을 테이블로 구성 (라벨 + 값) const itemRows = cardItems.map( (item: { label: string; value: string; fieldName?: string }) => { const itemValue = getCardValueLocal(item); return new TableRow({ children: [ new TableCell({ width: { size: pxToTwip(labelWidthPx), type: WidthType.DXA, }, children: [ new Paragraph({ children: [ new TextRun({ text: item.label, size: labelFontSizeCard, color: labelColorCard, bold: true, font: "맑은 고딕", }), ], }), ], borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }), new TableCell({ width: { size: pxToTwip(component.width - labelWidthPx - 16), type: WidthType.DXA, }, children: [ new Paragraph({ children: [ new TextRun({ text: itemValue, size: valueFontSizeCard, color: valueColorCard, font: "맑은 고딕", }), ], }), ], borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }), ], }); } ); const itemsTable = new Table({ rows: itemRows, width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); // 전체를 하나의 테이블 셀로 감싸기 const cardCell = new TableCell({ children: [...cardParagraphs, itemsTable], width: { size: pxToTwip(component.width), type: WidthType.DXA }, borders: component.showCardBorder !== false ? { top: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard, }, bottom: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard, }, left: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard, }, right: { style: BorderStyle.SINGLE, size: 4, color: borderColorCard, }, } : { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, verticalAlign: VerticalAlign.TOP, }); const cardTable = new Table({ rows: [new TableRow({ children: [cardCell] })], width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } children.push(cardTable); lastBottomY = adjustedY + component.height; } // 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 else if (component.type === "calculation") { const calcItems = component.calcItems || []; const resultLabel = component.resultLabel || "합계"; const calcLabelWidth = component.labelWidth || 120; const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13); const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13); const calcResultFontSize = pxToHalfPt( component.resultFontSize || 16 ); const calcLabelColor = (component.labelColor || "#374151").replace( "#", "" ); const calcValueColor = (component.valueColor || "#000000").replace( "#", "" ); const calcResultColor = ( component.resultColor || "#2563eb" ).replace("#", ""); const numberFormat = component.numberFormat || "currency"; const currencySuffix = component.currencySuffix || "원"; const borderColor = (component.borderColor || "#374151").replace( "#", "" ); // 숫자 포맷팅 함수 const formatNumberFn = (num: number): string => { if (numberFormat === "none") return String(num); if (numberFormat === "comma") return num.toLocaleString(); if (numberFormat === "currency") return num.toLocaleString() + currencySuffix; return String(num); }; // 쿼리 바인딩된 값 가져오기 const getCalcItemValueFn = (item: { label: string; value: number | string; operator: string; fieldName?: string; }): number => { if ( item.fieldName && component.queryId && queryResultsMap[component.queryId] ) { const qResult = queryResultsMap[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; const val = row[item.fieldName]; return typeof val === "number" ? val : parseFloat(String(val)) || 0; } } return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0; }; // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용) let calcResult = 0; if (calcItems.length > 0) { // 첫 번째 항목은 기준값 calcResult = getCalcItemValueFn( calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string; } ); // 두 번째 항목부터 연산자 적용 for (let i = 1; i < calcItems.length; i++) { const calcItem = calcItems[i]; const val = getCalcItemValueFn( calcItem as { label: string; value: number | string; operator: string; fieldName?: string; } ); switch ((calcItem as { operator: string }).operator) { case "+": calcResult += val; break; case "-": calcResult -= val; break; case "x": calcResult *= val; break; case "÷": calcResult = val !== 0 ? calcResult / val : calcResult; break; } } } // 테이블 행 생성 const calcTableRows: TableRow[] = []; // 각 항목 행 for (const calcItem of calcItems) { const itemValue = getCalcItemValueFn( calcItem as { label: string; value: number | string; operator: string; fieldName?: string; } ); calcTableRows.push( new TableRow({ children: [ new TableCell({ children: [ new Paragraph({ children: [ new TextRun({ text: calcItem.label, size: calcLabelFontSize, color: calcLabelColor, font: "맑은 고딕", }), ], }), ], width: { size: pxToTwip(calcLabelWidth), type: WidthType.DXA, }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), new TableCell({ children: [ new Paragraph({ alignment: AlignmentType.RIGHT, children: [ new TextRun({ text: formatNumberFn(itemValue), size: calcValueFontSize, color: calcValueColor, font: "맑은 고딕", }), ], }), ], borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), ], }) ); } // 구분선 행 calcTableRows.push( new TableRow({ children: [ new TableCell({ columnSpan: 2, children: [new Paragraph({ children: [] })], borders: { top: { style: BorderStyle.SINGLE, size: 8, color: borderColor, }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }), ], }) ); // 결과 행 calcTableRows.push( new TableRow({ children: [ new TableCell({ children: [ new Paragraph({ children: [ new TextRun({ text: resultLabel, size: calcResultFontSize, color: calcLabelColor, bold: true, font: "맑은 고딕", }), ], }), ], width: { size: pxToTwip(calcLabelWidth), type: WidthType.DXA, }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), new TableCell({ children: [ new Paragraph({ alignment: AlignmentType.RIGHT, children: [ new TextRun({ text: formatNumberFn(calcResult), size: calcResultFontSize, color: calcResultColor, bold: true, font: "맑은 고딕", }), ], }), ], borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, margins: { top: 50, bottom: 50, left: 100, right: 100 }, }), ], }) ); const calcTable = new Table({ rows: calcTableRows, width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, borders: { top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF", }, }, }); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } children.push(calcTable); lastBottomY = adjustedY + component.height; } // Barcode 컴포넌트 else if (component.type === "barcode") { if (component.barcodeImageBase64) { try { const base64Data = component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64; const imageBuffer = Buffer.from(base64Data, "base64"); // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } children.push( new Paragraph({ indent: { left: indentLeft }, children: [ new ImageRun({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }) ); } catch (imgError) { logger.error("바코드 이미지 오류:", imgError); // 바코드 이미지 생성 실패 시 텍스트로 대체 const barcodeValue = component.barcodeValue || "BARCODE"; children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ new TextRun({ text: `[${barcodeValue}]`, size: pxToHalfPt(12), font: "맑은 고딕", }), ], }) ); } } else { // 바코드 이미지가 없는 경우 텍스트로 대체 const barcodeValue = component.barcodeValue || "BARCODE"; children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ new TextRun({ text: `[${barcodeValue}]`, size: pxToHalfPt(12), font: "맑은 고딕", }), ], }) ); } lastBottomY = adjustedY + component.height; } // Checkbox 컴포넌트 else if (component.type === "checkbox") { // 체크 상태 결정 (쿼리 바인딩 또는 고정값) let isChecked = component.checkboxChecked === true; if (component.checkboxFieldName && component.queryId && queryResultsMap[component.queryId]) { const qResult = queryResultsMap[component.queryId]; if (qResult.rows && qResult.rows.length > 0) { const row = qResult.rows[0]; const val = row[component.checkboxFieldName]; // truthy/falsy 값 판정 if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") { isChecked = true; } else { isChecked = false; } } } const checkboxSymbol = isChecked ? "☑" : "☐"; const checkboxLabel = component.checkboxLabel || ""; const labelPosition = component.checkboxLabelPosition || "right"; const displayText = labelPosition === "left" ? `${checkboxLabel} ${checkboxSymbol}` : `${checkboxSymbol} ${checkboxLabel}`; // spacing을 위한 빈 paragraph if (spacingBefore > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [], }) ); } children.push( new Paragraph({ indent: { left: indentLeft }, children: [ new TextRun({ text: displayText.trim(), size: pxToHalfPt(component.fontSize || 14), font: "맑은 고딕", color: (component.fontColor || "#374151").replace("#", ""), }), ], }) ); lastBottomY = adjustedY + component.height; } // Table 컴포넌트 else if (component.type === "table" && component.queryId) { const queryResult = queryResultsMap[component.queryId]; if ( queryResult && queryResult.rows && queryResult.rows.length > 0 ) { // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 if (spacingBefore > 0 || indentLeft > 0) { children.push( new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [], }) ); } const columns = component.tableColumns && component.tableColumns.length > 0 ? component.tableColumns : queryResult.fields.map((field: string) => ({ field, header: field, align: "left", width: undefined, })); // 테이블 폰트 사이즈 (기본 12px) const tableFontSize = pxToHalfPt(component.fontSize || 12); // 헤더 행 const headerCells = columns.map( (col: { header: string; align?: string }) => new TableCell({ children: [ new Paragraph({ alignment: col.align === "center" ? AlignmentType.CENTER : col.align === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT, children: [ new TextRun({ text: col.header, bold: true, size: tableFontSize, font: "맑은 고딕", }), ], }), ], shading: { fill: ( component.headerBackgroundColor || "#f3f4f6" ).replace("#", ""), }, verticalAlign: VerticalAlign.CENTER, }) ); const headerRow = new TableRow({ children: headerCells }); // 데이터 행 const dataRows = queryResult.rows.map( (row: Record) => new TableRow({ children: columns.map( (col: { field: string; align?: string }) => new TableCell({ children: [ new Paragraph({ alignment: col.align === "center" ? AlignmentType.CENTER : col.align === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT, children: [ new TextRun({ text: String(row[col.field] ?? ""), size: tableFontSize, font: "맑은 고딕", }), ], }), ], verticalAlign: VerticalAlign.CENTER, }) ), }) ); const table = new Table({ width: { size: pxToTwip(component.width), type: WidthType.DXA }, indent: { size: indentLeft, type: WidthType.DXA }, rows: [headerRow, ...dataRows], }); children.push(table); lastBottomY = adjustedY + component.height; } } } // 빈 페이지 방지 if (children.length === 0) { children.push(new Paragraph({ children: [] })); } // 워터마크 헤더 생성 (전체 페이지 공유 워터마크) const watermark: WatermarkConfig | undefined = layoutConfig.watermark; let headers: { default?: Header } | undefined; if (watermark?.enabled && watermark.type === "text" && watermark.text) { // 워터마크 색상을 hex로 변환 (alpha 적용) const opacity = watermark.opacity ?? 0.3; const fontColor = watermark.fontColor || "#CCCCCC"; // hex 색상에서 # 제거 const cleanColor = fontColor.replace("#", ""); headers = { default: new Header({ children: [ new Paragraph({ alignment: AlignmentType.CENTER, children: [ new TextRun({ text: watermark.text, size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용 color: cleanColor, bold: true, }), ], }), ], }), }; } return { properties: { page: { size: { width: pageWidthTwip, height: pageHeightTwip, orientation: page.width > page.height ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT, }, margin: { top: marginTop, bottom: marginBottom, left: marginLeft, right: marginRight, }, }, }, headers, children, }; }); // Document 생성 const doc = new Document({ sections, }); // Buffer로 변환 const docxBuffer = await Packer.toBuffer(doc); // 파일명 인코딩 (한글 지원) const timestamp = new Date().toISOString().slice(0, 10); const safeFileName = encodeURIComponent(`${fileName}_${timestamp}.docx`); // DOCX 파일로 응답 res.setHeader( "Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ); res.setHeader( "Content-Disposition", `attachment; filename*=UTF-8''${safeFileName}` ); res.setHeader("Content-Length", docxBuffer.length); return res.send(docxBuffer); } catch (error: any) { logger.error("WORD 변환 오류:", error); return res.status(500).json({ success: false, message: error.message || "WORD 변환에 실패했습니다.", }); } } // ─── 비주얼 쿼리 빌더 API ───────────────────────────────────────────────────── async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const tables = await reportService.getSchemaTables(); return res.json({ success: true, data: tables }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다."; logger.error("스키마 테이블 조회 오류:", { error: message }); return res.status(500).json({ success: false, message }); } } async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { tableName } = req.params; if (!tableName) { return res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); } const columns = await reportService.getSchemaTableColumns(tableName); return res.json({ success: true, data: columns }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다."; logger.error("테이블 컬럼 조회 오류:", { error: message }); return res.status(500).json({ success: false, message }); } } async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { visualQuery } = req.body; if (!visualQuery || !visualQuery.tableName) { return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." }); } const result = await reportService.executeVisualQuery(visualQuery); const generatedSql = reportService.buildVisualQuerySql(visualQuery); return res.json({ success: true, data: { ...result, sql: generatedSql } }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다."; logger.error("비주얼 쿼리 미리보기 오류:", { error: message }); return res.status(500).json({ success: false, message }); } } } export default new ReportController();