import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { isPathAllowed } from '@basic-erp/sap-types'; import { AuditService } from '../audit/audit.service.js'; import type { AuthUser } from '../auth/auth.types.js'; import { AppConfigService } from '../config/app-config.service.js'; 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. */ export const HTML_REPORTS_PATH = '/reportes-html/ejecutar'; const DATE = /^\d{4}-\d{2}-\d{2}$/; /** Los valores de los parámetros: tipo comprobado, obligatorios exigidos, valor por defecto si está vacío y sin nombres de más. */ export function parseHtmlValues(def: HtmlReportDef, raw: Readonly>): HtmlReportValues { const known = new Set(def.params.map((p) => p.name)); const extra = Object.keys(raw).filter((k) => !known.has(k)); if (extra.length) throw new BadRequestException(`Parámetros desconocidos: ${extra.join(', ')}`); const out: Record = {}; const missing: string[] = []; for (const p of def.params) { const v = raw[p.name]; // un parámetro de solo lectura vale siempre su valor por defecto, escriba lo que escriba el cliente let text = p.readOnly ? '' : typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : ''; if (text === '') text = p.defaultValue ?? ''; if (text === '') { if (p.required) missing.push(p.label); out[p.name] = ''; continue; } if (text.length > 200) throw new BadRequestException(`El parámetro «${p.label}» es demasiado largo`); if (p.kind === 'number' && !Number.isFinite(Number(text))) throw new BadRequestException(`«${p.label}» debe ser un número`); if (p.kind === 'date' && !DATE.test(text)) throw new BadRequestException(`«${p.label}» debe ser una fecha yyyy-MM-dd`); if (p.kind === 'select' && p.options?.length && !p.options.some((o) => o.value === text)) throw new BadRequestException(`«${p.label}» no es una opción válida`); out[p.name] = text; } if (missing.length) throw new BadRequestException(`Falta indicar: ${missing.join(', ')}`); return out; } /** * Reportes HTML: plantillas Nunjucks que se rellenan con datos de la base de SAP y se imprimen desde el navegador. Lo que * hay lo define la rama de cada cliente (`html-reports.extensions.ts`); quien los ejecuta necesita el permiso de menú * «Reportes HTML». */ @Injectable() export class HtmlReportsService { private readonly logger = new Logger(HtmlReportsService.name); private renderer?: ReturnType; constructor( private readonly config: AppConfigService, private readonly permissions: PermissionsService, private readonly audit: AuditService, private readonly sapdb: SapDbService, private readonly pdfService: HtmlPdfService, ) {} private async assertCanRun(user: AuthUser): Promise { if (!isPathAllowed(await this.permissions.getPaths(user.webUser), HTML_REPORTS_PATH)) throw new ForbiddenException('No tiene permisos para ejecutar reportes HTML'); } async list(user: AuthUser): Promise { await this.assertCanRun(user); return [...HTML_REPORTS.values()] .map(({ code, title, category, description, params }) => ({ code, title, category, ...(description ? { description } : {}), enabled: true as const, params })) .sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title)); } /** El HTML del reporte con los valores indicados. */ async render(user: AuthUser, code: string, raw: Readonly>, ip: string): Promise { return this.run(user, code, raw, ip, 'html', (html) => html); } /** El PDF del reporte: el mismo HTML pasado por Chrome sin pantalla, con el papel y los márgenes de la plantilla. */ async pdf(user: AuthUser, code: string, raw: Readonly>, ip: string): Promise { return this.run(user, code, raw, ip, 'pdf', (html) => this.pdfService.toPdf(html)); } private async run(user: AuthUser, code: string, raw: Readonly>, ip: string, format: 'html' | 'pdf', finish: (html: string) => T | Promise): Promise { await this.assertCanRun(user); const def = HTML_REPORTS.get(code); if (!def) throw new NotFoundException(`No existe el reporte ${code}`); const values = parseHtmlValues(def, raw); // solo los nombres de los parámetros: los valores pueden ser datos de clientes const detail = { 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 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 out; } catch (e) { const message = e instanceof Error ? e.message : String(e); 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; } } }