2026-10-20 16:20:53 -05:00
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' ;
2026-10-20 16:53:24 -05:00
import { HtmlPdfService } from './html-pdf.service.js' ;
2026-10-20 16:20:53 -05:00
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 ];
2026-10-20 16:57:33 -05:00
// 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 ) : '' ;
2026-10-20 16:20:53 -05:00
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 ,
2026-10-20 16:53:24 -05:00
private readonly pdfService : HtmlPdfService ,
2026-10-20 16:20:53 -05:00
) {}
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 > {
2026-10-20 16:53:24 -05:00
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 > {
2026-10-20 16:20:53 -05:00
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
2026-10-20 16:53:24 -05:00
const detail = { format , params : def.params.map (( p ) => p . name ) };
2026-10-20 16:20:53 -05:00
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' ));
2026-10-20 16:53:24 -05:00
const out = await finish ( this . renderer . render ( def . template , { ... data , params : values }));
2026-10-20 16:20:53 -05:00
await this . audit . record ({ action : 'html_report' , webUser : user.webUser , ip , resource : code , detail });
2026-10-20 16:53:24 -05:00
return out ;
2026-10-20 16:20:53 -05:00
} catch ( e ) {
const message = e instanceof Error ? e.message : String ( e );
2026-10-20 16:53:24 -05:00
this . logger . error ( `Reporte HTML ${ code } ( ${ format } ) falló: ${ message } ` );
2026-10-20 16:20:53 -05:00
await this . audit . record ({ action : 'html_report' , webUser : user.webUser , ip , resource : code , outcome : 'error' , detail : { ... detail , error : message.slice ( 0 , 300 ) } });
throw e ;
}
}
}