feat: add pdf to the central invoice system

This commit is contained in:
Nathnael
2026-06-30 11:58:28 +00:00
parent 7fa18b8ee7
commit 5ad4efd7eb
11 changed files with 618 additions and 266 deletions

View File

@@ -1,101 +1,23 @@
import { existsSync } from 'fs';
import { Injectable } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
constructor(private readonly pdf: PdfRenderService) {}
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
/**
* Render the gate-clearance release document to PDF via the shared renderer,
* falling back to the release-specific hand-built layout when Chromium is
* unavailable.
*/
htmlToPdfBuffer(html: string): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label: 'Warehouse release',
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
});
}
private htmlToBasicPdfBuffer(html: string): Buffer {