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:
1 parent
2d358f0cf1
commit
f71577e792
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)
|
## 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»)
|
## Ver el nombre del campo de SAP («Campos»)
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ SAPDB_TIMEOUT=30
|
|||||||
SAPDB_MAX_CONCURRENCY=2
|
SAPDB_MAX_CONCURRENCY=2
|
||||||
SAPDB_MAX_ROWS=5000
|
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.
|
**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),
|
SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000),
|
||||||
// Carpeta de las plantillas de los reportes HTML (Nunjucks), relativa a apps/api.
|
// Carpeta de las plantillas de los reportes HTML (Nunjucks), relativa a apps/api.
|
||||||
HTML_REPORTS_DIR: z.string().min(1).default('./templates'),
|
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
|
SAPB1_SL_VERIFY_SSL: z
|
||||||
.enum(['true', 'false'])
|
.enum(['true', 'false'])
|
||||||
.default('true')
|
.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 { z } from 'zod';
|
||||||
import type { AuthenticatedRequest } from '../auth/auth.types.js';
|
import type { AuthenticatedRequest } from '../auth/auth.types.js';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe.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> {
|
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 ?? '');
|
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 { AuditModule } from '../audit/audit.module.js';
|
||||||
import { PermissionsModule } from '../permissions/permissions.module.js';
|
import { PermissionsModule } from '../permissions/permissions.module.js';
|
||||||
import { SapDbModule } from '../sapdb/sapdb.module.js';
|
import { SapDbModule } from '../sapdb/sapdb.module.js';
|
||||||
|
import { HtmlPdfService } from './html-pdf.service.js';
|
||||||
import { HtmlReportsController } from './html-reports.controller.js';
|
import { HtmlReportsController } from './html-reports.controller.js';
|
||||||
import { HtmlReportsService } from './html-reports.service.js';
|
import { HtmlReportsService } from './html-reports.service.js';
|
||||||
|
|
||||||
@@ -9,6 +10,6 @@ import { HtmlReportsService } from './html-reports.service.js';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, PermissionsModule, SapDbModule],
|
imports: [AuditModule, PermissionsModule, SapDbModule],
|
||||||
controllers: [HtmlReportsController],
|
controllers: [HtmlReportsController],
|
||||||
providers: [HtmlReportsService],
|
providers: [HtmlPdfService, HtmlReportsService],
|
||||||
})
|
})
|
||||||
export class HtmlReportsModule {}
|
export class HtmlReportsModule {}
|
||||||
@@ -7,6 +7,7 @@ import { PermissionsService } from '../permissions/permissions.service.js';
|
|||||||
import { SapDbService } from '../sapdb/sapdb.service.js';
|
import { SapDbService } from '../sapdb/sapdb.service.js';
|
||||||
import { HTML_REPORTS } from './html-reports.js';
|
import { HTML_REPORTS } from './html-reports.js';
|
||||||
import { createRenderer } from './html-render.js';
|
import { createRenderer } from './html-render.js';
|
||||||
|
import { HtmlPdfService } from './html-pdf.service.js';
|
||||||
import type { HtmlReportDef, HtmlReportInfo, HtmlReportValues } from './html-reports.types.js';
|
import type { HtmlReportDef, HtmlReportInfo, HtmlReportValues } from './html-reports.types.js';
|
||||||
|
|
||||||
/** Ruta de menú que permite ejecutar los reportes HTML. */
|
/** Ruta de menú que permite ejecutar los reportes HTML. */
|
||||||
@@ -55,6 +56,7 @@ export class HtmlReportsService {
|
|||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly sapdb: SapDbService,
|
private readonly sapdb: SapDbService,
|
||||||
|
private readonly pdfService: HtmlPdfService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertCanRun(user: AuthUser): Promise<void> {
|
private async assertCanRun(user: AuthUser): Promise<void> {
|
||||||
@@ -70,21 +72,30 @@ export class HtmlReportsService {
|
|||||||
|
|
||||||
/** El HTML del reporte con los valores indicados. */
|
/** El HTML del reporte con los valores indicados. */
|
||||||
async render(user: AuthUser, code: string, raw: Readonly<Record<string, unknown>>, ip: string): Promise<string> {
|
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);
|
await this.assertCanRun(user);
|
||||||
const def = HTML_REPORTS.get(code);
|
const def = HTML_REPORTS.get(code);
|
||||||
if (!def) throw new NotFoundException(`No existe el reporte ${code}`);
|
if (!def) throw new NotFoundException(`No existe el reporte ${code}`);
|
||||||
const values = parseHtmlValues(def, raw);
|
const values = parseHtmlValues(def, raw);
|
||||||
// solo los nombres de los parámetros: los valores pueden ser datos de clientes
|
// 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 {
|
try {
|
||||||
const data = await def.data(values, { query: async (name, params) => (await this.sapdb.run(user, name, params ?? {}, ip)).rows });
|
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'));
|
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 });
|
await this.audit.record({ action: 'html_report', webUser: user.webUser, ip, resource: code, detail });
|
||||||
return html;
|
return out;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(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) } });
|
await this.audit.record({ action: 'html_report', webUser: user.webUser, ip, resource: code, outcome: 'error', detail: { ...detail, error: message.slice(0, 300) } });
|
||||||
throw e;
|
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 { AppConfigService } from '../config/app-config.service.js';
|
||||||
import type { PermissionsService } from '../permissions/permissions.service.js';
|
import type { PermissionsService } from '../permissions/permissions.service.js';
|
||||||
import type { SapDbService } from '../sapdb/sapdb.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 { createRenderer } from './html-render.js';
|
||||||
import { validateHtmlReports } from './html-reports.js';
|
import { validateHtmlReports } from './html-reports.js';
|
||||||
import type { HtmlReportDef } from './html-reports.types.js';
|
import type { HtmlReportDef } from './html-reports.types.js';
|
||||||
@@ -88,18 +89,21 @@ describe('servicio de reportes HTML', () => {
|
|||||||
let paths: string[];
|
let paths: string[];
|
||||||
const run = vi.fn(async () => ({ rows: [{ CardName: 'ACME' }], truncated: false }));
|
const run = vi.fn(async () => ({ rows: [{ CardName: 'ACME' }], truncated: false }));
|
||||||
const record = vi.fn(async () => undefined);
|
const record = vi.fn(async () => undefined);
|
||||||
|
const toPdf = vi.fn(async (html: string) => Buffer.from(`%PDF ${html.length}`));
|
||||||
const service = () =>
|
const service = () =>
|
||||||
new HtmlReportsService(
|
new HtmlReportsService(
|
||||||
{ get: () => dir } as unknown as AppConfigService,
|
{ get: () => dir } as unknown as AppConfigService,
|
||||||
{ getPaths: async () => paths } as unknown as PermissionsService,
|
{ getPaths: async () => paths } as unknown as PermissionsService,
|
||||||
{ record } as unknown as AuditService,
|
{ record } as unknown as AuditService,
|
||||||
{ run } as unknown as SapDbService,
|
{ run } as unknown as SapDbService,
|
||||||
|
{ toPdf } as unknown as HtmlPdfService,
|
||||||
);
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
paths = ['*'];
|
paths = ['*'];
|
||||||
run.mockClear();
|
run.mockClear();
|
||||||
record.mockClear();
|
record.mockClear();
|
||||||
|
toPdf.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lista los reportes con sus parámetros, sin la plantilla ni el código de datos', async () => {
|
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('<p>ACME</p>');
|
||||||
expect(html).toContain('<i>3/3</i>');
|
expect(html).toContain('<i>3/3</i>');
|
||||||
expect(run).toHaveBeenCalledWith(user, 'guia', { docEntry: 144153 }, '10.0.0.5');
|
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 () => {
|
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`));
|
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. */
|
/** El HTML del reporte con los valores escritos. */
|
||||||
async render(code: string, values: GatewayValues): Promise<string> {
|
async render(code: string, values: GatewayValues): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ describe('Reportes HTML', () => {
|
|||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
expect(el.textContent).toContain('Cantidad de etiquetas');
|
expect(el.textContent).toContain('Cantidad de etiquetas');
|
||||||
expect(el.querySelector('button.main')?.textContent).toContain('Generar');
|
expect(el.querySelector('button.main')?.textContent).toContain('Generar PDF');
|
||||||
expect(el.querySelector('button.main')?.textContent).not.toContain('PDF');
|
const htmlButton = () => [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Ver como HTML') as HTMLButtonElement;
|
||||||
|
|
||||||
// falta un obligatorio: no se genera
|
// falta un obligatorio: no se genera
|
||||||
(el.querySelector('button.main') as HTMLButtonElement).click();
|
htmlButton().click();
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
expect(render).not.toHaveBeenCalled();
|
expect(render).not.toHaveBeenCalled();
|
||||||
expect(el.querySelector('.status')?.textContent).toContain('Falta indicar: Cantidad de etiquetas');
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(fixture.componentInstance as any).set(params[0], '6');
|
(fixture.componentInstance as any).set(params[0], '6');
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
(el.querySelector('button.main') as HTMLButtonElement).click();
|
htmlButton().click();
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
expect(render).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' });
|
expect(render).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' });
|
||||||
expect(gateway.export).not.toHaveBeenCalled();
|
expect(gateway.export).not.toHaveBeenCalled();
|
||||||
@@ -89,13 +89,34 @@ describe('Reportes HTML', () => {
|
|||||||
expect(print).toHaveBeenCalled();
|
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 () => {
|
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');
|
setup({ reports: vi.fn(async () => [REPORT]), render: vi.fn(async () => { throw new Error('boom'); }) }, 'etiquetas-bultos');
|
||||||
const fixture = TestBed.createComponent(ReportRunnerPage);
|
const fixture = TestBed.createComponent(ReportRunnerPage);
|
||||||
await tick(fixture);
|
await tick(fixture);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(fixture.componentInstance as any).set(params[0], '2');
|
(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);
|
await tick(fixture);
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('iframe')).toBeNull();
|
expect((fixture.nativeElement as HTMLElement).querySelector('iframe')).toBeNull();
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.status')?.textContent).toMatch(/error|boom/i);
|
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>
|
<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()) {
|
@if (htmlDoc()) {
|
||||||
<button sboActions class="btn" type="button" (click)="print()">Imprimir</button>
|
<button sboActions class="btn" type="button" (click)="print()">Imprimir</button>
|
||||||
}
|
}
|
||||||
@@ -69,8 +72,8 @@ export class ReportRunnerPage {
|
|||||||
private readonly api = inject(GatewayApi);
|
private readonly api = inject(GatewayApi);
|
||||||
private readonly htmlApi = inject(HtmlReportsApi);
|
private readonly htmlApi = inject(HtmlReportsApi);
|
||||||
/** Reportes HTML (se imprimen desde el navegador) o del Gateway (PDF), según la ruta (`data.source`). */
|
/** 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 isHtml = this.route.snapshot.data['source'] === 'html';
|
||||||
protected readonly action = this.isHtml ? 'Generar' : 'Generar PDF';
|
protected readonly action = 'Generar PDF';
|
||||||
private readonly htmlFrame = viewChild<ElementRef<HTMLIFrameElement>>('htmlFrame');
|
private readonly htmlFrame = viewChild<ElementRef<HTMLIFrameElement>>('htmlFrame');
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
@@ -124,7 +127,8 @@ export class ReportRunnerPage {
|
|||||||
this.htmlFrame()?.nativeElement.contentWindow?.print();
|
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();
|
const r = this.report();
|
||||||
if (!r || this.running()) return;
|
if (!r || this.running()) return;
|
||||||
const values: Record<string, Value> = {};
|
const values: Record<string, Value> = {};
|
||||||
@@ -137,13 +141,15 @@ export class ReportRunnerPage {
|
|||||||
this.running.set(true);
|
this.running.set(true);
|
||||||
this.message.set({ kind: 'info', text: 'Generando el reporte…' });
|
this.message.set({ kind: 'info', text: 'Generando el reporte…' });
|
||||||
try {
|
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)
|
// 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.htmlDoc.set(this.sanitizer.bypassSecurityTrustHtml(await this.htmlApi.render(r.code, values as GatewayValues)));
|
||||||
this.message.set({ kind: 'ok', text: 'Reporte generado. Pulse «Imprimir».' });
|
this.message.set({ kind: 'ok', text: 'Reporte generado. Pulse «Imprimir».' });
|
||||||
return;
|
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();
|
this.revoke();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
this.download.set({ url, name: `${r.code}.pdf` });
|
this.download.set({ url, name: `${r.code}.pdf` });
|
||||||
|
|||||||
Reference in new issue
Block a user