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()
|
@Injectable()
|
||||||
export class PuppeteerPdfGenerationService implements IPdfGenerationService {
|
export class PuppeteerPdfGeneratorService implements IPdfGeneratorService {
|
||||||
private readonly logger = new Logger(PuppeteerPdfGenerationService.name);
|
private readonly logger = new Logger(PuppeteerPdfGeneratorService.name);
|
||||||
|
|
||||||
async generatePdf(
|
async generatePdf(
|
||||||
title: string,
|
reportId: string,
|
||||||
reportType: string,
|
|
||||||
content: Record<string, unknown>,
|
content: Record<string, unknown>,
|
||||||
): Promise<Buffer> {
|
): Promise<string> {
|
||||||
const generatedAt = (content['generatedAt'] as string) || new Date().toISOString();
|
const generatedAt = (content['generatedAt'] as string) || new Date().toISOString();
|
||||||
const sections = (content['sections'] as Record<string, ReportSection>) || {};
|
const sections = (content['sections'] as Record<string, ReportSection>) || {};
|
||||||
|
const reportType = (content['reportType'] as string) || '';
|
||||||
const typeLabel = REPORT_TYPE_LABELS[reportType] || reportType;
|
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);
|
const html = this.buildHtml(title, typeLabel, generatedAt, sections);
|
||||||
|
|
||||||
@@ -63,14 +67,19 @@ export class PuppeteerPdfGenerationService implements IPdfGenerationService {
|
|||||||
headerTemplate: '<div></div>',
|
headerTemplate: '<div></div>',
|
||||||
footerTemplate: `
|
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;">
|
<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>
|
<span>Trang <span class="pageNumber"></span> / <span class="totalPages"></span></span>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`PDF generated for report: ${title} (${pdfBuffer.length} bytes)`);
|
// Write to temp file
|
||||||
return Buffer.from(pdfBuffer);
|
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 {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
@@ -496,7 +505,9 @@ export class PuppeteerPdfGenerationService implements IPdfGenerationService {
|
|||||||
|
|
||||||
// Build SVG line chart
|
// Build SVG line chart
|
||||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
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)
|
// Y-axis labels (5 ticks)
|
||||||
const yLabels = Array.from({ length: 5 }, (_, i) => {
|
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 {
|
export class MinioPdfStorageService implements IPdfStorageService {
|
||||||
private readonly logger = new Logger(MinioPdfStorageService.name);
|
private readonly logger = new Logger(MinioPdfStorageService.name);
|
||||||
private readonly s3: S3Client;
|
private readonly s3: S3Client;
|
||||||
private readonly endpoint: string;
|
|
||||||
private readonly port: number;
|
|
||||||
private readonly bucket: string;
|
private readonly bucket: string;
|
||||||
private readonly useSSL: boolean;
|
private readonly publicBaseUrl: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const accessKey = process.env['MINIO_ACCESS_KEY'];
|
const endpoint = process.env['MINIO_ENDPOINT'] || 'localhost';
|
||||||
const secretKey = process.env['MINIO_SECRET_KEY'];
|
const port = parseInt(process.env['MINIO_PORT'] || '9000', 10);
|
||||||
if (!accessKey || !secretKey) {
|
const useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
||||||
throw new Error('Missing MINIO_ACCESS_KEY or MINIO_SECRET_KEY environment variables');
|
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.bucket = process.env['MINIO_BUCKET'] || 'goodgo-media';
|
||||||
this.useSSL = process.env['MINIO_USE_SSL'] === 'true';
|
this.publicBaseUrl = `${protocol}://${endpoint}:${port}/${this.bucket}`;
|
||||||
|
|
||||||
const protocol = this.useSSL ? 'https' : 'http';
|
|
||||||
|
|
||||||
this.s3 = new S3Client({
|
this.s3 = new S3Client({
|
||||||
endpoint: `${protocol}://${this.endpoint}:${this.port}`,
|
endpoint: `${protocol}://${endpoint}:${port}`,
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: accessKey,
|
accessKeyId: process.env['MINIO_ACCESS_KEY'] || '',
|
||||||
secretAccessKey: secretKey,
|
secretAccessKey: process.env['MINIO_SECRET_KEY'] || '',
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadPdf(buffer: Buffer, reportId: string): Promise<string> {
|
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(
|
await this.s3.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
@@ -45,14 +38,12 @@ export class MinioPdfStorageService implements IPdfStorageService {
|
|||||||
Key: objectKey,
|
Key: objectKey,
|
||||||
Body: buffer,
|
Body: buffer,
|
||||||
ContentType: 'application/pdf',
|
ContentType: 'application/pdf',
|
||||||
ContentDisposition: 'inline',
|
ContentDisposition: `inline; filename="report-${reportId}.pdf"`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const protocol = this.useSSL ? 'https' : 'http';
|
const url = `${this.publicBaseUrl}/${objectKey}`;
|
||||||
const url = `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`;
|
this.logger.log(`PDF uploaded: ${url}`);
|
||||||
|
|
||||||
this.logger.log(`PDF uploaded: ${objectKey} (${buffer.length} bytes)`);
|
|
||||||
return 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 { ClaudeNarrativeService } from './infrastructure/services/claude-narrative.service';
|
||||||
import { PrismaInfrastructureDataService } from './infrastructure/services/infrastructure-data.service';
|
import { PrismaInfrastructureDataService } from './infrastructure/services/infrastructure-data.service';
|
||||||
import { PrismaMacroDataService } from './infrastructure/services/macro-data.service';
|
import { PrismaMacroDataService } from './infrastructure/services/macro-data.service';
|
||||||
import { MinioPdfStorageService } from './infrastructure/services/minio-pdf-storage.service';
|
import { PuppeteerPdfGeneratorService } from './infrastructure/services/pdf-generator.service';
|
||||||
import { PuppeteerPdfService } from './infrastructure/services/puppeteer-pdf.service';
|
import { MinioPdfStorageService } from './infrastructure/services/pdf-storage.service';
|
||||||
import { ReportGenerationProcessor } from './infrastructure/services/report-generation.processor';
|
import { ReportGenerationProcessor } from './infrastructure/services/report-generation.processor';
|
||||||
import { ReportsController } from './presentation/controllers/reports.controller';
|
import { ReportsController } from './presentation/controllers/reports.controller';
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ const QueryHandlers = [
|
|||||||
{ provide: MACRO_DATA_SERVICE, useClass: PrismaMacroDataService },
|
{ provide: MACRO_DATA_SERVICE, useClass: PrismaMacroDataService },
|
||||||
{ provide: INFRASTRUCTURE_DATA_SERVICE, useClass: PrismaInfrastructureDataService },
|
{ provide: INFRASTRUCTURE_DATA_SERVICE, useClass: PrismaInfrastructureDataService },
|
||||||
{ provide: AI_NARRATIVE_SERVICE, useClass: ClaudeNarrativeService },
|
{ provide: AI_NARRATIVE_SERVICE, useClass: ClaudeNarrativeService },
|
||||||
{ provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfService },
|
{ provide: PDF_GENERATOR_SERVICE, useClass: PuppeteerPdfGeneratorService },
|
||||||
{ provide: PDF_STORAGE_SERVICE, useClass: MinioPdfStorageService },
|
{ provide: PDF_STORAGE_SERVICE, useClass: MinioPdfStorageService },
|
||||||
|
|
||||||
// BullMQ processor
|
// BullMQ processor
|
||||||
|
|||||||
Reference in New Issue
Block a user