diff --git a/CLAUDE.md b/CLAUDE.md index 9aefd18..4d478b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` (`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('/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//` (`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('/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. ## TAGUMEDICA: etiquetas de bultos (solo en `cliente/tagumedica`) diff --git a/apps/api/.env.example b/apps/api/.env.example index 941ad4b..f7af286 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 + diff --git a/apps/api/README.md b/apps/api/README.md index 39584e5..840cf69 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -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. + diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index 92abb69..50ec138 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -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') diff --git a/apps/api/src/htmlreports/html-pdf.service.ts b/apps/api/src/htmlreports/html-pdf.service.ts new file mode 100644 index 0000000..8c55579 --- /dev/null +++ b/apps/api/src/htmlreports/html-pdf.service.ts @@ -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(work: () => Promise): Promise { + if (this.active >= this.config.get('HTML_PDF_MAX_CONCURRENCY')) await new Promise((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 { + 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 { + 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 }); + } + }); + } +} diff --git a/apps/api/src/htmlreports/html-pdf.spec.ts b/apps/api/src/htmlreports/html-pdf.spec.ts new file mode 100644 index 0000000..a41e71b --- /dev/null +++ b/apps/api/src/htmlreports/html-pdf.spec.ts @@ -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 = {}) => ({ 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 { + 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('

x

')).rejects.toThrow(ServiceUnavailableException); + await expect(s.toPdf('

x

')).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('

hola

'); + 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 }, '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('

Hola

2

'); + 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); +}); diff --git a/apps/api/src/htmlreports/html-reports.controller.ts b/apps/api/src/htmlreports/html-reports.controller.ts index 5abb74a..895dd47 100644 --- a/apps/api/src/htmlreports/html-reports.controller.ts +++ b/apps/api/src/htmlreports/html-reports.controller.ts @@ -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 { 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"` }); + } } diff --git a/apps/api/src/htmlreports/html-reports.module.ts b/apps/api/src/htmlreports/html-reports.module.ts index b35f1e6..04dd6a3 100644 --- a/apps/api/src/htmlreports/html-reports.module.ts +++ b/apps/api/src/htmlreports/html-reports.module.ts @@ -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 {} diff --git a/apps/api/src/htmlreports/html-reports.service.ts b/apps/api/src/htmlreports/html-reports.service.ts index 20c2430..a07a187 100644 --- a/apps/api/src/htmlreports/html-reports.service.ts +++ b/apps/api/src/htmlreports/html-reports.service.ts @@ -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 { @@ -70,21 +72,30 @@ export class HtmlReportsService { /** El HTML del reporte con los valores indicados. */ async render(user: AuthUser, code: string, raw: Readonly>, ip: string): Promise { + 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>, ip: string): Promise { + return this.run(user, code, raw, ip, 'pdf', (html) => this.pdfService.toPdf(html)); + } + + private async run(user: AuthUser, code: string, raw: Readonly>, ip: string, format: 'html' | 'pdf', finish: (html: string) => T | Promise): Promise { 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; } diff --git a/apps/api/src/htmlreports/html-reports.spec.ts b/apps/api/src/htmlreports/html-reports.spec.ts index 899091a..381325d 100644 --- a/apps/api/src/htmlreports/html-reports.spec.ts +++ b/apps/api/src/htmlreports/html-reports.spec.ts @@ -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('

ACME

'); expect(html).toContain('3/3'); 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('

ACME

')); + 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 () => { diff --git a/apps/web/src/app/features/reports/html-reports-api.ts b/apps/web/src/app/features/reports/html-reports-api.ts index 50445ed..249adca 100644 --- a/apps/web/src/app/features/reports/html-reports-api.ts +++ b/apps/web/src/app/features/reports/html-reports-api.ts @@ -13,6 +13,11 @@ export class HtmlReportsApi { return firstValueFrom(this.http.get(`${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 { + 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 { try { diff --git a/apps/web/src/app/features/reports/html-reports.spec.ts b/apps/web/src/app/features/reports/html-reports.spec.ts index bd0edcd..6d845f5 100644 --- a/apps/web/src/app/features/reports/html-reports.spec.ts +++ b/apps/web/src/app/features/reports/html-reports.spec.ts @@ -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('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); diff --git a/apps/web/src/app/features/reports/report-runner.page.ts b/apps/web/src/app/features/reports/report-runner.page.ts index ff4eceb..fc2e145 100644 --- a/apps/web/src/app/features/reports/report-runner.page.ts +++ b/apps/web/src/app/features/reports/report-runner.page.ts @@ -48,7 +48,10 @@ type Value = string | readonly string[]; } } - + + @if (isHtml) { + + } @if (htmlDoc()) { } @@ -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>('htmlFrame'); private readonly sanitizer = inject(DomSanitizer); @@ -124,7 +127,8 @@ export class ReportRunnerPage { this.htmlFrame()?.nativeElement.contentWindow?.print(); } - protected async run(): Promise { + /** `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 { const r = this.report(); if (!r || this.running()) return; const values: Record = {}; @@ -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` });