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

+1 -1
View File
@@ -60,7 +60,7 @@ Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): e
## Reportes HTML (plantillas por cliente)
Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente define como plantillas **Nunjucks** (la sintaxis de Jinja2: `{{ x }}`, `{% for %}`) rellenadas con datos de la base de SAP y que se **imprimen desde el navegador**. El motor es genérico y vive en `main` (`apps/api/src/htmlreports/`); los reportes los declara la rama del cliente en `html-reports.extensions.ts` (vacío en `main`) y sus plantillas van en `apps/api/templates/<cliente>/` (`HTML_REPORTS_DIR`; ver `templates/README.md`). Cada `HtmlReportDef` trae `code`, `title`, `category`, `params` (los mismos `GatewayParam` de los reportes del Gateway: texto, número, fecha, lista fija o ventana de selección de documentos/socios/artículos…, con valor por defecto), `template` y `data(values, ctx)`, que arma lo que ve la plantilla con `ctx.query(nombre, params)` (consultas registradas de `sapdb`, cuyo permiso de menú debe incluir `/reportes-html/ejecutar`). La plantilla recibe esos datos y `params`; los valores se escapan solos y `{{ asset('<cliente>/fonts/F.ttf') }}` entrega un archivo de la carpeta (fuente, logo) como `data:` URI, sin salir de ella. API: `GET /api/html-reports` y `POST /api/html-reports/:code/render {values}` (HTML); permiso de menú `/reportes-html/ejecutar`; auditoría `html_report` (solo los nombres de los parámetros). **Pantalla:** reutiliza la lista y el formulario de «Reportes» (`data.source = 'html'`); el resultado se ve en un marco (`sandbox`) con botón **Imprimir** y un recordatorio: *Márgenes predeterminados, encabezados y pies de página desactivados, escala 100 %* (Chrome y Edge respetan `@page`; conviene fijar `line-height`). **Verificado** (etiquetas de bultos de TAGUMEDICA, WeasyPrint frente al PDF de Chrome con la misma plantilla): mismas páginas, márgenes, rejilla y espacios; con `line-height` fijo el texto difiere 0,3 mm de media. Pendiente: salida PDF desde el servidor (Puppeteer) si hace falta; el botón PDF de los documentos aún no ofrece los reportes HTML.
Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente define como plantillas **Nunjucks** (la sintaxis de Jinja2: `{{ x }}`, `{% for %}`) rellenadas con datos de la base de SAP y que se **imprimen desde el navegador**. El motor es genérico y vive en `main` (`apps/api/src/htmlreports/`); los reportes los declara la rama del cliente en `html-reports.extensions.ts` (vacío en `main`) y sus plantillas van en `apps/api/templates/<cliente>/` (`HTML_REPORTS_DIR`; ver `templates/README.md`). Cada `HtmlReportDef` trae `code`, `title`, `category`, `params` (los mismos `GatewayParam` de los reportes del Gateway: texto, número, fecha, lista fija o ventana de selección de documentos/socios/artículos…, con valor por defecto), `template` y `data(values, ctx)`, que arma lo que ve la plantilla con `ctx.query(nombre, params)` (consultas registradas de `sapdb`, cuyo permiso de menú debe incluir `/reportes-html/ejecutar`). La plantilla recibe esos datos y `params`; los valores se escapan solos y `{{ asset('<cliente>/fonts/F.ttf') }}` entrega un archivo de la carpeta (fuente, logo) como `data:` URI, sin salir de ella. API: `GET /api/html-reports` y `POST /api/html-reports/:code/render {values}` (HTML); permiso de menú `/reportes-html/ejecutar`; auditoría `html_report` (solo los nombres de los parámetros). **Pantalla:** reutiliza la lista y el formulario de «Reportes» (`data.source = 'html'`); el resultado se ve en un marco (`sandbox`) con botón **Imprimir** y un recordatorio: *Márgenes predeterminados, encabezados y pies de página desactivados, escala 100 %* (Chrome y Edge respetan `@page`; conviene fijar `line-height`). **Verificado** (etiquetas de bultos de TAGUMEDICA, WeasyPrint frente al PDF de Chrome con la misma plantilla): mismas páginas, márgenes, rejilla y espacios; con `line-height` fijo el texto difiere 0,3 mm de media. **PDF desde el servidor** (botón principal **Generar PDF** de la pantalla; «Ver como HTML» queda como vista previa para imprimir desde el navegador): `POST /api/html-reports/:code/pdf` pasa el mismo HTML por Chrome o Chromium sin pantalla (`--print-to-pdf`, sin librerías nuevas ni descargas: usa el instalado, `CHROME_BIN` o los lugares habituales) y devuelve el PDF con el papel y los márgenes de la plantilla fijos, sin depender del cuadro de impresión; cola propia (`HTML_PDF_MAX_CONCURRENCY` 1, `HTML_PDF_TIMEOUT` 30 s); sin Chrome responde 503 con el aviso. **En el despliegue (Docker) hay que instalar Chromium** y, si corre como administrador, Chrome se lanza con `--no-sandbox` solo. **Al imprimir el PDF hay que elegir «Tamaño real / 100 %»** (no «Ajustar»), sobre todo con papel precortado. Pendiente: el botón PDF de los documentos aún no ofrece los reportes HTML.
## Ver el nombre del campo de SAP («Campos»)
+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 () => {
@@ -13,6 +13,11 @@ export class HtmlReportsApi {
return firstValueFrom(this.http.get<GatewayReport[]>(`${API_URL}/html-reports`));
}
/** El PDF del reporte (lo genera el servidor con el papel y los márgenes de la plantilla). */
pdf(code: string, values: GatewayValues): Promise<Blob> {
return firstValueFrom(this.http.post(`${API_URL}/html-reports/${encodeURIComponent(code)}/pdf`, { values }, { responseType: 'blob' }));
}
/** El HTML del reporte con los valores escritos. */
async render(code: string, values: GatewayValues): Promise<string> {
try {
@@ -60,11 +60,11 @@ describe('Reportes HTML', () => {
await tick(fixture);
const el = fixture.nativeElement as HTMLElement;
expect(el.textContent).toContain('Cantidad de etiquetas');
expect(el.querySelector('button.main')?.textContent).toContain('Generar');
expect(el.querySelector('button.main')?.textContent).not.toContain('PDF');
expect(el.querySelector('button.main')?.textContent).toContain('Generar PDF');
const htmlButton = () => [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Ver como HTML') as HTMLButtonElement;
// falta un obligatorio: no se genera
(el.querySelector('button.main') as HTMLButtonElement).click();
htmlButton().click();
await tick(fixture);
expect(render).not.toHaveBeenCalled();
expect(el.querySelector('.status')?.textContent).toContain('Falta indicar: Cantidad de etiquetas');
@@ -73,7 +73,7 @@ describe('Reportes HTML', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fixture.componentInstance as any).set(params[0], '6');
await tick(fixture);
(el.querySelector('button.main') as HTMLButtonElement).click();
htmlButton().click();
await tick(fixture);
expect(render).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' });
expect(gateway.export).not.toHaveBeenCalled();
@@ -89,13 +89,34 @@ describe('Reportes HTML', () => {
expect(print).toHaveBeenCalled();
});
it('«Generar PDF» pide el PDF al servidor y lo muestra con su descarga', async () => {
const pdf = vi.fn(async () => new Blob(['%PDF'], { type: 'application/pdf' }));
const render = vi.fn();
URL.createObjectURL = vi.fn(() => 'blob:x');
URL.revokeObjectURL = vi.fn();
setup({ reports: vi.fn(async () => [REPORT]), pdf, render }, 'etiquetas-bultos');
const fixture = TestBed.createComponent(ReportRunnerPage);
await tick(fixture);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fixture.componentInstance as any).set(params[0], '6');
const el = fixture.nativeElement as HTMLElement;
(el.querySelector('button.main') as HTMLButtonElement).click();
await tick(fixture);
expect(pdf).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' });
expect(render).not.toHaveBeenCalled();
expect(el.querySelector('iframe[title="Vista del PDF"]')).not.toBeNull();
expect(el.querySelector('a[download]')?.getAttribute('download')).toBe('etiquetas-bultos.pdf');
// la vista HTML y el PDF no conviven: al pedir uno desaparece el otro
expect(el.querySelector('iframe[title="Vista del reporte"]')).toBeNull();
});
it('un error del servidor se muestra y no deja un marco vacío', async () => {
setup({ reports: vi.fn(async () => [REPORT]), render: vi.fn(async () => { throw new Error('boom'); }) }, 'etiquetas-bultos');
const fixture = TestBed.createComponent(ReportRunnerPage);
await tick(fixture);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fixture.componentInstance as any).set(params[0], '2');
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('button.main')!.click();
[...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Ver como HTML')!.click();
await tick(fixture);
expect((fixture.nativeElement as HTMLElement).querySelector('iframe')).toBeNull();
expect((fixture.nativeElement as HTMLElement).querySelector('.status')?.textContent).toMatch(/error|boom/i);
@@ -48,7 +48,10 @@ type Value = string | readonly string[];
<iframe #htmlFrame class="pdf" title="Vista del reporte" sandbox="allow-same-origin allow-modals" [srcdoc]="doc"></iframe>
}
}
<button sboActions class="btn main" type="button" [disabled]="running() || !report()" (click)="run()">{{ running() ? 'Generando…' : action }}</button>
<button sboActions class="btn main" type="button" [disabled]="running() || !report()" (click)="run('pdf')">{{ running() ? 'Generando…' : action }}</button>
@if (isHtml) {
<button sboActions class="btn" type="button" [disabled]="running() || !report()" (click)="run('html')">Ver como HTML</button>
}
@if (htmlDoc()) {
<button sboActions class="btn" type="button" (click)="print()">Imprimir</button>
}
@@ -69,8 +72,8 @@ export class ReportRunnerPage {
private readonly api = inject(GatewayApi);
private readonly htmlApi = inject(HtmlReportsApi);
/** Reportes HTML (se imprimen desde el navegador) o del Gateway (PDF), según la ruta (`data.source`). */
private readonly isHtml = this.route.snapshot.data['source'] === 'html';
protected readonly action = this.isHtml ? 'Generar' : 'Generar PDF';
protected readonly isHtml = this.route.snapshot.data['source'] === 'html';
protected readonly action = 'Generar PDF';
private readonly htmlFrame = viewChild<ElementRef<HTMLIFrameElement>>('htmlFrame');
private readonly sanitizer = inject(DomSanitizer);
@@ -124,7 +127,8 @@ export class ReportRunnerPage {
this.htmlFrame()?.nativeElement.contentWindow?.print();
}
protected async run(): Promise<void> {
/** `pdf`: el PDF (del Gateway o, en los reportes HTML, generado por el servidor); `html`: la vista HTML para imprimir desde el navegador. */
protected async run(mode: 'pdf' | 'html'): Promise<void> {
const r = this.report();
if (!r || this.running()) return;
const values: Record<string, Value> = {};
@@ -137,13 +141,15 @@ export class ReportRunnerPage {
this.running.set(true);
this.message.set({ kind: 'info', text: 'Generando el reporte…' });
try {
if (this.isHtml) {
if (this.isHtml && mode === 'html') {
this.pdfUrl.set(null);
// el HTML lo arma nuestro servidor a partir de plantillas del proyecto (los valores ya van escapados)
this.htmlDoc.set(this.sanitizer.bypassSecurityTrustHtml(await this.htmlApi.render(r.code, values as GatewayValues)));
this.message.set({ kind: 'ok', text: 'Reporte generado. Pulse «Imprimir».' });
return;
}
const blob = await this.api.export(r.code, values as GatewayValues);
this.htmlDoc.set(null);
const blob = this.isHtml ? await this.htmlApi.pdf(r.code, values as GatewayValues) : await this.api.export(r.code, values as GatewayValues);
this.revoke();
const url = URL.createObjectURL(blob);
this.download.set({ url, name: `${r.code}.pdf` });