refactor(reports): consolidate duplicate PDF services into single implementations

Remove duplicate minio-pdf-storage and puppeteer-pdf services, keeping
the consolidated versions in pdf-generator.service.ts and pdf-storage.service.ts.
Update reports module imports to use the correct classes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:18:19 +07:00
parent 430c67f244
commit 3a9325719a
5 changed files with 36 additions and 256 deletions

View File

@@ -1,49 +0,0 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Injectable, Logger } from '@nestjs/common';
import { type IPdfStorageService } from '../../domain/services/pdf-storage.service';
@Injectable()
export class MinioPdfStorageService implements IPdfStorageService {
private readonly logger = new Logger(MinioPdfStorageService.name);
private readonly s3: S3Client;
private readonly bucket: string;
private readonly publicBaseUrl: string;
constructor() {
const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
const port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
const protocol = useSSL ? 'https' : 'http';
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`;
this.s3 = new S3Client({
endpoint: `${protocol}://${endpoint}:${port}`,
region: 'us-east-1',
credentials: {
accessKeyId: process.env['MINIO_ACCESS_KEY'] || '',
secretAccessKey: process.env['MINIO_SECRET_KEY'] || '',
},
forcePathStyle: true,
});
}
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
const objectKey = `reports/${reportId}.pdf`;
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: objectKey,
Body: buffer,
ContentType: 'application/pdf',
ContentDisposition: `inline; filename="report-${reportId}.pdf"`,
}),
);
const url = `${this.publicBaseUrl}/${objectKey}`;
this.logger.log(`PDF uploaded: ${url}`);
return url;
}
}

View File

@@ -32,17 +32,21 @@ interface ReportSection {
}
@Injectable()
export class PuppeteerPdfGenerationService implements IPdfGenerationService {
private readonly logger = new Logger(PuppeteerPdfGenerationService.name);
export class PuppeteerPdfGeneratorService implements IPdfGeneratorService {
private readonly logger = new Logger(PuppeteerPdfGeneratorService.name);
async generatePdf(
title: string,
reportType: string,
reportId: string,
content: Record<string, unknown>,
): Promise<Buffer> {
): Promise<string> {
const generatedAt = (content['generatedAt'] as string) || new Date().toISOString();
const sections = (content['sections'] as Record<string, ReportSection>) || {};
const reportType = (content['reportType'] as string) || '';
const typeLabel = REPORT_TYPE_LABELS[reportType] || reportType;
const title = (content['province'] as string)
|| (content['city'] as string)
|| (content['district'] as string)
|| reportId;
const html = this.buildHtml(title, typeLabel, generatedAt, sections);
@@ -63,14 +67,19 @@ export class PuppeteerPdfGenerationService implements IPdfGenerationService {
headerTemplate: '<div></div>',
footerTemplate: `
<div style="font-family: 'Be Vietnam Pro', Arial, sans-serif; font-size: 9px; color: #94a3b8; width: 100%; padding: 0 2cm; display: flex; justify-content: space-between;">
<span>GoodGo AI Report &mdash; ${this.escapeHtml(title)}</span>
<span>GoodGo AI Report</span>
<span>Trang <span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>
`,
});
this.logger.log(`PDF generated for report: ${title} (${pdfBuffer.length} bytes)`);
return Buffer.from(pdfBuffer);
// Write to temp file
const tmpDir = os.tmpdir();
const filePath = path.join(tmpDir, `goodgo-report-${reportId}-${Date.now()}.pdf`);
fs.writeFileSync(filePath, pdfBuffer);
this.logger.log(`PDF generated: ${filePath} (${pdfBuffer.length} bytes)`);
return filePath;
} finally {
await browser.close();
}
@@ -496,7 +505,9 @@ export class PuppeteerPdfGenerationService implements IPdfGenerationService {
// Build SVG line chart
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
const areaD = pathD + ` L ${points[points.length - 1].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} L ${points[0].x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
const lastPoint = points[points.length - 1]!;
const firstPoint = points[0]!;
const areaD = pathD + ` L ${lastPoint.x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} L ${firstPoint.x.toFixed(1)} ${(paddingTop + chartHeight).toFixed(1)} Z`;
// Y-axis labels (5 ticks)
const yLabels = Array.from({ length: 5 }, (_, i) => {

View File

@@ -6,38 +6,31 @@ import { type IPdfStorageService } from '../../domain/services/pdf-storage.servi
export class MinioPdfStorageService implements IPdfStorageService {
private readonly logger = new Logger(MinioPdfStorageService.name);
private readonly s3: S3Client;
private readonly endpoint: string;
private readonly port: number;
private readonly bucket: string;
private readonly useSSL: boolean;
private readonly publicBaseUrl: string;
constructor() {
const accessKey = process.env['MINIO_ACCESS_KEY'];
const secretKey = process.env['MINIO_SECRET_KEY'];
if (!accessKey || !secretKey) {
throw new Error('Missing MINIO_ACCESS_KEY or MINIO_SECRET_KEY environment variables');
}
const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
const port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
const protocol = useSSL ? 'https' : 'http';
this.endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
this.port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
this.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
this.useSSL = process.env['MINIO_USE_SSL'] === 'true';
const protocol = this.useSSL ? 'https' : 'http';
this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`;
this.s3 = new S3Client({
endpoint: `${protocol}://${this.endpoint}:${this.port}`,
endpoint: `${protocol}://${endpoint}:${port}`,
region: 'us-east-1',
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey,
accessKeyId: process.env['MINIO_ACCESS_KEY'] || '',
secretAccessKey: process.env['MINIO_SECRET_KEY'] || '',
},
forcePathStyle: true,
});
}
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
const objectKey = `reports/${reportId}/${Date.now()}-report.pdf`;
const objectKey = `reports/${reportId}.pdf`;
await this.s3.send(
new PutObjectCommand({
@@ -45,14 +38,12 @@ export class MinioPdfStorageService implements IPdfStorageService {
Key: objectKey,
Body: buffer,
ContentType: 'application/pdf',
ContentDisposition: 'inline',
ContentDisposition: `inline; filename="report-${reportId}.pdf"`,
}),
);
const protocol = this.useSSL ? 'https' : 'http';
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`;
this.logger.log(`PDF uploaded: ${objectKey} (${buffer.length} bytes)`);
const url = `${this.publicBaseUrl}/${objectKey}`;
this.logger.log(`PDF uploaded: ${url}`);
return url;
}
}

View File

@@ -1,173 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import puppeteer from 'puppeteer';
import { type IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
interface ReportSection {
title: string;
content?: string;
data?: Record<string, unknown>;
projects?: Array<Record<string, unknown>>;
summary?: Record<string, unknown>;
}
@Injectable()
export class PuppeteerPdfService implements IPdfGeneratorService {
private readonly logger = new Logger(PuppeteerPdfService.name);
async generatePdf(reportId: string, content: Record<string, unknown>): Promise<string> {
const html = this.buildHtml(content);
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfPath = `/tmp/report-${reportId}.pdf`;
await page.pdf({
path: pdfPath,
format: 'A4',
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
printBackground: true,
});
this.logger.log(`PDF generated: ${pdfPath}`);
return pdfPath;
} finally {
await browser.close();
}
}
private buildHtml(content: Record<string, unknown>): string {
const reportType = content['reportType'] as string ?? 'Báo cáo';
const province = (content['province'] ?? content['city'] ?? '') as string;
const generatedAt = content['generatedAt'] as string ?? new Date().toISOString();
const sections = content['sections'] as Record<string, ReportSection> | undefined;
const sectionHtml = sections
? Object.entries(sections).map(([key, section]) => this.renderSection(key, section)).join('\n')
: '<p>Không có dữ liệu.</p>';
return `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #1a1a1a;
}
.header {
text-align: center;
border-bottom: 2px solid #2563eb;
padding-bottom: 16px;
margin-bottom: 24px;
}
.header h1 { font-size: 18pt; color: #2563eb; margin-bottom: 4px; }
.header .meta { font-size: 9pt; color: #666; }
.section { margin-bottom: 20px; page-break-inside: avoid; }
.section h2 {
font-size: 13pt;
color: #2563eb;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 4px;
margin-bottom: 10px;
}
.section p { margin-bottom: 8px; white-space: pre-line; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
font-size: 10pt;
}
th, td { border: 1px solid #e5e7eb; padding: 6px 8px; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
.data-grid { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; }
.data-card {
flex: 1 1 200px;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px;
font-size: 10pt;
}
.data-card h4 { font-size: 9pt; color: #666; margin-bottom: 4px; }
.footer {
text-align: center;
font-size: 8pt;
color: #999;
border-top: 1px solid #e5e7eb;
padding-top: 8px;
margin-top: 32px;
}
</style>
</head>
<body>
<div class="header">
<h1>${this.escapeHtml(reportType)}${this.escapeHtml(province)}</h1>
<div class="meta">GoodGo Platform AI | Ngày tạo: ${new Date(generatedAt).toLocaleDateString('vi-VN')}</div>
</div>
${sectionHtml}
<div class="footer">
&copy; ${new Date().getFullYear()} GoodGo Platform — Báo cáo được tạo tự động bởi AI
</div>
</body>
</html>`;
}
private renderSection(key: string, section: ReportSection): string {
let body = '';
if (section.content) {
body += `<p>${this.escapeHtml(section.content)}</p>`;
}
if (section.projects && Array.isArray(section.projects) && section.projects.length > 0) {
body += '<table><thead><tr><th>Tên</th><th>Loại</th><th>Trạng thái</th><th>Vốn đầu tư</th></tr></thead><tbody>';
for (const p of section.projects) {
body += `<tr>
<td>${this.escapeHtml(String(p['name'] ?? ''))}</td>
<td>${this.escapeHtml(String(p['category'] ?? ''))}</td>
<td>${this.escapeHtml(String(p['status'] ?? ''))}</td>
<td>${p['investmentVND'] ? Number(p['investmentVND']).toLocaleString('vi-VN') + ' VND' : '—'}</td>
</tr>`;
}
body += '</tbody></table>';
}
if (section.data && typeof section.data === 'object' && !section.projects) {
const entries = Object.entries(section.data);
const firstEntry = entries[0];
if (entries.length > 0 && firstEntry && Array.isArray(firstEntry[1])) {
body += '<div class="data-grid">';
for (const [indicator, values] of entries) {
if (!Array.isArray(values) || values.length === 0) continue;
const latest = values[values.length - 1];
if (!latest) continue;
const typed = latest as { period: string; value: number; unit: string };
body += `<div class="data-card">
<h4>${this.escapeHtml(indicator)}</h4>
<strong>${typed.value.toLocaleString('vi-VN')} ${this.escapeHtml(typed.unit)}</strong>
<br><span style="font-size:8pt;color:#999">${this.escapeHtml(typed.period)}</span>
</div>`;
}
body += '</div>';
}
}
return `<div class="section" id="section-${key}"><h2>${this.escapeHtml(section.title)}</h2>${body}</div>`;
}
private escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}

View File

@@ -16,8 +16,8 @@ import { PrismaReportRepository } from './infrastructure/repositories/prisma-rep
import { ClaudeNarrativeService } from './infrastructure/services/claude-narrative.service';
import { PrismaInfrastructureDataService } from './infrastructure/services/infrastructure-data.service';
import { PrismaMacroDataService } from './infrastructure/services/macro-data.service';
import { MinioPdfStorageService } from './infrastructure/services/minio-pdf-storage.service';
import { PuppeteerPdfService } from './infrastructure/services/puppeteer-pdf.service';
import { PuppeteerPdfGeneratorService } from './infrastructure/services/pdf-generator.service';
import { MinioPdfStorageService } from './infrastructure/services/pdf-storage.service';
import { ReportGenerationProcessor } from './infrastructure/services/report-generation.processor';
import { ReportsController } from './presentation/controllers/reports.controller';
@@ -48,7 +48,7 @@ const QueryHandlers = [
{ provide: MACRO_DATA_SERVICE, useClass: PrismaMacroDataService },
{ provide: INFRASTRUCTURE_DATA_SERVICE, useClass: PrismaInfrastructureDataService },
{ provide: AI_NARRATIVE_SERVICE, useClass: ClaudeNarrativeService },
{ provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfService },
{ provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfGeneratorService },
{ provide: PDF_STORAGE_SERVICE, useClass: MinioPdfStorageService },
// BullMQ processor