Compare commits

..
25 Commits
Author SHA1 Message Date
GeorgeandClaude Sonnet 5.5 f5e15a8f79 feat(web): bienvenida con buscador de pantallas, recientes y una tarjeta por módulo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:07:52 -05:00
GeorgeandClaude Sonnet 5.5 a472731277 feat: parámetros de solo lectura en los reportes (se ven con su valor por defecto y no se pueden cambiar; el servidor los ignora)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:57:33 -05:00
GeorgeandClaude Sonnet 5.5 f71577e792 feat: botón Generar PDF en los reportes HTML (Chrome sin pantalla, papel y márgenes de la plantilla fijos)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:53:24 -05:00
GeorgeandClaude Sonnet 5.5 2d358f0cf1 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>
2026-10-20 16:20:53 -05:00
GeorgeandClaude Sonnet 5.5 a918d0a109 feat(api): la serie por defecto de un documento (SeriesService_GetDefaultSeries) como acción de solo consulta del proxy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:57:00 -05:00
GeorgeandClaude Sonnet 5.5 a0679179c8 test(api): la lista de consultas disponibles admite las que suma la rama de un cliente
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:46:41 -05:00
GeorgeandClaude Sonnet 5.5 0629ce4068 feat: campos de usuario por su definición en SAP (valores válidos y tabla de usuario enlazada): endpoint sapdb/user-fields y campo udfField en cabeceras de documentos y maestros
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:46:00 -05:00
GeorgeandClaude Sonnet 5.5 3caf94609d feat: puntos de extensión por cliente para el menú, las rutas del frontend y los permisos del proxy del Service Layer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:39:18 -05:00
GeorgeandClaude Sonnet 5.5 8ba845cc23 feat(web): «Campos» también en pagos, asientos y filas con varios controles; no se muestran las columnas internas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 09:09:45 -05:00
GeorgeandClaude Sonnet 5.5 6ad824c6c1 feat(web): botón «Campos» que muestra el nombre del campo de SAP junto a cada etiqueta y en las cabeceras de tablas (ayuda del cursor siempre)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:59:58 -05:00
GeorgeandClaude Sonnet 5.5 6887f9e474 feat: pestaña Stock por almacén en el lote (consulta estándar batch-stock del módulo sapdb; sin base configurada no aparece)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:31:13 -05:00
GeorgeandClaude Sonnet 5.5 5db5de65ad fix(web): las fechas con hora que trae SAP se ven en los campos de fecha de los maestros
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:19:55 -05:00
GeorgeandClaude Sonnet 5.5 91d5135dc7 feat: Lotes (datos maestros de inventario): lista y formulario de BatchNumberDetails con clave numérica y sin «Nuevo»
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:59:22 -05:00
GeorgeandClaude Sonnet 5.5 3e27f370dc feat(api): módulo de consultas de solo lectura a la base de SAP (HANA con hdb): consultas registradas con parámetros con nombre, cola, auditoría y extensión por cliente
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:31:17 -05:00
GeorgeandClaude Sonnet 5.5 9118985d96 docs: remoto del proyecto (Gitea) y estado de las ramas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:15:34 -05:00
GeorgeandClaude Sonnet 5.5 31f55c42a3 docs: guía de ramas por cliente (mejoras genéricas, traer main a la rama del cliente y qué va en cada lado)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:12:35 -05:00
GeorgeandClaude Sonnet 5.5 aa520d574f feat(web): el stock por almacén oculta los almacenes sin cantidades (casilla marcada por defecto; el total sigue sumando todos)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:08:17 -05:00
GeorgeandClaude Sonnet 5.5 a659276d9a feat(web): el stock por almacén totaliza en stock, comprometido y pedido en una fila bajo los títulos (columna total en las tablas de filas)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:06:21 -05:00
GeorgeandClaude Sonnet 5.5 d05b497cb2 feat(web): el artículo muestra el nombre del almacén por defecto y los nombres de las listas de precios y de la lista base
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 19:01:44 -05:00
GeorgeandClaude Sonnet 5.5 8af3b11ac2 feat(web): el stock por almacén muestra el nombre del almacén y la lista de artículos el nombre del grupo (columna nameOf en las tablas de filas)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:59:56 -05:00
GeorgeandClaude Sonnet 5.5 5253ef4cb7 fix(web): el casillero del código del proveedor principal es más ancho (el código largo se cortaba)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:56:27 -05:00
GeorgeandClaude Sonnet 5.5 0fdf10d7b2 feat(web): en los campos de lista con nombre el código es un casillero corto y el nombre ocupa el resto, alineados en todas las filas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:55:13 -05:00
GeorgeandClaude Sonnet 5.5 9e98160d01 feat(web): los campos de lista de los maestros muestran el nombre junto al código (grupo, moneda, empleado de ventas, condición de pago, lista de precios, grupo de artículos, proveedor)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:52:08 -05:00
GeorgeandClaude Sonnet 5.5 f985667b90 feat(web): la lista de clientes y proveedores muestra el Grupo (código y nombre, una sola consulta al catálogo)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:50:16 -05:00
GeorgeandClaude Sonnet 5.5 8ed44aa55b feat(web): la matrícula propone Dockey@ (layout de documento) como parámetro del documento y muestra a cuántos documentos se asocia
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 18:45:39 -05:00
106 changed files with 3730 additions and 184 deletions

No files matched your search

+21 -4
View File
@@ -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 ~289 kB, 82 kB comprimido; 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
@@ -48,12 +48,24 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
- **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`.
- **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin.
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base en las demás listas; las de `Invoices` filtran por subtipo exacto:** cuatro pantallas comparten el recurso y se separan por `DocumentSubType` (enum `BoDocumentSubType`: `bod_None` 0, `bod_InvoiceExempt` 1, `bod_DebitMemo` 2, `bod_Bill` 3, `bod_ExemptBill` 4, `bod_PurchaseDebitMemo` 5, `bod_ExportInvoice` 6…) y por `ReserveInvoice` (`tYES` = de reserva): Facturas (`tNO` y `eq bod_None`), Boletas (`tNO` y `eq bod_Bill`), Facturas de reserva (`tYES` y `eq bod_None`) y Boletas de reserva (`tYES` y `eq bod_Bill`); `baseFilter` para la lista y `fixed` (subtipo, y `ReserveInvoice` en las reservas) al crear. Nunca `ne`: los demás subtipos (factura exenta, nota de débito, boleta exenta, exportación) no se mezclan y tendrán su propia pantalla si se necesitan. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
- **Reportes Gateway** (Crystal Reports por el API Gateway de SAP B1, `apps/api/src/gateway/` y `apps/web/.../features/reports/`): menú «Reportes Gateway» con «Reportes» (lista agrupada por categoría y formulario de parámetros con el PDF en pantalla) y «Matrícula de reportes» (solo administradores: de los reportes que el gateway autoriza se eligen los que se usan, con título, categoría y cómo se pide cada parámetro). Servicio aparte del Service Layer: su sesión y su cola propias (`APIGATEWAY_*` en el `.env`; los datos salieron del `.env` del mini-erp y el certificado autofirmado va en `certs/apigateway.pem`). La matrícula vive en SQLite (`gateway_reports`) y arma el cuerpo de `ExportPDFData` (`[{name, type, value:[[…]]}]`: un valor `[[v]]`, varios `[[a],[b]]`, rango `[[desde,hasta]]`); los parámetros se piden con los mismos controles y ventanas de selección de los formularios (texto, número, fecha, lista fija, ventana de selección con un catálogo de listas: socios, artículos, almacenes, documentos por `DocEntry`…). Verificado en la base HANA: los reportes a medida (`DLN20015`, `RCRI0031`) generan PDF con valores discretos y fechas `yyyy-MM-dd`; los estándar de SAP (`RCRI0001`, `RCRI0002`, con títulos y rangos) devolvieron `(---)` con los formatos probados, el formato de rango/varios valores sigue sin confirmar. Los títulos y separadores de un reporte (`…@Title`, `…@separator`) se marcan ocultos. **Botón PDF de los documentos** (`reports/document-pdf.ts`, en la barra del formulario): cada reporte matriculado puede asociarse a pantallas de documento (`documents`, rutas de menú) con el parámetro que recibe el `DocEntry` (`documentParam`; los demás parámetros toman su valor por defecto) y marcarse «por defecto» (`defaultFor`, uno por pantalla, como en SAP: marcarlo quita la marca al anterior). «PDF» imprime el formato por defecto (o el primero) en una ventana con descarga; si hay más, el menú «Otros formatos» los lista. Sin formatos, sin permiso o sin API Gateway el botón no aparece. API: `GET /api/gateway/documents?screen=` y `POST /api/gateway/documents/export {screen, code, docEntry}`; basta el permiso de menú del documento (no el de «Reportes»). Aún no hay botón en pagos, asientos ni maestros.
- **Reportes Gateway** (Crystal Reports por el API Gateway de SAP B1, `apps/api/src/gateway/` y `apps/web/.../features/reports/`): menú «Reportes Gateway» con «Reportes» (lista agrupada por categoría y formulario de parámetros con el PDF en pantalla) y «Matrícula de reportes» (solo administradores: de los reportes que el gateway autoriza se eligen los que se usan, con título, categoría y cómo se pide cada parámetro). Servicio aparte del Service Layer: su sesión y su cola propias (`APIGATEWAY_*` en el `.env`; los datos salieron del `.env` del mini-erp y el certificado autofirmado va en `certs/apigateway.pem`). La matrícula vive en SQLite (`gateway_reports`) y arma el cuerpo de `ExportPDFData` (`[{name, type, value:[[…]]}]`: un valor `[[v]]`, varios `[[a],[b]]`, rango `[[desde,hasta]]`); los parámetros se piden con los mismos controles y ventanas de selección de los formularios (texto, número, fecha, lista fija, ventana de selección con un catálogo de listas: socios, artículos, almacenes, documentos por `DocEntry`…). Verificado en la base HANA: los reportes a medida (`DLN20015`, `RCRI0031`) generan PDF con valores discretos y fechas `yyyy-MM-dd`; los estándar de SAP (`RCRI0001`, `RCRI0002`, con títulos y rangos) devolvieron `(---)` con los formatos probados, el formato de rango/varios valores sigue sin confirmar. Los títulos y separadores de un reporte (`…@Title`, `…@separator`) se marcan ocultos. **Botón PDF de los documentos** (`reports/document-pdf.ts`, en la barra del formulario): cada reporte matriculado puede asociarse a pantallas de documento (`documents`, rutas de menú) con el parámetro que recibe el `DocEntry` (`documentParam`; los demás parámetros toman su valor por defecto) y marcarse «por defecto» (`defaultFor`, uno por pantalla, como en SAP: marcarlo quita la marca al anterior). «PDF» imprime el formato por defecto (o el primero) en una ventana con descarga; si hay más, el menú «Otros formatos» los lista. Sin formatos, sin permiso o sin API Gateway el botón no aparece. API: `GET /api/gateway/documents?screen=` y `POST /api/gateway/documents/export {screen, code, docEntry}`; basta el permiso de menú del documento (no el de «Reportes»). Aún no hay botón en pagos, asientos ni maestros. Un reporte con el parámetro `Dockey@` es un **layout de documento** de SAP: la matrícula lo propone como `documentParam`. En `SBO_TAGUMEDICA_21` el Gateway autoriza 179 reportes `RCRI…` (ninguno `DLN…`; `DLN20015` era de otra base): ahí candidatos como `RCRI0083` (GuiaRemision PT) hay que revisarlos por sus parámetros.
## Flujo de las pantallas de documento
Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles. **Indicador y folio juntos y en ese orden** (`IDENTITY_GROUP`, un `HeaderField` tipo `group` = varias celdas en una fila: Indicador con su lista, prefijo y número del folio) en facturas, boletas, reservas y notas de crédito de ventas y compras (`BILLING_DOCUMENT`) y en el asiento. Una empresa que los sustituye por campos de usuario arma su grupo con `fieldGroup(label, cells)` en `headerRight` de su extensión y oculta el del base con `hideHeaderFields: ['Indicator']` (oculta el grupo completo). Las **tres fechas** de la cabecera de todo documento de ventas y compras van juntas y en este orden, una debajo de la otra: contabilización, vencimiento y del documento (`TaxDate`), las tres con la fecha de hoy al crear. **Pestañas Contenido, Logística y Finanzas** en ventas y compras (`layout.tabs`, `tools/logistica.png` y `tools/finanzas.png`): Logística = destino y dirección de facturación (se eligen de las direcciones del socio, con su texto debajo), forma de envío, número de seguimiento y bloquear reclamaciones; Finanzas = entrada en el diario, cuenta asociada, bloqueo de pago, condiciones y forma de pago (de cobro en ventas, de pago en compras), indicador banco central, plazos, recálculo del vencimiento y proyecto. Al elegir el socio se cargan sus contactos y direcciones (`BusinessPartners(cod)` con `BPAddresses` y `ContactEmployees`) y se prellenan las direcciones, condiciones, forma de pago y cuenta asociada que trae (solo si el campo está vacío). La persona de contacto va bajo el nombre del socio. Las transferencias de stock y las solicitudes de traslado también llevan socio, contacto, destino, lista de precios (`PriceList`) y empleado (`SalesPersonCode`). Los códigos numéricos de las listas se guardan como número (`typedLookup`). Las extensiones de cliente pueden agregar campos a las pestañas (`logisticsLeft/Right`, `financeLeft/Right`). Sin verificar: «Responsable» de Logística (no se identificó su campo del Service Layer), canal del socio, cuota/consolidación de Finanzas. **Solicitudes de compra** (`/compras/solicitudes`, `tools/solicitud de compra.png`) son documentos de marketing sin socio: `marketingLayout({ requester: true })` — cabecera con tipo, solicitante (usuario de SAP: al elegirlo se completan nombre, correo, departamento y sucursal; `ReqType` 12), «Válido hasta», fecha necesaria e indicador/proyecto; líneas con proveedor sugerido (`LineVendor`), fecha necesaria por línea (columna `date` de la grilla) y cantidad/precio/descuento/impuesto/total/almacén; pie con gastos adicionales y totales; sin moneda, redondeo ni pestañas. Para el solicitante la API registra `Users` con `selectOnly` (`UserCode`, `UserName`, `eMail`, `Department`, `Branch`: `$select` obligatorio y limitado; la entidad trae contraseña y permisos), más `Departments` y `Branches`. Los documentos sin socio indican su maestro de impuestos con `DocumentScreen.taxResource`. **La dirección del documento** (`AddressExtension`: `ShipTo…` y `BillTo…`, `address-fields.ts`): el texto bajo el destino y la dirección de facturación se arma con sus partes (calle, número, bloque, edificio, ciudad, código postal, provincia, región, país) del documento guardado o, si se eligió otra, de la dirección del socio; el botón «…» abre una ventana (`aLine truncated
## Base de datos de SAP (consultas de solo lectura)
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. **PDF desde el servidor** (botón principal **Generar PDF** de la pantalla; «Ver como HTML» queda como vista previa para imprimir desde el navegador): `POST /api/html-reports/:code/pdf` pasa el mismo HTML por Chrome o Chromium sin pantalla (`--print-to-pdf`, sin librerías nuevas ni descargas: usa el instalado, `CHROME_BIN` o los lugares habituales) y devuelve el PDF con el papel y los márgenes de la plantilla fijos, sin depender del cuadro de impresión; cola propia (`HTML_PDF_MAX_CONCURRENCY` 1, `HTML_PDF_TIMEOUT` 30 s); sin Chrome responde 503 con el aviso. **En el despliegue (Docker) hay que instalar Chromium** y, si corre como administrador, Chrome se lanza con `--no-sandbox` solo. **Al imprimir el PDF hay que elegir «Tamaño real / 100 %»** (no «Ajustar»), sobre todo con papel precortado. Pendiente: 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.
## Cómo verificar cambios de interfaz
- Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP.
@@ -69,7 +81,7 @@ Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compart
Pendiente, en orden:
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla (en la carpeta de su módulo) declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. En ventas y compras la pantalla puede agregar campos (`headerLeft`/`headerRight`), reemplazar la cabecera (`header`) o elegir sus columnas de línea del catálogo `LINE_COLUMNS` (`lineColumns: [LINE_COLUMNS.item, LINE_COLUMNS.quantity, …]`; lo que se envía por línea sale de ellas); sin elegir, el documento lleva la cabecera y todas las columnas base.
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas).
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). **Campos de usuario por su definición en SAP (`udfField`):** en vez de escribir los valores válidos a mano, una extensión declara el campo por su tabla y alias (`udfField('OBTN', 'SIS_ESTLOT', 'Estado del lote')`; la clave es `U_SIS_ESTLOT`) y la pantalla lo pide como lo definió SAP, leyéndolo con `GET /api/sapdb/user-fields/:tabla/:alias` (`CUFD`/`UFD1`; `UserFieldsApi`, `sbo-udf-field`): **lista desplegable** si tiene valores válidos, **ventana de selección** (con el nombre junto al código) si está enlazado a una tabla de usuario (`CUFD.RTable`: la tabla sale de la definición del campo, nunca la elige quien llama), texto si no tiene ninguna o si la base de SAP no está configurada (`SAPDB_*`); un campo numérico (`N`) se guarda como número. Funciona en cabeceras de documentos y en maestros; las columnas de línea de usuario con valores válidos siguen siendo texto. El resultado se guarda 10 minutos en el servidor y una vez por sesión en el navegador (`SessionReset` lo olvida al iniciar o cerrar sesión). Verificado contra `SBO_TAGUMEDICA_21` (estado del lote: 4 valores; registro sanitario: 357 filas; movimiento de inventario: 84; tipo de operación: 47). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas).
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` (ventana de selección sobre `FactoringIndicators` —tabla OIDC; en tu SAP: 33 Factura, 39 Boleta; el recurso `Indicators` no existe en este Service Layer) y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`).
@@ -78,7 +90,7 @@ Pendiente, en orden:
**Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo).
**Redondeo** (`Rounding`, `RoundingDiffAmount`): fila «Redondeo» (casilla + importe) en el pie de ventas y compras, después de «Gastos adicionales» y antes del impuesto (como en SAP; ver `tools/factura.png`). Los campos siempre existen en el documento (se leen y se conservan); la fila solo se ve si la empresa usa redondeo por moneda (`OADM.RoundMthd`, `RoundingMethod = tYES` en el Service Layer; confirmado con el usuario; por documento no se muestra). Esa bandera y el separador de segmentos de las cuentas salen de `GET /api/company/settings` (backend: `CompanyService`, lee `CompanyService_GetAdminInfo` y entrega solo esos dos campos, con caché de 10 min). El importe solo cuenta con la casilla marcada; sin marcarla no se envía nada y SAP calcula el suyo. **Facturas y notas de crédito** (`billing: true`, `BILLING_DOCUMENT` en `screen-kit.ts`): el pie suma «Anticipo total» (`DownPaymentAmount`, tras el descuento), «Importe aplicado» (`PaidToDate`) y «Saldo pendiente» (`DocTotal − PaidToDate`, 0 en un documento nuevo), de solo lectura, como en SAP. **Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; configuración de SAP, no del código); en compras funciona (orden de compra 2885).
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. **Lotes** (`/inventario/lotes`, `masters/lotes/`, recurso `BatchNumberDetails`, solo campos estándar): lista (lote, artículo, descripción, vencimiento, estado; filtros de estado y de vencimiento) y formulario con estado (Liberado / Bloqueado / No accesible), atributos 1 y 2, fechas de admisión, fabricación y vencimiento y detalles. SAP crea los lotes desde los documentos, así que **no hay «Nuevo»**: el recurso admite solo `GET` y `PATCH`. Para esto el framework de maestros ganó `keyType: 'number'` (la clave es `DocEntry`; la navegación primero/anterior/siguiente/último no la pone entre comillas), `creatable: false` (sin botón «Nuevo»; `/nuevo` vuelve a la lista), `titleField` (el título muestra el número de lote, no la clave) y `badge` (la insignia sale del estado del lote). Los campos de usuario de cada cliente van en `lotes.extensions.ts` (vacío en `main`). **Stock del lote por almacén:** pestaña «Stock por almacén» (solo lectura, con fila «Total» y casilla para ocultar los almacenes sin cantidades) que sale de la consulta registrada `batch-stock` del módulo `sapdb` (tablas estándar `OBTQ`/`OBTN`/`OWHS`; el `DocEntry` del lote del Service Layer es `OBTN.AbsEntry`, verificado). Es una `MasterTab.query` (`sbo-query-table`): si la base de SAP no está configurada (`SAPDB_*`) o el usuario no tiene permiso, la pestaña no aparece (`SapDbApi.available`, una consulta por sesión; se reinicia al iniciar o cerrar sesión). Hoy solo con SQL de HANA; el de MSSQL se agrega cuando haya un cliente. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log.
5. ~~Migrar reportes/PDF vía API Gateway~~ — hecho (ver «Reportes Gateway» arriba; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro). **Pendiente, por cliente: reportes sencillos con plantilla HTML** (decidido con el usuario, no se construye en `main`; solo lo pide la rama de cada cliente). En el mini-erp era una sola plantilla (`mini-backend/templates/reports/stickers.html`, Jinja2 + WeasyPrint: `@page`, CSS grid, `page-break-after`, fuente propia en `fonts/`). Equivalente previsto: **Nunjucks** (misma sintaxis `{{ }}` / `{% for %}`; la plantilla se porta casi igual) + **Puppeteer** (Chrome headless, `page.pdf()`; soporta mejor `@page` y grid). Diseño: módulo genérico `apps/api/src/templates/` (renderiza y genera el PDF; cola de 1-2 con un solo navegador abierto; datos por el Service Layer) y las plantillas en `templates/<cliente>/<nombre>.njk` con su `fonts/`, dentro de la rama del cliente. Falta decidir cómo se elige una plantilla: botón PDF de un documento (recibe el `DocEntry`) o reporte más en «Reportes» con parámetros (encaja mejor en listados como los stickers). Si basta un documento simple, **pdfmake** evita Chrome (~150 MB en el despliegue). Aparte queda el botón PDF de cada documento con Crystal vía Gateway (falta el código de reporte de cada tipo).
@@ -91,3 +103,8 @@ Pendiente, en orden:
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.
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`) `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.
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.
9. **Estado al preparar las ramas (2026-10-19):** `main` limpio (120 commits), compila y pasa todo (libs 19, API 115, web 682), sin secretos versionados; el remoto es el Gitea `http://100.68.242.27:30000/george/basic-erp.git` (`origin`; `main` subida el 2026-10-19 y con seguimiento) y aún no hay ramas de cliente creadas.
+27
View File
@@ -45,3 +45,30 @@ APIGATEWAY_MAX_CONCURRENCY=2
# Certificado del gateway (PEM, relativo a apps/api) si es autofirmado
APIGATEWAY_CA_FILE=
APIGATEWAY_VERIFY_SSL=true
# --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional, sin motor el módulo queda sin configurar ---
# Hoy solo HANA. Usar un usuario de la base que SOLO pueda leer (nunca SYSTEM).
SAPDB_ENGINE=
SAPDB_HOST=
SAPDB_PORT=30015
SAPDB_USER=
SAPDB_PASSWORD=
# Esquema de la empresa (en HANA, el nombre de la base: SBO_EMPRESA)
SAPDB_SCHEMA=
SAPDB_TLS=false
SAPDB_CA_FILE=
SAPDB_VERIFY_SSL=true
# Espera máxima (segundos) de cada consulta, consultas a la vez y tope de filas
SAPDB_TIMEOUT=30
SAPDB_MAX_CONCURRENCY=2
SAPDB_MAX_ROWS=5000
# --- Reportes HTML (plantillas que se imprimen o se convierten en PDF) ---
# Carpeta de las plantillas Nunjucks, relativa a apps/api
HTML_REPORTS_DIR=./templates
# Chrome o Chromium con el que se genera el PDF (si se omite se busca el instalado)
CHROME_BIN=
# Espera máxima (segundos) de cada PDF y PDF que se generan a la vez
HTML_PDF_TIMEOUT=30
HTML_PDF_MAX_CONCURRENCY=1
+24
View File
@@ -108,3 +108,27 @@ Llama a `rs/v1/LoadAuthorizedCRList` (los reportes autorizados), `rs/v1/LoadCR`
La matrícula (tabla `gateway_reports` de la SQLite) es lo que arma el cuerpo de `ExportPDFData`: el usuario solo manda valores y el servidor los ordena según los parámetros matriculados (nombre y tipo de Crystal), exige los obligatorios y descarta lo que no esté matriculado. La auditoría registra `report_register`, `report_unregister` y `report_export` (solo los nombres de los parámetros, nunca los valores).
## Consultas a la base de datos de SAP (solo lectura)
Algunas pantallas necesitan datos que el Service Layer no entrega bien (por ejemplo, el stock de un lote por almacén). El módulo `src/sapdb/` ejecuta **consultas registradas, de solo lectura**, directamente en la base de SAP. Hoy el motor es **HANA** (`hdb`, JavaScript puro, sin módulos nativos); la interfaz (`SapDbAdapter`) deja listo sumar MSSQL con su adaptador.
- **Configuración (`SAPDB_*` en el `.env`):** `SAPDB_ENGINE=hana`, servidor, puerto (30015), usuario, clave y `SAPDB_SCHEMA` (la base de la empresa). Sin motor el módulo responde 503 y el resto de la API no se ve afectado. Usar un usuario de la base que **solo pueda leer**.
- **El frontend nunca manda SQL.** Pide una consulta por su nombre con sus parámetros: `POST /api/sapdb/queries/:name` `{params}` → `{rows, truncated}`; `GET /api/sapdb/queries` lista las que el usuario puede ejecutar.
- **Registro de consultas** (`sapdb-queries.ts` + `sapdb.extensions.ts`, vacío en `main`; la rama de cada cliente agrega las suyas): cada una declara su SQL por motor, sus parámetros con nombre (`:lote`, tipos `string | number | date`), el permiso de menú que basta para ejecutarla (`menu`, o `null` = cualquier sesión) y un tope de filas. Al arrancar se rechaza una consulta que no sea un único `SELECT`/`WITH`, que use verbos que escriben, que repita nombre o cuyos parámetros no coincidan con el SQL.
- **Parámetros:** el SQL lleva `:nombre` y el servidor los cambia por `?` con los valores como argumentos; un valor nunca se concatena al SQL.
- **Operación:** una conexión por consulta (se abre, fija el esquema, ejecuta y cierra; nunca confirma nada), cola propia (`SAPDB_MAX_CONCURRENCY`), tiempo máximo (`SAPDB_TIMEOUT`) y tope de filas (`SAPDB_MAX_ROWS`). La auditoría registra `db_query` con el nombre, los nombres de los parámetros y las filas devueltas, nunca los valores.
**Campos de usuario (`GET /api/sapdb/user-fields/:tabla/:alias`):** entrega la definición de un campo de usuario de SAP leída de `CUFD`/`UFD1` (tipo, tamaño, valores válidos) y, si está enlazado a una tabla de usuario (`CUFD.RTable`), sus filas `Code`/`Name` (hasta `SAPDB_MAX_ROWS`). La tabla enlazada sale de la definición del campo, no de la petición, así que no se puede leer una tabla cualquiera; la tabla y el alias se validan (letras, dígitos y «_»). Cualquier sesión puede pedirlo (es lo que SAP muestra al elegir en el campo); se guarda 10 minutos en memoria; se audita solo la tabla y el alias.
## Reportes HTML
Plantillas Nunjucks (`templates/`, una carpeta por cliente) que se rellenan con datos de la base de SAP y se ven como HTML o como PDF. Los reportes los declara la rama de cada cliente en `src/htmlreports/html-reports.extensions.ts` (vacío en `main`): código, título, parámetros (los mismos de los reportes del Gateway), plantilla y la función que arma los datos con consultas registradas de `sapdb`.
| Endpoint | Descripción |
|---|---|
| `GET /api/html-reports` | Reportes disponibles con sus parámetros |
| `POST /api/html-reports/:code/render` `{values}` | El HTML del reporte |
| `POST /api/html-reports/:code/pdf` `{values}` | El PDF: el mismo HTML pasado por Chrome sin pantalla, con el papel y los márgenes de la plantilla (`@page`) |
Permiso de menú `/reportes-html/ejecutar` en ambos. El PDF necesita **Chrome o Chromium instalado** (`CHROME_BIN`; si se omite se busca en los lugares habituales de macOS, Linux y Windows): sin él responde 503. Cola propia (`HTML_PDF_MAX_CONCURRENCY`) y tiempo máximo (`HTML_PDF_TIMEOUT`). La auditoría registra `html_report` con el formato y los nombres de los parámetros, nunca los valores.
+3
View File
@@ -29,6 +29,8 @@
"@nestjs/platform-express": "^12.0.1",
"@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 +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",
+3 -1
View File
@@ -4,13 +4,15 @@ 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';
import { SapModule } from './sap/sap.module.js';
import { SlModule } from './sl/sl.module.js';
@Module({
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule],
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule, HtmlReportsModule],
controllers: [HealthController],
})
export class AppModule {}
+31
View File
@@ -57,6 +57,37 @@ describe('validateEnv', () => {
expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false);
});
describe('base de datos de SAP', () => {
const db = { SAPDB_ENGINE: 'hana', SAPDB_HOST: 'hana.local', SAPDB_USER: 'lector', SAPDB_PASSWORD: 'p', SAPDB_SCHEMA: 'SBO_EMPRESA' };
it('es opcional: sin motor queda sin configurar y con valores por defecto', () => {
const env = validateEnv(sap);
expect(env.SAPDB_ENGINE).toBeUndefined();
expect(env.SAPDB_PORT).toBe(30015);
expect(env.SAPDB_TLS).toBe(false);
expect(env.SAPDB_TIMEOUT).toBe(30);
expect(env.SAPDB_MAX_CONCURRENCY).toBe(2);
expect(env.SAPDB_MAX_ROWS).toBe(5000);
});
it('con motor pide servidor, usuario, clave y esquema', () => {
expect(() => validateEnv({ ...sap, SAPDB_ENGINE: 'hana' })).toThrow(/SAPDB_HOST/);
expect(validateEnv({ ...sap, ...db }).SAPDB_ENGINE).toBe('hana');
});
it('el esquema solo admite letras, dígitos y «_» (se escribe en un SET SCHEMA)', () => {
expect(() => validateEnv({ ...sap, ...db, SAPDB_SCHEMA: 'X"; DROP' })).toThrow(/SAPDB_SCHEMA/);
});
it('un motor que aún no existe se rechaza', () => {
expect(() => validateEnv({ ...sap, ...db, SAPDB_ENGINE: 'mssql' })).toThrow(/SAPDB_ENGINE/);
});
it('no se permite desactivar la validación SSL en producción con TLS', () => {
expect(() => validateEnv({ ...sap, ...db, NODE_ENV: 'production', SAPDB_TLS: 'true', SAPDB_VERIFY_SSL: 'false' })).toThrow(/SAPDB_VERIFY_SSL/);
});
});
describe('API Gateway', () => {
it('es opcional: sin URL queda sin configurar y con valores por defecto', () => {
const env = validateEnv(sap);
+39
View File
@@ -65,6 +65,36 @@ export const envSchema = z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
// --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional; sin motor el módulo queda sin configurar ---
SAPDB_ENGINE: z.preprocess(emptyToUndefined, z.enum(['hana']).optional()),
SAPDB_HOST: z.preprocess(emptyToUndefined, z.string().optional()),
SAPDB_PORT: z.coerce.number().int().min(1).max(65535).default(30015),
SAPDB_USER: z.preprocess(emptyToUndefined, z.string().optional()),
SAPDB_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()),
// Esquema de la empresa (en HANA, el nombre de la base: `SBO_TAGUMEDICA_21`).
SAPDB_SCHEMA: z.preprocess(emptyToUndefined, z.string().regex(/^\w+$/, 'solo letras, dígitos y «_»').optional()),
SAPDB_TLS: z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true'),
SAPDB_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()),
SAPDB_VERIFY_SSL: z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
// Espera máxima (segundos) de cada consulta.
SAPDB_TIMEOUT: z.coerce.number().positive().default(30),
// Consultas que se ejecutan a la vez; el resto espera su turno (la base de SAP no es para esto).
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'),
// Chrome o Chromium con el que se genera el PDF de un reporte HTML (sin pantalla). Si no se indica, se busca el instalado.
CHROME_BIN: z.preprocess(emptyToUndefined, z.string().optional()),
// Espera máxima (segundos) de cada PDF y PDF que se generan a la vez (Chrome pesa).
HTML_PDF_TIMEOUT: z.coerce.number().positive().default(30),
HTML_PDF_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1),
SAPB1_SL_VERIFY_SSL: z
.enum(['true', 'false'])
.default('true')
@@ -81,6 +111,15 @@ export const envSchema = z
if (env.APIGATEWAY_CA_FILE && !existsSync(resolve(env.APIGATEWAY_CA_FILE))) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_CA_FILE'], message: `no existe el archivo ${resolve(env.APIGATEWAY_CA_FILE)}` });
}
if (env.SAPDB_CA_FILE && !existsSync(resolve(env.SAPDB_CA_FILE))) {
ctx.addIssue({ code: 'custom', path: ['SAPDB_CA_FILE'], message: `no existe el archivo ${resolve(env.SAPDB_CA_FILE)}` });
}
if (env.SAPDB_ENGINE && !(env.SAPDB_HOST && env.SAPDB_USER && env.SAPDB_PASSWORD && env.SAPDB_SCHEMA)) {
ctx.addIssue({ code: 'custom', path: ['SAPDB_ENGINE'], message: 'con SAPDB_ENGINE hacen falta SAPDB_HOST, SAPDB_USER, SAPDB_PASSWORD y SAPDB_SCHEMA' });
}
if (env.NODE_ENV === 'production' && env.SAPDB_TLS && !env.SAPDB_VERIFY_SSL) {
ctx.addIssue({ code: 'custom', path: ['SAPDB_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use SAPDB_CA_FILE' });
}
if (env.APIGATEWAY_URL && !(env.APIGATEWAY_USER && env.APIGATEWAY_PASSWORD && env.APIGATEWAY_COMPANYDB)) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_URL'], message: 'con APIGATEWAY_URL hacen falta APIGATEWAY_USER, APIGATEWAY_PASSWORD y APIGATEWAY_COMPANYDB' });
}
@@ -0,0 +1,96 @@
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service.js';
/** Dónde suele estar Chrome o Chromium (macOS, Linux y Windows). */
const CANDIDATES = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
];
/**
* Convierte el HTML de un reporte en PDF con Chrome sin pantalla (`--print-to-pdf`): el mismo motor que imprime el navegador,
* pero con el tamaño de papel y los márgenes de la plantilla (`@page`) fijos, sin depender del cuadro de impresión de cada
* usuario. No descarga nada: usa el Chrome o Chromium instalado (`CHROME_BIN`). Cola propia (`HTML_PDF_MAX_CONCURRENCY`).
*/
@Injectable()
export class HtmlPdfService {
private readonly logger = new Logger(HtmlPdfService.name);
private active = 0;
private readonly waiting: (() => void)[] = [];
constructor(private readonly config: AppConfigService) {}
/** Ruta de Chrome: la de `CHROME_BIN` o la primera instalada que se encuentre. */
chrome(): string | null {
const wanted = this.config.get('CHROME_BIN');
if (wanted) return existsSync(wanted) ? wanted : null;
return CANDIDATES.find((c) => existsSync(c)) ?? null;
}
private async turn<T>(work: () => Promise<T>): Promise<T> {
if (this.active >= this.config.get('HTML_PDF_MAX_CONCURRENCY')) await new Promise<void>((go) => this.waiting.push(go));
this.active++;
try {
return await work();
} finally {
this.active--;
this.waiting.shift()?.();
}
}
/** Ejecuta Chrome (separado para poder simularlo en las pruebas). */
protected run(bin: string, args: string[], timeoutMs: number): Promise<void> {
return new Promise((done, fail) => {
execFile(bin, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, _stdout, stderr) => {
if (err) fail(new Error(`Chrome no pudo generar el PDF: ${(stderr || err.message).toString().slice(0, 300)}`));
else done();
});
});
}
async toPdf(html: string): Promise<Buffer> {
const bin = this.chrome();
if (!bin) throw new ServiceUnavailableException('No hay Chrome o Chromium instalado para generar el PDF (indique su ruta en CHROME_BIN)');
return this.turn(async () => {
const dir = await mkdtemp(join(tmpdir(), 'reporte-'));
try {
const input = join(dir, 'reporte.html');
const output = join(dir, 'reporte.pdf');
await writeFile(input, html, 'utf8');
const args = [
'--headless=new',
'--disable-gpu',
'--no-pdf-header-footer',
'--disable-dev-shm-usage',
`--user-data-dir=${join(dir, 'perfil')}`,
// como administrador (contenedores) Chrome exige desactivar su sandbox
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
`--print-to-pdf=${output}`,
pathToFileURL(input).href,
];
await this.run(bin, args, this.config.get('HTML_PDF_TIMEOUT') * 1000);
const pdf = await readFile(output);
if (pdf.subarray(0, 4).toString('latin1') !== '%PDF') throw new Error('Chrome no devolvió un PDF');
return pdf;
} catch (e) {
this.logger.error(`No se pudo generar el PDF: ${e instanceof Error ? e.message : String(e)}`);
throw e;
} finally {
await rm(dir, { recursive: true, force: true });
}
});
}
}
+76
View File
@@ -0,0 +1,76 @@
import { existsSync } from 'node:fs';
import { ServiceUnavailableException } from '@nestjs/common';
import { vi } from 'vitest';
import type { AppConfigService } from '../config/app-config.service.js';
import { HtmlPdfService } from './html-pdf.service.js';
const config = (over: Record<string, unknown> = {}) => ({ get: (k: string) => ({ CHROME_BIN: undefined, HTML_PDF_TIMEOUT: 30, HTML_PDF_MAX_CONCURRENCY: 1, ...over })[k] }) as unknown as AppConfigService;
/** El servicio con Chrome simulado: escribe el PDF que se le pida donde dice `--print-to-pdf`. */
class FakeChrome extends HtmlPdfService {
calls: string[][] = [];
peak = 0;
private running = 0;
override chrome = () => '/usr/bin/chrome-falso';
protected override async run(_bin: string, args: string[]): Promise<void> {
this.calls.push(args);
this.running++;
this.peak = Math.max(this.peak, this.running);
await new Promise((r) => setTimeout(r, 5));
const { writeFile } = await import('node:fs/promises');
await writeFile(args.find((a) => a.startsWith('--print-to-pdf='))!.slice('--print-to-pdf='.length), '%PDF-1.4 falso');
this.running--;
}
}
describe('PDF de un reporte HTML', () => {
it('sin Chrome instalado avisa con 503 y dice cómo indicarlo', async () => {
const s = new HtmlPdfService(config({ CHROME_BIN: '/no/existe/chrome' }));
expect(s.chrome()).toBeNull();
await expect(s.toPdf('<p>x</p>')).rejects.toThrow(ServiceUnavailableException);
await expect(s.toPdf('<p>x</p>')).rejects.toThrow(/CHROME_BIN/);
});
it('pide a Chrome el PDF de la página sin encabezados ni pies y devuelve su contenido', async () => {
const s = new FakeChrome(config());
const pdf = await s.toPdf('<p>hola</p>');
expect(pdf.toString()).toContain('%PDF');
const args = s.calls[0];
expect(args).toEqual(expect.arrayContaining(['--headless=new', '--no-pdf-header-footer']));
expect(args.find((a) => a.startsWith('file://'))).toMatch(/reporte\.html$/);
});
it('solo genera HTML_PDF_MAX_CONCURRENCY PDF a la vez', async () => {
const s = new FakeChrome(config({ HTML_PDF_MAX_CONCURRENCY: 1 }));
await Promise.all([s.toPdf('a'), s.toPdf('b'), s.toPdf('c')]);
expect(s.peak).toBe(1);
expect(s.calls).toHaveLength(3);
});
it('un PDF que no lo es se rechaza', async () => {
const s = new FakeChrome(config());
vi.spyOn(s as unknown as { run: () => Promise<void> }, 'run').mockImplementation(async function (this: unknown, ...a: unknown[]) {
const args = a[1] as string[];
const { writeFile } = await import('node:fs/promises');
await writeFile(args.find((x) => x.startsWith('--print-to-pdf='))!.slice('--print-to-pdf='.length), 'no es pdf');
} as never);
await expect(s.toPdf('x')).rejects.toThrow(/no devolvió un PDF/);
});
// con Chrome de verdad (si está instalado): el PDF respeta el papel y los márgenes de la plantilla
const real = new HtmlPdfService(config());
it.skipIf(!real.chrome() || !existsSync('/opt/homebrew/bin/pdfinfo'))('con Chrome real genera un PDF Carta horizontal', async () => {
const pdf = await real.toPdf('<style>@page{size: letter landscape; margin: 1cm}</style><p>Hola</p><p style="page-break-before:always">2</p>');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
const { execFileSync } = await import('node:child_process');
const { mkdtempSync, rmSync, writeFileSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'prueba-pdf-'));
writeFileSync(join(dir, 'r.pdf'), pdf);
const info = execFileSync('/opt/homebrew/bin/pdfinfo', [join(dir, 'r.pdf')]).toString();
rmSync(dir, { recursive: true, force: true });
expect(info).toMatch(/Pages:\s+2/);
expect(info).toMatch(/792 x 612/);
}, 30000);
});
+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,34 @@
import { Body, Controller, Get, Header, HttpCode, Param, Post, Req, StreamableFile } 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 ?? '');
}
@Post(':code/pdf')
@HttpCode(200)
async pdf(@Req() req: AuthenticatedRequest, @Param('code', new ZodValidationPipe(codeSchema)) code: string, @Body(new ZodValidationPipe(bodySchema)) body: RenderDto) {
const pdf = await this.reports.pdf(req.user, code, body.values, req.ip ?? '');
return new StreamableFile(pdf, { type: 'application/pdf', disposition: `inline; filename="${code}.pdf"` });
}
}
@@ -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,15 @@
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 { HtmlPdfService } from './html-pdf.service.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: [HtmlPdfService, HtmlReportsService],
})
export class HtmlReportsModule {}
@@ -0,0 +1,104 @@
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 { HtmlPdfService } from './html-pdf.service.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];
// un parámetro de solo lectura vale siempre su valor por defecto, escriba lo que escriba el cliente
let text = p.readOnly ? '' : typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '';
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 readonly pdfService: HtmlPdfService,
) {}
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> {
return this.run(user, code, raw, ip, 'html', (html) => html);
}
/** El PDF del reporte: el mismo HTML pasado por Chrome sin pantalla, con el papel y los márgenes de la plantilla. */
async pdf(user: AuthUser, code: string, raw: Readonly<Record<string, unknown>>, ip: string): Promise<Buffer> {
return this.run(user, code, raw, ip, 'pdf', (html) => this.pdfService.toPdf(html));
}
private async run<T>(user: AuthUser, code: string, raw: Readonly<Record<string, unknown>>, ip: string, format: 'html' | 'pdf', finish: (html: string) => T | Promise<T>): Promise<T> {
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 = { format, 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 out = await finish(this.renderer.render(def.template, { ...data, params: values }));
await this.audit.record({ action: 'html_report', webUser: user.webUser, ip, resource: code, detail });
return out;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Reporte HTML ${code} (${format}) 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,160 @@
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 type { HtmlPdfService } from './html-pdf.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, params: [P({ name: 'papel', readOnly: true })] }])).toThrow(/solo lectura y necesita un valor por defecto/);
expect(validateHtmlReports([def])).toHaveLength(1);
});
});
describe('valores de los parámetros', () => {
it('un parámetro de solo lectura vale siempre su valor por defecto, aunque el cliente mande otro', () => {
const fijo = { ...def, params: [P({ name: 'papel', label: 'Papel', kind: 'select', options: [{ value: 'Carta', label: 'Carta' }, { value: 'A4', label: 'A4' }], defaultValue: 'Carta', readOnly: true })] };
expect(parseHtmlValues(fijo, {})).toEqual({ papel: 'Carta' });
expect(parseHtmlValues(fijo, { papel: 'A4' })).toEqual({ papel: 'Carta' });
});
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 toPdf = vi.fn(async (html: string) => Buffer.from(`%PDF ${html.length}`));
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,
{ toPdf } as unknown as HtmlPdfService,
);
beforeEach(() => {
paths = ['*'];
run.mockClear();
record.mockClear();
toPdf.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: { format: 'html', params: ['doc', 'cantidad', 'papel', 'fecha'] } }));
});
it('el PDF pasa el mismo HTML por Chrome y se audita como pdf', async () => {
const pdf = await service().pdf(user, 'etiquetas', { doc: '144153', cantidad: '3' }, '10.0.0.5');
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
expect(toPdf).toHaveBeenCalledWith(expect.stringContaining('<p>ACME</p>'));
expect(record).toHaveBeenCalledWith(expect.objectContaining({ detail: expect.objectContaining({ format: 'pdf' }) }));
});
it('el PDF exige los mismos permisos y parámetros que el HTML', async () => {
paths = ['/ventas/*'];
await expect(service().pdf(user, 'etiquetas', { doc: '1', cantidad: '1' }, '')).rejects.toThrow(ForbiddenException);
paths = ['*'];
await expect(service().pdf(user, 'etiquetas', { doc: '1' }, '')).rejects.toThrow(BadRequestException);
expect(toPdf).not.toHaveBeenCalled();
});
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' }));
});
});
+25
View File
@@ -0,0 +1,25 @@
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);
if (p.readOnly && !p.defaultValue) throw new Error(`El parámetro ${p.name} de ${r.code} es de solo lectura y necesita un valor por defecto`);
}
}
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[];
}
+73
View File
@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import hdb from 'hdb';
import type { SapDbAdapter, SapDbRow } from './sapdb.types.js';
export interface HanaOptions {
host: string;
port: number;
user: string;
password: string;
schema: string;
tls: boolean;
caFile?: string;
verifySsl: boolean;
}
/**
* Adaptador de SAP HANA con `hdb` (JavaScript puro, sin módulos nativos). Una conexión por consulta (la cola del
* servicio limita cuántas hay a la vez): se abre, fija el esquema de la empresa, ejecuta y se cierra. Nunca confirma
* nada (`autoCommit` apagado); el usuario de la base debe ser de solo lectura.
*/
export class HanaAdapter implements SapDbAdapter {
constructor(private readonly options: HanaOptions) {}
query(sql: string, args: readonly unknown[], opts: { timeoutMs: number }): Promise<SapDbRow[]> {
const o = this.options;
return new Promise<SapDbRow[]>((done, fail) => {
const client = hdb.createClient({
host: o.host,
port: o.port,
user: o.user,
password: o.password,
useTLS: o.tls,
...(o.tls && o.caFile ? { ca: readFileSync(resolve(o.caFile), 'utf8') } : {}),
...(o.tls ? { rejectUnauthorized: o.verifySsl } : {}),
});
let settled = false;
const finish = (err: Error | null, rows?: SapDbRow[]): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
client.end();
} catch {
/* la conexión ya se cerró */
}
if (err) fail(err);
else done(rows ?? []);
};
const timer = setTimeout(() => {
client.destroy();
finish(new Error(`La base de datos no respondió en ${Math.round(opts.timeoutMs / 1000)} s`));
}, opts.timeoutMs);
client.on('error', (err: Error) => finish(err));
client.connect((err?: Error) => {
if (err) return finish(err);
client.setAutoCommit(false);
// el esquema ya se validó al leer la configuración (solo letras, dígitos y «_»)
client.exec(`SET SCHEMA "${o.schema}"`, (e?: Error) => {
if (e) return finish(e);
client.prepare(sql, (pe: Error | undefined, stmt) => {
if (pe || !stmt) return finish(pe ?? new Error('No se pudo preparar la consulta'));
stmt.exec([...args], (xe: Error | undefined, rows?: unknown) => {
stmt.drop(() => undefined);
finish(xe ?? null, Array.isArray(rows) ? (rows as SapDbRow[]) : []);
});
});
});
});
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/** Lo mínimo de `hdb` (sin tipos propios) que usa el adaptador de HANA. */
declare module 'hdb' {
interface Statement {
exec(args: unknown[], cb: (err?: Error, rows?: unknown) => void): void;
drop(cb: () => void): void;
}
interface Client {
connect(cb: (err?: Error) => void): void;
setAutoCommit(on: boolean): void;
exec(sql: string, cb: (err?: Error) => void): void;
prepare(sql: string, cb: (err: Error | undefined, stmt?: Statement) => void): void;
on(event: 'error', cb: (err: Error) => void): void;
end(): void;
destroy(): void;
}
const hdb: { createClient(options: Record<string, unknown>): Client };
export default hdb;
}
+54
View File
@@ -0,0 +1,54 @@
import { assertReadOnly, paramNames } from './sapdb-sql.js';
import { CLIENT_SAPDB_QUERIES } from './sapdb.extensions.js';
import type { SapDbQuery } from './sapdb.types.js';
/** Consultas del proyecto base: solo una de comprobación (la base responde). Las de negocio van en `sapdb.extensions.ts`. */
export const BASE_SAPDB_QUERIES: readonly SapDbQuery[] = [
{
name: 'ping',
title: 'Comprobar la conexión con la base de datos',
sql: { hana: 'SELECT 1 AS "ok" FROM "DUMMY"' },
params: {},
menu: null,
},
{
// Tablas estándar de SAP B1 (OBTQ: cantidad por lote y almacén; OBTN: maestro de lotes; OWHS: almacenes). `absEntry` es el
// `DocEntry` del lote en el Service Layer.
name: 'batch-stock',
title: 'Stock de un lote por almacén',
sql: {
hana: `SELECT T1."WhsCode" AS "WarehouseCode", W."WhsName" AS "WarehouseName", T1."Quantity" AS "Quantity",
T1."CommitQty" AS "Committed", T1."CountQty" AS "Counted"
FROM "OBTQ" T1
INNER JOIN "OBTN" T0 ON T1."MdAbsEntry" = T0."AbsEntry" AND T1."ItemCode" = T0."ItemCode"
LEFT JOIN "OWHS" W ON W."WhsCode" = T1."WhsCode"
WHERE T0."AbsEntry" = :absEntry
ORDER BY T1."WhsCode"`,
},
params: { absEntry: { type: 'number' } },
menu: '/inventario/lotes',
maxRows: 1000,
},
];
/** Comprueba que los nombres no se repitan, que el SQL sea de solo lectura y que use justo los parámetros declarados. Lanza si no. */
export function validateQueries(queries: readonly SapDbQuery[]): readonly SapDbQuery[] {
const seen = new Set<string>();
for (const q of queries) {
if (!/^[a-z0-9][a-z0-9-]{0,59}$/.test(q.name)) throw new Error(`Nombre de consulta inválido: ${q.name}`);
if (seen.has(q.name)) throw new Error(`Consulta repetida: ${q.name}`);
seen.add(q.name);
const engines = Object.entries(q.sql);
if (!engines.length) throw new Error(`La consulta ${q.name} no tiene SQL`);
for (const [engine, sql] of engines) {
assertReadOnly(`${q.name} (${engine})`, sql);
const used = new Set(paramNames(sql));
const declared = new Set(Object.keys(q.params));
for (const n of used) if (!declared.has(n)) throw new Error(`La consulta ${q.name} (${engine}) usa :${n} sin declararlo`);
for (const n of declared) if (!used.has(n)) throw new Error(`La consulta ${q.name} (${engine}) declara ${n} y no lo usa`);
}
}
return queries;
}
export const SAPDB_QUERIES: ReadonlyMap<string, SapDbQuery> = new Map(validateQueries([...BASE_SAPDB_QUERIES, ...CLIENT_SAPDB_QUERIES]).map((q) => [q.name, q]));
+67
View File
@@ -0,0 +1,67 @@
import { BadRequestException } from '@nestjs/common';
import { assertReadOnly, bindNamed, paramNames, parseParams } from './sapdb-sql.js';
import { validateQueries } from './sapdb-queries.js';
import type { SapDbQuery } from './sapdb.types.js';
describe('assertReadOnly', () => {
it('acepta SELECT y WITH, con comentarios y literales que parecen verbos', () => {
expect(() => assertReadOnly('q', 'SELECT "UpdateDate", \'delete me\' AS "x" FROM "OBTN" -- drop table\nWHERE 1 = 1')).not.toThrow();
expect(() => assertReadOnly('q', 'WITH a AS (SELECT 1 AS "n" FROM "DUMMY") SELECT * FROM a')).not.toThrow();
expect(() => assertReadOnly('q', "SELECT REPLACE(\"ItemName\", 'a', 'b') AS \"n\" FROM \"OITM\"")).not.toThrow();
});
it('rechaza lo que escribe, varias sentencias y lo que no empieza por SELECT', () => {
for (const sql of ['DELETE FROM "OITM"', 'UPDATE "OITM" SET "a" = 1', 'SELECT 1 FROM "DUMMY"; DROP TABLE "OITM"', 'CALL "SP"()', 'SELECT 1 FROM "DUMMY" /* x */ ; SELECT 2 FROM "DUMMY"', 'WITH x AS (SELECT 1 AS "a" FROM "DUMMY") INSERT INTO "T" SELECT * FROM x']) {
expect(() => assertReadOnly('q', sql), sql).toThrow();
}
});
});
describe('parámetros con nombre', () => {
it('los nombres salen en orden y se ignoran los de literales, identificadores, comentarios y «::»', () => {
expect(paramNames('SELECT :a, \':no\', ":tampoco", x::int FROM t WHERE b = :b AND c = :a -- :fuera')).toEqual(['a', 'b', 'a']);
});
it('se cambian por «?» y los argumentos van en el mismo orden (un nombre repetido se repite)', () => {
const r = bindNamed('SELECT * FROM t WHERE a = :a AND b = :b AND c = :a AND d = \':a\'', { a: 1, b: 'x' });
expect(r.sql).toBe("SELECT * FROM t WHERE a = ? AND b = ? AND c = ? AND d = ':a'");
expect(r.args).toEqual([1, 'x', 1]);
});
it('un valor que parece SQL viaja como argumento, nunca dentro del texto', () => {
const r = bindNamed('SELECT 1 FROM t WHERE a = :a', { a: "x'; DROP TABLE t; --" });
expect(r.sql).toBe('SELECT 1 FROM t WHERE a = ?');
expect(r.args).toEqual(["x'; DROP TABLE t; --"]);
});
});
describe('parseParams', () => {
const q: SapDbQuery = {
name: 'demo',
title: 'Demo',
sql: { hana: 'SELECT 1 FROM "DUMMY" WHERE :lote = :lote AND :n = :n AND :f = :f AND :o = :o' },
params: { lote: { type: 'string' }, n: { type: 'number' }, f: { type: 'date' }, o: { type: 'string', required: false } },
menu: null,
};
it('comprueba tipos, exige los obligatorios y deja los opcionales en null', () => {
expect(parseParams(q, { lote: ' L1 ', n: '3', f: '2026-10-20' })).toEqual({ lote: 'L1', n: 3, f: '2026-10-20', o: null });
expect(() => parseParams(q, { n: 1, f: '2026-10-20' })).toThrow(BadRequestException);
expect(() => parseParams(q, { lote: 'a', n: 'x', f: '2026-10-20' })).toThrow(/número/);
expect(() => parseParams(q, { lote: 'a', n: 1, f: '20/10/2026' })).toThrow(/yyyy-MM-dd/);
expect(() => parseParams(q, { lote: 'a', n: 1, f: '2026-10-20', otro: 'x' })).toThrow(/desconocidos/);
});
});
describe('validateQueries', () => {
const base: SapDbQuery = { name: 'a', title: 'A', sql: { hana: 'SELECT 1 AS "x" FROM "DUMMY"' }, params: {}, menu: null };
it('rechaza nombres repetidos, SQL que escribe y parámetros sin declarar o sin usar', () => {
expect(() => validateQueries([base, base])).toThrow(/repetida/);
expect(() => validateQueries([{ ...base, sql: { hana: 'DELETE FROM "T"' } }])).toThrow(/SELECT/);
expect(() => validateQueries([{ ...base, sql: { hana: 'SELECT :x FROM "DUMMY"' } }])).toThrow(/sin declararlo/);
expect(() => validateQueries([{ ...base, params: { x: { type: 'string' } } }])).toThrow(/no lo usa/);
expect(() => validateQueries([{ ...base, name: 'Malo Nombre' }])).toThrow(/inválido/);
expect(validateQueries([base])).toHaveLength(1);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { BadRequestException } from '@nestjs/common';
import type { SapDbParamType, SapDbQuery } from './sapdb.types.js';
/** Quita comentarios y el contenido de los literales (`'…'`) y de los identificadores entre comillas (`"…"`), para revisar solo el SQL. */
function skeleton(sql: string): string {
let out = '';
let i = 0;
while (i < sql.length) {
const c = sql[i];
if (c === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n') i++;
} else if (c === '/' && sql[i + 1] === '*') {
const end = sql.indexOf('*/', i + 2);
i = end === -1 ? sql.length : end + 2;
} else if (c === "'" || c === '"') {
out += c + c;
i++;
while (i < sql.length && !(sql[i] === c && sql[i + 1] !== c)) i += sql[i] === c ? 2 : 1;
i++;
} else {
out += c;
i++;
}
}
return out;
}
const FORBIDDEN = /\b(insert|update|delete|merge|upsert|drop|alter|create|truncate|grant|revoke|call|exec|execute|commit|rollback)\b/i;
/** Una consulta registrada solo puede leer: un único `SELECT` o `WITH`, sin `;` ni verbos que escriben. Lanza si no cumple. */
export function assertReadOnly(name: string, sql: string): void {
const s = skeleton(sql).trim();
if (!/^(select|with)\b/i.test(s)) throw new Error(`La consulta ${name} debe empezar por SELECT o WITH`);
if (s.includes(';')) throw new Error(`La consulta ${name} no puede llevar «;»`);
const bad = FORBIDDEN.exec(s);
if (bad) throw new Error(`La consulta ${name} no puede usar «${bad[1].toUpperCase()}» (solo lectura)`);
}
/** Los `:nombre` que usa un SQL fuera de literales e identificadores, en orden de aparición. */
export function paramNames(sql: string): string[] {
const out: string[] = [];
const s = skeleton(sql);
for (const m of s.matchAll(/(?<![:\w]):([A-Za-z_]\w*)/g)) out.push(m[1]);
return out;
}
/** Cambia los `:nombre` por `?` y arma los argumentos en el mismo orden (un nombre repetido se repite). */
export function bindNamed(sql: string, values: Readonly<Record<string, unknown>>): { sql: string; args: unknown[] } {
const args: unknown[] = [];
let out = '';
let i = 0;
while (i < sql.length) {
const c = sql[i];
if (c === '-' && sql[i + 1] === '-') {
const end = sql.indexOf('\n', i);
const stop = end === -1 ? sql.length : end;
out += sql.slice(i, stop);
i = stop;
} else if (c === '/' && sql[i + 1] === '*') {
const end = sql.indexOf('*/', i + 2);
const stop = end === -1 ? sql.length : end + 2;
out += sql.slice(i, stop);
i = stop;
} else if (c === "'" || c === '"') {
let j = i + 1;
while (j < sql.length && !(sql[j] === c && sql[j + 1] !== c)) j += sql[j] === c ? 2 : 1;
out += sql.slice(i, j + 1);
i = j + 1;
} else if (c === ':' && sql[i - 1] !== ':' && /[A-Za-z_]/.test(sql[i + 1] ?? '')) {
const m = /^[A-Za-z_]\w*/.exec(sql.slice(i + 1))!;
args.push(values[m[0]] ?? null);
out += '?';
i += 1 + m[0].length;
} else {
out += c;
i++;
}
}
return { sql: out, args };
}
const DATE = /^\d{4}-\d{2}-\d{2}$/;
function coerce(name: string, type: SapDbParamType, raw: unknown): string | number {
if (type === 'number') {
const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN;
if (!Number.isFinite(n)) throw new BadRequestException(`El parámetro ${name} debe ser un número`);
return n;
}
if (typeof raw !== 'string') throw new BadRequestException(`El parámetro ${name} debe ser un texto`);
const text = raw.trim();
if (text.length > 200) throw new BadRequestException(`El parámetro ${name} es demasiado largo`);
if (type === 'date' && !DATE.test(text)) throw new BadRequestException(`El parámetro ${name} debe ser una fecha yyyy-MM-dd`);
return text;
}
/** Los valores recibidos según los parámetros declarados: tipo comprobado, obligatorios exigidos y sin nombres de más. */
export function parseParams(query: SapDbQuery, raw: Readonly<Record<string, unknown>>): Record<string, string | number | null> {
const unknown = Object.keys(raw).filter((k) => !(k in query.params));
if (unknown.length) throw new BadRequestException(`Parámetros desconocidos: ${unknown.join(', ')}`);
const out: Record<string, string | number | null> = {};
for (const [name, def] of Object.entries(query.params)) {
const v = raw[name];
if (v === undefined || v === null || v === '') {
if (def.required ?? true) throw new BadRequestException(`Falta el parámetro ${name}`);
out[name] = null;
} else {
out[name] = coerce(name, def.type, v);
}
}
return out;
}
+35
View File
@@ -0,0 +1,35 @@
import { Body, Controller, Get, 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 { SapDbService } from './sapdb.service.js';
const nameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/, 'Nombre de consulta inválido');
const bodySchema = z.object({ params: z.record(z.string(), z.union([z.string().max(200), z.number()])).default({}) });
type Body_ = z.infer<typeof bodySchema>;
/** Consultas de solo lectura a la base de datos de SAP (las registradas; el permiso lo declara cada una). */
@Controller('sapdb')
export class SapDbController {
constructor(private readonly sapdb: SapDbService) {}
@Get('queries')
queries(@Req() req: AuthenticatedRequest) {
return this.sapdb.available(req.user);
}
@Get('user-fields/:table/:alias')
userField(@Req() req: AuthenticatedRequest, @Param('table') table: string, @Param('alias') alias: string) {
return this.sapdb.userField(req.user, table, alias, req.ip ?? '');
}
@Post('queries/:name')
@HttpCode(200)
run(
@Req() req: AuthenticatedRequest,
@Param('name', new ZodValidationPipe(nameSchema)) name: string,
@Body(new ZodValidationPipe(bodySchema)) body: Body_,
) {
return this.sapdb.run(req.user, name, body.params, req.ip ?? '');
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { SapDbQuery } from './sapdb.types.js';
/**
* Consultas a la base de SAP que agrega la rama de cada cliente (vacío en `main`): el SQL depende de sus tablas y de
* sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú.
*/
export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = [];
+37
View File
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module.js';
import { AppConfigService } from '../config/app-config.service.js';
import { PermissionsModule } from '../permissions/permissions.module.js';
import { HanaAdapter } from './hana.adapter.js';
import { SapDbController } from './sapdb.controller.js';
import { SapDbService } from './sapdb.service.js';
import { SAPDB_ADAPTER, type SapDbAdapter } from './sapdb.types.js';
/** Consultas de solo lectura a la base de SAP B1 (hoy HANA): su propia cola y sus propias consultas registradas. */
@Module({
imports: [AuditModule, PermissionsModule],
controllers: [SapDbController],
exports: [SapDbService],
providers: [
{
provide: SAPDB_ADAPTER,
inject: [AppConfigService],
// sin motor configurado no hay adaptador: el servicio responde 503 y el resto de la API sigue igual
useFactory: (config: AppConfigService): SapDbAdapter | null =>
config.get('SAPDB_ENGINE') === 'hana'
? new HanaAdapter({
host: config.get('SAPDB_HOST')!,
port: config.get('SAPDB_PORT'),
user: config.get('SAPDB_USER')!,
password: config.get('SAPDB_PASSWORD')!,
schema: config.get('SAPDB_SCHEMA')!,
tls: config.get('SAPDB_TLS'),
caFile: config.get('SAPDB_CA_FILE'),
verifySsl: config.get('SAPDB_VERIFY_SSL'),
})
: null,
},
SapDbService,
],
})
export class SapDbModule {}
@@ -0,0 +1,61 @@
import { ForbiddenException } from '@nestjs/common';
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 { SapDbAdapter, SapDbQuery } from './sapdb.types.js';
// un registro con una consulta que exige permiso de menú (la de `main` no lo exige)
vi.mock('./sapdb-queries.js', () => {
const q: SapDbQuery = {
name: 'lot-stock',
title: 'Stock de un lote',
sql: { hana: 'SELECT "WhsCode" FROM "OBTQ" WHERE "DistNumber" = :lote' },
params: { lote: { type: 'string' } },
menu: ['/inventario/lotes', '/inventario/liberar-lotes'],
};
return { SAPDB_QUERIES: new Map([[q.name, q]]) };
});
const { SapDbService } = await import('./sapdb.service.js');
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
describe('permiso de una consulta', () => {
const query = vi.fn(async () => [{ WhsCode: '201' }]);
const record = vi.fn(async () => undefined);
let paths: string[];
const service = () =>
new SapDbService(
{ query } as SapDbAdapter,
{ get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: 1, SAPDB_MAX_ROWS: 100, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
beforeEach(() => {
query.mockClear();
record.mockClear();
});
it('basta el permiso de cualquiera de las rutas que declara la consulta', async () => {
paths = ['/inventario/liberar-lotes'];
expect((await service().run(user, 'lot-stock', { lote: 'L1' }, '')).rows).toEqual([{ WhsCode: '201' }]);
expect(query).toHaveBeenCalledWith('SELECT "WhsCode" FROM "OBTQ" WHERE "DistNumber" = ?', ['L1'], expect.anything());
});
it('sin permiso se deniega, se audita y no se consulta; tampoco aparece en la lista', async () => {
paths = ['/ventas/*'];
await expect(service().run(user, 'lot-stock', { lote: 'L1' }, '')).rejects.toThrow(ForbiddenException);
expect(query).not.toHaveBeenCalled();
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', outcome: 'denied', resource: 'lot-stock' }));
expect(await service().available(user)).toEqual([]);
});
it('un parámetro de más o de menos se rechaza antes de llegar a la base', async () => {
paths = ['*'];
await expect(service().run(user, 'lot-stock', {}, '')).rejects.toThrow(/Falta/);
await expect(service().run(user, 'lot-stock', { lote: 'L1', x: '1' }, '')).rejects.toThrow(/desconocidos/);
expect(query).not.toHaveBeenCalled();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { BadGatewayException, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
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 { SapDbService } from './sapdb.service.js';
import type { SapDbAdapter } from './sapdb.types.js';
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
describe('SapDbService', () => {
let query: ReturnType<typeof vi.fn>;
let record: ReturnType<typeof vi.fn>;
let paths: string[];
let max: number;
const make = (adapter: SapDbAdapter | null) =>
new SapDbService(
adapter,
{ get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: max, SAPDB_MAX_ROWS: 3, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
beforeEach(() => {
query = vi.fn(async () => [{ ok: 1 }]);
record = vi.fn(async () => undefined);
paths = ['*'];
max = 2;
});
it('sin base configurada responde 503', async () => {
const s = make(null);
expect(s.configured).toBe(false);
await expect(s.run(user, 'ping', {}, '10.0.0.5')).rejects.toThrow(ServiceUnavailableException);
});
it('ejecuta una consulta registrada y audita solo los nombres de los parámetros', async () => {
const s = make({ query } as SapDbAdapter);
expect(await s.run(user, 'ping', {}, '10.0.0.5')).toEqual({ rows: [{ ok: 1 }], truncated: false });
expect(query).toHaveBeenCalledWith('SELECT 1 AS "ok" FROM "DUMMY"', [], { timeoutMs: 5000 });
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', resource: 'ping', detail: expect.objectContaining({ params: [], rows: 1 }) }));
});
it('una consulta que no existe da 404 y nunca llega a la base', async () => {
await expect(make({ query } as SapDbAdapter).run(user, 'drop-everything', {}, '')).rejects.toThrow(NotFoundException);
expect(query).not.toHaveBeenCalled();
});
it('recorta lo que pasa del tope de filas y lo avisa', async () => {
query.mockResolvedValue([{ n: 1 }, { n: 2 }, { n: 3 }, { n: 4 }]);
const r = await make({ query } as SapDbAdapter).run(user, 'ping', {}, '');
expect(r.rows).toHaveLength(3);
expect(r.truncated).toBe(true);
});
it('un error de la base se audita y sale como 502 con el mensaje', async () => {
query.mockRejectedValue(new Error('invalid table name: OXXX'));
await expect(make({ query } as SapDbAdapter).run(user, 'ping', {}, '')).rejects.toThrow(BadGatewayException);
expect(record).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'error', detail: expect.objectContaining({ error: 'invalid table name: OXXX' }) }));
});
it('la cola deja pasar solo SAPDB_MAX_CONCURRENCY consultas a la vez', async () => {
max = 1;
let running = 0;
let peak = 0;
query.mockImplementation(async () => {
running++;
peak = Math.max(peak, running);
await new Promise((r) => setTimeout(r, 5));
running--;
return [];
});
const s = make({ query } as SapDbAdapter);
await Promise.all([s.run(user, 'ping', {}, ''), s.run(user, 'ping', {}, ''), s.run(user, 'ping', {}, '')]);
expect(peak).toBe(1);
expect(query).toHaveBeenCalledTimes(3);
});
it('lista las consultas disponibles', async () => {
// las de la rama de un cliente se suman a las del base: se comprueba que estén las del base
const names = (await make({ query } as SapDbAdapter).available(user)).map((q) => q.name);
expect(names).toEqual(expect.arrayContaining(['ping', 'batch-stock']));
});
});
+114
View File
@@ -0,0 +1,114 @@
import { BadGatewayException, BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException, NotImplementedException } 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 { SAPDB_QUERIES } from './sapdb-queries.js';
import { FIELD_ALIAS, TABLE_ID, type UserFieldInfo, readUserField } from './user-fields.js';
import { bindNamed, parseParams } from './sapdb-sql.js';
import { SAPDB_ADAPTER, type SapDbAdapter, type SapDbQuery, type SapDbResult } from './sapdb.types.js';
/**
* Consultas de solo lectura a la base de datos de SAP B1. Solo se ejecutan las registradas (`sapdb-queries.ts` y la
* extensión del cliente), con parámetros tipados y cola propia (`SAPDB_MAX_CONCURRENCY`): la base de SAP no es un
* servicio para ráfagas. Quien consulta necesita el permiso de menú que declara la consulta.
*/
@Injectable()
export class SapDbService {
private readonly logger = new Logger(SapDbService.name);
private active = 0;
private readonly waiting: (() => void)[] = [];
constructor(
@Inject(SAPDB_ADAPTER) private readonly adapter: SapDbAdapter | null,
private readonly config: AppConfigService,
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
) {}
get configured(): boolean {
return this.adapter !== null;
}
/** Las consultas que el usuario puede ejecutar (nombre y título). */
async available(user: AuthUser): Promise<{ name: string; title: string }[]> {
const paths = await this.permissions.getPaths(user.webUser);
return [...SAPDB_QUERIES.values()].filter((q) => this.allowed(paths, q)).map((q) => ({ name: q.name, title: q.title }));
}
private allowed(paths: readonly string[], q: SapDbQuery): boolean {
if (q.menu === null) return true;
return [q.menu].flat().some((m) => isPathAllowed([...paths], m));
}
private async turn<T>(work: () => Promise<T>): Promise<T> {
if (this.active >= this.config.get('SAPDB_MAX_CONCURRENCY')) await new Promise<void>((go) => this.waiting.push(go));
this.active++;
try {
return await work();
} finally {
this.active--;
this.waiting.shift()?.();
}
}
private readonly userFields = new Map<string, { at: number; info: UserFieldInfo | null }>();
/**
* Definición de un campo de usuario de SAP (tipo, valores válidos y tabla de usuario enlazada con sus filas). Cualquier
* sesión puede pedirla: es la misma información que SAP muestra al elegir en el campo. Se guarda 10 minutos en memoria.
*/
async userField(user: AuthUser, table: string, alias: string, ip: string): Promise<UserFieldInfo> {
if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)');
if (!TABLE_ID.test(table) || !FIELD_ALIAS.test(alias)) throw new BadRequestException('Tabla o campo inválido');
const key = `${table.toUpperCase()}|${alias.toUpperCase()}`;
const cached = this.userFields.get(key);
if (cached && Date.now() - cached.at < 10 * 60_000) {
if (!cached.info) throw new NotFoundException(`No existe el campo de usuario U_${alias} en ${table}`);
return cached.info;
}
const opts = { timeoutMs: this.config.get('SAPDB_TIMEOUT') * 1000, maxRows: this.config.get('SAPDB_MAX_ROWS') };
try {
const info = await this.turn(() => readUserField(this.adapter!, this.config.get('SAPDB_ENGINE') ?? 'hana', table, alias, opts));
this.userFields.set(key, { at: Date.now(), info });
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: 'user-field', detail: { table, alias, found: info !== null } });
if (!info) throw new NotFoundException(`No existe el campo de usuario U_${alias} en ${table}`);
return info;
} catch (e) {
if (e instanceof NotFoundException) throw e;
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Campo de usuario ${table}.${alias} falló: ${message}`);
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: 'user-field', outcome: 'error', detail: { table, alias, error: message.slice(0, 300) } });
throw new BadGatewayException(`No se pudo consultar el campo de usuario: ${message.slice(0, 200)}`);
}
}
async run(user: AuthUser, name: string, rawParams: Readonly<Record<string, unknown>>, ip: string): Promise<SapDbResult> {
if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)');
const query = SAPDB_QUERIES.get(name);
if (!query) throw new NotFoundException(`No existe la consulta ${name}`);
if (!this.allowed(await this.permissions.getPaths(user.webUser), query)) {
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, outcome: 'denied' });
throw new ForbiddenException('No tiene permisos para esta consulta');
}
const sql = query.sql[this.config.get('SAPDB_ENGINE') ?? 'hana'];
if (!sql) throw new NotImplementedException(`La consulta ${name} no está disponible para este motor de base de datos`);
const values = parseParams(query, rawParams);
const bound = bindNamed(sql, values);
const cap = Math.min(query.maxRows ?? Infinity, this.config.get('SAPDB_MAX_ROWS'));
// solo los nombres de los parámetros: los valores pueden ser datos de clientes
const detail = { params: Object.keys(query.params) };
const started = Date.now();
try {
const rows = await this.turn(() => this.adapter!.query(bound.sql, bound.args, { timeoutMs: this.config.get('SAPDB_TIMEOUT') * 1000 }));
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, detail: { ...detail, rows: rows.length, ms: Date.now() - started } });
return { rows: rows.slice(0, cap), truncated: rows.length > cap };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Consulta ${name} falló: ${message}`);
await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, outcome: 'error', detail: { ...detail, error: message.slice(0, 300) } });
throw new BadGatewayException(`No se pudo consultar la base de datos: ${message.slice(0, 200)}`);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/** Motores de base de datos de SAP B1 que el módulo sabe consultar (hoy solo HANA; MSSQL se suma con su adaptador). */
export type SapDbEngine = 'hana';
export type SapDbParamType = 'string' | 'number' | 'date';
/**
* Una consulta registrada: el frontend pide `name` con sus parámetros y nunca manda SQL. Lo que pasa a la base es
* siempre el SQL del registro, con los parámetros como marcadores (`:nombre`), nunca concatenados.
*/
export interface SapDbQuery {
/** Identificador estable (`lot-stock`); es lo que viaja en la URL y en la auditoría. */
name: string;
title: string;
/** El SQL por motor (solo `SELECT`/`WITH`); una consulta puede declarar solo el motor que usa su cliente. */
sql: Partial<Record<SapDbEngine, string>>;
/** Parámetros con nombre (`:warehouse` en el SQL). */
params: Readonly<Record<string, { type: SapDbParamType; required?: boolean }>>;
/** Ruta(s) de menú cuyo permiso basta para ejecutarla; `null` = cualquier sesión. */
menu: string | readonly string[] | null;
/** Tope de filas de esta consulta (no puede superar `SAPDB_MAX_ROWS`). */
maxRows?: number;
}
export type SapDbRow = Record<string, unknown>;
/** Lo que un motor implementa: ejecutar un `SELECT` con argumentos posicionales y devolver filas. */
export interface SapDbAdapter {
query(sql: string, args: readonly unknown[], opts: { timeoutMs: number }): Promise<SapDbRow[]>;
}
/** Respuesta de una consulta. */
export interface SapDbResult {
rows: SapDbRow[];
/** Hubo más filas que el tope y se recortó. */
truncated: boolean;
}
/** Token de inyección del adaptador del motor configurado (`null` si el módulo no está configurado). */
export const SAPDB_ADAPTER = Symbol('SAPDB_ADAPTER');
+107
View File
@@ -0,0 +1,107 @@
import { NotFoundException } from '@nestjs/common';
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 { SapDbService } from './sapdb.service.js';
import type { SapDbAdapter } from './sapdb.types.js';
import { readUserField } from './user-fields.js';
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
const opts = { timeoutMs: 1000, maxRows: 2 };
/** Una base simulada: responde según el SQL. */
function fakeDb(over: { info?: unknown[]; values?: unknown[]; linked?: unknown[] | Error } = {}) {
const calls: { sql: string; args: readonly unknown[] }[] = [];
const adapter: SapDbAdapter = {
async query(sql, args) {
calls.push({ sql, args });
if (sql.includes('"CUFD"')) return (over.info ?? []) as never;
if (sql.includes('"UFD1"')) return (over.values ?? []) as never;
if (over.linked instanceof Error) throw over.linked;
return (over.linked ?? []) as never;
},
};
return { adapter, calls };
}
describe('campo de usuario de SAP', () => {
it('un campo con valores válidos entrega su tipo y sus valores', async () => {
const db = fakeDb({ info: [{ FieldID: 12, Descr: 'Estado del lote', TypeID: 'A', EditSize: 3, RTable: null }], values: [{ FldValue: '101', Descr: 'Cuarentena' }, { FldValue: '102', Descr: 'Aprobado' }] });
expect(await readUserField(db.adapter, 'hana', 'OBTN', 'SIS_ESTLOT', opts)).toEqual({
table: 'OBTN', alias: 'SIS_ESTLOT', description: 'Estado del lote', type: 'A', size: 3,
validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }],
linkedTable: null, linkedRows: [], linkedTruncated: false,
});
// el campo y la tabla viajan como argumentos, nunca dentro del SQL
expect(db.calls[0].args).toEqual(['OBTN', 'SIS_ESTLOT']);
expect(db.calls[1].args).toEqual(['OBTN', 12]);
});
it('un campo enlazado a una tabla de usuario entrega sus filas, recortadas al tope', async () => {
const db = fakeDb({ info: [{ FieldID: 3, Descr: 'Registro sanitario', TypeID: 'A', EditSize: 50, RTable: 'SIS_REGSAN' }], linked: [{ Code: '1', Name: 'A' }, { Code: '2', Name: null }, { Code: '3', Name: 'C' }] });
const r = await readUserField(db.adapter, 'hana', 'OBTN', 'SIS_REGSAN', opts);
expect(r?.linkedTable).toBe('SIS_REGSAN');
expect(r?.linkedRows).toEqual([{ code: '1', name: 'A' }, { code: '2', name: '' }]);
expect(r?.linkedTruncated).toBe(true);
expect(db.calls[2].sql).toBe('SELECT "Code", "Name" FROM "@SIS_REGSAN" ORDER BY "Code"');
});
it('la tabla enlazada sale del campo y se valida: un nombre raro no llega al SQL', async () => {
const db = fakeDb({ info: [{ FieldID: 3, Descr: 'X', TypeID: 'A', EditSize: 5, RTable: 'X"; DROP TABLE "OITM' }] });
const r = await readUserField(db.adapter, 'hana', 'OBTN', 'X', opts);
expect(r?.linkedRows).toEqual([]);
expect(db.calls).toHaveLength(2);
});
it('si el enlace no es a una tabla con Code y Name, el campo se trata como libre; un campo que no existe da null', async () => {
const db = fakeDb({ info: [{ FieldID: 3, Descr: 'X', TypeID: 'A', EditSize: 5, RTable: 'OCRD' }], linked: new Error('invalid column name: Code') });
expect((await readUserField(db.adapter, 'hana', 'OBTN', 'X', opts))?.linkedRows).toEqual([]);
expect(await readUserField(fakeDb().adapter, 'hana', 'OBTN', 'NADA', opts)).toBeNull();
});
});
describe('SapDbService.userField', () => {
const make = (adapter: SapDbAdapter | null) => {
const record = vi.fn(async () => undefined);
const service = new SapDbService(
adapter,
{ get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: 2, SAPDB_MAX_ROWS: 100, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService,
{ getPaths: async () => ['*'] } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
return { service, record };
};
it('rechaza una tabla o un campo con caracteres raros antes de tocar la base', async () => {
const db = fakeDb();
const { service } = make(db.adapter);
await expect(service.userField(user, 'OBTN"; --', 'X', '')).rejects.toThrow(/inválido/);
await expect(service.userField(user, 'OBTN', 'a b', '')).rejects.toThrow(/inválido/);
expect(db.calls).toHaveLength(0);
});
it('un campo que no existe da 404 y se recuerda (no se vuelve a preguntar)', async () => {
const db = fakeDb();
const { service } = make(db.adapter);
await expect(service.userField(user, 'OBTN', 'NADA', '')).rejects.toThrow(NotFoundException);
const calls = db.calls.length;
await expect(service.userField(user, 'obtn', 'nada', '')).rejects.toThrow(NotFoundException);
expect(db.calls.length).toBe(calls);
});
it('guarda la respuesta en memoria y audita solo la tabla y el campo', async () => {
const db = fakeDb({ info: [{ FieldID: 1, Descr: 'E', TypeID: 'A', EditSize: 3, RTable: null }] });
const { service, record } = make(db.adapter);
await service.userField(user, 'OBTN', 'SIS_ESTLOT', '10.0.0.5');
const calls = db.calls.length;
await service.userField(user, 'OBTN', 'SIS_ESTLOT', '10.0.0.5');
expect(db.calls.length).toBe(calls);
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', resource: 'user-field', detail: { table: 'OBTN', alias: 'SIS_ESTLOT', found: true } }));
});
it('sin base configurada responde 503', async () => {
await expect(make(null).service.userField(user, 'OBTN', 'X', '')).rejects.toThrow(/no está configurada/);
});
});
+82
View File
@@ -0,0 +1,82 @@
import type { SapDbAdapter, SapDbEngine } from './sapdb.types.js';
/** Lo que SAP sabe de un campo de usuario: su tipo, sus valores válidos y la tabla de usuario a la que está enlazado. */
export interface UserFieldInfo {
table: string;
alias: string;
description: string;
/** Tipo de SAP: `A` alfanumérico, `N` numérico, `D` fecha… */
type: string;
size: number;
/** Valores válidos del campo (vacío si acepta cualquier valor). */
validValues: { value: string; description: string }[];
/** Tabla de usuario enlazada (`SIS_REGSAN`), si el campo está vinculado a una. */
linkedTable: string | null;
/** Filas (`Code` y `Name`) de la tabla enlazada. */
linkedRows: { code: string; name: string }[];
/** La tabla enlazada tiene más filas de las que se entregan. */
linkedTruncated: boolean;
}
/** Una tabla de SAP (`OBTN`, `@MI_TABLA`) y el alias de un campo de usuario (`SIS_ESTLOT`, sin el `U_`). */
export const TABLE_ID = /^@?[A-Za-z0-9_]{1,30}$/;
export const FIELD_ALIAS = /^[A-Za-z0-9_]{1,30}$/;
/** Una tabla enlazada se escribe en el SQL entre comillas: solo letras, dígitos y «_». */
const LINKED = /^[A-Za-z0-9_]{1,30}$/;
interface Sql {
info: string;
values: string;
linked: (table: string) => string;
}
/** El SQL de cada motor (`CUFD` define los campos de usuario y `UFD1` sus valores válidos; existen igual en HANA y MSSQL). */
const SQL: Record<SapDbEngine, Sql> = {
hana: {
info: 'SELECT "FieldID", "Descr", "TypeID", "EditSize", "RTable" FROM "CUFD" WHERE "TableID" = ? AND "AliasID" = ?',
values: 'SELECT "FldValue", "Descr" FROM "UFD1" WHERE "TableID" = ? AND "FieldID" = ? ORDER BY "IndexID"',
linked: (t) => `SELECT "Code", "Name" FROM "@${t}" ORDER BY "Code"`,
},
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/**
* Lee de la base la definición de un campo de usuario. Devuelve `null` si el campo no existe. La tabla enlazada sale de
* la propia definición del campo (no la elige quien llama), así que no se puede leer una tabla cualquiera.
*/
export async function readUserField(
adapter: SapDbAdapter,
engine: SapDbEngine,
table: string,
alias: string,
opts: { timeoutMs: number; maxRows: number },
): Promise<UserFieldInfo | null> {
const sql = SQL[engine];
const [field] = await adapter.query(sql.info, [table, alias], opts);
if (!field) return null;
const values = await adapter.query(sql.values, [table, field['FieldID']], opts);
const linkedTable = text(field['RTable']) || null;
let linkedRows: UserFieldInfo['linkedRows'] = [];
let linkedTruncated = false;
if (linkedTable && LINKED.test(linkedTable)) {
try {
const rows = await adapter.query(sql.linked(linkedTable), [], opts);
linkedTruncated = rows.length > opts.maxRows;
linkedRows = rows.slice(0, opts.maxRows).map((r) => ({ code: text(r['Code']), name: text(r['Name']) }));
} catch {
/* el enlace no es a una tabla de usuario con Code y Name: el campo se trata como de valores libres */
}
}
return {
table,
alias,
description: text(field['Descr']),
type: text(field['TypeID']),
size: Number(field['EditSize'] ?? 0) || 0,
validValues: values.map((v) => ({ value: text(v['FldValue']), description: text(v['Descr']) })),
linkedTable,
linkedRows,
linkedTruncated,
};
}
@@ -0,0 +1,15 @@
import type { SlResource } from './sl-resources.js';
/**
* Lo que la rama de un cliente suma al proxy del Service Layer (vacío en `main`).
*
* `CLIENT_RESOURCE_PATHS`: pantallas suyas (rutas de menú) que además pueden leer (`read`) o escribir (`write`) un
* recurso que ya existe, por nombre. Por ejemplo, «Inmovilizar lotes» crea solicitudes de traslado:
*
* InventoryTransferRequests: { write: ['/inventario/inmovilizar-lotes'], read: ['/inventario/inmovilizar-lotes'] },
*
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
*/
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {};
export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { SL_RESOURCE_MAP, SL_RESOURCES } from './sl-resources.js';
import { SL_RESOURCE_MAP, SL_RESOURCES, extendResources } from './sl-resources.js';
describe('recursos del Service Layer', () => {
it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => {
@@ -17,6 +17,13 @@ describe('recursos del Service Layer', () => {
expect(r.read).toBeNull();
});
it('la serie por defecto de un documento también es una acción de solo consulta', () => {
const r = SL_RESOURCE_MAP.get('SeriesService_GetDefaultSeries')!;
expect(r.methods).toEqual(['POST']);
expect(r.write).toBeNull();
expect(r.read).toBeNull();
});
it('dimensiones, proyectos y cuentas son catálogos de solo lectura', () => {
for (const name of ['FactoringIndicators', 'DistributionRules', 'Projects', 'AccountSegmentations', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) {
const r = SL_RESOURCE_MAP.get(name)!;
@@ -85,6 +92,13 @@ describe('recursos del Service Layer', () => {
for (const name of ['ShippingTypes', 'WizardPaymentMethods']) expect(SL_RESOURCE_MAP.get(name)!.methods, name).toEqual(['GET']);
});
it('los lotes se consultan y se modifican, pero no se crean desde aquí (nacen en los documentos)', () => {
const r = SL_RESOURCE_MAP.get('BatchNumberDetails')!;
expect(r.key).toBe('number');
expect(r.methods).toEqual(['GET', 'PATCH']);
expect(r.write).toBe('/inventario/lotes');
});
it('los usuarios de SAP solo entregan los campos que necesita el solicitante', () => {
const r = SL_RESOURCE_MAP.get('Users')!;
expect(r.methods).toEqual(['GET']);
@@ -92,3 +106,24 @@ describe('recursos del Service Layer', () => {
expect(r.selectOnly).not.toContain('UserPassword');
});
});
describe('extensiones de recursos por cliente', () => {
const base = [
{ name: 'Docs', key: 'number', methods: ['GET', 'POST'], read: ['/a'], write: '/a' },
{ name: 'Cat', key: 'string', methods: ['GET'], read: null, write: [] },
] as const;
it('suma pantallas del cliente a los permisos de un recurso, sin tocar los de cualquier sesión', () => {
const r = extendResources(base as never, { Docs: { read: ['/x'], write: ['/x'] }, Cat: { read: ['/x'] } }, []);
expect(r[0]).toMatchObject({ read: ['/a', '/x'], write: ['/a', '/x'] });
expect(r[1].read).toBeNull();
});
it('agrega recursos nuevos y rechaza repetidos, inexistentes y EmployeesInfo', () => {
const nuevo = { name: 'U_TABLA', key: 'string', methods: ['GET'], read: null, write: [] } as never;
expect(extendResources(base as never, {}, [nuevo]).map((x) => x.name)).toEqual(['Docs', 'Cat', 'U_TABLA']);
expect(() => extendResources(base as never, { Nada: { read: ['/x'] } }, [])).toThrow(/no existe el recurso/);
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'Docs' } as never])).toThrow(/ya existe/);
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'EmployeesInfo' } as never])).toThrow(/EmployeesInfo/);
});
});
+40 -1
View File
@@ -1,3 +1,5 @@
import { CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES } from './sl-resources.extensions.js';
export type SlMethod = 'GET' | 'POST' | 'PATCH';
/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */
@@ -56,7 +58,7 @@ const queryAction = (name: string): SlResource => ({ name, key: 'string', method
*
* Las rutas de menú son las mismas que usará el frontend (permisos por pantalla).
*/
export const SL_RESOURCES: readonly SlResource[] = [
const BASE_SL_RESOURCES: readonly SlResource[] = [
// Ventas
document('Quotations', '/ventas/ofertas'),
document('Orders', '/ventas/ordenes'),
@@ -97,6 +99,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
// Maestros (consulta para cualquier sesión; escritura según el módulo)
catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']),
catalog('Items', 'string', '/inventario/articulos'),
// Datos maestros de lotes: SAP los crea desde los documentos (entradas, recepciones…), aquí solo se consultan y modifican.
{ name: 'BatchNumberDetails', key: 'number', methods: ['GET', 'PATCH'], read: null, write: '/inventario/lotes' },
catalog('Warehouses', 'string'),
catalog('Currencies', 'string'),
catalog('SalesTaxCodes', 'string'),
@@ -121,6 +125,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('AccountSegmentations', 'number'),
// Series de numeración válidas de un tipo de documento.
queryAction('SeriesService_GetDocumentSeries'),
// La serie que SAP usa por defecto para un tipo de documento (`{ Series }`), para mostrarla elegida de entrada.
queryAction('SeriesService_GetDefaultSeries'),
// Tipo de cambio de una moneda en una fecha (para documentos en moneda extranjera): solo consulta.
queryAction('SBOBobService_GetCurrencyRate'),
// Logística y finanzas de los documentos: formas de envío y métodos de pago.
@@ -130,4 +136,37 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('AdditionalExpenses', 'number'),
];
const union = (a: RequiredPath | null, extra: readonly string[] | undefined): RequiredPath | null => {
// `null` = cualquier sesión: no se restringe por agregar pantallas
if (a === null || !extra?.length) return a;
return [...new Set([a, extra].flat())];
};
/**
* Suma lo que declara la rama de un cliente: pantallas suyas con permiso sobre recursos que ya existen
* (`paths`: por nombre de recurso) y recursos nuevos (tablas de usuario, objetos propios). `EmployeesInfo` nunca se
* registra (trae `U_WEB_PASS`); un recurso que no existe o repetido es un error.
*/
export function extendResources(
base: readonly SlResource[],
paths: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>>,
extra: readonly SlResource[],
): readonly SlResource[] {
const names = new Set(base.map((r) => r.name));
for (const name of Object.keys(paths)) {
if (!names.has(name)) throw new Error(`sl-resources.extensions: no existe el recurso «${name}»`);
}
for (const r of extra) {
if (r.name === 'EmployeesInfo') throw new Error('sl-resources.extensions: EmployeesInfo no se puede registrar (trae U_WEB_PASS)');
if (names.has(r.name)) throw new Error(`sl-resources.extensions: el recurso «${r.name}» ya existe`);
names.add(r.name);
}
return [
...base.map((r) => (paths[r.name] ? { ...r, read: union(r.read, paths[r.name].read), write: union(r.write, paths[r.name].write) } : r)),
...extra,
];
}
export const SL_RESOURCES: readonly SlResource[] = extendResources(BASE_SL_RESOURCES, CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES);
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
+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.
+13 -1
View File
@@ -1,6 +1,7 @@
import { isDevMode } from '@angular/core';
import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types';
import { CLIENT_PATHS, CLIENT_ROUTES } from './client-routes';
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { JOURNAL_PATH } from './features/finance/journal-paths';
@@ -121,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],
@@ -146,7 +157,7 @@ const adminRoutes: Routes = [
];
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
const builtPaths = new Set([...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(
@@ -181,6 +192,7 @@ export const routes: Routes = [
...journalRoutes,
...reportRoutes,
...adminRoutes,
...CLIENT_ROUTES,
...pendingRoutes,
],
},
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { CLIENT_PATHS, CLIENT_ROUTES, clientRoute } from './client-routes';
describe('rutas de cliente', () => {
it('una pantalla de cliente cuelga de su ruta de menú, con permiso y en diferido', () => {
const r = clientRoute('/inventario/ejemplo', 'Ejemplo', async () => class {});
expect(r.path).toBe('inventario/ejemplo');
expect(r.data).toEqual({ path: '/inventario/ejemplo', title: 'Ejemplo' });
expect(r.canActivate).toHaveLength(1);
expect(r.loadComponent).toBeTypeOf('function');
});
it('las rutas de cliente salen de la lista de rutas', () => {
expect(CLIENT_PATHS).toEqual(CLIENT_ROUTES.map((r) => `/${r.path}`));
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { Route, Routes } from '@angular/router';
import { permissionGuard } from './core/auth/guards';
/** Una pantalla de cliente: ruta del menú (`/inventario/inmovilizar-lotes`), título y su componente en diferido. */
export const clientRoute = (path: string, title: string, loadComponent: NonNullable<Route['loadComponent']>): Route => ({
path: path.slice(1),
canActivate: [permissionGuard],
data: { path, title },
loadComponent,
});
/**
* Pantallas propias del cliente (vacío en `main`): cada una con su entrada en `menu.extensions.ts` de `libs/sap-types`.
* Se cargan en diferido, como todas.
*
* Ejemplo:
*
* clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)),
*/
export const CLIENT_ROUTES: Routes = [];
/** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */
export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`);
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import type { LoginRequest, LoginResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
import { SessionReset } from '../session-reset';
import { MenuService } from '../menu/menu.service';
import { SessionStore } from './session.store';
@@ -12,11 +13,13 @@ export class AuthService {
private readonly http = inject(HttpClient);
private readonly session = inject(SessionStore);
private readonly menu = inject(MenuService);
private readonly reset = inject(SessionReset);
private readonly router = inject(Router);
async login(credentials: LoginRequest): Promise<void> {
const res = await firstValueFrom(this.http.post<LoginResponse>(`${API_URL}/auth/login`, credentials));
this.menu.reset();
this.reset.run();
this.session.set(res);
}
@@ -44,5 +47,6 @@ export class AuthService {
private end(): void {
this.session.clear();
this.menu.reset();
this.reset.run();
}
}
+61
View File
@@ -0,0 +1,61 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { TextField } from '../shared/form';
import { fieldDbName, type HeaderField } from '../features/documents/document-layouts';
import { FieldNames, isSapField } from './field-names';
@Component({ imports: [TextField], template: '<sbo-text-field label="Descripción" dbName="ItemName" [value]="\'x\'" />' })
class Host {}
describe('Ver campos', () => {
beforeEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
it('el nombre del campo de SAP siempre sale como ayuda de la etiqueta, y debajo solo con «Ver campos»', () => {
const names = TestBed.inject(FieldNames);
const fixture = TestBed.createComponent(Host);
fixture.detectChanges();
const label = () => fixture.nativeElement.querySelector('label') as HTMLElement;
expect(label().getAttribute('title')).toBe('ItemName');
expect(label().querySelector('.db')).toBeNull();
names.toggle();
fixture.detectChanges();
expect(label().querySelector('.db')?.textContent).toBe('ItemName');
});
it('se recuerda en el navegador', () => {
TestBed.inject(FieldNames).toggle();
expect(localStorage.getItem('erp_fields')).toBe('1');
TestBed.resetTestingModule();
expect(TestBed.inject(FieldNames).show()).toBe(true);
});
});
describe('nombre del campo de una fila del formulario', () => {
const name = (f: HeaderField) => fieldDbName(f);
it('un campo sencillo muestra su campo y una fila con varios controles los muestra todos', () => {
expect(name({ type: 'text', key: 'NumAtCard', label: 'Nº de referencia' })).toBe('NumAtCard');
expect(name({ type: 'folio', label: 'Folio', prefixKey: 'FolioPrefixString', numberKey: 'FolioNumber' })).toBe('FolioPrefixString · FolioNumber');
expect(name({ type: 'group', label: 'Indicador', cells: [{ kind: 'text', key: 'Indicator', label: 'I' }, { kind: 'text', key: 'FolioPrefixString', label: 'P' }] })).toBe('Indicator · FolioPrefixString');
expect(name({ type: 'series', key: 'Series', label: 'Número' })).toBe('Series · DocNum');
expect(name({ type: 'currency', label: 'Moneda' })).toBe('DocCurrency · DocRate');
expect(name({ type: 'rounding', label: 'Redondeo' })).toBe('Rounding · RoundingDiffAmount');
});
it('los valores calculados muestran de dónde salen', () => {
expect(name({ type: 'computed', label: 'Total', source: 'total' })).toBe('DocTotal');
expect(name({ type: 'computed', label: 'Saldo', source: 'balance' })).toBe('DocTotal − PaidToDate');
});
});
describe('qué columnas son campos de SAP', () => {
it('los de SAP empiezan con mayúscula; los internos de la interfaz, no', () => {
for (const k of ['ItemCode', 'U_SIS_ESTLOT', 'AccounttNum']) expect(isSapField(k), k).toBe(true);
for (const k of ['docNum', 'balance', '_Name', '_manageBatches']) expect(isSapField(k), k).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Injectable, signal } from '@angular/core';
const KEY = 'erp_fields';
/** Un nombre de campo de SAP empieza con mayúscula (`ItemCode`, `U_SIS_X`); los de columnas internas de la interfaz (`docNum`, `_Name`) no. */
export const isSapField = (key: string): boolean => /^[A-Z]/.test(key);
/**
* «Ver campos»: muestra, junto a cada etiqueta, el nombre del campo de SAP (`ItemCode`, `U_SIS_ESTLOT`) con el que se lee
* y se guarda. Útil para quien configura el sistema o arma una consulta; el nombre también sale siempre como ayuda al
* pasar el cursor sobre la etiqueta. Se recuerda en el navegador.
*/
@Injectable({ providedIn: 'root' })
export class FieldNames {
readonly show = signal(this.read());
toggle(): void {
const next = !this.show();
this.show.set(next);
try {
localStorage.setItem(KEY, next ? '1' : '0');
} catch {
/* sin almacenamiento: vale para esta sesión */
}
}
private read(): boolean {
try {
return localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
}
@@ -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 () => {
@@ -0,0 +1,62 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it } from 'vitest';
import { MenuService } from './menu.service';
import { RecentScreens } from './recent-screens';
@Component({ template: '' })
class Page {}
const ROUTES = ['ventas/ordenes', 'ventas/entrega', 'inventario/lotes', 'compras/ordenes', 'compras/facturas', 'bancos/pagos-recibidos', 'finanzas/asientos'].map((path) => ({ path, component: Page }));
describe('RecentScreens', () => {
async function setup(allowed: string[] = ['*']) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [provideRouter(ROUTES), provideHttpClient(), provideHttpClientTesting()] });
const loaded = TestBed.inject(MenuService).ensureLoaded();
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: allowed });
await loaded;
return { recent: TestBed.inject(RecentScreens), router: TestBed.inject(Router) };
}
beforeEach(() => localStorage.clear());
it('anota las pantallas del menú que se abren, la más reciente primero, y las recuerda', async () => {
const { recent, router } = await setup();
await router.navigateByUrl('/ventas/ordenes');
await router.navigateByUrl('/inventario/lotes');
expect(recent.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
expect(JSON.parse(localStorage.getItem('erp_recent')!)).toEqual(['/inventario/lotes', '/ventas/ordenes']);
// otra sesión del navegador las recupera
const again = (await setup()).recent;
expect(again.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
});
it('una pantalla abierta de nuevo sube al primer lugar sin repetirse, y el detalle cuenta como su pantalla', async () => {
const { recent, router } = await setup();
await router.navigateByUrl('/ventas/ordenes');
await router.navigateByUrl('/inventario/lotes');
await router.navigateByUrl('/ventas/ordenes');
expect(recent.paths()).toEqual(['/ventas/ordenes', '/inventario/lotes']);
recent.record('/inventario/lotes/75437?x=1');
expect(recent.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
});
it('guarda solo las 5 últimas e ignora lo que no es del menú', async () => {
const { recent, router } = await setup();
for (const p of ['/ventas/ordenes', '/ventas/entrega', '/inventario/lotes', '/compras/ordenes', '/compras/facturas', '/bancos/pagos-recibidos']) await router.navigateByUrl(p);
expect(recent.paths()).toHaveLength(5);
expect(recent.paths()[0]).toBe('/bancos/pagos-recibidos');
expect(recent.paths()).not.toContain('/ventas/ordenes');
recent.record('/no-esta-en-el-menu');
expect(recent.paths()).toHaveLength(5);
});
it('un valor dañado del navegador no rompe nada', async () => {
localStorage.setItem('erp_recent', '{no es json');
expect((await setup()).recent.paths()).toEqual([]);
});
});
@@ -0,0 +1,46 @@
import { Injectable, inject, signal } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { MenuService } from './menu.service';
const KEY = 'erp_recent';
const MAX = 5;
/**
* Las últimas pantallas del menú que se abrieron (hasta 5, la más reciente primero), recordadas en el navegador. Se llenan
* solas al navegar; la bienvenida las ofrece como atajo. Solo cuentan las pantallas del menú del usuario.
*/
@Injectable({ providedIn: 'root' })
export class RecentScreens {
private readonly menu = inject(MenuService);
readonly paths = signal<readonly string[]>(this.read());
constructor() {
inject(Router)
.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe((e) => this.record(e.urlAfterRedirects));
}
/** Anota la pantalla del menú a la que pertenece la ruta (`/inventario/lotes/75437` cuenta como `/inventario/lotes`). */
record(url: string): void {
const path = url.split(/[?#]/)[0];
const item = this.menu.modules().flatMap((m) => m.items).find((i) => path === i.path || path.startsWith(`${i.path}/`));
if (!item || this.paths()[0] === item.path) return;
const next = [item.path, ...this.paths().filter((p) => p !== item.path)].slice(0, MAX);
this.paths.set(next);
try {
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* sin almacenamiento: vale para esta sesión */
}
}
private read(): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]');
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(0, MAX) : [];
} catch {
return [];
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
/**
* Lo que debe olvidarse al iniciar o cerrar sesión (los permisos y lo que depende de ellos). Cada servicio con datos de la
* sesión se registra aquí al crearse, así el servicio de sesión no tiene que conocerlos (y no arrastra su código al arranque).
*/
@Injectable({ providedIn: 'root' })
export class SessionReset {
private readonly callbacks: (() => void)[] = [];
register(callback: () => void): void {
this.callbacks.push(callback);
}
run(): void {
for (const cb of this.callbacks) cb();
}
}
+42
View File
@@ -0,0 +1,42 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
import { SessionReset } from '../session-reset';
/** Respuesta de una consulta a la base de SAP. */
export interface SapDbResult {
rows: Record<string, unknown>[];
truncated: boolean;
}
/**
* Consultas registradas de solo lectura a la base de SAP (`/api/sapdb`). Sin base configurada o sin permiso, la API
* responde 503 / 403 y la consulta simplemente no está disponible (las pantallas ocultan lo que dependa de ella).
*/
@Injectable({ providedIn: 'root' })
export class SapDbApi {
private readonly http = inject(HttpClient);
private names?: Promise<Set<string>>;
constructor() {
inject(SessionReset).register(() => this.reset());
}
/** La consulta existe para este usuario y la base está configurada. Se pregunta una vez por sesión. */
available(name: string): Promise<boolean> {
this.names ??= firstValueFrom(this.http.get<{ name: string }[]>(`${API_URL}/sapdb/queries`))
.then((list) => new Set(list.map((q) => q.name)))
.catch(() => new Set<string>());
return this.names.then((set) => set.has(name));
}
/** Al iniciar o cerrar sesión: otro usuario puede tener otros permisos. */
reset(): void {
this.names = undefined;
}
run(name: string, params: Record<string, unknown>): Promise<SapDbResult> {
return firstValueFrom(this.http.post<SapDbResult>(`${API_URL}/sapdb/queries/${encodeURIComponent(name)}`, { params }));
}
}
+3 -3
View File
@@ -18,14 +18,14 @@ export class SlMasterApi {
}
/** Clave del registro vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string): Promise<string | null> {
const q = masterNavigationQuery(target, keyField, from);
async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string, numeric = false): Promise<string | null> {
const q = masterNavigationQuery(target, keyField, from, numeric);
let params = new HttpParams().set('$select', keyField).set('$top', 1).set('$orderby', q.$orderby);
const filter = combineFilters(baseFilter, q.$filter);
if (filter) params = params.set('$filter', filter);
const res = await firstValueFrom(this.http.get<SlListResponse<Rec>>(this.url(resource), { params }));
const key = res.value[0]?.[keyField];
return typeof key === 'string' ? key : null;
return typeof key === 'string' ? key : typeof key === 'number' ? String(key) : null;
}
/** Crea el registro; SAP devuelve el creado. */
+49
View File
@@ -0,0 +1,49 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
import { SessionReset } from '../session-reset';
/** La definición de un campo de usuario de SAP (`GET /api/sapdb/user-fields/:tabla/:campo`). */
export interface UserFieldInfo {
table: string;
alias: string;
description: string;
/** `A` alfanumérico, `N` numérico, `D` fecha, `M` memo… */
type: string;
size: number;
validValues: { value: string; description: string }[];
linkedTable: string | null;
linkedRows: { code: string; name: string }[];
linkedTruncated: boolean;
}
/**
* Valores válidos y tabla enlazada de los campos de usuario, tal como están definidos en SAP: así un campo de usuario
* se muestra como lista desplegable o con su ventana de selección sin escribir sus valores en el código. Se pregunta una
* vez por campo y sesión; sin base de datos configurada (o sin el campo) devuelve `null` y el campo se pide como texto.
*/
@Injectable({ providedIn: 'root' })
export class UserFieldsApi {
private readonly http = inject(HttpClient);
private cache = new Map<string, Promise<UserFieldInfo | null>>();
constructor() {
inject(SessionReset).register(() => this.reset());
}
get(table: string, alias: string): Promise<UserFieldInfo | null> {
const key = `${table}|${alias}`;
let hit = this.cache.get(key);
if (!hit) {
hit = firstValueFrom(this.http.get<UserFieldInfo>(`${API_URL}/sapdb/user-fields/${encodeURIComponent(table)}/${encodeURIComponent(alias)}`)).catch(() => null);
this.cache.set(key, hit);
}
return hit;
}
/** Al iniciar o cerrar sesión. */
reset(): void {
this.cache = new Map();
}
}
@@ -11,10 +11,10 @@
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-select-field label="Pago a" [options]="typeOptions" [value]="docType()" (valueChange)="onType($event)" [readonly]="saved()" />
<sbo-select-field label="Pago a" dbName="DocType" [options]="typeOptions" [value]="docType()" (valueChange)="onType($event)" [readonly]="saved()" />
@if (!toAccounts()) {
<sbo-lookup-field
[label]="screen.partner.label"
[label]="screen.partner.label" dbName="CardCode"
[lookup]="partnerLookup"
[value]="cardCode()"
[readonly]="saved()"
@@ -23,17 +23,17 @@
(valueChange)="onPartnerText($event)"
(picked)="onPartner($event)"
/>
<sbo-text-field label="Nombre" [value]="cardName()" readonly />
<sbo-text-field label="Nombre" dbName="CardName" [value]="cardName()" readonly />
}
</div>
<div class="sbo-col">
@if (isNew()) {
<sbo-select-field label="Serie" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
<sbo-select-field label="Serie" dbName="Series" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
} @else {
<sbo-text-field label="Número" [value]="'' + (doc()?.['DocNum'] ?? '')" readonly />
<sbo-text-field label="Número" dbName="DocNum" [value]="'' + (doc()?.['DocNum'] ?? '')" readonly />
}
<sbo-date-field label="Fecha de contabilización" [(value)]="docDate" [readonly]="saved()" required />
<sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" />
<sbo-date-field label="Fecha de contabilización" dbName="DocDate" [(value)]="docDate" [readonly]="saved()" required />
<sbo-date-field label="Fecha del documento" dbName="TaxDate" [(value)]="taxDate" [readonly]="saved()" />
</div>
</div>
@@ -66,15 +66,15 @@
<h3>Medios de pago</h3>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-lookup-field label="Cuenta de efectivo" [lookup]="accountLookup" [value]="means().cashAccount" (valueChange)="setMeans({ cashAccount: $event })" [readonly]="saved()" />
<sbo-number-field label="Efectivo" [decimals]="2" [value]="means().cashSum" (valueChange)="setMeans({ cashSum: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de transferencia" [lookup]="accountLookup" [value]="means().transferAccount" (valueChange)="setMeans({ transferAccount: $event })" [readonly]="saved()" />
<sbo-number-field label="Transferencia" [decimals]="2" [value]="means().transferSum" (valueChange)="setMeans({ transferSum: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de efectivo" dbName="CashAccount" [lookup]="accountLookup" [value]="means().cashAccount" (valueChange)="setMeans({ cashAccount: $event })" [readonly]="saved()" />
<sbo-number-field label="Efectivo" dbName="CashSum" [decimals]="2" [value]="means().cashSum" (valueChange)="setMeans({ cashSum: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de transferencia" dbName="TransferAccount" [lookup]="accountLookup" [value]="means().transferAccount" (valueChange)="setMeans({ transferAccount: $event })" [readonly]="saved()" />
<sbo-number-field label="Transferencia" dbName="TransferSum" [decimals]="2" [value]="means().transferSum" (valueChange)="setMeans({ transferSum: $event })" [readonly]="saved()" />
</div>
<div class="sbo-col">
<sbo-date-field label="Fecha de transferencia" [value]="means().transferDate" (valueChange)="setMeans({ transferDate: $event })" [readonly]="saved()" />
<sbo-text-field label="Referencia" [value]="means().transferReference" (valueChange)="setMeans({ transferReference: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de cheques" [lookup]="accountLookup" [value]="means().checkAccount" (valueChange)="setMeans({ checkAccount: $event })" [readonly]="saved()" />
<sbo-date-field label="Fecha de transferencia" dbName="TransferDate" [value]="means().transferDate" (valueChange)="setMeans({ transferDate: $event })" [readonly]="saved()" />
<sbo-text-field label="Referencia" dbName="TransferReference" [value]="means().transferReference" (valueChange)="setMeans({ transferReference: $event })" [readonly]="saved()" />
<sbo-lookup-field label="Cuenta de cheques" dbName="CheckAccount" [lookup]="accountLookup" [value]="means().checkAccount" (valueChange)="setMeans({ checkAccount: $event })" [readonly]="saved()" />
</div>
</div>
@@ -94,8 +94,8 @@
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
<sbo-textarea-field label="Comentarios" [(value)]="remarks" [readonly]="saved()" />
<sbo-text-field label="Comentarios del asiento" [(value)]="journalRemarks" [readonly]="saved()" />
<sbo-textarea-field label="Comentarios" dbName="Remarks" [(value)]="remarks" [readonly]="saved()" />
<sbo-text-field label="Comentarios del asiento" dbName="JournalRemarks" [(value)]="journalRemarks" [readonly]="saved()" />
</div>
<div class="sbo-col">
<table class="totals">
@@ -133,7 +133,7 @@
@switch (f.type) {
@case ('partner') {
<sbo-lookup-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[required]="!!f.required"
[readonly]="readOnly() || !isNew() || locked(f)"
[value]="str(header()[f.key])"
@@ -143,9 +143,19 @@
(picked)="onPartner($event)"
/>
}
@case ('udf') {
<sbo-udf-field
[label]="f.label" [dbName]="dbNames(f)"
[table]="f.table" [alias]="f.alias"
[required]="!!f.required"
[readonly]="readOnly() || !!f.readonly || locked(f)"
[value]="header()[f.key]"
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('checkbox') {
<sbo-checkbox-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[readonly]="readOnly() || !!f.readonly || locked(f)"
[value]="header()[f.key] === 'tYES'"
(valueChange)="setHeader(f.key, $event ? 'tYES' : 'tNO')"
@@ -153,7 +163,7 @@
}
@case ('lookup') {
<sbo-lookup-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[required]="!!f.required"
[readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])"
@@ -165,7 +175,7 @@
}
@case ('warehouse') {
<sbo-lookup-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[required]="!!f.required"
[readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])"
@@ -176,7 +186,7 @@
}
@case ('date') {
<sbo-date-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[required]="!!f.required"
[readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])"
@@ -185,7 +195,7 @@
}
@case ('select') {
<sbo-select-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[required]="!!f.required"
[readonly]="readOnly() || locked(f)"
[options]="f.options"
@@ -196,7 +206,7 @@
}
@case ('number') {
<sbo-number-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[decimals]="f.decimals"
[readonly]="readOnly() || locked(f)"
[value]="+str(header()[f.key])"
@@ -205,14 +215,14 @@
}
@case ('textarea') {
<sbo-textarea-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[readonly]="readOnly() || locked(f)"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('folio') {
<sbo-field [label]="f.label">
<sbo-field [label]="f.label" [dbName]="dbNames(f)">
<sbo-input
width="90px"
[maxlength]="10"
@@ -229,7 +239,7 @@
</sbo-field>
}
@case ('group') {
<sbo-field [label]="f.label">
<sbo-field [label]="f.label" [dbName]="dbNames(f)">
@for (c of f.cells; track c.key) {
<sbo-input
[width]="c.width"
@@ -246,7 +256,7 @@
</sbo-field>
}
@case ('series') {
<sbo-field [label]="f.label">
<sbo-field [label]="f.label" [dbName]="dbNames(f)">
<sbo-select
width="170px"
ariaLabel="Serie de numeración"
@@ -261,7 +271,7 @@
}
@case ('expenses') {
<div class="exp">
<h3>{{ f.label }}</h3>
<h3 [attr.title]="dbNames(f)">{{ f.label }}</h3>
<sbo-rows-table
[columns]="expenseColumns"
[rows]="expenses()"
@@ -277,7 +287,7 @@
}
@case ('contact') {
<sbo-select-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[readonly]="readOnly() || locked(f)"
[options]="contactOptions()"
placeholder=""
@@ -287,7 +297,7 @@
}
@case ('address') {
<sbo-select-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[readonly]="readOnly() || locked(f)"
[options]="f.side === 'ship' ? shipOptions() : billOptions()"
placeholder=""
@@ -302,7 +312,7 @@
</sbo-field>
}
@case ('currency') {
<sbo-field [label]="f.label">
<sbo-field [label]="f.label" [dbName]="dbNames(f)">
<sbo-select
width="170px"
ariaLabel="Moneda del documento"
@@ -322,7 +332,7 @@
}
@case ('rounding') {
@if (roundingEnabled()) {
<sbo-field [label]="f.label">
<sbo-field [label]="f.label" [dbName]="dbNames(f)">
<sbo-checkbox
ariaLabel="Redondear el total del documento"
[readonly]="readOnly() || locked(f)"
@@ -340,11 +350,11 @@
}
}
@case ('computed') {
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
<sbo-text-field [label]="f.label" [dbName]="dbNames(f)" readonly [value]="computedValue(f.source)" />
}
@default {
<sbo-text-field
[label]="f.label"
[label]="f.label" [dbName]="dbNames(f)"
[readonly]="readOnly() || !!f.readonly || locked(f)"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
@@ -10,7 +10,7 @@ import { formatNumber, todayIso } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { ControlAccountsService } from '../../core/sl/control-accounts';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput } from '../../shared/form';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput, UdfField } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup, VhRow } from '../../shared/value-help/value-help.types';
@@ -35,7 +35,7 @@ import {
validateLayout,
} from './document-model';
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, fieldDbName, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type AddressSide, type AddressValues, addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
import { DocumentPdf } from '../reports/document-pdf';
import { AddressDialog } from './address-dialog';
@@ -76,7 +76,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, UdfField, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -450,6 +450,8 @@ export class DocumentFormPage {
}
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
protected readonly dbNames = fieldDbName;
protected computedValue(source: ComputedSource): string {
switch (source) {
case 'docNum':
@@ -12,6 +12,19 @@ export type GroupCell =
| { kind: 'text'; key: string; label: string; width?: string; maxlength?: number }
| { kind: 'lookup'; key: string; label: string; lookup: VhLookup; width?: string };
/**
* Un campo de usuario por su definición en SAP: la tabla (`OBTN`, `OWTQ`…) y el alias sin el `U_` (`SIS_ESTLOT`). Sus valores
* válidos o su tabla de usuario enlazada se leen de SAP al abrir la pantalla; no se escriben aquí.
*/
export const udfField = (table: string, alias: string, label: string, extra: { required?: boolean; missing?: string; readonly?: boolean } = {}): HeaderField => ({
type: 'udf',
key: `U_${alias}`,
label,
table,
alias,
...extra,
});
/** Fila con varios campos juntos, para la cabecera de un documento. */
export const fieldGroup = (label: string, cells: readonly GroupCell[]): HeaderField => ({ type: 'group', label, cells });
@@ -40,6 +53,11 @@ export type HeaderField =
/** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */
| { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean; onPick?: (row: Record<string, unknown>) => Record<string, unknown> }
| { type: 'textarea'; key: string; label: string }
/**
* Campo de usuario (`U_*`) que se pide como lo definió SAP: lista desplegable si tiene valores válidos, ventana de
* selección si está enlazado a una tabla de usuario y texto en otro caso (`udfField`).
*/
| { type: 'udf'; key: string; label: string; table: string; alias: string; required?: boolean; missing?: string; readonly?: boolean }
/** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean }
/** Abre la lista de almacenes. */
@@ -592,3 +610,42 @@ export const hasPartnerData = (layout: DocumentLayout): boolean => allFields(lay
export const hasCurrency = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'currency');
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio' || (f.type === 'group' && f.cells.some((c) => c.key === 'FolioNumber')));
/**
* Nombre(s) del campo de SAP de una fila del formulario, para «Campos» y la ayuda del cursor: el de un campo sencillo, o
* todos los de una fila con varios controles (`FolioPrefixString · FolioNumber`).
*/
export function fieldDbName(f: HeaderField): string | undefined {
switch (f.type) {
case 'folio':
return `${f.prefixKey} · ${f.numberKey}`;
case 'group':
return f.cells.map((c) => c.key).join(' · ');
case 'series':
return 'Series · DocNum';
case 'currency':
return 'DocCurrency · DocRate';
case 'rounding':
return 'Rounding · RoundingDiffAmount';
case 'expenses':
return 'DocumentAdditionalExpenses';
case 'computed':
return COMPUTED_DB_NAMES[f.source];
default:
return 'key' in f ? f.key : undefined;
}
}
const COMPUTED_DB_NAMES: Readonly<Record<ComputedSource, string>> = {
docNum: 'DocNum',
partnerName: 'CardName',
partnerTaxId: 'FederalTaxID',
subtotal: 'Σ líneas',
expenses: 'Σ DocumentAdditionalExpenses',
tax: 'VatSum',
total: 'DocTotal',
downPayment: 'DownPaymentAmount',
applied: 'PaidToDate',
balance: 'DocTotal − PaidToDate',
};
@@ -12,21 +12,21 @@
<div class="sbo-hdr">
<div class="sbo-col">
@if (isNew()) {
<sbo-select-field label="Serie" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
<sbo-select-field label="Serie" dbName="Series" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
} @else {
<sbo-text-field label="Número" [value]="'' + (doc()?.['JdtNum'] ?? '')" readonly />
<sbo-text-field label="Número" dbName="JdtNum" [value]="'' + (doc()?.['JdtNum'] ?? '')" readonly />
}
<sbo-text-field label="Comentarios" [value]="memo()" (valueChange)="set('memo', $event)" [readonly]="saved()" />
<sbo-text-field label="Comentarios" dbName="Memo" [value]="memo()" (valueChange)="set('memo', $event)" [readonly]="saved()" />
</div>
<div class="sbo-col">
<sbo-date-field label="Fecha de contabilización" [(value)]="referenceDate" [readonly]="saved()" required />
<sbo-date-field label="Fecha de vencimiento" [value]="dueDate()" (valueChange)="onDueDate($event)" [readonly]="saved()" />
<sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" />
<sbo-date-field label="Fecha de contabilización" dbName="ReferenceDate" [(value)]="referenceDate" [readonly]="saved()" required />
<sbo-date-field label="Fecha de vencimiento" dbName="DueDate" [value]="dueDate()" (valueChange)="onDueDate($event)" [readonly]="saved()" />
<sbo-date-field label="Fecha del documento" dbName="TaxDate" [(value)]="taxDate" [readonly]="saved()" />
</div>
</div>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-field label="Indicador / Folio">
<sbo-field label="Indicador / Folio" dbName="Indicator · FolioPrefixString · FolioNumber">
<sbo-input width="80px" ariaLabel="Indicador" [readonly]="saved()" [value]="indicator()" (valueChange)="indicator.set($event)" />
@if (!saved()) {
<button type="button" class="sbo-btn" aria-label="Abrir la lista de indicadores" title="Lista de valores" (click)="indicatorOpen.set(true)">…</button>
@@ -36,9 +36,9 @@
</sbo-field>
</div>
<div class="sbo-col">
<sbo-text-field label="Referencia 1" [value]="reference()" (valueChange)="set('reference', $event)" [readonly]="saved()" />
<sbo-text-field label="Referencia 2" [value]="reference2()" (valueChange)="set('reference2', $event)" [readonly]="saved()" />
<sbo-text-field label="Referencia 3" [value]="reference3()" (valueChange)="set('reference3', $event)" [readonly]="saved()" />
<sbo-text-field label="Referencia 1" dbName="Reference" [value]="reference()" (valueChange)="set('reference', $event)" [readonly]="saved()" />
<sbo-text-field label="Referencia 2" dbName="Reference2" [value]="reference2()" (valueChange)="set('reference2', $event)" [readonly]="saved()" />
<sbo-text-field label="Referencia 3" dbName="Reference3" [value]="reference3()" (valueChange)="set('reference3', $event)" [readonly]="saved()" />
</div>
</div>
@@ -0,0 +1,97 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionStore } from '../../core/auth/session.store';
import { MenuService } from '../../core/menu/menu.service';
import { HomePage } from './home.page';
describe('HomePage', () => {
let fixture: ComponentFixture<HomePage>;
const el = () => fixture.nativeElement as HTMLElement;
const tick = async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
};
async function open(allowed: string[], recent: string[] = []) {
TestBed.resetTestingModule();
localStorage.clear();
if (recent.length) localStorage.setItem('erp_recent', JSON.stringify(recent));
TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
const loaded = TestBed.inject(MenuService).ensureLoaded();
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: allowed });
await loaded;
fixture = TestBed.createComponent(HomePage);
await tick();
}
const type = async (text: string) => {
const input = el().querySelector('input.find') as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input'));
await tick();
return input;
};
const cards = () => [...el().querySelectorAll('.card')].map((c) => [c.querySelector('h2')?.textContent?.trim(), [...c.querySelectorAll('a')].map((a) => a.textContent?.trim())] as const);
beforeEach(() => TestBed.resetTestingModule());
it('muestra una tarjeta por módulo con sus pantallas en lista, solo las permitidas', async () => {
await open(['/ventas/ordenes', '/ventas/entrega', '/inventario/lotes']);
expect(cards()).toEqual([
['Ventas', ['Órdenes de venta', 'Entregas']],
['Inventario', ['Lotes']],
]);
expect(el().querySelector('.card a')?.getAttribute('href')).toBe('/ventas/ordenes');
});
it('el saludo lleva el nombre y la empresa', async () => {
await open(['*']);
TestBed.inject(SessionStore).set({ token: 't', user: { webUser: 'u', empId: 1, firstName: 'Jose', lastName: 'H' }, company: 'SBO_X', sessionTimeoutMinutes: 30 } as never);
await tick();
expect(el().querySelector('h1')?.textContent).toContain('Bienvenido, Jose');
expect(el().querySelector('.sub')?.textContent).toContain('SBO_X');
});
it('el buscador filtra las pantallas de todos los módulos, sin tildes ni mayúsculas, y dice a qué módulo pertenecen', async () => {
await open(['*']);
await type('ORDENES');
const found = [...el().querySelectorAll('.found a')].map((a) => [a.querySelector('strong')?.textContent, a.querySelector('span')?.textContent]);
expect(found).toEqual([['Órdenes de venta', 'Ventas'], ['Órdenes de compra', 'Compras']]);
expect(el().querySelector('.cards')).toBeNull();
// varias palabras: todas deben estar (en la pantalla o en su módulo)
await type('compra orden');
expect([...el().querySelectorAll('.found a strong')].map((s) => s.textContent)).toEqual(['Órdenes de compra']);
});
it('sin coincidencias lo dice, y vaciar el buscador (o Esc) devuelve las tarjetas', async () => {
await open(['*']);
const input = await type('zzzz');
expect(el().querySelector('.found .none')?.textContent).toContain('Ninguna pantalla coincide con «zzzz»');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await tick();
expect(el().querySelector('.cards')).not.toBeNull();
expect((el().querySelector('input.find') as HTMLInputElement).value).toBe('');
});
it('Enter abre la primera coincidencia', async () => {
await open(['*']);
const navigate = vi.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true);
const input = await type('lotes');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(navigate).toHaveBeenCalledWith('/inventario/lotes');
});
it('ofrece las pantallas recientes que el usuario todavía puede abrir', async () => {
await open(['/ventas/ordenes', '/inventario/lotes'], ['/inventario/lotes', '/compras/ordenes', '/ventas/ordenes']);
expect([...el().querySelectorAll('.recent a')].map((a) => a.textContent?.trim())).toEqual(['Lotes', 'Órdenes de venta']);
});
it('sin recientes no muestra esa fila', async () => {
await open(['*']);
expect(el().querySelector('.recent')).toBeNull();
});
});
+111 -18
View File
@@ -1,36 +1,129 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { SessionStore } from '../../core/auth/session.store';
import { MenuService } from '../../core/menu/menu.service';
import { RecentScreens } from '../../core/menu/recent-screens';
/** Sin tildes ni mayúsculas, para que «ordenes» encuentre «Órdenes de venta». */
const plain = (s: string): string => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
/**
* Bienvenida: un buscador de pantallas, las últimas que se abrieron y una tarjeta por módulo del menú con sus pantallas en
* lista. Solo lo que el usuario puede abrir.
*/
@Component({
selector: 'sbo-home-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
template: `
<h1>Bienvenido, {{ session.user()?.firstName }}</h1>
<p class="sub">{{ session.company() }}</p>
@for (m of menu.modules(); track m.id) {
<section>
<h2>{{ m.label }}</h2>
<div class="links">
@for (i of m.items; track i.path) {
<a [routerLink]="i.path">{{ i.label }}</a>
}
</div>
</section>
<header class="top">
<div>
<h1>Bienvenido, {{ session.user()?.firstName }}</h1>
<p class="sub">{{ session.company() }}</p>
</div>
<input
class="find"
type="search"
placeholder="Buscar pantalla…"
aria-label="Buscar pantalla"
autocomplete="off"
[value]="query()"
(input)="query.set($any($event.target).value)"
(keydown.enter)="openFirst()"
(keydown.escape)="query.set('')"
/>
</header>
@if (query().trim()) {
<ul class="found" role="list" aria-label="Pantallas encontradas">
@for (r of found(); track r.path) {
<li><a [routerLink]="r.path"><strong>{{ r.label }}</strong> <span>{{ r.module }}</span></a></li>
} @empty {
<li class="none">Ninguna pantalla coincide con «{{ query().trim() }}».</li>
}
</ul>
} @else {
@if (recent().length) {
<section class="recent" aria-label="Recientes">
<h2>Recientes</h2>
<div class="chips">
@for (r of recent(); track r.path) {
<a [routerLink]="r.path" [title]="r.module">{{ r.label }}</a>
}
</div>
</section>
}
<div class="cards">
@for (m of menu.modules(); track m.id) {
<section class="card">
<h2>{{ m.label }}</h2>
<ul role="list">
@for (i of m.items; track i.path) {
<li><a [routerLink]="i.path">{{ i.label }}</a></li>
}
</ul>
</section>
}
</div>
}
`,
styles: `
:host { display: block; }
.top { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; margin-bottom: 14px; }
h1 { margin: 0; font-size: 18px; font-weight: 600; }
.sub { margin: 2px 0 16px; color: var(--quiet); }
h2 { margin: 12px 0 6px; font-size: 13px; color: var(--quiet); text-transform: uppercase; letter-spacing: .4px; }
.links { display: flex; flex-wrap: wrap; gap: 6px; }
a { padding: 4px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--ink); text-decoration: none; }
a:hover { background: var(--row-sel); border-color: var(--focus); }
.sub { margin: 2px 0 0; color: var(--quiet); }
.find { width: min(320px, 100%); height: 26px; padding: 1px 8px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
.find:focus { border-color: var(--focus); outline: none; }
h2 { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--quiet); }
.recent { margin-bottom: 14px; }
.recent h2 { margin-bottom: 4px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips a { padding: 3px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-pill, 12px); color: var(--ink); text-decoration: none; }
.chips a:hover { background: var(--row-sel); border-color: var(--focus); }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; align-items: start; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; }
.card h2 { padding: 6px 12px; background: var(--grid-head); border-bottom: 1px solid var(--line); color: var(--ink); }
.card ul, .found { list-style: none; margin: 0; padding: 0; }
.card li a { display: block; padding: 4px 12px; color: var(--ink); text-decoration: none; }
.card li a:hover { background: var(--row-sel); }
.card li + li { border-top: 1px solid var(--line-soft); }
.found { max-width: 560px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); }
.found li + li { border-top: 1px solid var(--line-soft); }
.found a { display: flex; justify-content: space-between; gap: 12px; padding: 5px 12px; color: var(--ink); text-decoration: none; }
.found a:hover { background: var(--row-sel); }
.found span { color: var(--quiet); }
.found .none { padding: 10px 12px; color: var(--quiet); }
`,
})
export class HomePage {
protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService);
private readonly router = inject(Router);
private readonly recents = inject(RecentScreens);
protected readonly query = signal('');
/** Todas las pantallas que el usuario puede abrir, con su módulo. */
private readonly all = computed(() => this.menu.modules().flatMap((m) => m.items.map((i) => ({ path: i.path, label: i.label, module: m.label }))));
/** Las pantallas que coinciden con lo escrito (en el nombre de la pantalla o del módulo), sin tildes ni mayúsculas. */
protected readonly found = computed(() => {
const words = plain(this.query()).split(/\s+/).filter(Boolean);
return this.all().filter((r) => words.every((w) => plain(`${r.label} ${r.module}`).includes(w)));
});
/** Las últimas pantallas abiertas que el usuario todavía puede ver. */
protected readonly recent = computed(() => {
const byPath = new Map(this.all().map((r) => [r.path, r]));
return this.recents.paths().flatMap((p) => byPath.get(p) ?? []);
});
/** Enter abre la primera coincidencia. */
protected openFirst(): void {
const first = this.found()[0];
if (first) void this.router.navigateByUrl(first.path);
}
}
@@ -26,10 +26,11 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
createLabel: 'Nuevo artículo',
orderby: 'ItemCode',
extraFields: ['Frozen'],
listNames: [{ key: 'ItemsGroupCode', resource: 'ItemGroups', valueField: 'Number', labelField: 'GroupName' }],
columns: [
{ key: 'ItemCode', title: 'Nº de artículo', width: '170px', mono: true, searchable: true },
{ key: 'ItemName', title: 'Descripción', searchable: true },
{ key: 'ItemsGroupCode', title: 'Grupo', type: 'number', align: 'left', width: '90px', format: (r) => String(r['ItemsGroupCode'] ?? '') },
{ key: 'ItemsGroupCode', title: 'Grupo', type: 'number', align: 'left', width: '200px', format: (r) => String(r['ItemsGroupCode'] ?? '') },
{ key: 'QuantityOnStock', title: 'En stock', type: 'number', width: '110px' },
{
key: 'Frozen',
@@ -129,11 +130,12 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
rowKey: 'PriceList',
identity: ['PriceList'],
columns: [
{ key: 'PriceList', title: 'Lista de precios', type: 'text', readonly: true, width: '130px' },
{ key: 'PriceList', title: 'Lista de precios', type: 'text', readonly: true, width: '90px' },
{ key: 'PriceListName', title: 'Nombre', type: 'text', width: '260px', nameOf: { codeKey: 'PriceList', resource: 'PriceLists', valueField: 'PriceListNo', labelField: 'PriceListName' } },
{ key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '130px' },
{ key: 'Currency', title: 'Moneda', type: 'text', width: '90px' },
{ key: 'Factor', title: 'Factor', type: 'number', readonly: true, width: '90px' },
{ key: 'BasePriceList', title: 'Lista base', type: 'text', readonly: true, width: '100px' },
{ key: 'BasePriceList', title: 'Lista base', type: 'text', readonly: true, width: '240px', nameOf: { codeKey: 'BasePriceList', resource: 'PriceLists', valueField: 'PriceListNo', labelField: 'PriceListName', withCode: true } },
],
},
},
@@ -146,12 +148,15 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [
key: 'ItemWarehouseInfoCollection',
mode: 'replace',
readonly: true,
hideEmptyBy: ['InStock', 'Committed', 'Ordered'],
hideEmptyLabel: 'Solo almacenes con stock, comprometido o pedido',
identity: ['WarehouseCode'],
columns: [
{ key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' },
{ key: 'InStock', title: 'En stock', type: 'number', width: '110px' },
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px' },
{ key: 'Ordered', title: 'Pedido', type: 'number', width: '110px' },
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px', nameOf: { codeKey: 'WarehouseCode', resource: 'Warehouses', valueField: 'WarehouseCode', labelField: 'WarehouseName' } },
{ key: 'InStock', title: 'En stock', type: 'number', width: '110px', total: true },
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px', total: true },
{ key: 'Ordered', title: 'Pedido', type: 'number', width: '110px', total: true },
{ key: 'MinimalStock', title: 'Mínimo', type: 'number', width: '100px' },
{ key: 'MaximalStock', title: 'Máximo', type: 'number', width: '100px' },
],
@@ -0,0 +1,17 @@
import type { MasterExtensions } from '../master-extensions';
/**
* Campos que este cliente agrega a la pantalla de lotes. En `main` va vacío: el proyecto base solo lleva campos
* estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos estándar adicionales o de
* usuario (`U_*`) y no toca ningún otro archivo.
*
* Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura
* (`readOnlyFields`). Un nombre que no exista, o ocultar un campo obligatorio, lanza un error al arrancar.
*
* Ejemplo (los campos son de ejemplo):
*
* '/inventario/lotes': {
* newTabs: [{ id: 'calidad', label: 'Calidad', left: [{ type: 'text', key: 'U_Ubicacion', label: 'Ubicación' }], right: [] }],
* },
*/
export const LOTES_EXTENSIONS: MasterExtensions = {};
@@ -0,0 +1,94 @@
import type { SelectFilter } from '../../../core/sl/list-filters';
import type { HeaderField } from '../../documents/document-layouts';
import type { MasterScreen } from '../master-types';
const field = (f: HeaderField): HeaderField => f;
/** Estado del lote (`BatchNumberDetailStatus`). */
const STATUS = [
{ value: 'bdsStatus_Released', label: 'Liberado' },
{ value: 'bdsStatus_Locked', label: 'Bloqueado' },
{ value: 'bdsStatus_NotAccessible', label: 'No accesible' },
] as const;
const statusLabel = (v: unknown): string => STATUS.find((s) => s.value === v)?.label ?? String(v ?? '');
const statusTone = (v: unknown) => (v === 'bdsStatus_Released' ? ('ok' as const) : v === 'bdsStatus_Locked' ? ('warn' as const) : ('muted' as const));
const statusFilter: SelectFilter = { type: 'select', key: 'Status', label: 'Estado', options: STATUS };
/**
* Lotes (`BatchNumberDetails`): datos maestros del lote de un artículo (estado, atributos, fechas). SAP crea los lotes
* desde los documentos (entradas, recepciones…), así que aquí solo se consultan y se modifican. Solo campos estándar.
*/
export const LOTES_SCREENS: readonly MasterScreen[] = [
{
path: '/inventario/lotes',
resource: 'BatchNumberDetails',
keyField: 'DocEntry',
keyType: 'number',
creatable: false,
titleField: 'Batch',
singular: 'Lote',
createLabel: '',
orderby: 'DocEntry desc',
extraFields: ['DocEntry', 'Status'],
badge: (d) => (d['Status'] ? { label: statusLabel(d['Status']), tone: statusTone(d['Status']) } : undefined),
columns: [
{ key: 'Batch', title: 'Lote', width: '160px', mono: true, searchable: true },
{ key: 'ItemCode', title: 'Nº de artículo', width: '170px', mono: true, searchable: true },
{ key: 'ItemDescription', title: 'Descripción', searchable: true },
{ key: 'ExpirationDate', title: 'Vencimiento', type: 'date', width: '110px' },
{ key: 'Status', title: 'Estado', width: '110px', format: (r) => statusLabel(r['Status']), tone: (r) => statusTone(r['Status']) },
],
filters: [statusFilter, { type: 'date', key: 'ExpirationDate', label: 'Vencimiento' }],
layout: {
header: {
left: [
field({ type: 'text', key: 'Batch', label: 'Lote', immutable: true }),
field({ type: 'text', key: 'ItemCode', label: 'Nº de artículo', immutable: true }),
field({ type: 'text', key: 'ItemDescription', label: 'Descripción', immutable: true }),
],
right: [
field({ type: 'select', key: 'Status', label: 'Estado', options: STATUS }),
field({ type: 'text', key: 'DocEntry', label: 'Número interno', immutable: true }),
],
},
tabs: [
{
id: 'general',
label: 'General',
left: [
field({ type: 'text', key: 'BatchAttribute1', label: 'Atributo 1' }),
field({ type: 'text', key: 'BatchAttribute2', label: 'Atributo 2' }),
],
right: [
field({ type: 'date', key: 'AdmissionDate', label: 'Fecha de admisión' }),
field({ type: 'date', key: 'ManufacturingDate', label: 'Fecha de fabricación' }),
field({ type: 'date', key: 'ExpirationDate', label: 'Fecha de vencimiento' }),
],
},
{
id: 'stock',
label: 'Stock por almacén',
left: [],
right: [],
query: {
name: 'batch-stock',
params: (d) => ({ absEntry: d['DocEntry'] }),
hideEmptyBy: ['Quantity', 'Committed', 'Counted'],
hideEmptyLabel: 'Solo almacenes con cantidad, comprometido o contado',
columns: [
{ key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' },
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px' },
{ key: 'Quantity', title: 'En stock', type: 'number', width: '110px', total: true },
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px', total: true },
{ key: 'Counted', title: 'Contado', type: 'number', width: '110px', total: true },
],
},
},
{ id: 'detalles', label: 'Detalles', left: [field({ type: 'textarea', key: 'Details', label: 'Detalles' })], right: [] },
],
newHeader: () => ({}),
},
},
];
@@ -0,0 +1,147 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MasterFormPage } from '../master-form.page';
import { masterNavigationQuery } from '../master-model';
import { BASE_MASTER_SCREENS } from '../master-screens';
const lote = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"2"',
DocEntry: 74701,
Batch: '2090456',
ItemCode: '10101070119011602',
ItemDescription: 'CATGUT SIMPLE 6/0',
Status: 'bdsStatus_Released',
BatchAttribute1: 'PROV-1',
BatchAttribute2: null,
AdmissionDate: '2026-01-05T00:00:00Z',
ManufacturingDate: '2025-12-01T00:00:00Z',
ExpirationDate: '2028-12-01T00:00:00Z',
Details: null,
...over,
});
describe('Lotes (BatchNumberDetails)', () => {
const screen = BASE_MASTER_SCREENS.find((s) => s.path === '/inventario/lotes')!;
let fixture: ComponentFixture<MasterFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(key: string | null, doc?: Record<string, unknown>) {
const params = key === null ? {} : { key };
const route = { snapshot: { data: { screen, title: screen.singular }, paramMap: convertToParamMap(params) }, paramMap: of(convertToParamMap(params)) };
await TestBed.configureTestingModule({
imports: [MasterFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(MasterFormPage);
await tick();
// la pestaña de stock depende de una consulta a la base de SAP: ¿está configurada y el usuario puede ejecutarla?
const queries = ctrl.match('/api/sapdb/queries');
for (const q of queries) queriesAnswer === 'fail' ? q.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' }) : q.flush([{ name: 'ping' }, { name: 'batch-stock' }]);
if (key !== null) ctrl.expectOne(`/api/sl/BatchNumberDetails/${key}`).flush(doc ?? {});
await tick();
}
let queriesAnswer: 'ok' | 'fail' = 'ok';
beforeEach(() => {
queriesAnswer = 'ok';
TestBed.resetTestingModule();
});
it('la pantalla usa solo campos estándar, clave numérica y no se crea desde aquí', () => {
expect(screen.keyField).toBe('DocEntry');
expect(screen.keyType).toBe('number');
expect(screen.creatable).toBe(false);
expect(screen.columns[0].key).toBe('Batch');
});
it('muestra el número de lote en el título, su estado y los datos del artículo sin poder cambiarlos', async () => {
await open('74701', lote());
expect(el().querySelector('.title')?.textContent).toContain('Lote · 2090456');
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Liberado');
const readonly = (value: string) => [...el().querySelectorAll<HTMLInputElement>('input')].find((i) => i.value === value)!.readOnly;
expect(readonly('2090456')).toBe(true);
expect(readonly('10101070119011602')).toBe(true);
expect(readonly('CATGUT SIMPLE 6/0')).toBe(true);
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Stock por almacén', 'Detalles']);
});
it('las fechas que trae SAP con hora se ven en el selector de fecha', async () => {
await open('74701', lote());
const dates = [...el().querySelectorAll<HTMLInputElement>('input[type=date]')].map((i) => i.value);
expect(dates).toEqual(['2026-01-05', '2025-12-01', '2028-12-01']);
});
it('sin base de datos de SAP configurada (o sin permiso) no hay pestaña de stock', async () => {
queriesAnswer = 'fail';
await open('74701', lote());
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Detalles']);
});
it('el stock del lote se pide por su número interno y se totaliza', async () => {
await open('74701', lote());
(el().querySelectorAll('[role=tab]')[1] as HTMLElement).click();
await tick();
const req = ctrl.expectOne('/api/sapdb/queries/batch-stock');
expect(req.request.body).toEqual({ params: { absEntry: 74701 } });
req.flush({
truncated: false,
rows: [
{ WarehouseCode: '201', WarehouseName: 'APROBADOS DRO', Quantity: '7.500000', Committed: '0.000000', Counted: '0.000000' },
{ WarehouseCode: '100', WarehouseName: 'CUARENTENA INSUMOS', Quantity: '2.500000', Committed: '1.000000', Counted: '0.000000' },
{ WarehouseCode: '150', WarehouseName: 'PRODUCTOS EN PROCESO LAB', Quantity: '0.000000', Committed: '0.000000', Counted: '0.000000' },
],
});
await tick();
const tab = el().querySelectorAll('sbo-tab')[1];
// los almacenes sin cantidades se ocultan; el total suma todos
expect([...tab.querySelectorAll('tbody tr')].map((r) => r.querySelector('input')?.value)).toEqual(['201', '100']);
expect(tab.querySelector('thead tr:nth-child(2)')?.textContent).toContain('10');
});
it('un lote bloqueado se ve como tal', async () => {
await open('74701', lote({ Status: 'bdsStatus_Locked' }));
expect(el().querySelector('.badge.tone-warn')?.textContent?.trim()).toBe('Bloqueado');
});
it('no hay botón «Nuevo» y la ruta /nuevo vuelve a la lista', async () => {
await open('74701', lote());
expect(el().querySelector('sbo-toolbar')?.textContent).not.toContain('Nuevo');
TestBed.resetTestingModule();
await open(null);
expect(navigate).toHaveBeenCalledWith(['..'], expect.anything());
});
it('modifica solo lo que cambió con PATCH a su número interno y If-Match', async () => {
await open('74701', lote());
page().setValue('Status', 'bdsStatus_Locked');
page().setValue('BatchAttribute2', 'Estante 4');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/BatchNumberDetails/74701');
expect(req.request.headers.get('If-Match')).toBe('W/"2"');
expect(req.request.body).toEqual({ Status: 'bdsStatus_Locked', BatchAttribute2: 'Estante 4' });
});
it('la navegación por la clave numérica no la pone entre comillas', () => {
expect(masterNavigationQuery('next', 'DocEntry', '74701', true)).toEqual({ $filter: 'DocEntry gt 74701', $orderby: 'DocEntry asc' });
expect(masterNavigationQuery('prev', 'DocEntry', '74701', true)).toEqual({ $filter: 'DocEntry lt 74701', $orderby: 'DocEntry desc' });
expect(masterNavigationQuery('next', 'CardCode', 'C1')).toEqual({ $filter: "CardCode gt 'C1'", $orderby: 'CardCode asc' });
});
});
@@ -225,7 +225,7 @@ describe('formulario con tablas', () => {
it('precios: solo se envía la lista que cambió, sin reemplazar la colección', async () => {
await open('/inventario/articulos', 'A1', item());
const price = cell(4, 1, 1);
const price = cell(4, 1, 2);
expect(price.value).toBe('50.00');
price.value = '777';
price.dispatchEvent(new Event('change'));
@@ -252,7 +252,7 @@ describe('formulario con tablas', () => {
await open('/inventario/articulos', 'A1', item());
const tab = el().querySelectorAll('sbo-tab')[5];
const inputs = [...tab.querySelectorAll<HTMLInputElement>('tbody input')];
expect(inputs.map((i) => i.value)).toEqual(['01', '990', '2', '1', '0', '0']);
expect(inputs.map((i) => i.value)).toEqual(['01', '', '990', '2', '1', '0', '0']); // la segunda es el nombre del almacén (llega del catálogo)
expect(inputs.every((i) => i.readOnly)).toBe(true);
expect(tab.querySelector('.addrow')).toBeNull();
expect(page().dirty()).toBe(false);
@@ -261,7 +261,7 @@ describe('formulario con tablas', () => {
it('un artículo con cambios en un campo y en los precios envía las dos cosas juntas', async () => {
await open('/inventario/articulos', 'A1', item());
page().setValue('ItemName', 'Pieza nueva');
const price = cell(4, 0, 1);
const price = cell(4, 0, 2);
price.value = '120';
price.dispatchEvent(new Event('change'));
await tick();
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form';
import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField, UdfField } from '../../shared/form';
import { WAREHOUSE_LOOKUP } from '../documents/document-layouts';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { HeaderField } from '../documents/document-layouts';
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
@@ -12,12 +13,12 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-master-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField],
imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField, UdfField],
template: `
@switch (field().type) {
@case ('checkbox') {
<sbo-checkbox-field
[label]="field().label"
[label]="field().label" [dbName]="dbName()"
[readonly]="readOnly() || !!ro()"
[value]="value() === 'tYES'"
(valueChange)="changed.emit($event ? 'tYES' : 'tNO')"
@@ -25,7 +26,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
}
@case ('lookup') {
<sbo-lookup-field
[label]="field().label"
[label]="field().label" [dbName]="dbName()"
[required]="required()"
[readonly]="readOnly()"
[value]="str()"
@@ -36,7 +37,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
}
@case ('warehouse') {
<sbo-lookup-field
[label]="field().label"
[label]="field().label" [dbName]="dbName()"
[required]="required()"
[readonly]="readOnly()"
[value]="str()"
@@ -45,9 +46,19 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
resolveOnLeave
/>
}
@case ('udf') {
<sbo-udf-field
[label]="field().label" [dbName]="dbName()"
[table]="udf().table" [alias]="udf().alias"
[required]="required()"
[readonly]="readOnly() || !!ro()"
[value]="value()"
(valueChange)="changed.emit($any($event))"
/>
}
@case ('select') {
<sbo-select-field
[label]="field().label"
[label]="field().label" [dbName]="dbName()"
[required]="required()"
[readonly]="readOnly()"
[options]="options()"
@@ -58,7 +69,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
}
@case ('number') {
<sbo-number-field
[label]="field().label"
[label]="field().label" [dbName]="dbName()"
[decimals]="decimals()"
[readonly]="readOnly() || !!ro()"
[value]="num()"
@@ -66,14 +77,14 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
/>
}
@case ('date') {
<sbo-date-field [label]="field().label" [required]="required()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
<sbo-date-field [label]="field().label" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
}
@case ('textarea') {
<sbo-textarea-field [label]="field().label" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
<sbo-textarea-field [label]="field().label" [dbName]="dbName()" [readonly]="readOnly()" [value]="str()" (valueChange)="changed.emit($event)" />
}
@default {
<sbo-text-field
[label]="field().label"
[label]="field().label" [dbName]="dbName()"
[required]="required()"
[readonly]="readOnly() || !!ro()"
[value]="str()"
@@ -93,14 +104,25 @@ export class MasterField {
readonly saved = input(false);
readonly changed = output<string | number>();
protected readonly warehouse = WAREHOUSE_LOOKUP;
protected readonly udf = computed(() => {
const f = this.field();
if (f.type !== 'udf') throw new Error('campo sin definición de usuario');
return f;
});
protected readonly warehouse: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' };
/** Un código numérico de SAP (grupo, lista de precios) se guarda como número. */
protected typed(code: string): string | number {
const f = this.field();
return f.type === 'lookup' && f.lookup.valueType === 'number' && /^\d+$/.test(code.trim()) ? Number(code) : code;
}
protected readonly str = computed(() => text(this.value()));
/** El texto del campo; una fecha de SAP llega como `2031-10-31T00:00:00Z` y el selector de fecha pide `yyyy-MM-dd`. */
protected readonly dbName = computed(() => {
const f = this.field();
return 'key' in f ? f.key : undefined;
});
protected readonly str = computed(() => (this.field().type === 'date' ? text(this.value()).slice(0, 10) : text(this.value())));
protected readonly num = computed(() => (this.value() === '' || this.value() === null || this.value() === undefined ? null : Number(this.value())));
protected readonly required = computed(() => {
const f = this.field();
@@ -1,6 +1,8 @@
<sbo-toolbar bleed aria-label="Acciones del registro">
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Registro nuevo"><sbo-icon name="new" />Nuevo</a>
@if (creatable) {
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Registro nuevo"><sbo-icon name="new" />Nuevo</a>
}
<span class="tb-sep"></span>
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
@@ -25,7 +27,7 @@
</div>
<sbo-tabs ariaLabel="Datos del registro">
@for (tab of layout.tabs; track tab.id) {
@for (tab of tabs(); track tab.id) {
<sbo-tab [label]="tab.label">
<div class="sbo-hdr">
<div class="sbo-col">
@@ -39,12 +41,19 @@
}
</div>
</div>
@if (tab.query; as q) {
@if (doc(); as d) {
<sbo-query-table [query]="q.name" [params]="q.params(d)" [columns]="q.columns" [hideEmptyBy]="q.hideEmptyBy" [hideEmptyLabel]="q.hideEmptyLabel ?? 'Ocultar las filas en 0'" />
}
}
@if (tab.collection; as c) {
<sbo-rows-table
[columns]="c.columns"
[rows]="rowsOf(c)"
(rowsChange)="setRows(c, $event)"
[readonly]="!!c.readonly"
[hideEmptyBy]="c.readonly ? (c.hideEmptyBy ?? null) : null"
[hideEmptyLabel]="c.hideEmptyLabel ?? 'Ocultar las filas en 0'"
[removable]="growable(c)"
[emptyText]="c.readonly ? 'Sin datos.' : 'Sin filas.'"
/>
@@ -1,10 +1,12 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, resource, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { SapDbApi } from '../../core/sl/sapdb-api';
import { SlMasterApi } from '../../core/sl/sl-master';
import { QueryTable } from '../../shared/grid/query-table';
import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
@@ -38,7 +40,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-master-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
imports: [ChartTreePanel, Icon, MasterField, QueryTable, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
styles: `
.addrow { margin: 6px 0 0; }
@@ -63,6 +65,28 @@ export class MasterFormPage {
initialValue: this.route.snapshot.paramMap.get('key'),
});
protected readonly isNew = computed(() => this.key() === null);
/** SAP no crea este registro desde aquí (los lotes): sin botón «Nuevo» y `/nuevo` vuelve a la lista. */
protected readonly creatable = this.screen.creatable !== false;
/** Pestañas que dependen de una consulta a la base de SAP: solo se muestran si la base está configurada y el usuario puede ejecutarla. */
private readonly sapdb = inject(SapDbApi);
private readonly queryTabs = this.layout.tabs.filter((t) => t.query);
private readonly shownQueryTabs = resource({
loader: async () => {
const shown = new Set<string>();
for (const t of this.queryTabs) if (await this.sapdb.available(t.query!.name)) shown.add(t.id);
return shown;
},
});
protected readonly tabs = computed(() => {
if (!this.queryTabs.length) return this.layout.tabs;
const shown = this.shownQueryTabs.hasValue() ? this.shownQueryTabs.value() : new Set<string>();
return this.layout.tabs.filter((t) => !t.query || shown.has(t.id));
});
constructor() {
if (!this.creatable && this.isNew()) void this.router.navigate(['..'], { relativeTo: this.route });
}
// --- datos ---------------------------------------------------------------------------------
@@ -128,10 +152,12 @@ export class MasterFormPage {
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
const d = this.doc();
if (!d) return undefined;
if (this.screen.badge) return this.screen.badge(d);
return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' };
});
protected readonly windowTitle = computed(() => {
const d = this.doc();
if (this.screen.titleField && d) return `${this.screen.singular} · ${text(d[this.screen.titleField])}`;
const code = this.screen.displayField && d ? displayCode({ Code: text(d[this.screen.keyField]), FormatCode: text(d[this.screen.displayField]) }) : d?.[this.screen.keyField];
return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`;
});
@@ -189,7 +215,7 @@ export class MasterFormPage {
protected async go(target: MasterNavTarget): Promise<void> {
try {
const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter);
const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter, this.screen.keyType === 'number');
if (next === null) {
this.message.set({ kind: 'warn', text: 'No hay más registros en esa dirección.' });
return;
@@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { API_URL } from '../../core/api';
import { ListView } from '../../shared/list/list-view';
import type { ListRow } from '../../shared/list/list.types';
import type { ListColumn, ListRow } from '../../shared/list/list.types';
import type { MasterScreen } from './master-types';
/** Lista de un maestro (clientes, proveedores, artículos); abre uno o crea uno nuevo. */
@@ -13,13 +15,13 @@ import type { MasterScreen } from './master-types';
<sbo-list-view
[title]="title"
[resource]="screen.resource"
[columns]="screen.columns"
[columns]="columns"
[extraFields]="screen.extraFields ?? []"
[filters]="screen.filters"
[orderby]="screen.orderby"
[baseFilter]="screen.baseFilter"
[rowKey]="screen.keyField"
[createLabel]="screen.createLabel"
[createLabel]="screen.creatable === false ? '' : screen.createLabel"
(open)="open($event)"
(create)="create()"
/>
@@ -32,6 +34,32 @@ export class MasterListPage {
protected readonly screen = this.route.snapshot.data['screen'] as MasterScreen;
protected readonly title = this.route.snapshot.data['title'] as string;
private readonly http = inject(HttpClient);
private readonly names = signal<Record<string, Map<string, string>>>({});
/** Las columnas de la pantalla; las de un catálogo muestran `código · nombre` apenas llega el catálogo (una sola consulta). */
protected readonly columns: readonly ListColumn[] = this.screen.columns.map((c) => {
if (!this.screen.listNames?.some((n) => n.key === c.key)) return c;
return {
...c,
format: (r: ListRow) => {
const code = r[c.key];
if (code === null || code === undefined || code === '') return '';
const name = this.names()[c.key]?.get(String(code));
return name ? `${code} · ${name}` : String(code);
},
};
});
constructor() {
for (const n of this.screen.listNames ?? []) {
const params = new HttpParams().set('$select', `${n.valueField},${n.labelField}`).set('$top', 500);
this.http.get<{ value?: Record<string, unknown>[] }>(`${API_URL}/sl/${n.resource}`, { params }).subscribe({
next: (res) => this.names.update((m) => ({ ...m, [n.key]: new Map((res.value ?? []).map((x) => [String(x[n.valueField]), String(x[n.labelField] ?? '')])) })),
error: () => undefined, // sin nombres se ve el código
});
}
}
protected open(row: ListRow): void {
void this.router.navigate([String(row[this.screen.keyField])], { relativeTo: this.route });
@@ -13,18 +13,21 @@ export const MASTER_LOOKUPS = {
orderby: 'Code',
baseFilter: `Type eq '${type === 'cSupplier' ? 'bbpgt_VendorGroup' : 'bbpgt_CustomerGroup'}'`,
valueType: 'number',
nameKey: 'Name',
columns: [col('Code', 'Código', { width: '90px', mono: true }), col('Name', 'Nombre')],
}),
currency: {
title: 'Monedas',
resource: 'Currencies',
orderby: 'Code',
nameKey: 'Name',
columns: [col('Code', 'Código', { width: '90px', mono: true }), col('Name', 'Nombre')],
} satisfies VhLookup,
paymentTerms: {
title: 'Condiciones de pago',
resource: 'PaymentTermsTypes',
orderby: 'GroupNumber',
nameKey: 'PaymentTermsGroupName',
valueType: 'number',
columns: [col('GroupNumber', 'Código', { width: '90px', mono: true }), col('PaymentTermsGroupName', 'Nombre')],
} satisfies VhLookup,
@@ -32,6 +35,7 @@ export const MASTER_LOOKUPS = {
title: 'Listas de precios',
resource: 'PriceLists',
orderby: 'PriceListNo',
nameKey: 'PriceListName',
valueType: 'number',
columns: [col('PriceListNo', 'Código', { width: '90px', mono: true }), col('PriceListName', 'Nombre')],
} satisfies VhLookup,
@@ -39,6 +43,7 @@ export const MASTER_LOOKUPS = {
title: 'Empleados de ventas',
resource: 'SalesPersons',
orderby: 'SalesEmployeeCode',
nameKey: 'SalesEmployeeName',
valueType: 'number',
baseFilter: `Active eq 'tYES'`,
columns: [col('SalesEmployeeCode', 'Código', { width: '90px', mono: true }), col('SalesEmployeeName', 'Nombre')],
@@ -51,12 +56,15 @@ export const MASTER_LOOKUPS = {
resource: 'BusinessPartners',
orderby: 'CardCode',
baseFilter: `CardType eq 'cSupplier'`,
nameKey: 'CardName',
codeWidth: '150px',
columns: [col('CardCode', 'Código', { width: '130px', mono: true }), col('CardName', 'Nombre')],
} satisfies VhLookup,
itemGroup: {
title: 'Grupos de artículos',
resource: 'ItemGroups',
orderby: 'Number',
nameKey: 'GroupName',
valueType: 'number',
columns: [col('Number', 'Código', { width: '90px', mono: true }), col('GroupName', 'Nombre')],
} satisfies VhLookup,
@@ -89,8 +89,8 @@ describe('pantallas de maestros', () => {
for (const s of BASE_MASTER_SCREENS) {
for (const k of masterKeysOf(s.layout)) expect(k.startsWith('U_'), `${s.path} ${k}`).toBe(false);
for (const c of s.columns) expect(c.key.startsWith('U_')).toBe(false);
expect(s.orderby).toBe(s.keyField);
expect(s.columns[0].key).toBe(s.keyField);
expect(s.orderby.split(' ')[0]).toBe(s.keyField);
expect(s.columns[0].key).toBe(s.titleField ?? s.keyField);
}
});
@@ -43,10 +43,11 @@ export function masterNavigationQuery(
target: MasterNavTarget,
keyField: string,
current: string | null,
numeric = false,
): { $filter?: string; $orderby: string } {
const asc = `${keyField} asc`;
const desc = `${keyField} desc`;
const quoted = current === null ? '' : `'${escapeODataString(current)}'`;
const quoted = current === null ? '' : numeric ? String(Number(current)) : `'${escapeODataString(current)}'`;
switch (target) {
case 'first':
return { $orderby: asc };
@@ -3,4 +3,4 @@
* configuración completa (`master-screens.ts`) se carga en diferido al abrir una. Un test comprueba que
* coincide con `MASTER_SCREENS`.
*/
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/finanzas/plan-de-cuentas'];
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/inventario/lotes', '/finanzas/plan-de-cuentas'];
@@ -2,13 +2,15 @@ import { FINANZAS_EXTENSIONS } from './finanzas/finanzas.extensions';
import { FINANZAS_SCREENS } from './finanzas/finanzas.screens';
import { ARTICULOS_SCREENS } from './articulos/articulos.screens';
import { ARTICULOS_EXTENSIONS } from './articulos/articulos.extensions';
import { LOTES_EXTENSIONS } from './lotes/lotes.extensions';
import { LOTES_SCREENS } from './lotes/lotes.screens';
import { type MasterExtensions, applyMasterExtensions } from './master-extensions';
import { SOCIOS_EXTENSIONS } from './socios-negocio/socios.extensions';
import { SOCIOS_SCREENS } from './socios-negocio/socios.screens';
import type { MasterScreen } from './master-types';
/** Las pantallas de maestros del proyecto base, agrupadas por módulo (una carpeta cada uno). */
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...FINANZAS_SCREENS];
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...LOTES_SCREENS, ...FINANZAS_SCREENS];
/** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */
function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions): MasterExtensions {
@@ -22,6 +24,7 @@ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions)
export const MASTER_EXTENSIONS: MasterExtensions = {
...ownedBy(SOCIOS_SCREENS, SOCIOS_EXTENSIONS),
...ownedBy(ARTICULOS_SCREENS, ARTICULOS_EXTENSIONS),
...ownedBy(LOTES_SCREENS, LOTES_EXTENSIONS),
...ownedBy(FINANZAS_SCREENS, FINANZAS_EXTENSIONS),
};
@@ -1,6 +1,7 @@
import type { ListFilter } from '../../core/sl/list-filters';
import type { DetailColumn } from '../../shared/grid/grid.types';
import type { ListColumn } from '../../shared/list/list.types';
import type { WindowBadge } from '../../shared/window';
import type { FieldColumns } from '../documents/document-layouts';
import type { DocRecord } from '../documents/document-model';
@@ -20,6 +21,9 @@ export interface MasterCollection {
mode: 'replace' | 'merge';
/** Campo que identifica una fila en `merge` (`PriceList`). */
rowKey?: string;
/** Columnas numéricas por las que se ocultan las filas en 0, con una casilla marcada por defecto (solo con `readonly`). */
hideEmptyBy?: readonly string[];
hideEmptyLabel?: string;
/** Solo se muestra (el stock por almacén). */
readonly?: boolean;
/** Se pueden añadir y quitar filas (por defecto sí; no en `merge` ni en solo lectura). */
@@ -36,6 +40,17 @@ export interface MasterTab extends FieldColumns {
id: string;
label: string;
collection?: MasterCollection;
/**
* Una tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (`/api/sapdb`; el stock de un
* lote por almacén). Si la base no está configurada o el usuario no tiene permiso, la pestaña no se muestra.
*/
query?: {
name: string;
params: (doc: DocRecord) => Record<string, unknown>;
columns: readonly DetailColumn[];
hideEmptyBy?: readonly string[];
hideEmptyLabel?: string;
};
}
/**
@@ -64,6 +79,14 @@ export interface MasterScreen {
resource: string;
/** Campo clave del recurso (`CardCode`, `ItemCode`). */
keyField: string;
/** Tipo de la clave (`DocEntry` de un lote es numérica); por defecto, texto. */
keyType?: 'string' | 'number';
/** `false` si SAP no crea el registro desde aquí (los lotes nacen en los documentos): sin botón «Nuevo». */
creatable?: boolean;
/** Campo que se muestra en el título junto al nombre en singular, en vez de la clave (el número de lote). */
titleField?: string;
/** La insignia del estado del registro (por defecto, Activo / Inactivo según `Frozen`). */
badge?: (doc: DocRecord) => WindowBadge | undefined;
/** Campo que se muestra en el título en vez de la clave, si la tiene (`FormatCode` del plan de cuentas). */
displayField?: string;
/** Nombre en singular, para el título del formulario (`Cliente`). */
@@ -71,6 +94,8 @@ export interface MasterScreen {
createLabel: string;
columns: readonly ListColumn[];
extraFields?: readonly string[];
/** Columnas de la lista cuyo valor es un código de otro catálogo: se muestra `código · nombre` (`GroupCode` → `BusinessPartnerGroups`). */
listNames?: readonly { key: string; resource: string; valueField: string; labelField: string }[];
filters: readonly ListFilter[];
orderby: string;
/** Filtro fijo de la lista y de la navegación (ej. solo los clientes). */
@@ -40,10 +40,12 @@ const partner = (opts: { path: string; type: 'cCustomer' | 'cSupplier'; singular
orderby: 'CardCode',
baseFilter: `CardType eq '${opts.type}'`,
extraFields: ['Frozen'],
listNames: [{ key: 'GroupCode', resource: 'BusinessPartnerGroups', valueField: 'Code', labelField: 'Name' }],
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true, searchable: true },
{ key: 'CardName', title: 'Nombre', searchable: true },
{ key: 'FederalTaxID', title: 'RUT', width: '120px', mono: true, searchable: true },
{ key: 'GroupCode', title: 'Grupo', type: 'number', align: 'left', width: '170px' },
{ key: 'Phone1', title: 'Teléfono', width: '130px', searchable: true },
{ key: 'EmailAddress', title: 'Correo electrónico', searchable: true },
statusColumn,
@@ -0,0 +1,39 @@
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 PDF del reporte (lo genera el servidor con el papel y los márgenes de la plantilla). */
pdf(code: string, values: GatewayValues): Promise<Blob> {
return firstValueFrom(this.http.post(`${API_URL}/html-reports/${encodeURIComponent(code)}/pdf`, { values }, { responseType: 'blob' }));
}
/** 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,137 @@
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 PDF');
const htmlButton = () => [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Ver como HTML') as HTMLButtonElement;
// falta un obligatorio: no se genera
htmlButton().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);
htmlButton().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('«Generar PDF» pide el PDF al servidor y lo muestra con su descarga', async () => {
const pdf = vi.fn(async () => new Blob(['%PDF'], { type: 'application/pdf' }));
const render = vi.fn();
URL.createObjectURL = vi.fn(() => 'blob:x');
URL.revokeObjectURL = vi.fn();
setup({ reports: vi.fn(async () => [REPORT]), pdf, render }, '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], '6');
const el = fixture.nativeElement as HTMLElement;
(el.querySelector('button.main') as HTMLButtonElement).click();
await tick(fixture);
expect(pdf).toHaveBeenCalledWith('etiquetas-bultos', { cantidad: '6', papel: 'Carta' });
expect(render).not.toHaveBeenCalled();
expect(el.querySelector('iframe[title="Vista del PDF"]')).not.toBeNull();
expect(el.querySelector('a[download]')?.getAttribute('download')).toBe('etiquetas-bultos.pdf');
// la vista HTML y el PDF no conviven: al pedir uno desaparece el otro
expect(el.querySelector('iframe[title="Vista del reporte"]')).toBeNull();
});
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).querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Ver como HTML')!.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 });
});
});
@@ -38,6 +38,9 @@ const KINDS: { value: GatewayParamKind; label: string }[] = [
];
const yesNo = [{ value: YES, label: YES }, { value: NO, label: NO }];
/** El parámetro `Dockey@` es el de los layouts de documento de SAP: recibe el número del documento. */
export const dockeyParam = (params: readonly { name: string }[]): string => params.find((p) => /^dockey@/i.test(p.name))?.name ?? '';
/** Una definición de parámetro como fila editable de la tabla. */
const toRow = (p: GatewayParam): Row => ({
name: p.name,
@@ -106,7 +109,7 @@ export function toParam(r: Row): GatewayParam {
<p class="hint">«Tipo de control» decide cómo se pide al usuario; con «Ventana de selección» se elige la lista. «Oculto» es para títulos y separadores del reporte: no se piden ni se envían. «Opciones» de una lista fija: <code>valor=etiqueta; valor=etiqueta</code>.</p>
<sbo-rows-table [columns]="columns" [rows]="d.params" (rowsChange)="patch({ params: $event })" [removable]="false" emptyText="El reporte no tiene parámetros." />
<h2>Botón PDF de los documentos</h2>
<p class="hint">Marque los documentos que ofrecen este formato. El que se marca «Por defecto» es el que abre el botón «PDF» de ese documento (uno solo por documento, como en SAP); los demás salen en «Otros formatos». El número del documento (<code>DocEntry</code>) se envía en el parámetro elegido abajo; los demás parámetros toman su valor por defecto.</p>
<p class="hint">Un reporte con el parámetro <code>Dockey@</code> es un layout de documento de SAP y se propone solo aquí. Marque los documentos que ofrecen este formato. El que se marca «Por defecto» es el que abre el botón «PDF» de ese documento (uno solo por documento, como en SAP); los demás salen en «Otros formatos». El número del documento (<code>DocEntry</code>) se envía en el parámetro elegido abajo; los demás parámetros toman su valor por defecto.</p>
<sbo-select-field label="Parámetro del documento" [options]="documentParamOptions(d)" [value]="d.documentParam" (valueChange)="patch({ documentParam: '' + ($event ?? '') })" />
<table class="grid docs">
<thead><tr><th>Documento</th><th class="chk">Ofrecer</th><th class="chk">Por defecto</th></tr></thead>
@@ -126,7 +129,7 @@ export function toParam(r: Row): GatewayParam {
</div>
<table class="grid">
<thead>
<tr><th>Código</th><th>Título</th><th>Categoría</th><th class="num">Parámetros</th><th>Estado</th><th></th></tr>
<tr><th>Código</th><th>Título</th><th>Categoría</th><th class="num">Parámetros</th><th>Documentos</th><th>Estado</th><th></th></tr>
</thead>
<tbody>
@for (r of rows(); track r.code) {
@@ -135,6 +138,7 @@ export function toParam(r: Row): GatewayParam {
<td>{{ r.title }}</td>
<td>{{ r.category }}</td>
<td class="num">{{ r.params.length }}</td>
<td>{{ r.documents?.length ? r.documents!.length + ' documento(s)' : '' }}</td>
<td>{{ r.enabled ? 'Habilitado' : 'Deshabilitado' }}</td>
<td class="acts">
<button type="button" class="btn" (click)="edit(r)">Editar</button>
@@ -147,7 +151,7 @@ export function toParam(r: Row): GatewayParam {
</td>
</tr>
} @empty {
<tr class="none"><td colspan="6">{{ registry.isLoading() ? 'Cargando…' : 'Aún no hay reportes matriculados. Pulse «Matricular reporte».' }}</td></tr>
<tr class="none"><td colspan="7">{{ registry.isLoading() ? 'Cargando…' : 'Aún no hay reportes matriculados. Pulse «Matricular reporte».' }}</td></tr>
}
</tbody>
</table>
@@ -271,7 +275,7 @@ export class RegistryPage {
this.message.set({ kind: 'info', text: `Cargando los parámetros de ${chosen.code}…` });
try {
const params = await this.api.parameters(chosen.code);
this.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow), documents: [], documentParam: '', defaultFor: [] });
this.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow), documents: [], documentParam: dockeyParam(params), defaultFor: [] });
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
@@ -281,7 +285,7 @@ export class RegistryPage {
// --- editar y guardar ----------------------------------------------------------------------------
protected edit(r: GatewayReport): void {
this.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow), documents: r.documents ?? [], documentParam: r.documentParam ?? '', defaultFor: r.defaultFor ?? [] });
this.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow), documents: r.documents ?? [], documentParam: r.documentParam ?? dockeyParam(r.params), defaultFor: r.defaultFor ?? [] });
this.message.set(null);
}
@@ -60,6 +60,15 @@ describe('ReportParamField', () => {
expect(select.value).toBe('2');
});
it('un parámetro de solo lectura se ve con su valor pero no se puede cambiar', async () => {
await show(param({ kind: 'select', label: 'Papel', options: [{ value: 'Carta', label: 'Carta' }], readOnly: true }), 'Carta');
const select = el().querySelector('select') as HTMLSelectElement;
expect(select.value).toBe('Carta');
expect(select.disabled || select.hasAttribute('readonly') || select.closest('[readonly]') !== null).toBe(true);
await show(param({ label: 'Nombre', readOnly: true }), 'fijo');
expect(inputs()[0].readOnly).toBe(true);
});
it('un rango pide desde y hasta y los emite como lista', async () => {
await show(param({ kind: 'date', range: true }), ['2026-01-01', '2026-01-31']);
expect(labels().join(' ')).toContain('desde / hasta');
@@ -24,23 +24,23 @@ const asList = (v: Value): readonly string[] => (typeof v === 'string' ? (v ===
@case ('single') {
@switch (p.kind) {
@case ('number') {
<sbo-number-field [label]="p.label" [required]="p.required" [value]="num(single())" (valueChange)="value.set($event === null ? '' : '' + $event)" />
<sbo-number-field [label]="p.label" [required]="p.required" [readonly]="!!p.readOnly" [value]="num(single())" (valueChange)="value.set($event === null ? '' : '' + $event)" />
}
@case ('date') {
<sbo-date-field [label]="p.label" [required]="p.required" [value]="single()" (valueChange)="value.set($event)" />
<sbo-date-field [label]="p.label" [required]="p.required" [readonly]="!!p.readOnly" [value]="single()" (valueChange)="value.set($event)" />
}
@case ('select') {
<sbo-select-field [label]="p.label" [required]="p.required" [options]="p.options ?? []" placeholder="" [value]="single() || null" (valueChange)="value.set($event === null ? '' : '' + $event)" />
<sbo-select-field [label]="p.label" [required]="p.required" [readonly]="!!p.readOnly" [options]="p.options ?? []" placeholder="" [value]="single() || null" (valueChange)="value.set($event === null ? '' : '' + $event)" />
}
@case ('lookup') {
@if (catalog(); as c) {
<sbo-lookup-field [label]="p.label" [required]="p.required" [lookup]="c.lookup" resolveOnLeave [value]="single()" (valueChange)="value.set($event)" />
<sbo-lookup-field [label]="p.label" [required]="p.required" [readonly]="!!p.readOnly" [lookup]="c.lookup" resolveOnLeave [value]="single()" (valueChange)="value.set($event)" />
} @else {
<sbo-text-field [label]="p.label" [required]="p.required" [value]="single()" (valueChange)="value.set($event)" />
<sbo-text-field [label]="p.label" [required]="p.required" [readonly]="!!p.readOnly" [value]="single()" (valueChange)="value.set($event)" />
}
}
@default {
<sbo-text-field [label]="p.label" [required]="p.required" [placeholder]="p.placeholder ?? ''" [value]="single()" (valueChange)="value.set($event)" />
<sbo-text-field [label]="p.label" [required]="p.required" [readonly]="!!p.readOnly" [placeholder]="p.placeholder ?? ''" [value]="single()" (valueChange)="value.set($event)" />
}
}
}
@@ -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,18 @@ type Value = string | readonly string[];
@if (pdfUrl(); as url) {
<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('pdf')">{{ running() ? 'Generando…' : action }}</button>
@if (isHtml) {
<button sboActions class="btn" type="button" [disabled]="running() || !report()" (click)="run('html')">Ver como HTML</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) {
<a sboActions class="btn" [href]="d.url" [attr.download]="d.name">Descargar</a>
}
@@ -57,14 +68,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`). */
protected readonly isHtml = this.route.snapshot.data['source'] === 'html';
protected readonly action = 'Generar PDF';
private readonly htmlFrame = viewChild<ElementRef<HTMLIFrameElement>>('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<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). */
protected readonly visible = computed(() => (this.report()?.params ?? []).filter((p) => !p.hidden));
@@ -74,6 +90,7 @@ export class ReportRunnerPage {
private readonly values = signal<Record<string, Value>>({});
protected readonly running = signal(false);
protected readonly pdfUrl = signal<SafeResourceUrl | null>(null);
protected readonly htmlDoc = signal<SafeHtml | null>(null);
protected readonly download = signal<{ url: string; name: string } | null>(null);
private readonly message = signal<StatusMessage | null>(null);
@@ -87,7 +104,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,7 +122,13 @@ export class ReportRunnerPage {
if (d) URL.revokeObjectURL(d.url);
}
protected async run(): Promise<void> {
/** Imprime el reporte (solo el marco, no la pantalla). */
protected print(): void {
this.htmlFrame()?.nativeElement.contentWindow?.print();
}
/** `pdf`: el PDF (del Gateway o, en los reportes HTML, generado por el servidor); `html`: la vista HTML para imprimir desde el navegador. */
protected async run(mode: 'pdf' | 'html'): Promise<void> {
const r = this.report();
if (!r || this.running()) return;
const values: Record<string, Value> = {};
@@ -118,7 +141,15 @@ export class ReportRunnerPage {
this.running.set(true);
this.message.set({ kind: 'info', text: 'Generando el reporte…' });
try {
const blob = await this.api.export(r.code, values as GatewayValues);
if (this.isHtml && mode === 'html') {
this.pdfUrl.set(null);
// 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;
}
this.htmlDoc.set(null);
const blob = this.isHtml ? await this.htmlApi.pdf(r.code, values as GatewayValues) : await this.api.export(r.code, values as GatewayValues);
this.revoke();
const url = URL.createObjectURL(blob);
this.download.set({ url, name: `${r.code}.pdf` });
@@ -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';
}
</ul>
} @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>
`,
@@ -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() });
@@ -201,3 +201,11 @@ describe('toParam', () => {
expect(toParam({ name: 'Q', kind: 'lookup', lookup: 'articulos' })).not.toHaveProperty('options');
});
});
describe('layouts de documento', () => {
it('el parámetro Dockey@ se propone como el que recibe el número del documento', async () => {
const { dockeyParam } = await import('./registry.page');
expect(dockeyParam([{ name: 'Title@Title' }, { name: 'Dockey@' }])).toBe('Dockey@');
expect(dockeyParam([{ name: 'Item@Select * from OITM' }])).toBe('');
});
});
+10
View File
@@ -31,6 +31,7 @@
color: var(--shell-quiet);
}
.out,
.fields,
.burger {
background: transparent;
color: var(--shell-ink);
@@ -42,7 +43,16 @@
.burger {
padding: 2px 8px;
}
.fields {
margin-right: 10px;
font-size: 12px;
padding: 1px 8px;
}
.fields.on {
background: rgba(255, 255, 255, 0.18);
}
.out:hover,
.fields:hover,
.burger:hover {
background: rgba(255, 255, 255, 0.08);
}
+1
View File
@@ -5,6 +5,7 @@
</button>
<a class="brand" routerLink="/">Basic ERP <small>{{ session.company() }}</small></a>
<span class="sp"></span>
<button class="fields" type="button" [class.on]="fieldNames.show()" [attr.aria-pressed]="fieldNames.show()" title="Mostrar el nombre del campo de SAP debajo de cada etiqueta" (click)="fieldNames.toggle()">Campos</button>
<sbo-theme-switcher />
<span class="user">{{ session.fullName() }}</span>
<button class="out" type="button" (click)="logout()">Salir</button>
+59 -1
View File
@@ -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<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']);
});
});
+22 -4
View File
@@ -1,11 +1,15 @@
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';
import { AuthService } from '../core/auth/auth.service';
import { SessionStore } from '../core/auth/session.store';
import { MenuService } from '../core/menu/menu.service';
import { RecentScreens } from '../core/menu/recent-screens';
import { ThemeSwitcher } from '../core/theme/theme-switcher';
const SIDEBAR_KEY = 'erp_sidebar';
@@ -31,6 +35,7 @@ export class MainLayout {
private readonly auth = inject(AuthService);
protected readonly session = inject(SessionStore);
protected readonly menu = inject(MenuService);
protected readonly fieldNames = inject(FieldNames);
/** En pantallas estrechas el menú se abre encima del contenido. */
protected readonly sidebarOpen = signal(false);
@@ -41,10 +46,16 @@ 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));
// empieza a anotar las pantallas que se abren (las ofrece la bienvenida)
inject(RecentScreens);
void this.segments.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. */
protected toggleSide(): void {
@@ -62,17 +73,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();
}
@@ -8,4 +8,6 @@ export abstract class FieldBase {
readonly readOnly = input(false, { alias: 'readonly', transform: booleanAttribute });
readonly disabled = input(false, { transform: booleanAttribute });
readonly error = input<string>();
/** Nombre del campo de SAP (ayuda y «Ver campos»). */
readonly dbName = input<string>();
}
+6 -6
View File
@@ -13,7 +13,7 @@ import { SboField } from './sbo-field';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, TextInput],
template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()">
<sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-input
[(value)]="value"
[readonly]="readOnly()"
@@ -37,7 +37,7 @@ export class TextField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, CheckboxInput],
template: `
<sbo-field [label]="label()" [error]="error()">
<sbo-field [label]="label()" [dbName]="dbName()" [error]="error()">
<sbo-checkbox [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" />
</sbo-field>
`,
@@ -52,7 +52,7 @@ export class CheckboxField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, TextareaInput],
template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()">
<sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-textarea
[(value)]="value"
[readonly]="readOnly()"
@@ -78,7 +78,7 @@ export class TextareaField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, NumberInput],
template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()">
<sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-number
[(value)]="value"
[decimals]="decimals()"
@@ -100,7 +100,7 @@ export class NumberField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, DateInput],
template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()">
<sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-date [(value)]="value" [readonly]="readOnly()" [disabled]="disabled()" [required]="required()" />
</sbo-field>
`,
@@ -115,7 +115,7 @@ export class DateField extends FieldBase {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SboField, SelectInput],
template: `
<sbo-field [label]="label()" [required]="required()" [error]="error()">
<sbo-field [label]="label()" [dbName]="dbName()" [required]="required()" [error]="error()">
<sbo-select
[(value)]="value"
[options]="options()"
+1
View File
@@ -4,3 +4,4 @@ export { SboField } from './sbo-field';
// Atajos con etiqueta (un control por fila)
export { CheckboxField, DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
export { LookupField } from './lookup-field';
export { UdfField } from './udf-field';
+40 -1
View File
@@ -19,6 +19,9 @@ import { TextInput } from './controls';
import { FieldBase } from './field-base';
import { SboField } from './sbo-field';
/** Nombres ya consultados de los códigos de las listas con `nameKey`. */
const NAMES = new Map<string, string>();
/**
* Campo con botón "…" que abre una lista de valores (flecha dorada de SAP).
* Escribir el código a mano también es válido; elegir de la lista lo rellena y emite `picked`.
@@ -30,6 +33,7 @@ import { SboField } from './sbo-field';
template: `
<sbo-field
[label]="label()"
[dbName]="dbName()"
[required]="required()"
[error]="error() || missingText()"
action
@@ -38,6 +42,7 @@ import { SboField } from './sbo-field';
>
<sbo-input
#box
[width]="lookup().nameKey ? (lookup().codeWidth ?? '96px') : undefined"
[value]="shown()"
(valueChange)="typing($event)"
[readonly]="readOnly()"
@@ -56,6 +61,9 @@ import { SboField } from './sbo-field';
>
…
</button>
@if (lookup().nameKey) {
<sbo-input readonly [attr.title]="name()" [ariaLabel]="'Nombre de ' + label()" [value]="name()" />
}
</sbo-field>
<sbo-value-help
[open]="listOpen()"
@@ -70,7 +78,9 @@ import { SboField } from './sbo-field';
(closed)="closeList()"
/>
`,
styles: ':host { display: block; }',
styles: `
:host { display: block; }
`,
})
export class LookupField extends FieldBase {
readonly value = model('');
@@ -96,6 +106,10 @@ export class LookupField extends FieldBase {
private resolved = '';
private readonly box = viewChild.required<TextInput>('box');
/** Nombre de lo elegido, junto al código (`lookup.nameKey`). */
protected readonly name = signal('');
private nameFor = '';
/** El usuario está escribiendo: se muestra lo que escribe y no el texto con que la lista presenta el código guardado. */
private readonly editing = signal(false);
/**
@@ -117,10 +131,30 @@ export class LookupField extends FieldBase {
if (!this.editing()) {
if (codes && v !== '') codes.ensure([v]);
this.resolved = v.trim();
void this.loadName(v.trim());
}
});
}
/** El nombre del código guardado; se pregunta una vez por código (el resultado queda en `NAMES`). */
private async loadName(code: string): Promise<void> {
const lookup = this.lookup();
const nameKey = lookup.nameKey;
this.nameFor = code;
if (!nameKey || !code) return void this.name.set('');
const cacheKey = `${lookup.resource}|${lookup.baseFilter ?? ''}|${code}`;
const cached = NAMES.get(cacheKey);
if (cached !== undefined) return void this.name.set(cached);
try {
const row = await this.injector.get(SlLookupApi).find(lookup, code);
const text = row ? String(row[nameKey] ?? '') : '';
NAMES.set(cacheKey, text);
if (this.nameFor === code) this.name.set(text);
} catch {
if (this.nameFor === code) this.name.set('');
}
}
protected typing(text: string): void {
this.editing.set(true);
this.value.set(text);
@@ -145,6 +179,11 @@ export class LookupField extends FieldBase {
const key = this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? '';
this.missing.set(null);
this.lookup().codes?.remember(row);
const nameKey = this.lookup().nameKey;
if (nameKey) {
this.nameFor = String(row[this.lookup().valueKey ?? this.lookup().columns[0]?.key ?? ''] ?? '');
this.name.set(String(row[nameKey] ?? ''));
}
this.editing.set(false);
this.value.set(String(row[key] ?? ''));
this.resolved = this.value();
+18 -3
View File
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, booleanAttribute, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, booleanAttribute, inject, input, output } from '@angular/core';
import { FieldNames } from '../../core/field-names';
let nextId = 0;
@@ -19,8 +20,11 @@ let nextId = 0;
[class.req]="required()"
[class.ga]="action()"
[class.link]="linkable()"
[attr.title]="dbName() ?? null"
(click)="labelClick.emit()"
>{{ label() }}</label
><span class="t">{{ label() }}</span>@if (dbName() && names.show()) {
<span class="db">{{ dbName() }}</span>
}</label
>
<div class="ctl">
<div class="sbo-group"><ng-content /></div>
@@ -47,7 +51,7 @@ let nextId = 0;
text-overflow: ellipsis;
white-space: nowrap;
}
label.req::after {
label.req .t::after {
content: ' *';
color: var(--err);
}
@@ -65,6 +69,14 @@ let nextId = 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
.db {
display: block;
margin-top: -2px;
font: 10.5px/1.2 var(--mono, monospace);
color: var(--quiet);
white-space: normal;
overflow-wrap: anywhere;
}
label.link {
cursor: pointer;
}
@@ -83,7 +95,10 @@ let nextId = 0;
`,
})
export class SboField {
protected readonly names = inject(FieldNames);
readonly label = input.required<string>();
/** Nombre del campo de SAP; sale como ayuda del cursor y, con «Ver campos», debajo de la etiqueta. */
readonly dbName = input<string>();
readonly required = input(false, { transform: booleanAttribute });
readonly action = input(false, { transform: booleanAttribute });
/** La etiqueta es un enlace (ej. abrir el socio de negocio). */
@@ -0,0 +1,89 @@
import { Component, signal } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { UdfField } from './udf-field';
@Component({
imports: [UdfField],
template: '<sbo-udf-field label="Campo" [table]="table" [alias]="alias" [value]="value()" (valueChange)="value.set($event)" />',
})
class Host {
table = 'OBTN';
alias = 'SIS_X';
value = signal<unknown>('');
}
const base = { table: 'OBTN', alias: 'SIS_X', description: 'Campo X', size: 10, validValues: [], linkedTable: null, linkedRows: [], linkedTruncated: false };
describe('campo de usuario de SAP', () => {
let fixture: ComponentFixture<Host>;
let ctrl: HttpTestingController;
const el = () => fixture.nativeElement as HTMLElement;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(answer: object, value: unknown = '') {
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(Host);
fixture.componentInstance.value.set(value);
await tick();
const req = ctrl.expectOne('/api/sapdb/user-fields/OBTN/SIS_X');
if (answer && typeof answer === 'object' && 'fail' in answer) req.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' });
else req.flush(answer as Record<string, unknown>);
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
it('con valores válidos se pide en una lista desplegable con ellos', async () => {
await open({ ...base, validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }] }, '102');
const select = el().querySelector('select') as HTMLSelectElement;
expect([...select.options].map((o) => o.textContent?.trim())).toEqual(expect.arrayContaining(['101 · Cuarentena', '102 · Aprobado']));
expect(select.value).toBe('102');
select.value = '101';
select.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.value()).toBe('101');
});
it('enlazado a una tabla de usuario, muestra su nombre junto al código y abre su lista', async () => {
await open({ ...base, linkedTable: 'SIS_REGSAN', linkedRows: [{ code: '10001', name: 'DM2462E' }, { code: '10002', name: 'OTRO' }] }, '10001');
await tick(); // el nombre se resuelve de las filas ya cargadas
const inputs = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
expect(inputs).toEqual(expect.arrayContaining(['10001', 'DM2462E']));
expect(el().querySelector('button.sbo-btn')).not.toBeNull();
});
it('un campo numérico guarda número, no texto', async () => {
await open({ ...base, type: 'N', validValues: [{ value: '1', description: 'Uno' }, { value: '2', description: 'Dos' }] }, 1);
const select = el().querySelector('select') as HTMLSelectElement;
select.value = '2';
select.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.value()).toBe(2);
});
it('sin base de datos configurada o sin el campo, se pide como texto', async () => {
await open({ fail: true }, 'abc');
expect(el().querySelector('select')).toBeNull();
const input = el().querySelector('input') as HTMLInputElement;
expect(input.value).toBe('abc');
input.value = 'xyz';
input.dispatchEvent(new Event('input'));
await tick();
expect(fixture.componentInstance.value()).toBe('xyz');
});
it('un campo sin valores válidos ni tabla enlazada también es texto', async () => {
await open({ ...base });
expect(el().querySelector('select')).toBeNull();
expect(el().querySelector('input')).not.toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { ChangeDetectionStrategy, Component, computed, input, model, resource } from '@angular/core';
import { UserFieldsApi, type UserFieldInfo } from '../../core/sl/user-fields';
import type { VhLookup } from '../value-help/value-help.types';
import { inject } from '@angular/core';
import { SelectField, TextField } from './fields';
import { FieldBase } from './field-base';
import { LookupField } from './lookup-field';
/**
* Campo de usuario de SAP (`U_*`) que se pide según como lo definió SAP: lista desplegable si tiene valores válidos,
* ventana de selección si está enlazado a una tabla de usuario, texto en otro caso (o mientras carga, o si la base de datos
* no está configurada). El tipo numérico se guarda como número.
*/
@Component({
selector: 'sbo-udf-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LookupField, SelectField, TextField],
template: `
@if (kind() === 'select') {
<sbo-select-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [options]="options()" placeholder="" [value]="str() || null" (valueChange)="emit($event)" />
} @else if (kind() === 'lookup') {
<sbo-lookup-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [lookup]="lookup()" [value]="str()" (valueChange)="emit($event)" resolveOnLeave />
} @else {
<sbo-text-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [value]="str()" (valueChange)="emit($event)" />
}
`,
styles: ':host { display: block; }',
})
export class UdfField extends FieldBase {
private readonly api = inject(UserFieldsApi);
/** Tabla del campo (`OBTN`, `OWTQ`, `@MI_TABLA`) y alias sin el `U_` (`SIS_ESTLOT`). */
readonly table = input.required<string>();
readonly alias = input.required<string>();
readonly value = model<unknown>('');
protected readonly info = resource({
params: () => ({ table: this.table(), alias: this.alias() }),
loader: ({ params }) => this.api.get(params.table, params.alias),
});
private readonly def = computed<UserFieldInfo | null>(() => (this.info.hasValue() ? this.info.value() : null));
protected readonly kind = computed<'select' | 'lookup' | 'text'>(() => {
const d = this.def();
return d?.validValues.length ? 'select' : d?.linkedRows.length ? 'lookup' : 'text';
});
protected readonly str = computed(() => {
const v = this.value();
return v === null || v === undefined ? '' : String(v);
});
protected readonly options = computed(() => (this.def()?.validValues ?? []).map((v) => ({ value: v.value, label: v.description ? `${v.value} · ${v.description}` : v.value })));
protected readonly lookup = computed<VhLookup>(() => {
const d = this.def()!;
return {
title: d.description || d.linkedTable || 'Valores',
resource: d.linkedTable ?? 'UserTable',
columns: [
{ key: 'code', title: 'Código', width: '140px', mono: true, searchable: true },
{ key: 'name', title: 'Nombre', searchable: true },
],
rows: d.linkedRows.map((r) => ({ code: r.code, name: r.name })),
valueKey: 'code',
nameKey: 'name',
codeWidth: '140px',
};
});
protected emit(v: string | number | null): void {
const s = v === null ? '' : String(v);
this.value.set(this.def()?.type === 'N' && s.trim() !== '' && Number.isFinite(Number(s)) ? Number(s) : s);
}
}
@@ -59,6 +59,8 @@ export interface DetailColumn {
lookup?: VhLookup;
/** Decimales fijos de una columna numérica; sin él, hasta 3. */
decimals?: number;
/** Se suma la columna en una fila «Total» al pie de la tabla (stock por almacén). */
total?: boolean;
/**
* Una celda que puede tomar su valor de varias listas (la «Cuenta / socio» de un asiento: una cuenta, un cliente o
* un proveedor). El botón «…» pregunta cuál abrir; un código escrito se busca en ellas en orden.
@@ -70,6 +72,11 @@ export interface DetailColumn {
lookupFor?: (row: Record<string, unknown>) => VhLookup | undefined;
/** La celda no se edita en esta fila. */
readonlyFor?: (row: Record<string, unknown>) => boolean;
/**
* La columna muestra el nombre de lo que identifica el código de otra columna (`WarehouseCode` → nombre del almacén);
* el catálogo se consulta una sola vez. No es un campo que se guarde.
*/
nameOf?: { codeKey: string; resource: string; valueField: string; labelField: string; /** Se ve `código · nombre` en vez de solo el nombre. */ withCode?: boolean };
/** Texto calculado de la fila (solo se muestra; no es un campo que se guarde). */
computed?: (row: Record<string, unknown>) => string;
/** Campos de la fila que cambian además del propio al elegir o escribir un valor de la lista (la cuenta elegida primero completa el «Cuenta / socio»). */
@@ -151,3 +151,10 @@ tr.none td {
position: absolute;
left: -9999px;
}
th .db {
display: block;
font: 10.5px/1.2 var(--mono, monospace);
font-weight: 400;
color: var(--quiet);
}
+3 -1
View File
@@ -15,7 +15,9 @@
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key; let j = $index) {
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required">{{ c.title }}</th>
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required" [attr.title]="isSap(c.key) ? c.key : null">{{ c.title }}@if (names.show() && isSap(c.key)) {
<span class="db">{{ c.key }}</span>
}</th>
@if (hasDetails() && j === detailAt()) {
<th class="det">Lotes / Series</th>
}
@@ -12,6 +12,7 @@ import {
output,
signal,
} from '@angular/core';
import { FieldNames, isSapField } from '../../core/field-names';
import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
@@ -57,6 +58,8 @@ export class LinesGrid {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
protected readonly names = inject(FieldNames);
protected readonly isSap = isSapField;
constructor() {
// las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan
@@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, resource } from '@angular/core';
import { apiErrorMessage } from '../../core/api';
import { SapDbApi } from '../../core/sl/sapdb-api';
import type { DetailColumn } from './grid.types';
import { RowsTable } from './rows-table';
/** Tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (el stock de un lote por almacén). */
@Component({
selector: 'sbo-query-table',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RowsTable],
template: `
@if (result.error()) {
<p class="err" role="alert">{{ error() }}</p>
} @else if (result.isLoading()) {
<p class="hint">Consultando…</p>
} @else {
<sbo-rows-table [columns]="columns()" [rows]="rows()" [readonly]="true" [removable]="false" [hideEmptyBy]="hideEmptyBy() ?? null" [hideEmptyLabel]="hideEmptyLabel()" emptyText="Sin datos." />
@if (result.value()?.truncated) {
<p class="hint">Hay más filas de las que se muestran.</p>
}
}
`,
styles: `
:host { display: block; }
.hint { margin: 6px 0; color: var(--quiet); }
.err { margin: 6px 0; color: var(--err); }
`,
})
export class QueryTable {
private readonly api = inject(SapDbApi);
readonly query = input.required<string>();
readonly params = input.required<Record<string, unknown>>();
readonly columns = input.required<readonly DetailColumn[]>();
readonly hideEmptyBy = input<readonly string[]>();
readonly hideEmptyLabel = input('Ocultar las filas en 0');
/** La consulta y sus parámetros como texto: quien nos usa puede armar un objeto nuevo en cada ciclo y no por eso se vuelve a consultar. */
private readonly request = computed(() => JSON.stringify({ query: this.query(), params: this.params() }));
protected readonly result = resource({
params: () => this.request(),
loader: ({ params }) => {
const r = JSON.parse(params) as { query: string; params: Record<string, unknown> };
return this.api.run(r.query, r.params);
},
});
protected readonly rows = computed(() => (this.result.hasValue() ? this.result.value().rows : []));
protected readonly error = computed(() => apiErrorMessage(this.result.error()));
}
+74 -4
View File
@@ -1,4 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core';
import { API_URL } from '../../core/api';
import { FieldNames, isSapField } from '../../core/field-names';
import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
@@ -20,21 +23,37 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ValueHelp],
template: `
@if (hideEmptyBy()) {
<label class="only"><input type="checkbox" [checked]="onlyFilled()" (change)="onlyFilled.set($any($event.target).checked)" /> {{ hideEmptyLabel() }}</label>
}
<div class="wrap">
<table>
<thead>
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
<th [style.width]="c.width" [class.req]="c.required" [attr.title]="isSap(c.key) ? c.key : null">{{ c.title }}@if (fieldNames.show() && isSap(c.key)) {
<span class="db">{{ c.key }}</span>
}</th>
}
@if (canRemove()) {
<th class="del"></th>
}
</tr>
@if (hasTotals() && rows().length) {
<tr>
<td class="rn"></td>
@for (c of columns(); track c.key; let first = $first) {
<td class="tot" [class.num]="c.total">{{ c.total ? totalOf(c) : first ? 'Total' : '' }}</td>
}
@if (canRemove()) {
<td class="del"></td>
}
</tr>
}
</thead>
<tbody>
@for (row of rows(); track $index; let i = $index) {
@for (row of shown(); track $index; let i = $index) {
<tr>
<td class="rn">{{ i + 1 }}</td>
@for (c of columns(); track c.key) {
@@ -86,7 +105,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + 2">{{ emptyText() }}</td>
<td [attr.colspan]="columns().length + 2">{{ rows().length ? 'Ninguna fila cumple el filtro.' : emptyText() }}</td>
</tr>
}
</tbody>
@@ -117,6 +136,8 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
`,
styles: `
:host { display: block; }
.only { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 6px; cursor: pointer; }
.only input { width: auto; height: auto; margin: 0; }
.pick { display: grid; grid-template-columns: minmax(0, 1fr) 22px; }
.pick button { padding: 0; background: var(--btn); border: 1px solid var(--field-b); border-radius: var(--radius); cursor: pointer; }
.pick button:hover { background: var(--btn-h); }
@@ -127,9 +148,12 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
.menu button:hover { background: var(--btn-h); }
table { width: 100%; min-width: max-content; border-collapse: collapse; }
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
th.req::after { content: ' *'; color: var(--err); }
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; }
td.ro { background: var(--field-ro); }
td.tot { padding: 0 8px; font-weight: 600; background: var(--grid-head); }
td.tot.num { text-align: right; }
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
td.del, th.del { width: 30px; text-align: center; }
input, select { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
@@ -149,10 +173,39 @@ export class RowsTable {
/** Se pueden quitar filas (por defecto, si la tabla es editable). */
readonly removable = input(true);
readonly emptyText = input('Sin filas.');
/** Columnas numéricas por las que se puede ocultar las filas en 0 (solo lectura): aparece una casilla encima de la tabla, marcada. */
readonly hideEmptyBy = input<readonly string[] | null>(null);
readonly hideEmptyLabel = input('Ocultar las filas en 0');
protected readonly onlyFilled = signal(true);
/** Las filas que se ven; los totales siguen sumando todas. */
protected readonly shown = computed(() => {
const keys = this.hideEmptyBy();
const rows = this.rows();
if (!keys || !this.onlyFilled()) return rows;
return rows.filter((r) => keys.some((k) => Number(r[k]) !== 0 && Number.isFinite(Number(r[k]))));
});
protected readonly fieldNames = inject(FieldNames);
protected readonly isSap = isSapField;
private readonly lookupApi = inject(SlLookupApi);
private readonly http = inject(HttpClient);
/** Nombres de los catálogos de las columnas `nameOf`, por recurso. */
private readonly names = signal<Record<string, Map<string, string>>>({});
private readonly requested = new Set<string>();
constructor() {
effect(() => {
for (const c of this.columns()) {
const n = c.nameOf;
if (!n || this.requested.has(n.resource)) continue;
this.requested.add(n.resource);
const params = new HttpParams().set('$select', `${n.valueField},${n.labelField}`).set('$top', 500);
this.http.get<{ value?: Record<string, unknown>[] }>(`${API_URL}/sl/${n.resource}`, { params }).subscribe({
next: (res) => this.names.update((m) => ({ ...m, [n.resource]: new Map((res.value ?? []).map((x) => [String(x[n.valueField]), String(x[n.labelField] ?? '')])) })),
error: () => undefined, // sin nombres, la columna queda vacía
});
}
});
// las celdas de código con texto propio (la cuenta con su formato) piden los textos de las filas que se cargan
effect(() => {
const rows = this.rows();
@@ -209,12 +262,29 @@ export class RowsTable {
return !this.readonly() && this.removable();
}
protected readonly hasTotals = computed(() => this.columns().some((c) => c.total));
/** La suma de una columna numérica en todas las filas. */
protected totalOf(c: DetailColumn): string {
let sum = 0;
for (const r of this.rows()) {
const v = Number(r[c.key]);
if (Number.isFinite(v)) sum += v;
}
return formatNumber(sum, c.decimals);
}
protected locked(c: DetailColumn, row?: Row): boolean {
return this.readonly() || !!c.readonly || !!c.computed || (!!row && !!c.readonlyFor?.(row));
return this.readonly() || !!c.readonly || !!c.computed || !!c.nameOf || (!!row && !!c.readonlyFor?.(row));
}
protected text(row: Row, c: DetailColumn): string {
if (c.computed) return c.computed(row);
if (c.nameOf) {
const code = String(row[c.nameOf.codeKey] ?? '');
const name = this.names()[c.nameOf.resource]?.get(code);
return c.nameOf.withCode ? (name ? `${code} · ${name}` : code) : (name ?? '');
}
const v = row[c.key];
if (c.type === 'date') return dateOnly(v);
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, c.decimals);
@@ -75,3 +75,10 @@ tr.none td {
width: 30px;
text-align: center;
}
th .db {
display: block;
font: 10.5px/1.2 var(--mono, monospace);
font-weight: 400;
color: var(--quiet);
}
+4 -1
View File
@@ -19,10 +19,13 @@
[style.text-align]="align(c)"
[class.pick]="searchable(c)"
[class.active]="c.key === activeColumnKey()"
[attr.title]="searchable(c) ? 'Buscar en esta columna' : null"
[attr.title]="(searchable(c) ? 'Buscar en esta columna' : '') + (searchable(c) && isSap(c.key) ? ' · ' : '') + (isSap(c.key) ? c.key : '') || null"
(click)="columnPicked.emit(c)"
>
{{ c.title }}
@if (names.show() && isSap(c.key)) {
<span class="db">{{ c.key }}</span>
}
</th>
}
</tr>
+5 -1
View File
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { FieldNames, isSapField } from '../../core/field-names';
import { ColResize } from '../table/col-resize';
import { Badge } from '../window/badge';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types';
@@ -15,6 +16,9 @@ import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type To
styleUrl: './data-table.css',
})
export class DataTable {
protected readonly names = inject(FieldNames);
protected readonly isSap = isSapField;
readonly columns = input.required<readonly ListColumn[]>();
readonly rows = input.required<readonly ListRow[]>();
/** Campo único de cada fila. */
@@ -24,6 +24,10 @@ export interface VhLookup {
rows?: readonly VhRow[];
/** Otros campos por los que se reconoce un código escrito a mano (`FormatCode` de una cuenta: el usuario no conoce el `_SYS…` interno). */
alsoMatch?: readonly string[];
/** Columna con el nombre de lo elegido: el campo lo muestra junto al código (`Name` de un grupo, `SalesEmployeeName`). */
nameKey?: string;
/** Ancho del casillero del código cuando hay `nameKey` (por defecto 96 px; un código largo como el de un proveedor necesita más). */
codeWidth?: string;
/** Cómo se muestra el código guardado en una celda (la cuenta con su formato) y cómo se aprenden los de las filas elegidas. */
codes?: CodeDisplay;
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more