ERP-node/backend-node/src/controllers/reportController.ts

1289 lines
44 KiB
TypeScript
Raw Normal View History

2025-10-01 11:34:17 +09:00
/**
*
*/
import { Request, Response, NextFunction } from "express";
import reportService from "../services/reportService";
import {
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
CreateTemplateRequest,
} from "../types/report";
2025-10-01 16:53:35 +09:00
import path from "path";
import fs from "fs";
import {
Document,
Packer,
Paragraph,
TextRun,
ImageRun,
Table,
TableRow,
TableCell,
WidthType,
AlignmentType,
VerticalAlign,
BorderStyle,
PageOrientation,
convertMillimetersToTwip,
} from "docx";
2025-10-01 11:34:17 +09:00
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",
});
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
data: result,
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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: "리포트를 찾을 수 없습니다.",
});
}
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
data: report,
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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);
2025-10-01 11:45:17 +09:00
return res.status(201).json({
2025-10-01 11:34:17 +09:00
success: true,
data: {
reportId,
},
message: "리포트가 생성되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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: "수정할 내용이 없습니다.",
});
}
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
message: "리포트가 수정되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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: "리포트를 찾을 수 없습니다.",
});
}
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
message: "리포트가 삭제되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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: "리포트를 찾을 수 없습니다.",
});
}
2025-10-01 11:45:17 +09:00
return res.status(201).json({
2025-10-01 11:34:17 +09:00
success: true,
data: {
reportId: newReportId,
},
message: "리포트가 복사되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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 layoutData = {
...layout,
components: layout.components ? JSON.parse(layout.components) : [],
};
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
data: layoutData,
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* 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.canvasWidth ||
!data.canvasHeight ||
!data.pageOrientation ||
!data.components
) {
return res.status(400).json({
success: false,
message: "필수 레이아웃 정보가 누락되었습니다.",
});
}
await reportService.saveLayout(reportId, data, userId);
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
message: "레이아웃이 저장되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
* 릿
* GET /api/admin/reports/templates
*/
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await reportService.getTemplates();
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
data: templates,
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
* 릿
* 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);
2025-10-01 15:03:52 +09:00
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
);
2025-10-01 11:45:17 +09:00
return res.status(201).json({
2025-10-01 11:34:17 +09:00
success: true,
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
* 릿
* 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: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
});
}
2025-10-01 11:45:17 +09:00
return res.json({
2025-10-01 11:34:17 +09:00
success: true,
message: "템플릿이 삭제되었습니다.",
});
} catch (error) {
2025-10-01 11:45:17 +09:00
return next(error);
2025-10-01 11:34:17 +09:00
}
}
/**
*
* POST /api/admin/reports/:reportId/queries/:queryId/execute
*/
async executeQuery(req: Request, res: Response, next: NextFunction) {
try {
const { reportId, queryId } = req.params;
2025-10-01 14:36:46 +09:00
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
const result = await reportService.executeQuery(
reportId,
queryId,
parameters,
2025-10-01 14:36:46 +09:00
sqlQuery,
externalConnectionId
);
return res.json({
success: true,
data: result,
});
} catch (error: any) {
return res.status(400).json({
success: false,
message: error.message || "쿼리 실행에 실패했습니다.",
});
}
}
2025-10-01 14:36:46 +09:00
/**
* 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);
}
}
2025-10-01 16:53:35 +09:00
/**
*
* 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<string, unknown>[] }> =
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<string, unknown>[] }>,
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
): (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;
result.push(
new ParagraphRef({
alignment,
children: [
new TextRunRef({
text: displayValue,
size: fontSizeHalfPt,
color: (component.fontColor || "#000000").replace("#", ""),
bold: component.fontWeight === "bold" || component.fontWeight === "600",
font: "맑은 고딕",
}),
],
})
);
}
// 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 }));
}
}
// 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 sections = layoutConfig.pages
.sort((a: any, b: any) => a.page_order - b.page_order)
.map((page: any) => {
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);
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;
// 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈
const textCell = new TableCell({
children: [
new Paragraph({
alignment,
children: [
new TextRun({
text: displayValue,
size: fontSizeHalfPt,
color: (component.fontColor || "#000000").replace("#", ""),
bold: component.fontWeight === "bold" || component.fontWeight === "600",
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 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;
}
// 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<string, unknown>) =>
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: [] }));
}
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,
},
},
},
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 변환에 실패했습니다.",
});
}
}
2025-10-01 11:34:17 +09:00
}
export default new ReportController();