Merge branch 'main' into cliente/tagumedica
This commit is contained in:
commit
0534becc9d
13 files changed
+287
-19
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user