feat: botón Generar PDF en los reportes HTML (Chrome sin pantalla, papel y márgenes de la plantilla fijos)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 16:53:24 -05:00
1 parent 2d358f0cf1
commit f71577e792
13 files changed
+287 -19

No files matched your search

+9
View File
@@ -63,3 +63,12 @@ SAPDB_TIMEOUT=30
SAPDB_MAX_CONCURRENCY=2
SAPDB_MAX_ROWS=5000
# --- Reportes HTML (plantillas que se imprimen o se convierten en PDF) ---
# Carpeta de las plantillas Nunjucks, relativa a apps/api
HTML_REPORTS_DIR=./templates
# Chrome o Chromium con el que se genera el PDF (si se omite se busca el instalado)
CHROME_BIN=
# Espera máxima (segundos) de cada PDF y PDF que se generan a la vez
HTML_PDF_TIMEOUT=30
HTML_PDF_MAX_CONCURRENCY=1
+12
View File
@@ -120,3 +120,15 @@ Algunas pantallas necesitan datos que el Service Layer no entrega bien (por ejem
**Campos de usuario (`GET /api/sapdb/user-fields/:tabla/:alias`):** entrega la definición de un campo de usuario de SAP leída de `CUFD`/`UFD1` (tipo, tamaño, valores válidos) y, si está enlazado a una tabla de usuario (`CUFD.RTable`), sus filas `Code`/`Name` (hasta `SAPDB_MAX_ROWS`). La tabla enlazada sale de la definición del campo, no de la petición, así que no se puede leer una tabla cualquiera; la tabla y el alias se validan (letras, dígitos y «_»). Cualquier sesión puede pedirlo (es lo que SAP muestra al elegir en el campo); se guarda 10 minutos en memoria; se audita solo la tabla y el alias.
## Reportes HTML
Plantillas Nunjucks (`templates/`, una carpeta por cliente) que se rellenan con datos de la base de SAP y se ven como HTML o como PDF. Los reportes los declara la rama de cada cliente en `src/htmlreports/html-reports.extensions.ts` (vacío en `main`): código, título, parámetros (los mismos de los reportes del Gateway), plantilla y la función que arma los datos con consultas registradas de `sapdb`.
| Endpoint | Descripción |
|---|---|
| `GET /api/html-reports` | Reportes disponibles con sus parámetros |
| `POST /api/html-reports/:code/render` `{values}` | El HTML del reporte |
| `POST /api/html-reports/:code/pdf` `{values}` | El PDF: el mismo HTML pasado por Chrome sin pantalla, con el papel y los márgenes de la plantilla (`@page`) |
Permiso de menú `/reportes-html/ejecutar` en ambos. El PDF necesita **Chrome o Chromium instalado** (`CHROME_BIN`; si se omite se busca en los lugares habituales de macOS, Linux y Windows): sin él responde 503. Cola propia (`HTML_PDF_MAX_CONCURRENCY`) y tiempo máximo (`HTML_PDF_TIMEOUT`). La auditoría registra `html_report` con el formato y los nombres de los parámetros, nunca los valores.
+5
View File
@@ -90,6 +90,11 @@ export const envSchema = z
SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000),
// Carpeta de las plantillas de los reportes HTML (Nunjucks), relativa a apps/api.
HTML_REPORTS_DIR: z.string().min(1).default('./templates'),
// Chrome o Chromium con el que se genera el PDF de un reporte HTML (sin pantalla). Si no se indica, se busca el instalado.
CHROME_BIN: z.preprocess(emptyToUndefined, z.string().optional()),
// Espera máxima (segundos) de cada PDF y PDF que se generan a la vez (Chrome pesa).
HTML_PDF_TIMEOUT: z.coerce.number().positive().default(30),
HTML_PDF_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1),
SAPB1_SL_VERIFY_SSL: z
.enum(['true', 'false'])
.default('true')
@@ -0,0 +1,96 @@
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service.js';
/** Dónde suele estar Chrome o Chromium (macOS, Linux y Windows). */
const CANDIDATES = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
];
/**
* Convierte el HTML de un reporte en PDF con Chrome sin pantalla (`--print-to-pdf`): el mismo motor que imprime el navegador,
* pero con el tamaño de papel y los márgenes de la plantilla (`@page`) fijos, sin depender del cuadro de impresión de cada
* usuario. No descarga nada: usa el Chrome o Chromium instalado (`CHROME_BIN`). Cola propia (`HTML_PDF_MAX_CONCURRENCY`).
*/
@Injectable()
export class HtmlPdfService {
private readonly logger = new Logger(HtmlPdfService.name);
private active = 0;
private readonly waiting: (() => void)[] = [];
constructor(private readonly config: AppConfigService) {}
/** Ruta de Chrome: la de `CHROME_BIN` o la primera instalada que se encuentre. */
chrome(): string | null {
const wanted = this.config.get('CHROME_BIN');
if (wanted) return existsSync(wanted) ? wanted : null;
return CANDIDATES.find((c) => existsSync(c)) ?? null;
}
private async turn<T>(work: () => Promise<T>): Promise<T> {
if (this.active >= this.config.get('HTML_PDF_MAX_CONCURRENCY')) await new Promise<void>((go) => this.waiting.push(go));
this.active++;
try {
return await work();
} finally {
this.active--;
this.waiting.shift()?.();
}
}
/** Ejecuta Chrome (separado para poder simularlo en las pruebas). */
protected run(bin: string, args: string[], timeoutMs: number): Promise<void> {
return new Promise((done, fail) => {
execFile(bin, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, _stdout, stderr) => {
if (err) fail(new Error(`Chrome no pudo generar el PDF: ${(stderr || err.message).toString().slice(0, 300)}`));
else done();
});
});
}
async toPdf(html: string): Promise<Buffer> {
const bin = this.chrome();
if (!bin) throw new ServiceUnavailableException('No hay Chrome o Chromium instalado para generar el PDF (indique su ruta en CHROME_BIN)');
return this.turn(async () => {
const dir = await mkdtemp(join(tmpdir(), 'reporte-'));
try {
const input = join(dir, 'reporte.html');
const output = join(dir, 'reporte.pdf');
await writeFile(input, html, 'utf8');
const args = [
'--headless=new',
'--disable-gpu',
'--no-pdf-header-footer',
'--disable-dev-shm-usage',
`--user-data-dir=${join(dir, 'perfil')}`,
// como administrador (contenedores) Chrome exige desactivar su sandbox
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
`--print-to-pdf=${output}`,
pathToFileURL(input).href,
];
await this.run(bin, args, this.config.get('HTML_PDF_TIMEOUT') * 1000);
const pdf = await readFile(output);
if (pdf.subarray(0, 4).toString('latin1') !== '%PDF') throw new Error('Chrome no devolvió un PDF');
return pdf;
} catch (e) {
this.logger.error(`No se pudo generar el PDF: ${e instanceof Error ? e.message : String(e)}`);
throw e;
} finally {
await rm(dir, { recursive: true, force: true });
}
});
}
}
+76
View File
@@ -0,0 +1,76 @@
import { existsSync } from 'node:fs';
import { ServiceUnavailableException } from '@nestjs/common';
import { vi } from 'vitest';
import type { AppConfigService } from '../config/app-config.service.js';
import { HtmlPdfService } from './html-pdf.service.js';
const config = (over: Record<string, unknown> = {}) => ({ get: (k: string) => ({ CHROME_BIN: undefined, HTML_PDF_TIMEOUT: 30, HTML_PDF_MAX_CONCURRENCY: 1, ...over })[k] }) as unknown as AppConfigService;
/** El servicio con Chrome simulado: escribe el PDF que se le pida donde dice `--print-to-pdf`. */
class FakeChrome extends HtmlPdfService {
calls: string[][] = [];
peak = 0;
private running = 0;
override chrome = () => '/usr/bin/chrome-falso';
protected override async run(_bin: string, args: string[]): Promise<void> {
this.calls.push(args);
this.running++;
this.peak = Math.max(this.peak, this.running);
await new Promise((r) => setTimeout(r, 5));
const { writeFile } = await import('node:fs/promises');
await writeFile(args.find((a) => a.startsWith('--print-to-pdf='))!.slice('--print-to-pdf='.length), '%PDF-1.4 falso');
this.running--;
}
}
describe('PDF de un reporte HTML', () => {
it('sin Chrome instalado avisa con 503 y dice cómo indicarlo', async () => {
const s = new HtmlPdfService(config({ CHROME_BIN: '/no/existe/chrome' }));
expect(s.chrome()).toBeNull();
await expect(s.toPdf('<p>x</p>')).rejects.toThrow(ServiceUnavailableException);
await expect(s.toPdf('<p>x</p>')).rejects.toThrow(/CHROME_BIN/);
});
it('pide a Chrome el PDF de la página sin encabezados ni pies y devuelve su contenido', async () => {
const s = new FakeChrome(config());
const pdf = await s.toPdf('<p>hola</p>');
expect(pdf.toString()).toContain('%PDF');
const args = s.calls[0];
expect(args).toEqual(expect.arrayContaining(['--headless=new', '--no-pdf-header-footer']));
expect(args.find((a) => a.startsWith('file://'))).toMatch(/reporte\.html$/);
});
it('solo genera HTML_PDF_MAX_CONCURRENCY PDF a la vez', async () => {
const s = new FakeChrome(config({ HTML_PDF_MAX_CONCURRENCY: 1 }));
await Promise.all([s.toPdf('a'), s.toPdf('b'), s.toPdf('c')]);
expect(s.peak).toBe(1);
expect(s.calls).toHaveLength(3);
});
it('un PDF que no lo es se rechaza', async () => {
const s = new FakeChrome(config());
vi.spyOn(s as unknown as { run: () => Promise<void> }, 'run').mockImplementation(async function (this: unknown, ...a: unknown[]) {
const args = a[1] as string[];
const { writeFile } = await import('node:fs/promises');
await writeFile(args.find((x) => x.startsWith('--print-to-pdf='))!.slice('--print-to-pdf='.length), 'no es pdf');
} as never);
await expect(s.toPdf('x')).rejects.toThrow(/no devolvió un PDF/);
});
// con Chrome de verdad (si está instalado): el PDF respeta el papel y los márgenes de la plantilla
const real = new HtmlPdfService(config());
it.skipIf(!real.chrome() || !existsSync('/opt/homebrew/bin/pdfinfo'))('con Chrome real genera un PDF Carta horizontal', async () => {
const pdf = await real.toPdf('<style>@page{size: letter landscape; margin: 1cm}</style><p>Hola</p><p style="page-break-before:always">2</p>');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
const { execFileSync } = await import('node:child_process');
const { mkdtempSync, rmSync, writeFileSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'prueba-pdf-'));
writeFileSync(join(dir, 'r.pdf'), pdf);
const info = execFileSync('/opt/homebrew/bin/pdfinfo', [join(dir, 'r.pdf')]).toString();
rmSync(dir, { recursive: true, force: true });
expect(info).toMatch(/Pages:\s+2/);
expect(info).toMatch(/792 x 612/);
}, 30000);
});
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Header, HttpCode, Param, Post, Req } from '@nestjs/common';
import { Body, Controller, Get, Header, HttpCode, Param, Post, Req, StreamableFile } from '@nestjs/common';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.types.js';
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
@@ -24,4 +24,11 @@ export class HtmlReportsController {
render(@Req() req: AuthenticatedRequest, @Param('code', new ZodValidationPipe(codeSchema)) code: string, @Body(new ZodValidationPipe(bodySchema)) body: RenderDto): Promise<string> {
return this.reports.render(req.user, code, body.values, req.ip ?? '');
}
@Post(':code/pdf')
@HttpCode(200)
async pdf(@Req() req: AuthenticatedRequest, @Param('code', new ZodValidationPipe(codeSchema)) code: string, @Body(new ZodValidationPipe(bodySchema)) body: RenderDto) {
const pdf = await this.reports.pdf(req.user, code, body.values, req.ip ?? '');
return new StreamableFile(pdf, { type: 'application/pdf', disposition: `inline; filename="${code}.pdf"` });
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module.js';
import { PermissionsModule } from '../permissions/permissions.module.js';
import { SapDbModule } from '../sapdb/sapdb.module.js';
import { HtmlPdfService } from './html-pdf.service.js';
import { HtmlReportsController } from './html-reports.controller.js';
import { HtmlReportsService } from './html-reports.service.js';
@@ -9,6 +10,6 @@ import { HtmlReportsService } from './html-reports.service.js';
@Module({
imports: [AuditModule, PermissionsModule, SapDbModule],
controllers: [HtmlReportsController],
providers: [HtmlReportsService],
providers: [HtmlPdfService, HtmlReportsService],
})
export class HtmlReportsModule {}
@@ -7,6 +7,7 @@ import { PermissionsService } from '../permissions/permissions.service.js';
import { SapDbService } from '../sapdb/sapdb.service.js';
import { HTML_REPORTS } from './html-reports.js';
import { createRenderer } from './html-render.js';
import { HtmlPdfService } from './html-pdf.service.js';
import type { HtmlReportDef, HtmlReportInfo, HtmlReportValues } from './html-reports.types.js';
/** Ruta de menú que permite ejecutar los reportes HTML. */
@@ -55,6 +56,7 @@ export class HtmlReportsService {
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
private readonly sapdb: SapDbService,
private readonly pdfService: HtmlPdfService,
) {}
private async assertCanRun(user: AuthUser): Promise<void> {
@@ -70,21 +72,30 @@ export class HtmlReportsService {
/** El HTML del reporte con los valores indicados. */
async render(user: AuthUser, code: string, raw: Readonly<Record<string, unknown>>, ip: string): Promise<string> {
return this.run(user, code, raw, ip, 'html', (html) => html);
}
/** El PDF del reporte: el mismo HTML pasado por Chrome sin pantalla, con el papel y los márgenes de la plantilla. */
async pdf(user: AuthUser, code: string, raw: Readonly<Record<string, unknown>>, ip: string): Promise<Buffer> {
return this.run(user, code, raw, ip, 'pdf', (html) => this.pdfService.toPdf(html));
}
private async run<T>(user: AuthUser, code: string, raw: Readonly<Record<string, unknown>>, ip: string, format: 'html' | 'pdf', finish: (html: string) => T | Promise<T>): Promise<T> {
await this.assertCanRun(user);
const def = HTML_REPORTS.get(code);
if (!def) throw new NotFoundException(`No existe el reporte ${code}`);
const values = parseHtmlValues(def, raw);
// solo los nombres de los parámetros: los valores pueden ser datos de clientes
const detail = { params: def.params.map((p) => p.name) };
const detail = { format, params: def.params.map((p) => p.name) };
try {
const data = await def.data(values, { query: async (name, params) => (await this.sapdb.run(user, name, params ?? {}, ip)).rows });
this.renderer ??= createRenderer(this.config.get('HTML_REPORTS_DIR'));
const html = this.renderer.render(def.template, { ...data, params: values });
const out = await finish(this.renderer.render(def.template, { ...data, params: values }));
await this.audit.record({ action: 'html_report', webUser: user.webUser, ip, resource: code, detail });
return html;
return out;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Reporte HTML ${code} falló: ${message}`);
this.logger.error(`Reporte HTML ${code} (${format}) falló: ${message}`);
await this.audit.record({ action: 'html_report', webUser: user.webUser, ip, resource: code, outcome: 'error', detail: { ...detail, error: message.slice(0, 300) } });
throw e;
}
+20 -1
View File
@@ -9,6 +9,7 @@ import type { AuthUser } from '../auth/auth.types.js';
import type { AppConfigService } from '../config/app-config.service.js';
import type { PermissionsService } from '../permissions/permissions.service.js';
import type { SapDbService } from '../sapdb/sapdb.service.js';
import type { HtmlPdfService } from './html-pdf.service.js';
import { createRenderer } from './html-render.js';
import { validateHtmlReports } from './html-reports.js';
import type { HtmlReportDef } from './html-reports.types.js';
@@ -88,18 +89,21 @@ describe('servicio de reportes HTML', () => {
let paths: string[];
const run = vi.fn(async () => ({ rows: [{ CardName: 'ACME' }], truncated: false }));
const record = vi.fn(async () => undefined);
const toPdf = vi.fn(async (html: string) => Buffer.from(`%PDF ${html.length}`));
const service = () =>
new HtmlReportsService(
{ get: () => dir } as unknown as AppConfigService,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
{ run } as unknown as SapDbService,
{ toPdf } as unknown as HtmlPdfService,
);
beforeEach(() => {
paths = ['*'];
run.mockClear();
record.mockClear();
toPdf.mockClear();
});
it('lista los reportes con sus parámetros, sin la plantilla ni el código de datos', async () => {
@@ -113,7 +117,22 @@ describe('servicio de reportes HTML', () => {
expect(html).toContain('<p>ACME</p>');
expect(html).toContain('<i>3/3</i>');
expect(run).toHaveBeenCalledWith(user, 'guia', { docEntry: 144153 }, '10.0.0.5');
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'html_report', resource: 'etiquetas', detail: { params: ['doc', 'cantidad', 'papel', 'fecha'] } }));
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'html_report', resource: 'etiquetas', detail: { format: 'html', params: ['doc', 'cantidad', 'papel', 'fecha'] } }));
});
it('el PDF pasa el mismo HTML por Chrome y se audita como pdf', async () => {
const pdf = await service().pdf(user, 'etiquetas', { doc: '144153', cantidad: '3' }, '10.0.0.5');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
expect(toPdf).toHaveBeenCalledWith(expect.stringContaining('<p>ACME</p>'));
expect(record).toHaveBeenCalledWith(expect.objectContaining({ detail: expect.objectContaining({ format: 'pdf' }) }));
});
it('el PDF exige los mismos permisos y parámetros que el HTML', async () => {
paths = ['/ventas/*'];
await expect(service().pdf(user, 'etiquetas', { doc: '1', cantidad: '1' }, '')).rejects.toThrow(ForbiddenException);
paths = ['*'];
await expect(service().pdf(user, 'etiquetas', { doc: '1' }, '')).rejects.toThrow(BadRequestException);
expect(toPdf).not.toHaveBeenCalled();
});
it('sin el permiso de menú no se lista ni se ejecuta; un reporte que no existe da 404; un parámetro mal puesto, 400', async () => {