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

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

No files matched your search

+6 -2
View File
@@ -29,7 +29,7 @@ Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestr
## Criterios de la interfaz ## 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 ## 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. 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/<cliente>/` (`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('<cliente>/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») ## 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. 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/<nombre-cliente>`); ahí viven sus personalizaciones y fixes. 2. Cada cliente tiene su branch (`cliente/<nombre-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. 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. 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 <hash>` desde `main`); (c) `git checkout cliente/<nombre>` 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. 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 <hash>` desde `main`); (c) `git checkout cliente/<nombre>` 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/<nombre> && 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). 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/<nombre> && 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. 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.
+2
View File
@@ -30,6 +30,7 @@
"@nestjs/typeorm": "^12.0.2", "@nestjs/typeorm": "^12.0.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"hdb": "^0.19.12", "hdb": "^0.19.12",
"nunjucks": "^3.2.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^1.1.2", "typeorm": "^1.1.2",
@@ -44,6 +45,7 @@
"@types/better-sqlite3": "^9.6.0", "@types/better-sqlite3": "^9.6.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/nunjucks": "^3.2.6",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0", "oxlint": "^1.58.0",
+2 -1
View File
@@ -4,6 +4,7 @@ import { AuthModule } from './auth/auth.module.js';
import { AppConfigModule } from './config/config.module.js'; import { AppConfigModule } from './config/config.module.js';
import { DatabaseModule } from './database/database.module.js'; import { DatabaseModule } from './database/database.module.js';
import { GatewayModule } from './gateway/gateway.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 { SapDbModule } from './sapdb/sapdb.module.js';
import { HealthController } from './health/health.controller.js'; import { HealthController } from './health/health.controller.js';
import { PermissionsModule } from './permissions/permissions.module.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'; import { SlModule } from './sl/sl.module.js';
@Module({ @Module({
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule], imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule, HtmlReportsModule],
controllers: [HealthController], controllers: [HealthController],
}) })
export class AppModule {} export class AppModule {}
+2
View File
@@ -88,6 +88,8 @@ export const envSchema = z
SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2), SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
// Tope de filas de cualquier consulta; lo que pase se recorta. // Tope de filas de cualquier consulta; lo que pase se recorta.
SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000), 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 SAPB1_SL_VERIFY_SSL: z
.enum(['true', 'false']) .enum(['true', 'false'])
.default('true') .default('true')
+34
View File
@@ -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>&lt;b&gt;ACME &amp; Cía&lt;/b&gt;</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' }));
});
});
+24
View File
@@ -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[];
}
+1
View File
@@ -11,6 +11,7 @@ import { SAPDB_ADAPTER, type SapDbAdapter } from './sapdb.types.js';
@Module({ @Module({
imports: [AuditModule, PermissionsModule], imports: [AuditModule, PermissionsModule],
controllers: [SapDbController], controllers: [SapDbController],
exports: [SapDbService],
providers: [ providers: [
{ {
provide: SAPDB_ADAPTER, provide: SAPDB_ADAPTER,
+7
View File
@@ -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/<cliente>/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('<cliente>/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.
+11 -1
View File
@@ -122,6 +122,16 @@ const reportRoutes: Routes = [
{ path: ':code', loadComponent: () => import('./features/reports/report-runner.page').then((m) => m.ReportRunnerPage) }, { 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', path: 'reportes/matricula',
canActivate: [permissionGuard], canActivate: [permissionGuard],
@@ -147,7 +157,7 @@ const adminRoutes: Routes = [
]; ];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ /** 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) const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !builtPaths.has(item.path)) .filter((item) => !builtPaths.has(item.path))
.map( .map(
@@ -36,7 +36,7 @@ describe('MenuService', () => {
const p = menu.ensureLoaded(); const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] }); ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await p; 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 () => { it('si falla marca el error y permite reintentar', async () => {
@@ -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<GatewayReport[]> {
return firstValueFrom(this.http.get<GatewayReport[]>(`${API_URL}/html-reports`));
}
/** El HTML del reporte con los valores escritos. */
async render(code: string, values: GatewayValues): Promise<string> {
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;
}
}
}
@@ -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<unknown>) => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
function setup(html: Partial<Record<keyof HtmlReportsApi, unknown>>, 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 () => '<p>Etiqueta 1/6</p>');
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('<p>Etiqueta 1/6</p>');
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<HTMLButtonElement>('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 });
});
});
@@ -1,10 +1,11 @@
import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, resource, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, computed, inject, resource, signal, viewChild } from '@angular/core';
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser'; import { DomSanitizer, type SafeHtml, type SafeResourceUrl } from '@angular/platform-browser';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { type GatewayParam, type GatewayReport, type GatewayValues, exportBody } from '@basic-erp/sap-types'; import { type GatewayParam, type GatewayReport, type GatewayValues, exportBody } from '@basic-erp/sap-types';
import { apiErrorMessage } from '../../core/api'; import { apiErrorMessage } from '../../core/api';
import { SboWindow, type StatusMessage } from '../../shared/window'; import { SboWindow, type StatusMessage } from '../../shared/window';
import { GatewayApi, blobErrorMessage } from './gateway-api'; import { GatewayApi, blobErrorMessage } from './gateway-api';
import { HtmlReportsApi } from './html-reports-api';
import { ReportParamField } from './report-param-field'; import { ReportParamField } from './report-param-field';
type Value = string | readonly string[]; type Value = string | readonly string[];
@@ -42,8 +43,15 @@ type Value = string | readonly string[];
@if (pdfUrl(); as url) { @if (pdfUrl(); as url) {
<iframe class="pdf" title="Vista del PDF" [src]="url"></iframe> <iframe class="pdf" title="Vista del PDF" [src]="url"></iframe>
} }
@if (htmlDoc(); as doc) {
<p class="desc print-hint">Para imprimir bien: <strong>Márgenes: predeterminados · Encabezados y pies de página: desactivados · Escala: 100 %</strong>.</p>
<iframe #htmlFrame class="pdf" title="Vista del reporte" sandbox="allow-same-origin allow-modals" [srcdoc]="doc"></iframe>
}
}
<button sboActions class="btn main" type="button" [disabled]="running() || !report()" (click)="run()">{{ running() ? 'Generando…' : action }}</button>
@if (htmlDoc()) {
<button sboActions class="btn" type="button" (click)="print()">Imprimir</button>
} }
<button sboActions class="btn main" type="button" [disabled]="running() || !report()" (click)="run()">{{ running() ? 'Generando…' : 'Generar PDF' }}</button>
@if (download(); as d) { @if (download(); as d) {
<a sboActions class="btn" [href]="d.url" [attr.download]="d.name">Descargar</a> <a sboActions class="btn" [href]="d.url" [attr.download]="d.name">Descargar</a>
} }
@@ -57,14 +65,19 @@ type Value = string | readonly string[];
`, `,
}) })
export class ReportRunnerPage { export class ReportRunnerPage {
private readonly api = inject(GatewayApi);
private readonly route = inject(ActivatedRoute); 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<ElementRef<HTMLIFrameElement>>('htmlFrame');
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
protected readonly title = this.route.snapshot.data['title'] as string; protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly code = this.route.snapshot.paramMap.get('code') ?? ''; 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<GatewayReport | undefined>(() => (this.reports.hasValue() ? this.reports.value().find((r) => r.code === this.code) : undefined)); protected readonly report = computed<GatewayReport | undefined>(() => (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). */ /** 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)); protected readonly visible = computed(() => (this.report()?.params ?? []).filter((p) => !p.hidden));
@@ -74,6 +87,7 @@ export class ReportRunnerPage {
private readonly values = signal<Record<string, Value>>({}); private readonly values = signal<Record<string, Value>>({});
protected readonly running = signal(false); protected readonly running = signal(false);
protected readonly pdfUrl = signal<SafeResourceUrl | null>(null); protected readonly pdfUrl = signal<SafeResourceUrl | null>(null);
protected readonly htmlDoc = signal<SafeHtml | null>(null);
protected readonly download = signal<{ url: string; name: string } | null>(null); protected readonly download = signal<{ url: string; name: string } | null>(null);
private readonly message = signal<StatusMessage | null>(null); private readonly message = signal<StatusMessage | null>(null);
@@ -87,7 +101,7 @@ export class ReportRunnerPage {
const message = this.message(); const message = this.message();
if (message) return message; if (message) return message;
if (!this.report()) return { kind: 'err', text: `El reporte ${this.code} no está matriculado o no está habilitado.` }; 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. */ /** 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); 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<void> { protected async run(): Promise<void> {
const r = this.report(); const r = this.report();
if (!r || this.running()) return; if (!r || this.running()) return;
@@ -118,6 +137,12 @@ export class ReportRunnerPage {
this.running.set(true); this.running.set(true);
this.message.set({ kind: 'info', text: 'Generando el reporte…' }); this.message.set({ kind: 'info', text: 'Generando el reporte…' });
try { 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); const blob = await this.api.export(r.code, values as GatewayValues);
this.revoke(); this.revoke();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -4,6 +4,7 @@ import type { GatewayReport } from '@basic-erp/sap-types';
import { apiErrorMessage } from '../../core/api'; import { apiErrorMessage } from '../../core/api';
import { SboWindow, type StatusMessage } from '../../shared/window'; import { SboWindow, type StatusMessage } from '../../shared/window';
import { GatewayApi } from './gateway-api'; 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. */ /** Lista de los reportes matriculados, agrupados por categoría y con búsqueda; elegir uno abre su formulario de parámetros. */
@Component({ @Component({
@@ -31,7 +32,7 @@ import { GatewayApi } from './gateway-api';
} }
</ul> </ul>
} @empty { } @empty {
<p class="none">{{ 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».' }}</p> <p class="none">{{ 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».' }}</p>
} }
</sbo-window> </sbo-window>
`, `,
@@ -51,9 +52,11 @@ import { GatewayApi } from './gateway-api';
`, `,
}) })
export class ReportsPage { export class ReportsPage {
private readonly api = inject(GatewayApi);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); 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 title = this.route.snapshot.data['title'] as string;
protected readonly reports = resource({ loader: () => this.api.reports() }); protected readonly reports = resource({ loader: () => this.api.reports() });
+59 -1
View File
@@ -1,8 +1,10 @@
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component } from '@angular/core';
import { type ComponentFixture, TestBed } from '@angular/core/testing'; 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { MenuService } from '../core/menu/menu.service';
import { MainLayout } from './main-layout'; import { MainLayout } from './main-layout';
describe('MainLayout: menú lateral', () => { describe('MainLayout: menú lateral', () => {
@@ -52,3 +54,59 @@ describe('MainLayout: menú lateral', () => {
expect(localStorage.getItem('erp_sidebar')).toBeNull(); expect(localStorage.getItem('erp_sidebar')).toBeNull();
}); });
}); });
describe('MainLayout: módulos del menú', () => {
let fixture: ComponentFixture<MainLayout>;
const el = () => fixture.nativeElement as HTMLElement;
const mods = () => [...el().querySelectorAll<HTMLButtonElement>('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']);
});
});
+17 -4
View File
@@ -1,6 +1,8 @@
import { FieldNames } from '../core/field-names'; import { FieldNames } from '../core/field-names';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; 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 { CompanySettingsService } from '../core/company/company-settings';
import { AccountCodesService } from '../core/sl/account-codes'; import { AccountCodesService } from '../core/sl/account-codes';
import { AccountSegmentsService } from '../core/sl/account-segments'; import { AccountSegmentsService } from '../core/sl/account-segments';
@@ -43,10 +45,14 @@ export class MainLayout {
private readonly accountCodes = inject(AccountCodesService); private readonly accountCodes = inject(AccountCodesService);
constructor() { 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 this.segments.ensureLoaded();
void inject(CompanySettingsService).ensureLoaded(); void inject(CompanySettingsService).ensureLoaded();
} }
private readonly collapsed = signal<ReadonlySet<string>>(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<ReadonlySet<string>>(new Set());
/** El botón ☰: en pantallas estrechas abre o cierra el menú sobre el contenido; en las anchas lo oculta o lo muestra. */ /** 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 { protected toggleSide(): void {
@@ -64,17 +70,24 @@ export class MainLayout {
} }
protected isOpen(moduleId: string): boolean { protected isOpen(moduleId: string): boolean {
return !this.collapsed().has(moduleId); return this.expanded().has(moduleId);
} }
protected toggle(moduleId: string): void { protected toggle(moduleId: string): void {
this.collapsed.update((s) => { this.expanded.update((s) => {
const next = new Set(s); const next = new Set(s);
if (!next.delete(moduleId)) next.add(moduleId); if (!next.delete(moduleId)) next.add(moduleId);
return next; 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 { protected retryMenu(): void {
void this.menu.ensureLoaded(); void this.menu.ensureLoaded();
} }
+2 -1
View File
@@ -12,7 +12,8 @@ export type AuditAction =
| 'report_export' | 'report_export'
| 'report_register' | 'report_register'
| 'report_unregister' | 'report_unregister'
| 'db_query'; | 'db_query'
| 'html_report';
export type AuditOutcome = 'ok' | 'error' | 'denied'; export type AuditOutcome = 'ok' | 'error' | 'denied';
+6
View File
@@ -80,6 +80,12 @@ const BASE_MENU: readonly MenuModule[] = [
{ path: '/reportes/matricula', label: 'Matrícula de reportes' }, { 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', id: 'socios',
label: 'Socios de negocio', label: 'Socios de negocio',
+51 -3
View File
@@ -29,6 +29,7 @@
"@nestjs/typeorm": "^12.0.2", "@nestjs/typeorm": "^12.0.2",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"hdb": "^0.19.12", "hdb": "^0.19.12",
"nunjucks": "^3.2.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^1.1.2", "typeorm": "^1.1.2",
@@ -43,6 +44,7 @@
"@types/better-sqlite3": "^9.6.0", "@types/better-sqlite3": "^9.6.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/nunjucks": "^3.2.6",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0", "oxlint": "^1.58.0",
@@ -55,6 +57,40 @@
"vitest": "^4.1.2" "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": { "apps/web": {
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
@@ -5520,6 +5556,13 @@
"undici-types": ">=7.24.0 <7.24.7" "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": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -5756,6 +5799,12 @@
"url": "https://opencollective.com/vitest" "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": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5886,7 +5935,6 @@
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/assertion-error": { "node_modules/assertion-error": {
@@ -6273,7 +6321,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"readdirp": "^5.0.0" "readdirp": "^5.0.0"
@@ -10016,7 +10064,7 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"