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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 — ${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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
© ${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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user