diff --git a/CLAUDE.md b/CLAUDE.md index 1882d42..0a08150 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestr ## Criterios de la interfaz -Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~298 kB, 85 kB comprimido; poco margen: lo nuevo que no sea del arranque va en diferido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). El menú lateral se oculta con el botón ☰ (en pantallas anchas gana ancho para formularios y tablas y se recuerda en `localStorage` `erp_sidebar`; en estrechas se abre sobre el contenido); cada módulo además se pliega por su cuenta. +Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~298 kB, 85 kB comprimido; poco margen: lo nuevo que no sea del arranque va en diferido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). El menú lateral **arranca comprimido** (se despliega el módulo del lugar donde se está al entrar por una ruta directa) y se oculta con el botón ☰ (en pantallas anchas gana ancho para formularios y tablas y se recuerda en `localStorage` `erp_sidebar`; en estrechas se abre sobre el contenido); cada módulo además se pliega por su cuenta. ## Comandos @@ -58,6 +58,10 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): ejecuta consultas **registradas** de solo lectura directamente en la base de SAP, para lo que el Service Layer no da bien (por ejemplo, el stock de un lote por almacén o el almacén de origen de un traslado). Hoy el motor es **HANA** (`hdb`, JavaScript puro); la interfaz `SapDbAdapter` deja listo sumar **MSSQL** (SAP10 de pruebas) con otro adaptador, y cada consulta declara su SQL por motor. En `main` solo existe `ping`; **las consultas de negocio van en la rama de cada cliente** (`sapdb.extensions.ts`, vacío en `main`) porque dependen de sus tablas y campos de usuario. El frontend nunca manda SQL: pide la consulta por nombre con parámetros tipados (`POST /api/sapdb/queries/:name`); el SQL usa `:nombre` y el servidor lo cambia por `?` (nunca se concatena). Al arrancar se rechaza una consulta que no sea un único `SELECT`/`WITH`. Cola propia (`SAPDB_MAX_CONCURRENCY` 2), tope de filas y tiempo máximo; auditoría `db_query` (nombres, no valores). Variables `SAPDB_*` en el `.env`. Verificado contra `SBO_TAGUMEDICA_21` (conexión, esquema y parámetro). **Aviso:** el `.env` del mini-erp usa el usuario `SYSTEM` de HANA (superusuario): conviene un usuario de la base que solo pueda leer; el módulo igualmente solo ejecuta los `SELECT` registrados. +## Reportes HTML (plantillas por cliente) + +Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente define como plantillas **Nunjucks** (la sintaxis de Jinja2: `{{ x }}`, `{% for %}`) rellenadas con datos de la base de SAP y que se **imprimen desde el navegador**. El motor es genérico y vive en `main` (`apps/api/src/htmlreports/`); los reportes los declara la rama del cliente en `html-reports.extensions.ts` (vacío en `main`) y sus plantillas van en `apps/api/templates//` (`HTML_REPORTS_DIR`; ver `templates/README.md`). Cada `HtmlReportDef` trae `code`, `title`, `category`, `params` (los mismos `GatewayParam` de los reportes del Gateway: texto, número, fecha, lista fija o ventana de selección de documentos/socios/artículos…, con valor por defecto), `template` y `data(values, ctx)`, que arma lo que ve la plantilla con `ctx.query(nombre, params)` (consultas registradas de `sapdb`, cuyo permiso de menú debe incluir `/reportes-html/ejecutar`). La plantilla recibe esos datos y `params`; los valores se escapan solos y `{{ asset('/fonts/F.ttf') }}` entrega un archivo de la carpeta (fuente, logo) como `data:` URI, sin salir de ella. API: `GET /api/html-reports` y `POST /api/html-reports/:code/render {values}` (HTML); permiso de menú `/reportes-html/ejecutar`; auditoría `html_report` (solo los nombres de los parámetros). **Pantalla:** reutiliza la lista y el formulario de «Reportes» (`data.source = 'html'`); el resultado se ve en un marco (`sandbox`) con botón **Imprimir** y un recordatorio: *Márgenes predeterminados, encabezados y pies de página desactivados, escala 100 %* (Chrome y Edge respetan `@page`; conviene fijar `line-height`). **Verificado** (etiquetas de bultos de TAGUMEDICA, WeasyPrint frente al PDF de Chrome con la misma plantilla): mismas páginas, márgenes, rejilla y espacios; con `line-height` fijo el texto difiere 0,3 mm de media. Pendiente: salida PDF desde el servidor (Puppeteer) si hace falta; el botón PDF de los documentos aún no ofrece los reportes HTML. + ## Ver el nombre del campo de SAP («Campos») El mini-erp mostraba el nombre del campo de la base junto a cada casillero (`dbField`). Aquí es general: el botón **Campos** de la barra superior (`FieldNames`, se recuerda en `localStorage` `erp_fields`) muestra, debajo de cada etiqueta y en la cabecera de las tablas (listas, líneas de documento, tablas de filas), el nombre con el que el Service Layer lee y guarda el campo (`ItemCode`, `U_SIS_ESTLOT`); sin activarlo, el nombre sale siempre como ayuda al pasar el cursor. Los campos lo reciben con `dbName` (`FieldBase`/`SboField`); los formularios de maestros y documentos lo pasan solos desde su configuración declarativa. Cubre los formularios de maestros, de documentos (incluidas las filas con varios controles: `FolioPrefixString · FolioNumber`, `DocCurrency · DocRate`…, con `fieldDbName` en `document-layouts.ts`), de pagos y de asientos (sus campos van escritos a mano en el HTML con `dbName="…"`: un campo nuevo debe llevarlo) y las cabeceras de tablas. Solo se muestran como campos de SAP los nombres que empiezan con mayúscula (`isSapField`): las columnas internas de la interfaz (`docNum`, `_Name`) no. @@ -99,7 +103,7 @@ Pendiente, en orden: 2. Cada cliente tiene su branch (`cliente/`); ahí viven sus personalizaciones y fixes. 3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente. 4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio. -5. **Puntos de extensión por cliente** (archivos vacíos en `main`; la rama del cliente los completa sin tocar nada más): `libs/sap-types/src/menu.extensions.ts` (`CLIENT_MENU_ITEMS`: pantallas nuevas del menú, por id de módulo), `apps/web/src/app/client-routes.ts` (`CLIENT_ROUTES` con `clientRoute(ruta, título, import)`: sus pantallas, en diferido y con permiso), `apps/api/src/sl/sl-resources.extensions.ts` (`CLIENT_RESOURCE_PATHS`: rutas de menú del cliente que además pueden leer o escribir un recurso del Service Layer que ya existe, y `CLIENT_SL_RESOURCES`: recursos nuevos; nunca `EmployeesInfo`) y `apps/api/src/sapdb/sapdb.extensions.ts` (consultas a la base de SAP), más las extensiones de campos de cada módulo (`*.extensions.ts`). Un módulo o recurso que no existe o una ruta repetida lanza un error al arrancar. +5. **Puntos de extensión por cliente** (archivos vacíos en `main`; la rama del cliente los completa sin tocar nada más): `libs/sap-types/src/menu.extensions.ts` (`CLIENT_MENU_ITEMS`: pantallas nuevas del menú, por id de módulo), `apps/web/src/app/client-routes.ts` (`CLIENT_ROUTES` con `clientRoute(ruta, título, import)`: sus pantallas, en diferido y con permiso), `apps/api/src/sl/sl-resources.extensions.ts` (`CLIENT_RESOURCE_PATHS`: rutas de menú del cliente que además pueden leer o escribir un recurso del Service Layer que ya existe, y `CLIENT_SL_RESOURCES`: recursos nuevos; nunca `EmployeesInfo`) `apps/api/src/sapdb/sapdb.extensions.ts` (consultas a la base de SAP), `apps/api/src/htmlreports/html-reports.extensions.ts` (reportes HTML), más las extensiones de campos de cada módulo (`*.extensions.ts`). Un módulo o recurso que no existe o una ruta repetida lanza un error al arrancar. 6. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick ` desde `main`); (c) `git checkout cliente/` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama. 7. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/ && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario). 8. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos. diff --git a/apps/api/package.json b/apps/api/package.json index aee5ce7..119398c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,6 +30,7 @@ "@nestjs/typeorm": "^12.0.2", "better-sqlite3": "^12.11.1", "hdb": "^0.19.12", + "nunjucks": "^3.2.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^1.1.2", @@ -44,6 +45,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/express": "^5.0.0", "@types/node": "^24.0.0", + "@types/nunjucks": "^3.2.6", "@types/supertest": "^7.0.0", "@vitest/coverage-v8": "^4.1.2", "oxlint": "^1.58.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7922367..bcb7585 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index 7c9539a..92abb69 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -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') diff --git a/apps/api/src/htmlreports/html-render.ts b/apps/api/src/htmlreports/html-render.ts new file mode 100644 index 0000000..ada4f4c --- /dev/null +++ b/apps/api/src/htmlreports/html-render.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { extname, relative, resolve } from 'node:path'; +import nunjucks from 'nunjucks'; + +const MIME: Record = { + '.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 } { + 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) }; +} diff --git a/apps/api/src/htmlreports/html-reports.controller.ts b/apps/api/src/htmlreports/html-reports.controller.ts new file mode 100644 index 0000000..5abb74a --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.controller.ts @@ -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; + +/** 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 { + return this.reports.render(req.user, code, body.values, req.ip ?? ''); + } +} diff --git a/apps/api/src/htmlreports/html-reports.extensions.ts b/apps/api/src/htmlreports/html-reports.extensions.ts new file mode 100644 index 0000000..8ece299 --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.extensions.ts @@ -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[] = []; diff --git a/apps/api/src/htmlreports/html-reports.module.ts b/apps/api/src/htmlreports/html-reports.module.ts new file mode 100644 index 0000000..b35f1e6 --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.module.ts @@ -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 {} diff --git a/apps/api/src/htmlreports/html-reports.service.ts b/apps/api/src/htmlreports/html-reports.service.ts new file mode 100644 index 0000000..20c2430 --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.service.ts @@ -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>): 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]; + 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; + + constructor( + private readonly config: AppConfigService, + private readonly permissions: PermissionsService, + private readonly audit: AuditService, + private readonly sapdb: SapDbService, + ) {} + + 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 { + 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; + } + } +} diff --git a/apps/api/src/htmlreports/html-reports.spec.ts b/apps/api/src/htmlreports/html-reports.spec.ts new file mode 100644 index 0000000..899091a --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.spec.ts @@ -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()); +vi.mock('./html-reports.js', async (orig) => ({ ...(await orig()), HTML_REPORTS: registry })); + +const dir = mkdtempSync(join(tmpdir(), 'plantillas-')); +mkdirSync(join(dir, 'c')); +writeFileSync(join(dir, 'c', 'etiquetas.njk'), '

{{ cliente }}

{% for n in numeros %}{{ n }}/{{ params.cantidad }}{% endfor %}'); +writeFileSync(join(dir, 'c', 'f.ttf'), 'FUENTE'); +writeFileSync(join(dir, 'secreto.txt'), 'no'); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const P = (over: Partial & Pick): 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: 'ACME & Cía', numeros: [1, 2], params: { cantidad: '2' } }); + expect(html).toContain('

<b>ACME & Cía</b>

'); + expect(html).toContain('1/22/2'); + }); + + 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('

ACME

'); + expect(html).toContain('3/3'); + 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' })); + }); +}); diff --git a/apps/api/src/htmlreports/html-reports.ts b/apps/api/src/htmlreports/html-reports.ts new file mode 100644 index 0000000..f9d40b8 --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.ts @@ -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(); + 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(); + 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 = new Map(validateHtmlReports([...BASE_HTML_REPORTS, ...CLIENT_HTML_REPORTS]).map((r) => [r.code, r])); diff --git a/apps/api/src/htmlreports/html-reports.types.ts b/apps/api/src/htmlreports/html-reports.types.ts new file mode 100644 index 0000000..69a2c98 --- /dev/null +++ b/apps/api/src/htmlreports/html-reports.types.ts @@ -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): Promise; +} + +/** Los valores ya validados de los parámetros, por nombre (siempre texto; los vacíos opcionales vienen como `''`). */ +export type HtmlReportValues = Readonly>; + +/** + * 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>; +} + +/** 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[]; +} diff --git a/apps/api/src/sapdb/sapdb.module.ts b/apps/api/src/sapdb/sapdb.module.ts index 1c2703a..ed83123 100644 --- a/apps/api/src/sapdb/sapdb.module.ts +++ b/apps/api/src/sapdb/sapdb.module.ts @@ -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, diff --git a/apps/api/templates/README.md b/apps/api/templates/README.md new file mode 100644 index 0000000..cdf39b4 --- /dev/null +++ b/apps/api/templates/README.md @@ -0,0 +1,7 @@ +# Plantillas de los reportes HTML + +Cada archivo `.njk` es una plantilla [Nunjucks](https://mozilla.github.io/nunjucks/) (sintaxis de Jinja2: `{{ x }}`, `{% for %}`, `{% if %}`) de un reporte HTML. Los reportes (código, parámetros y datos) se declaran en `src/htmlreports/html-reports.extensions.ts` de la rama del cliente, y sus plantillas van aquí, en una carpeta por cliente (`templates//etiquetas.njk`). + +- Los valores se escapan solos. En la plantilla están los datos que arma el reporte y `params` (los valores de los parámetros). +- `{{ asset('/fonts/Fuente.ttf') }}` entrega un archivo de esta carpeta (fuente, logo) como `data:` URI, para que el reporte sea un solo documento. +- Para imprimir: `@page { size: letter landscape; margin: … }` define el papel y los márgenes (Chrome y Edge los respetan con «Márgenes: predeterminados», «Encabezados y pies de página» desactivados y escala 100 %). Conviene fijar `line-height`, porque cada motor tiene el suyo por defecto. diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 0f9b27d..306ccdf 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -122,6 +122,16 @@ const reportRoutes: Routes = [ { path: ':code', loadComponent: () => import('./features/reports/report-runner.page').then((m) => m.ReportRunnerPage) }, ], }, + { + // Reportes HTML: plantillas que cada cliente define; misma lista y mismo formulario de parámetros, con `data.source` + path: 'reportes-html/ejecutar', + canActivate: [permissionGuard], + data: { path: '/reportes-html/ejecutar', title: menuLabel('/reportes-html/ejecutar'), source: 'html' }, + children: [ + { path: '', loadComponent: () => import('./features/reports/reports.page').then((m) => m.ReportsPage) }, + { path: ':code', loadComponent: () => import('./features/reports/report-runner.page').then((m) => m.ReportRunnerPage) }, + ], + }, { path: 'reportes/matricula', canActivate: [permissionGuard], @@ -147,7 +157,7 @@ const adminRoutes: Routes = [ ]; /** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ -const builtPaths = new Set([...CLIENT_PATHS, ...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']); +const builtPaths = new Set([...CLIENT_PATHS, ...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes-html/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']); const pendingRoutes: Routes = MENU.flatMap((m) => m.items) .filter((item) => !builtPaths.has(item.path)) .map( diff --git a/apps/web/src/app/core/menu/menu.service.spec.ts b/apps/web/src/app/core/menu/menu.service.spec.ts index 07e7de3..710c924 100644 --- a/apps/web/src/app/core/menu/menu.service.spec.ts +++ b/apps/web/src/app/core/menu/menu.service.spec.ts @@ -36,7 +36,7 @@ describe('MenuService', () => { const p = menu.ensureLoaded(); ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] }); await p; - expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'reportes', 'socios', 'admin']); + expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'reportes', 'reportes-html', 'socios', 'admin']); }); it('si falla marca el error y permite reintentar', async () => { diff --git a/apps/web/src/app/features/reports/html-reports-api.ts b/apps/web/src/app/features/reports/html-reports-api.ts new file mode 100644 index 0000000..50445ed --- /dev/null +++ b/apps/web/src/app/features/reports/html-reports-api.ts @@ -0,0 +1,34 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import type { GatewayReport, GatewayValues } from '@basic-erp/sap-types'; +import { firstValueFrom } from 'rxjs'; +import { API_URL } from '../../core/api'; + +/** Reportes HTML (`/api/html-reports`): plantillas que el servidor rellena con datos de SAP y se imprimen desde el navegador. */ +@Injectable({ providedIn: 'root' }) +export class HtmlReportsApi { + private readonly http = inject(HttpClient); + + reports(): Promise { + return firstValueFrom(this.http.get(`${API_URL}/html-reports`)); + } + + /** El HTML del reporte con los valores escritos. */ + async render(code: string, values: GatewayValues): Promise { + try { + return await firstValueFrom(this.http.post(`${API_URL}/html-reports/${encodeURIComponent(code)}/render`, { values }, { responseType: 'text' })); + } catch (e) { + // la respuesta se pidió como texto: el mensaje de error llega como JSON en texto + if (e instanceof HttpErrorResponse && typeof e.error === 'string') { + let message: unknown; + try { + message = (JSON.parse(e.error) as { message?: unknown }).message; + } catch { + /* no era JSON */ + } + if (typeof message === 'string') throw new HttpErrorResponse({ error: { message }, status: e.status, statusText: e.statusText, url: e.url ?? undefined }); + } + throw e; + } + } +} diff --git a/apps/web/src/app/features/reports/html-reports.spec.ts b/apps/web/src/app/features/reports/html-reports.spec.ts new file mode 100644 index 0000000..bd0edcd --- /dev/null +++ b/apps/web/src/app/features/reports/html-reports.spec.ts @@ -0,0 +1,116 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import type { GatewayParam, GatewayReport } from '@basic-erp/sap-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GatewayApi } from './gateway-api'; +import { HtmlReportsApi } from './html-reports-api'; +import { ReportRunnerPage } from './report-runner.page'; +import { ReportsPage } from './reports.page'; + +const params: GatewayParam[] = [ + { name: 'cantidad', type: 'xsd:decimal', label: 'Cantidad de etiquetas', kind: 'number', required: true }, + { name: 'papel', type: 'xsd:string', label: 'Papel', kind: 'select', required: false, defaultValue: 'Carta', options: [{ value: 'Carta', label: 'Carta' }, { value: 'A4', label: 'A4' }] }, +]; +const REPORT: GatewayReport = { code: 'etiquetas-bultos', title: 'Etiquetas de bultos', category: 'Ventas', description: 'Etiquetas de una guía', enabled: true, params }; + +const tick = async (fixture: ComponentFixture) => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); +}; + +function setup(html: Partial>, code?: string) { + TestBed.resetTestingModule(); + const gateway = { reports: vi.fn(async () => []), export: vi.fn() }; + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: HtmlReportsApi, useValue: html }, + { provide: GatewayApi, useValue: gateway }, + { provide: ActivatedRoute, useValue: { snapshot: { data: { title: 'Reportes', source: 'html' }, paramMap: convertToParamMap(code ? { code } : {}) } } }, + ], + }); + return gateway; +} + +describe('Reportes HTML', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('la lista sale de los reportes HTML (no del Gateway) y avisa si no hay', async () => { + const gateway = setup({ reports: vi.fn(async () => [REPORT]) }); + const fixture = TestBed.createComponent(ReportsPage); + await tick(fixture); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Etiquetas de bultos'); + expect(gateway.reports).not.toHaveBeenCalled(); + + setup({ reports: vi.fn(async () => []) }); + const empty = TestBed.createComponent(ReportsPage); + await tick(empty); + expect((empty.nativeElement as HTMLElement).textContent).toContain('No hay reportes HTML definidos'); + }); + + it('pide los parámetros, genera el HTML y lo muestra en un marco que se imprime', async () => { + const render = vi.fn(async () => '

Etiqueta 1/6

'); + const gateway = setup({ reports: vi.fn(async () => [REPORT]), render }, 'etiquetas-bultos'); + const fixture = TestBed.createComponent(ReportRunnerPage); + await tick(fixture); + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain('Cantidad de etiquetas'); + expect(el.querySelector('button.main')?.textContent).toContain('Generar'); + expect(el.querySelector('button.main')?.textContent).not.toContain('PDF'); + + // falta un obligatorio: no se genera + (el.querySelector('button.main') as HTMLButtonElement).click(); + await tick(fixture); + expect(render).not.toHaveBeenCalled(); + expect(el.querySelector('.status')?.textContent).toContain('Falta indicar: Cantidad de etiquetas'); + + // se llena y se genera + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fixture.componentInstance as any).set(params[0], '6'); + await tick(fixture); + (el.querySelector('button.main') as HTMLButtonElement).click(); + await tick(fixture); + expect(render).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' }); + expect(gateway.export).not.toHaveBeenCalled(); + const frame = el.querySelector('iframe') as HTMLIFrameElement; + expect(frame.getAttribute('srcdoc')).toBe('

Etiqueta 1/6

'); + expect(frame.getAttribute('sandbox')).toContain('allow-same-origin'); + expect(el.textContent).toContain('Márgenes: predeterminados'); + + const print = vi.fn(); + Object.defineProperty(frame, 'contentWindow', { value: { print } }); + const imprimir = [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Imprimir') as HTMLButtonElement; + imprimir.click(); + expect(print).toHaveBeenCalled(); + }); + + it('un error del servidor se muestra y no deja un marco vacío', async () => { + setup({ reports: vi.fn(async () => [REPORT]), render: vi.fn(async () => { throw new Error('boom'); }) }, 'etiquetas-bultos'); + const fixture = TestBed.createComponent(ReportRunnerPage); + await tick(fixture); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fixture.componentInstance as any).set(params[0], '2'); + (fixture.nativeElement as HTMLElement).querySelector('button.main')!.click(); + await tick(fixture); + expect((fixture.nativeElement as HTMLElement).querySelector('iframe')).toBeNull(); + expect((fixture.nativeElement as HTMLElement).querySelector('.status')?.textContent).toMatch(/error|boom/i); + }); +}); + +describe('HtmlReportsApi', () => { + it('el mensaje de error del servidor (JSON en texto) se entrega como mensaje legible', async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + const api = TestBed.inject(HtmlReportsApi); + const ctrl = TestBed.inject(HttpTestingController); + const pending = api.render('etiquetas-bultos', { cantidad: '0' }); + ctrl.expectOne('/api/html-reports/etiquetas-bultos/render').flush(JSON.stringify({ message: 'Falta indicar: Entrega' }), { status: 400, statusText: 'Bad Request' }); + await expect(pending).rejects.toMatchObject({ error: { message: 'Falta indicar: Entrega' }, status: 400 }); + }); +}); + diff --git a/apps/web/src/app/features/reports/report-runner.page.ts b/apps/web/src/app/features/reports/report-runner.page.ts index 90bea05..ff4eceb 100644 --- a/apps/web/src/app/features/reports/report-runner.page.ts +++ b/apps/web/src/app/features/reports/report-runner.page.ts @@ -1,10 +1,11 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, resource, signal } from '@angular/core'; -import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser'; +import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, resource, signal, viewChild } from '@angular/core'; +import { DomSanitizer, type SafeHtml, type SafeResourceUrl } from '@angular/platform-browser'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { type GatewayParam, type GatewayReport, type GatewayValues, exportBody } from '@basic-erp/sap-types'; import { apiErrorMessage } from '../../core/api'; import { SboWindow, type StatusMessage } from '../../shared/window'; import { GatewayApi, blobErrorMessage } from './gateway-api'; +import { HtmlReportsApi } from './html-reports-api'; import { ReportParamField } from './report-param-field'; type Value = string | readonly string[]; @@ -42,8 +43,15 @@ type Value = string | readonly string[]; @if (pdfUrl(); as url) { } + @if (htmlDoc(); as doc) { + + + } + } + + @if (htmlDoc()) { + } - @if (download(); as d) { Descargar } @@ -57,14 +65,19 @@ type Value = string | readonly string[]; `, }) export class ReportRunnerPage { - private readonly api = inject(GatewayApi); private readonly route = inject(ActivatedRoute); + private readonly api = inject(GatewayApi); + private readonly htmlApi = inject(HtmlReportsApi); + /** Reportes HTML (se imprimen desde el navegador) o del Gateway (PDF), según la ruta (`data.source`). */ + private readonly isHtml = this.route.snapshot.data['source'] === 'html'; + protected readonly action = this.isHtml ? 'Generar' : 'Generar PDF'; + private readonly htmlFrame = viewChild>('htmlFrame'); private readonly sanitizer = inject(DomSanitizer); protected readonly title = this.route.snapshot.data['title'] as string; protected readonly code = this.route.snapshot.paramMap.get('code') ?? ''; - private readonly reports = resource({ loader: () => this.api.reports() }); + private readonly reports = resource({ loader: () => (this.isHtml ? this.htmlApi.reports() : this.api.reports()) }); protected readonly report = computed(() => (this.reports.hasValue() ? this.reports.value().find((r) => r.code === this.code) : undefined)); /** Los parámetros que se piden (los títulos y separadores del reporte no). */ protected readonly visible = computed(() => (this.report()?.params ?? []).filter((p) => !p.hidden)); @@ -74,6 +87,7 @@ export class ReportRunnerPage { private readonly values = signal>({}); protected readonly running = signal(false); protected readonly pdfUrl = signal(null); + protected readonly htmlDoc = signal(null); protected readonly download = signal<{ url: string; name: string } | null>(null); private readonly message = signal(null); @@ -87,7 +101,7 @@ export class ReportRunnerPage { const message = this.message(); if (message) return message; if (!this.report()) return { kind: 'err', text: `El reporte ${this.code} no está matriculado o no está habilitado.` }; - return { kind: 'info', text: this.visible().some((p) => p.required) ? 'Indique los parámetros obligatorios (*) y pulse «Generar PDF».' : 'Pulse «Generar PDF».' }; + return { kind: 'info', text: this.visible().some((p) => p.required) ? `Indique los parámetros obligatorios (*) y pulse «${this.action}».` : `Pulse «${this.action}».` }; }); /** El valor de un parámetro: lo que escribió el usuario o su valor por defecto. */ @@ -105,6 +119,11 @@ export class ReportRunnerPage { if (d) URL.revokeObjectURL(d.url); } + /** Imprime el reporte (solo el marco, no la pantalla). */ + protected print(): void { + this.htmlFrame()?.nativeElement.contentWindow?.print(); + } + protected async run(): Promise { const r = this.report(); if (!r || this.running()) return; @@ -118,6 +137,12 @@ export class ReportRunnerPage { this.running.set(true); this.message.set({ kind: 'info', text: 'Generando el reporte…' }); try { + if (this.isHtml) { + // el HTML lo arma nuestro servidor a partir de plantillas del proyecto (los valores ya van escapados) + this.htmlDoc.set(this.sanitizer.bypassSecurityTrustHtml(await this.htmlApi.render(r.code, values as GatewayValues))); + this.message.set({ kind: 'ok', text: 'Reporte generado. Pulse «Imprimir».' }); + return; + } const blob = await this.api.export(r.code, values as GatewayValues); this.revoke(); const url = URL.createObjectURL(blob); diff --git a/apps/web/src/app/features/reports/reports.page.ts b/apps/web/src/app/features/reports/reports.page.ts index 8a7dcd2..96b7597 100644 --- a/apps/web/src/app/features/reports/reports.page.ts +++ b/apps/web/src/app/features/reports/reports.page.ts @@ -4,6 +4,7 @@ import type { GatewayReport } from '@basic-erp/sap-types'; import { apiErrorMessage } from '../../core/api'; import { SboWindow, type StatusMessage } from '../../shared/window'; import { GatewayApi } from './gateway-api'; +import { HtmlReportsApi } from './html-reports-api'; /** Lista de los reportes matriculados, agrupados por categoría y con búsqueda; elegir uno abre su formulario de parámetros. */ @Component({ @@ -31,7 +32,7 @@ import { GatewayApi } from './gateway-api'; } } @empty { -

{{ reports.isLoading() ? 'Cargando reportes…' : query() ? 'Ningún reporte coincide con la búsqueda.' : 'No hay reportes matriculados. Un administrador los matricula en «Matrícula de reportes».' }}

+

{{ reports.isLoading() ? 'Cargando reportes…' : query() ? 'Ningún reporte coincide con la búsqueda.' : isHtml ? 'No hay reportes HTML definidos en esta instalación.' : 'No hay reportes matriculados. Un administrador los matricula en «Matrícula de reportes».' }}

} `, @@ -51,9 +52,11 @@ import { GatewayApi } from './gateway-api'; `, }) export class ReportsPage { - private readonly api = inject(GatewayApi); - private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + /** Reportes del Gateway (Crystal) o reportes HTML, según la ruta (`data.source`). */ + protected readonly isHtml = this.route.snapshot.data['source'] === 'html'; + private readonly api = this.isHtml ? inject(HtmlReportsApi) : inject(GatewayApi); + private readonly router = inject(Router); protected readonly title = this.route.snapshot.data['title'] as string; protected readonly reports = resource({ loader: () => this.api.reports() }); diff --git a/apps/web/src/app/layout/main-layout.spec.ts b/apps/web/src/app/layout/main-layout.spec.ts index b07775c..1041436 100644 --- a/apps/web/src/app/layout/main-layout.spec.ts +++ b/apps/web/src/app/layout/main-layout.spec.ts @@ -1,8 +1,10 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { Component } from '@angular/core'; import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { Router, provideRouter } from '@angular/router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MenuService } from '../core/menu/menu.service'; import { MainLayout } from './main-layout'; describe('MainLayout: menú lateral', () => { @@ -52,3 +54,59 @@ describe('MainLayout: menú lateral', () => { expect(localStorage.getItem('erp_sidebar')).toBeNull(); }); }); + +describe('MainLayout: módulos del menú', () => { + let fixture: ComponentFixture; + const el = () => fixture.nativeElement as HTMLElement; + const mods = () => [...el().querySelectorAll('button.mod')]; + const openOnes = () => mods().filter((m) => m.getAttribute('aria-expanded') === 'true').map((m) => m.textContent?.trim().replace(/^[▾▸]\s*/, '')); + + @Component({ template: '' }) + class Page {} + + async function open(url?: string) { + vi.stubGlobal('matchMedia', () => ({ matches: false })); + await TestBed.configureTestingModule({ + providers: [provideRouter([{ path: 'inventario/lotes', component: Page }, { path: '', component: Page }]), provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + // en la aplicación el guardián carga el menú antes de mostrar el layout + const loaded = TestBed.inject(MenuService).ensureLoaded(); + TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: ['*'] }); + await loaded; + if (url) await TestBed.inject(Router).navigateByUrl(url); + fixture = TestBed.createComponent(MainLayout); + fixture.detectChanges(); + TestBed.inject(HttpTestingController).match(() => true).forEach((r) => r.flush({ value: [] })); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + }); + + it('arranca comprimido: ningún módulo desplegado', async () => { + await open(); + expect(mods().length).toBeGreaterThan(3); + expect(openOnes()).toEqual([]); + expect(el().querySelectorAll('nav.side a').length).toBe(0); + }); + + it('un clic despliega el módulo y otro lo pliega', async () => { + await open(); + mods()[0].click(); + fixture.detectChanges(); + expect(openOnes()).toHaveLength(1); + expect(el().querySelectorAll('nav.side a').length).toBeGreaterThan(0); + mods()[0].click(); + fixture.detectChanges(); + expect(openOnes()).toEqual([]); + }); + + it('al entrar por una ruta de un módulo, ese módulo (y solo ese) queda desplegado', async () => { + await open('/inventario/lotes'); + expect(openOnes()).toEqual(['Inventario']); + }); +}); diff --git a/apps/web/src/app/layout/main-layout.ts b/apps/web/src/app/layout/main-layout.ts index ba4ad2d..5a3a91a 100644 --- a/apps/web/src/app/layout/main-layout.ts +++ b/apps/web/src/app/layout/main-layout.ts @@ -1,6 +1,8 @@ import { FieldNames } from '../core/field-names'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; import { CompanySettingsService } from '../core/company/company-settings'; import { AccountCodesService } from '../core/sl/account-codes'; import { AccountSegmentsService } from '../core/sl/account-segments'; @@ -43,10 +45,14 @@ export class MainLayout { private readonly accountCodes = inject(AccountCodesService); constructor() { + const router = inject(Router); + this.reveal(router.url); + router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd), takeUntilDestroyed()).subscribe((e) => this.reveal(e.urlAfterRedirects)); void this.segments.ensureLoaded(); void inject(CompanySettingsService).ensureLoaded(); } - private readonly collapsed = signal>(new Set()); + /** Módulos desplegados: el menú arranca comprimido; se abre el del lugar donde se está (o se llega por una ruta directa). */ + private readonly expanded = signal>(new Set()); /** El botón ☰: en pantallas estrechas abre o cierra el menú sobre el contenido; en las anchas lo oculta o lo muestra. */ protected toggleSide(): void { @@ -64,17 +70,24 @@ export class MainLayout { } protected isOpen(moduleId: string): boolean { - return !this.collapsed().has(moduleId); + return this.expanded().has(moduleId); } protected toggle(moduleId: string): void { - this.collapsed.update((s) => { + this.expanded.update((s) => { const next = new Set(s); if (!next.delete(moduleId)) next.add(moduleId); return next; }); } + /** Despliega el módulo al que pertenece la ruta (para ver dónde se está al entrar por un enlace o al recargar). */ + private reveal(url: string): void { + const path = url.split(/[?#]/)[0]; + const module = this.menu.modules().find((m) => m.items.some((i) => path === i.path || path.startsWith(`${i.path}/`))); + if (module && !this.expanded().has(module.id)) this.expanded.update((s) => new Set(s).add(module.id)); + } + protected retryMenu(): void { void this.menu.ensureLoaded(); } diff --git a/libs/sap-types/src/audit.ts b/libs/sap-types/src/audit.ts index 17cc248..d340b1c 100644 --- a/libs/sap-types/src/audit.ts +++ b/libs/sap-types/src/audit.ts @@ -12,7 +12,8 @@ export type AuditAction = | 'report_export' | 'report_register' | 'report_unregister' - | 'db_query'; + | 'db_query' + | 'html_report'; export type AuditOutcome = 'ok' | 'error' | 'denied'; diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index 89b4acd..c839d5d 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -80,6 +80,12 @@ const BASE_MENU: readonly MenuModule[] = [ { path: '/reportes/matricula', label: 'Matrícula de reportes' }, ], }, + { + id: 'reportes-html', + label: 'Reportes HTML', + // plantillas que cada cliente define (etiquetas, comprobantes…) y se imprimen desde el navegador + items: [{ path: '/reportes-html/ejecutar', label: 'Reportes' }], + }, { id: 'socios', label: 'Socios de negocio', diff --git a/package-lock.json b/package-lock.json index a18b13a..ce705d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@nestjs/typeorm": "^12.0.2", "better-sqlite3": "^12.11.1", "hdb": "^0.19.12", + "nunjucks": "^3.2.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^1.1.2", @@ -43,6 +44,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/express": "^5.0.0", "@types/node": "^24.0.0", + "@types/nunjucks": "^3.2.6", "@types/supertest": "^7.0.0", "@vitest/coverage-v8": "^4.1.2", "oxlint": "^1.58.0", @@ -55,6 +57,40 @@ "vitest": "^4.1.2" } }, + "apps/api/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "apps/api/node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "apps/web": { "version": "0.0.0", "dependencies": { @@ -5520,6 +5556,13 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/nunjucks": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.6.tgz", + "integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -5756,6 +5799,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5886,7 +5935,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, "license": "MIT" }, "node_modules/assertion-error": { @@ -6273,7 +6321,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -10016,7 +10064,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0"