feat: menú Reportes HTML (motor de plantillas Nunjucks para reportes que se imprimen desde el navegador) y menú lateral comprimido al iniciar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 16:20:53 -05:00
1 parent a918d0a109
commit 2d358f0cf1
25 files changed
+725 -23

No files matched your search

@@ -0,0 +1,92 @@
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 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<Record<string, unknown>>): 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<string, string> = {};
const missing: string[] = [];
for (const p of def.params) {
const v = raw[p.name];
let text = 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<typeof createRenderer>;
constructor(
private readonly config: AppConfigService,
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
private readonly sapdb: SapDbService,
) {}
private async assertCanRun(user: AuthUser): Promise<void> {
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<HtmlReportInfo[]> {
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<Record<string, unknown>>, ip: string): Promise<string> {
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) };
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 });
await this.audit.record({ action: 'html_report', webUser: user.webUser, ip, resource: code, detail });
return html;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Reporte HTML ${code} 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;
}
}
}