Merge branch 'main' into cliente/tagumedica
This commit is contained in:
commit
63dc9b10ca
25 files changed
+725
-23
No files matched your search
@@ -4,6 +4,7 @@ import { AuthModule } from './auth/auth.module.js';
|
||||
import { AppConfigModule } from './config/config.module.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { GatewayModule } from './gateway/gateway.module.js';
|
||||
import { HtmlReportsModule } from './htmlreports/html-reports.module.js';
|
||||
import { SapDbModule } from './sapdb/sapdb.module.js';
|
||||
import { HealthController } from './health/health.controller.js';
|
||||
import { PermissionsModule } from './permissions/permissions.module.js';
|
||||
@@ -11,7 +12,7 @@ import { SapModule } from './sap/sap.module.js';
|
||||
import { SlModule } from './sl/sl.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule],
|
||||
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule, HtmlReportsModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -88,6 +88,8 @@ export const envSchema = z
|
||||
SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
|
||||
// Tope de filas de cualquier consulta; lo que pase se recorta.
|
||||
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'),
|
||||
SAPB1_SL_VERIFY_SSL: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { extname, relative, resolve } from 'node:path';
|
||||
import nunjucks from 'nunjucks';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
|
||||
/**
|
||||
* Motor de plantillas de los reportes HTML (Nunjucks, la misma sintaxis de Jinja2: `{{ x }}`, `{% for %}`, `{% if %}`).
|
||||
* Escapa el HTML de los valores. La función `asset('fonts/Orbitron-Bold.ttf')` entrega un archivo de la carpeta de plantillas
|
||||
* (fuente, logo) como `data:` URI, para que el reporte sea un solo documento que se imprime igual desde cualquier lugar.
|
||||
*/
|
||||
export function createRenderer(dir: string): { render(template: string, context: Record<string, unknown>): string } {
|
||||
const root = resolve(dir);
|
||||
const env = new nunjucks.Environment(new nunjucks.FileSystemLoader(root, { noCache: process.env['NODE_ENV'] !== 'production' }), { autoescape: true, throwOnUndefined: false });
|
||||
env.addGlobal('asset', (path: string): string => {
|
||||
const file = resolve(root, path);
|
||||
// solo archivos de la carpeta de plantillas
|
||||
if (relative(root, file).startsWith('..') || !existsSync(file)) throw new Error(`No existe el archivo «${path}» en la carpeta de plantillas`);
|
||||
const mime = MIME[extname(file).toLowerCase()];
|
||||
if (!mime) throw new Error(`Tipo de archivo no admitido en asset(): ${path}`);
|
||||
return `data:${mime};base64,${readFileSync(file).toString('base64')}`;
|
||||
});
|
||||
return { render: (template, context) => env.render(template, context) };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, Header, HttpCode, Param, Post, Req } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types.js';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
|
||||
import { HtmlReportsService } from './html-reports.service.js';
|
||||
|
||||
const codeSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/, 'Código de reporte inválido');
|
||||
const bodySchema = z.object({ values: z.record(z.string(), z.union([z.string().max(500), z.number()])).default({}) });
|
||||
type RenderDto = z.infer<typeof bodySchema>;
|
||||
|
||||
/** Reportes HTML (plantillas que se imprimen desde el navegador): lista y ejecución, con el permiso de menú «Reportes HTML». */
|
||||
@Controller('html-reports')
|
||||
export class HtmlReportsController {
|
||||
constructor(private readonly reports: HtmlReportsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Req() req: AuthenticatedRequest) {
|
||||
return this.reports.list(req.user);
|
||||
}
|
||||
|
||||
@Post(':code/render')
|
||||
@HttpCode(200)
|
||||
@Header('Content-Type', 'text/html; charset=utf-8')
|
||||
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 ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { HtmlReportDef } from './html-reports.types.js';
|
||||
|
||||
/**
|
||||
* Reportes HTML que agrega la rama de un cliente (vacío en `main`): cada uno con su plantilla Nunjucks (en la carpeta de
|
||||
* plantillas del cliente), sus parámetros y la función que arma sus datos con consultas registradas de `sapdb`.
|
||||
*/
|
||||
export const CLIENT_HTML_REPORTS: readonly HtmlReportDef[] = [];
|
||||
@@ -0,0 +1,14 @@
|
||||
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 { HtmlReportsController } from './html-reports.controller.js';
|
||||
import { HtmlReportsService } from './html-reports.service.js';
|
||||
|
||||
/** Reportes HTML: plantillas Nunjucks rellenas con datos de SAP, que se imprimen desde el navegador. */
|
||||
@Module({
|
||||
imports: [AuditModule, PermissionsModule, SapDbModule],
|
||||
controllers: [HtmlReportsController],
|
||||
providers: [HtmlReportsService],
|
||||
})
|
||||
export class HtmlReportsModule {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import type { GatewayParam } from '@basic-erp/sap-types';
|
||||
import { vi } from 'vitest';
|
||||
import type { AuditService } from '../audit/audit.service.js';
|
||||
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 { createRenderer } from './html-render.js';
|
||||
import { validateHtmlReports } from './html-reports.js';
|
||||
import type { HtmlReportDef } from './html-reports.types.js';
|
||||
|
||||
// el registro que usa el servicio (el de `main` está vacío): se llena con el reporte de prueba
|
||||
const registry = vi.hoisted(() => new Map<string, unknown>());
|
||||
vi.mock('./html-reports.js', async (orig) => ({ ...(await orig<typeof import('./html-reports.js')>()), HTML_REPORTS: registry }));
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'plantillas-'));
|
||||
mkdirSync(join(dir, 'c'));
|
||||
writeFileSync(join(dir, 'c', 'etiquetas.njk'), '<p>{{ cliente }}</p>{% for n in numeros %}<i>{{ n }}/{{ params.cantidad }}</i>{% endfor %}<style>@font-face{src:url("{{ asset(\'c/f.ttf\') }}")}</style>');
|
||||
writeFileSync(join(dir, 'c', 'f.ttf'), 'FUENTE');
|
||||
writeFileSync(join(dir, 'secreto.txt'), 'no');
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const P = (over: Partial<GatewayParam> & Pick<GatewayParam, 'name'>): GatewayParam => ({ type: 'xsd:string', label: over.name, kind: 'text', required: false, ...over });
|
||||
const def: HtmlReportDef = {
|
||||
code: 'etiquetas',
|
||||
title: 'Etiquetas',
|
||||
category: 'Ventas',
|
||||
params: [P({ name: 'doc', label: 'Entrega', kind: 'lookup', required: true, lookup: 'doc:DeliveryNotes' }), P({ name: 'cantidad', label: 'Cantidad', kind: 'number', required: true }), P({ name: 'papel', label: 'Papel', kind: 'select', options: [{ value: 'Carta', label: 'Carta' }, { value: 'A4', label: 'A4' }], defaultValue: 'Carta' }), P({ name: 'fecha', label: 'Fecha', kind: 'date' })],
|
||||
template: 'c/etiquetas.njk',
|
||||
data: async (v, ctx) => {
|
||||
const rows = await ctx.query('guia', { docEntry: Number(v['doc']) });
|
||||
return { cliente: String(rows[0]?.['CardName']), numeros: Array.from({ length: Number(v['cantidad']) }, (_, i) => i + 1) };
|
||||
},
|
||||
};
|
||||
|
||||
registry.set(def.code, def);
|
||||
const { HtmlReportsService, parseHtmlValues } = await import('./html-reports.service.js');
|
||||
|
||||
describe('registro de reportes HTML', () => {
|
||||
it('rechaza códigos repetidos o inválidos, parámetros repetidos y plantillas fuera de la carpeta', () => {
|
||||
expect(() => validateHtmlReports([def, def])).toThrow(/repetido/);
|
||||
expect(() => validateHtmlReports([{ ...def, code: 'Mal Código' }])).toThrow(/inválido/);
|
||||
expect(() => validateHtmlReports([{ ...def, params: [P({ name: 'a' }), P({ name: 'a' })] }])).toThrow(/repite el parámetro/);
|
||||
expect(() => validateHtmlReports([{ ...def, template: '../fuera.njk' }])).toThrow(/dentro de la carpeta/);
|
||||
expect(() => validateHtmlReports([{ ...def, template: '/etc/passwd' }])).toThrow(/dentro de la carpeta/);
|
||||
expect(validateHtmlReports([def])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valores de los parámetros', () => {
|
||||
it('exige los obligatorios, aplica el valor por defecto y comprueba tipos', () => {
|
||||
expect(parseHtmlValues(def, { doc: '144153', cantidad: 6 })).toEqual({ doc: '144153', cantidad: '6', papel: 'Carta', fecha: '' });
|
||||
expect(() => parseHtmlValues(def, { cantidad: '6' })).toThrow(/Falta indicar: Entrega/);
|
||||
expect(() => parseHtmlValues(def, { doc: '1', cantidad: 'x' })).toThrow(/debe ser un número/);
|
||||
expect(() => parseHtmlValues(def, { doc: '1', cantidad: '2', fecha: '20/10/2026' })).toThrow(/yyyy-MM-dd/);
|
||||
expect(() => parseHtmlValues(def, { doc: '1', cantidad: '2', papel: 'Oficio' })).toThrow(/opción válida/);
|
||||
expect(() => parseHtmlValues(def, { doc: '1', cantidad: '2', otro: 'x' })).toThrow(/desconocidos/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plantillas', () => {
|
||||
const r = createRenderer(dir);
|
||||
|
||||
it('rellena la plantilla y escapa el HTML de los valores', () => {
|
||||
const html = r.render('c/etiquetas.njk', { cliente: '<b>ACME & Cía</b>', numeros: [1, 2], params: { cantidad: '2' } });
|
||||
expect(html).toContain('<p><b>ACME & Cía</b></p>');
|
||||
expect(html).toContain('<i>1/2</i><i>2/2</i>');
|
||||
});
|
||||
|
||||
it('asset() entrega un archivo de la carpeta como data: URI', () => {
|
||||
expect(r.render('c/etiquetas.njk', { cliente: 'x', numeros: [], params: {} })).toContain(`data:font/ttf;base64,${Buffer.from('FUENTE').toString('base64')}`);
|
||||
});
|
||||
|
||||
it('asset() no sale de la carpeta de plantillas ni entrega tipos que no son de recursos', () => {
|
||||
writeFileSync(join(dir, 'malo.njk'), "{{ asset('../etc/passwd') }}");
|
||||
writeFileSync(join(dir, 'malo2.njk'), "{{ asset('secreto.txt') }}");
|
||||
expect(() => r.render('malo.njk', {})).toThrow(/No existe el archivo/);
|
||||
expect(() => r.render('malo2.njk', {})).toThrow(/Tipo de archivo no admitido/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('servicio de reportes HTML', () => {
|
||||
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
|
||||
let paths: string[];
|
||||
const run = vi.fn(async () => ({ rows: [{ CardName: 'ACME' }], truncated: false }));
|
||||
const record = vi.fn(async () => undefined);
|
||||
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,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
paths = ['*'];
|
||||
run.mockClear();
|
||||
record.mockClear();
|
||||
});
|
||||
|
||||
it('lista los reportes con sus parámetros, sin la plantilla ni el código de datos', async () => {
|
||||
const list = await service().list(user);
|
||||
expect(list).toEqual([{ code: 'etiquetas', title: 'Etiquetas', category: 'Ventas', enabled: true, params: def.params }]);
|
||||
expect(list[0]).not.toHaveProperty('template');
|
||||
});
|
||||
|
||||
it('genera el HTML con los datos de la consulta y audita solo los nombres de los parámetros', async () => {
|
||||
const html = await service().render(user, 'etiquetas', { doc: '144153', cantidad: '3' }, '10.0.0.5');
|
||||
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'] } }));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
paths = ['/ventas/*'];
|
||||
await expect(service().list(user)).rejects.toThrow(ForbiddenException);
|
||||
await expect(service().render(user, 'etiquetas', { doc: '1', cantidad: '1' }, '')).rejects.toThrow(ForbiddenException);
|
||||
paths = ['/reportes-html/ejecutar'];
|
||||
await expect(service().render(user, 'nada', {}, '')).rejects.toThrow(NotFoundException);
|
||||
await expect(service().render(user, 'etiquetas', { doc: '1' }, '')).rejects.toThrow(BadRequestException);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('un error al armar los datos se audita y se propaga', async () => {
|
||||
run.mockRejectedValueOnce(new Error('tabla inexistente'));
|
||||
await expect(service().render(user, 'etiquetas', { doc: '1', cantidad: '1' }, '')).rejects.toThrow('tabla inexistente');
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'error' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CLIENT_HTML_REPORTS } from './html-reports.extensions.js';
|
||||
import type { HtmlReportDef } from './html-reports.types.js';
|
||||
|
||||
/** Reportes del proyecto base: ninguno (cada cliente trae los suyos). */
|
||||
export const BASE_HTML_REPORTS: readonly HtmlReportDef[] = [];
|
||||
|
||||
/** Comprueba códigos únicos, parámetros sin repetir y plantilla dentro de la carpeta de plantillas. Lanza si no. */
|
||||
export function validateHtmlReports(reports: readonly HtmlReportDef[]): readonly HtmlReportDef[] {
|
||||
const seen = new Set<string>();
|
||||
for (const r of reports) {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,59}$/.test(r.code)) throw new Error(`Código de reporte HTML inválido: ${r.code}`);
|
||||
if (seen.has(r.code)) throw new Error(`Reporte HTML repetido: ${r.code}`);
|
||||
seen.add(r.code);
|
||||
if (!r.template || r.template.startsWith('/') || r.template.split('/').includes('..')) throw new Error(`La plantilla de ${r.code} debe estar dentro de la carpeta de plantillas`);
|
||||
const names = new Set<string>();
|
||||
for (const p of r.params) {
|
||||
if (names.has(p.name)) throw new Error(`El reporte ${r.code} repite el parámetro ${p.name}`);
|
||||
names.add(p.name);
|
||||
}
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
export const HTML_REPORTS: ReadonlyMap<string, HtmlReportDef> = new Map(validateHtmlReports([...BASE_HTML_REPORTS, ...CLIENT_HTML_REPORTS]).map((r) => [r.code, r]));
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { GatewayParam } from '@basic-erp/sap-types';
|
||||
import type { SapDbRow } from '../sapdb/sapdb.types.js';
|
||||
|
||||
/** Lo que un reporte HTML puede pedir mientras arma sus datos. */
|
||||
export interface HtmlReportContext {
|
||||
/** Ejecuta una consulta registrada de la base de SAP (`sapdb`); necesita que su permiso de menú incluya `/reportes-html/ejecutar`. */
|
||||
query(name: string, params?: Record<string, unknown>): Promise<SapDbRow[]>;
|
||||
}
|
||||
|
||||
/** Los valores ya validados de los parámetros, por nombre (siempre texto; los vacíos opcionales vienen como `''`). */
|
||||
export type HtmlReportValues = Readonly<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Un reporte HTML: una plantilla (Nunjucks) que se rellena con los datos que arma `data` y se imprime desde el navegador.
|
||||
* Los parámetros se piden con los mismos controles que los reportes del Gateway (texto, número, fecha, lista fija o
|
||||
* ventana de selección de documentos, socios, artículos…).
|
||||
*/
|
||||
export interface HtmlReportDef {
|
||||
/** Identificador estable (`etiquetas-bultos`); es lo que viaja en la URL y en la auditoría. */
|
||||
code: string;
|
||||
title: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
params: readonly GatewayParam[];
|
||||
/** Plantilla dentro de la carpeta de plantillas (`HTML_REPORTS_DIR`), por ejemplo `tagumedica/etiquetas.njk`. */
|
||||
template: string;
|
||||
/** Arma lo que ve la plantilla a partir de los valores de los parámetros. */
|
||||
data(values: HtmlReportValues, ctx: HtmlReportContext): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Lo que el frontend ve de un reporte (sin la plantilla ni el código que arma los datos). */
|
||||
export interface HtmlReportInfo {
|
||||
code: string;
|
||||
title: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
enabled: true;
|
||||
params: readonly GatewayParam[];
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { SAPDB_ADAPTER, type SapDbAdapter } from './sapdb.types.js';
|
||||
@Module({
|
||||
imports: [AuditModule, PermissionsModule],
|
||||
controllers: [SapDbController],
|
||||
exports: [SapDbService],
|
||||
providers: [
|
||||
{
|
||||
provide: SAPDB_ADAPTER,
|
||||
|
||||
Reference in new issue
Block a user