Merge branch 'main' into cliente/tagumedica
This commit is contained in:
commit
0534becc9d
13 files changed
+287
-19
No files matched your search
@@ -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.
|
||||
|
||||
## TAGUMEDICA: etiquetas de bultos (solo en `cliente/tagumedica`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` });
|
||||
|
||||
Reference in new issue
Block a user