/** * 리포트 관리 컨트롤러 */ import { Request, Response, NextFunction } from "express"; import reportService from "../services/reportService"; import { CreateReportRequest, UpdateReportRequest, SaveLayoutRequest, CreateTemplateRequest, } from "../types/report"; 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, } from "docx"; import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; export class ReportController { /** * 리포트 목록 조회 * GET /api/admin/reports */ async getReports(req: Request, res: Response, next: NextFunction) { try { const { page = "1", limit = "20", searchText = "", 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, reportType: reportType as string, useYn: useYn as string, sortBy: sortBy as string, sortOrder: sortOrder as "ASC" | "DESC", }); return res.json({ success: true, data: result, }); } catch (error) { return next(error); } } /** * 리포트 상세 조회 * GET /api/admin/reports/:reportId */ async getReportById(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const report = await reportService.getReportById(reportId); if (!report) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다.", }); } return res.json({ success: true, data: report, }); } catch (error) { return next(error); } } /** * 리포트 생성 * POST /api/admin/reports */ async createReport(req: Request, res: Response, next: NextFunction) { try { const data: CreateReportRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 if (!data.reportNameKor || !data.reportType) { return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다.", }); } const reportId = await reportService.createReport(data, userId); return res.status(201).json({ success: true, data: { reportId, }, message: "리포트가 생성되었습니다.", }); } catch (error) { return next(error); } } /** * 리포트 수정 * PUT /api/admin/reports/:reportId */ async updateReport(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const data: UpdateReportRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; const success = await reportService.updateReport(reportId, data, userId); if (!success) { return res.status(400).json({ success: false, message: "수정할 내용이 없습니다.", }); } return res.json({ success: true, message: "리포트가 수정되었습니다.", }); } catch (error) { return next(error); } } /** * 리포트 삭제 * DELETE /api/admin/reports/:reportId */ async deleteReport(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const success = await reportService.deleteReport(reportId); if (!success) { return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다.", }); } return res.json({ success: true, message: "리포트가 삭제되었습니다.", }); } catch (error) { return next(error); } } /** * 리포트 복사 * POST /api/admin/reports/:reportId/copy */ async copyReport(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const userId = (req as any).user?.userId || "SYSTEM"; const newReportId = await reportService.copyReport(reportId, userId); 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); } } /** * 레이아웃 조회 * GET /api/admin/reports/:reportId/layout */ async getLayout(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const layout = await reportService.getLayout(reportId); if (!layout) { return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다.", }); } // components 컬럼에서 JSON 파싱 const parsedComponents = layout.components ? JSON.parse(layout.components) : null; let layoutData; // 새 구조 (layoutConfig.pages)인지 확인 if ( parsedComponents && parsedComponents.pages && Array.isArray(parsedComponents.pages) ) { // pages 배열을 직접 포함하여 반환 layoutData = { ...layout, pages: parsedComponents.pages, components: [], // 호환성을 위해 빈 배열 }; } else { // 기존 구조: components 배열 layoutData = { ...layout, components: parsedComponents || [], }; } return res.json({ success: true, data: layoutData, }); } catch (error) { return next(error); } } /** * 레이아웃 저장 * PUT /api/admin/reports/:reportId/layout */ async saveLayout(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const data: SaveLayoutRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 (페이지 기반 구조) if ( !data.layoutConfig || !data.layoutConfig.pages || data.layoutConfig.pages.length === 0 ) { return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다.", }); } await reportService.saveLayout(reportId, data, userId); return res.json({ success: true, message: "레이아웃이 저장되었습니다.", }); } catch (error) { return next(error); } } /** * 템플릿 목록 조회 * GET /api/admin/reports/templates */ async getTemplates(req: Request, res: Response, next: NextFunction) { try { const templates = await reportService.getTemplates(); return res.json({ success: true, data: templates, }); } catch (error) { return next(error); } } /** * 템플릿 생성 * POST /api/admin/reports/templates */ async createTemplate(req: Request, res: Response, next: NextFunction) { try { const data: CreateTemplateRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 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); } } /** * 현재 리포트를 템플릿으로 저장 * POST /api/admin/reports/:reportId/save-as-template */ async saveAsTemplate(req: Request, res: Response, next: NextFunction) { try { const { reportId } = req.params; const { templateNameKor, templateNameEng, description } = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 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); } } /** * 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) * POST /api/admin/reports/templates/create-from-layout */ async createTemplateFromLayout( req: Request, res: Response, next: NextFunction ) { try { const { templateNameKor, templateNameEng, templateType, description, layoutConfig, defaultQueries = [], } = req.body; const userId = (req as any).user?.userId || "SYSTEM"; // 필수 필드 검증 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); } } /** * 템플릿 삭제 * DELETE /api/admin/reports/templates/:templateId */ async deleteTemplate(req: Request, 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); } } /** * 쿼리 실행 * POST /api/admin/reports/:reportId/queries/:queryId/execute */ async executeQuery(req: Request, 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: any) { return res.status(400).json({ success: false, message: error.message || "쿼리 실행에 실패했습니다.", }); } } /** * 외부 DB 연결 목록 조회 (활성화된 것만) * GET /api/admin/reports/external-connections */ async getExternalConnections( req: Request, res: Response, next: NextFunction ) { try { const { ExternalDbConnectionService } = await import( "../services/externalDbConnectionService" ); const result = await ExternalDbConnectionService.getConnections({ is_active: "Y", company_code: req.body.companyCode || "", }); return res.json(result); } catch (error) { return next(error); } } /** * 이미지 파일 업로드 * POST /api/admin/reports/upload-image */ async uploadImage(req: Request, res: Response, next: NextFunction) { try { if (!req.file) { return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다.", }); } const companyCode = req.body.companyCode || "SYSTEM"; 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); // 웹에서 접근 가능한 URL 반환 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); } } /** * 컴포넌트 데이터를 WORD(DOCX)로 변환 * POST /api/admin/reports/export-word */ async exportToWord(req: Request, res: Response, next: NextFunction) { try { const { layoutConfig, queryResults, fileName = "리포트" } = req.body; if (!layoutConfig || !layoutConfig.pages) { return res.status(400).json({ success: false, message: "레이아웃 데이터가 필요합니다.", }); } // mm를 twip으로 변환 const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); // px를 twip으로 변환 (1px = 15twip at 96DPI) const pxToTwip = (px: number) => Math.round(px * 15); // 쿼리 결과 맵 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, Word는 half-pt 단위 사용) // px * 0.75 * 2 = 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"); result.push( new ParagraphRef({ children: [ ...textRuns, new ImageRunRef({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, 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("#", ""); const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", ""); // 바코드 값 결정 (쿼리 바인딩 또는 고정값) 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) { console.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 }, 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 }, 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) { console.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 paragraph = new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [ ...textRuns, new ImageRun({ data: imageBuffer, transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75), }, type: "png", }), ], }); children.push(paragraph); } catch (imgError) { console.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) { console.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) { console.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 = page.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) { console.error("WORD 변환 오류:", error); return res.status(500).json({ success: false, message: error.message || "WORD 변환에 실패했습니다.", }); } } } export default new ReportController();