Compare commits

..
Author SHA1 Message Date
George 336f4c1875 Merge branch 'main' into cliente/tagumedica 2026-10-20 17:08:54 -05:00
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 67fbcdcfbf feat(tagumedica): el parámetro Papel de las etiquetas se muestra como Carta y no se puede cambiar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:57:46 -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
George b7c684d39e Merge branch 'main' into cliente/tagumedica 2026-10-20 16:57:33 -05:00
GeorgeandClaude Sonnet 5.5 0252717206 feat(tagumedica): etiquetas de bultos solo en papel Carta precortado (sin parámetro de papel) y vista HTML con los márgenes en pantalla
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:54:29 -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
George 0534becc9d Merge branch 'main' into cliente/tagumedica 2026-10-20 16:53:24 -05:00
GeorgeandClaude Sonnet 5.5 3fbfe9328e feat(tagumedica): reporte HTML de etiquetas de bultos (plantilla migrada de Jinja2, consulta a HANA y parámetros entrega, cantidad y papel)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:49:01 -05:00
George 63dc9b10ca Merge branch 'main' into cliente/tagumedica 2026-10-20 16:47:18 -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 0d49129bcd feat(tagumedica): la serie por defecto de SAP queda elegida en las solicitudes de inmovilizar y liberar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:57:34 -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
George 3eb3269561 Merge branch 'main' into cliente/tagumedica 2026-10-20 15:57:00 -05:00
GeorgeandClaude Sonnet 5.5 51fbae34f5 feat(tagumedica): el botón «Campos» funciona en las pantallas del flujo de lotes (campos y cabeceras de tablas)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:55:51 -05:00
GeorgeandClaude Sonnet 5.5 2d25b3b880 feat(tagumedica): motivo, comentario y acción a tomar en áreas de 3 filas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:54:53 -05:00
GeorgeandClaude Sonnet 5.5 5728fc8e5b feat(tagumedica): flujo de lotes (inmovilizar, liberar y confirmar la liberación) con solicitudes de traslado, consultas a HANA y campos de usuario por su definición en SAP
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:52:01 -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
George 72b3cd1d43 Merge branch 'main' into cliente/tagumedica 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
George 6cb5d3fce3 Merge branch 'main' into cliente/tagumedica 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
George de5964606c Merge branch 'main' into cliente/tagumedica 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
George 074eed6d81 Merge branch 'main' into cliente/tagumedica 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
George ac4fdea372 Merge branch 'main' into cliente/tagumedica 2026-10-20 08:59:58 -05:00
GeorgeandClaude Sonnet 5.5 58eafa72ec feat(tagumedica): campos de usuario de los lotes (calidad, liberación, registro sanitario y ensayos)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:42:43 -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
113 changed files with 5263 additions and 168 deletions

No files matched your search

+32 -7
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
@@ -54,6 +54,30 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
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.
## TAGUMEDICA: etiquetas de bultos (solo en `cliente/tagumedica`)
Primer reporte HTML del cliente (`/reportes-html/ejecutar` → «Etiquetas de bultos»), migrado de `mini-erp` (`templates/reports/stickers.html`, Jinja2 + WeasyPrint): plantilla `apps/api/templates/tagumedica/etiquetas-bultos.njk` (la misma, con `line-height: 1.2` y la fuente Orbitron incrustada con `asset()`), definición en `htmlreports/etiquetas-bultos.ts` y consulta `delivery-labels` (HANA: `ODLN`/`DLN12`/`OCST`, reducida a los 4 datos que usa la plantilla: cliente, dirección, lugar —departamento, provincia y distrito— y documento: el `NumAtCard` o el folio). **Parámetros:** la entrega (ventana de selección de documentos, valor = `DocEntry`) y la cantidad de etiquetas (entero de 1 a 500). **El papel es siempre Carta precortado**: el parámetro «Papel» se muestra con «Carta» pero es de solo lectura (`readOnly`, el servidor ignora lo que llegue): los márgenes de la plantilla (`1.6cm 1.5cm 1.8cm 2.7cm`) y la rejilla están ajustados a los recortes de la hoja, así que no se tocan sin probar con papel; la salida buena es el **PDF** (botón «Generar PDF»), que se imprime en **«Tamaño real / 100 %»**. Sale en hojas horizontales de 4 etiquetas (2 × 2) numeradas `1/6`… Verificado con la entrega 144153 contra `SBO_TAGUMEDICA_21` (consulta + plantilla + `HtmlPdfService`: PDF de 2 páginas Carta de 792 × 612 pt en 0,6 s; la primera etiqueta cae a 76,5 pt del borde izquierdo y 45,4 pt del superior, más el relleno). La vista HTML lleva los mismos márgenes en pantalla (`@media screen`). Los usuarios necesitan el permiso de menú `/reportes-html/*` (o `/reportes-html/ejecutar`). Pendiente: ofrecerlo también en el botón PDF de Entregas.
## 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.
## TAGUMEDICA: flujo de lotes (solo en `cliente/tagumedica`)
Tres pantallas del menú Inventario, migradas del mini-erp (`features/client/lotes/`, registradas con los puntos de extensión de cliente): **Inmovilizar lotes**, **Liberar lotes** y **Confirmar liberación de lote**. Crean y modifican **solicitudes de traslado** (`InventoryTransferRequests`) con campos de usuario de la tabla `OWTQ`. Valores del negocio en `lotes-flujo.config.ts` (almacén de inmovilizados `302`, aprobados `201`, `U_SIS_MOTALM` 107 «INMOVILIZADO DT» / 108 «LIBERACIÓN DT», movimiento `XTD`, operación `11`, despacho `N`); lógica pura y probada en `lotes-flujo.ts`.
- **Inmovilizar / Liberar** (`lote-transfer.page`, una sola pantalla con `data.flow`): se busca el lote por su número (consulta `lot-stock` a HANA: artículo + almacén con stock), se marcan los almacenes con la cantidad (por defecto todo el stock; no se puede superar), se completa la solicitud y se crea con una línea por almacén de origen y el lote en cada una (`BatchNumbers`). Inmovilizar: destino 302 y motivo, comentario y acción obligatorios, no se puede volver a mover lo que ya está en 302. Liberar: origen 302 (editable), destino 201, motivo 108 y solo el motivo obligatorio. La serie es opcional (sin elegir, SAP usa la por defecto); un error de SAP deja el panel con lo escrito.
- **Confirmar liberación** (`lote-release-confirm.page`): lista las solicitudes abiertas con `U_SIS_MOTALM = 108`; al elegir una se pide completa y, por cada línea, la consulta `lot-sources` (transferencias `OWTR` que llevaron el lote al almacén de inmovilizados, `OITL`/`ITL1`) da de qué almacenes vino; `allocateByOrigin` reparte la cantidad a prorrata (enteros; el último almacén absorbe el redondeo) y el usuario lo corrige. Al confirmar se reemplazan las líneas (`B1S-ReplaceCollectionsOnPatch`, la primera conserva su `LineNum`, las demás van sin él, una línea sin filas se quita). **Diferencia con el mini-erp:** aquí el reparto de cada línea debe sumar su cantidad.
- Los catálogos (movimiento de inventario, tipo de operación, despacho, motivo de almacén) salen de la definición de esos campos en SAP (`/api/sapdb/user-fields/OWTQ/…`: tablas de usuario `@SIS_MOVINV` y `@EXX_TIPOOPER` enlazadas, valores válidos de `SIS_TIPDESP` y `SIS_MOTALM`); no hay SQL propio para ellos. Permisos: las tres rutas de menú leen y modifican `InventoryTransferRequests` (`sl-resources.extensions.ts`) y ejecutan las consultas de `sapdb.extensions.ts`.
- **No se ha escrito nada en SAP desde estas pantallas** (las pruebas usan un Service Layer simulado): `SBO_TAGUMEDICA_21` es del cliente y no se escribe sin preguntar; `SBODemoCL` no tiene estos campos de usuario.
## 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 +93,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 +102,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,7 +115,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. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick <hash>` desde `main`); (c) `git checkout cliente/<nombre>` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama.
6. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/<nombre> && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario).
7. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos.
8. **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; **aún no hay remoto configurado** (hace falta la URL para `git remote add origin …` y el primer push) ni ramas de cliente creadas.
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,78 @@
import { resolve } from 'node:path';
import { BadRequestException } from '@nestjs/common';
import { SAPDB_QUERIES } from '../sapdb/sapdb-queries.js';
import { bindNamed } from '../sapdb/sapdb-sql.js';
import { createRenderer } from './html-render.js';
import { ETIQUETAS_BULTOS, MAX_LABELS, labelsData } from './etiquetas-bultos.js';
import { parseHtmlValues } from './html-reports.service.js';
import { HTML_REPORTS } from './html-reports.js';
const ROW = { CardName: 'CLÍNICA <PLASTIC> & CIA S.A.C.', Guia: 'T001-37070', Direccion: 'AV. DOMINGO ORUE NRO. 469', Departamento: 'LIMA', Provincia: 'LIMA', Distrito: 'SURQUILLO' };
const render = createRenderer(resolve(import.meta.dirname, '../../templates')).render;
const html = (n: number) => render(ETIQUETAS_BULTOS.template, labelsData(ROW, n));
const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
describe('etiquetas de bultos', () => {
it('pide la entrega y la cantidad; el papel se muestra como Carta (precortado) pero no se puede cambiar', () => {
expect(HTML_REPORTS.get('etiquetas-bultos')).toBe(ETIQUETAS_BULTOS);
expect(ETIQUETAS_BULTOS.params.map((p) => [p.name, p.kind, p.required])).toEqual([['docEntry', 'lookup', true], ['cantidad', 'number', true], ['papel', 'select', true]]);
expect(ETIQUETAS_BULTOS.params[0].lookup).toBe('doc:DeliveryNotes');
expect(ETIQUETAS_BULTOS.params[2]).toMatchObject({ defaultValue: 'Carta', readOnly: true });
expect(parseHtmlValues(ETIQUETAS_BULTOS, { docEntry: '144153', cantidad: '6' })).toEqual({ docEntry: '144153', cantidad: '6', papel: 'Carta' });
// aunque el cliente mande otro papel, el servidor usa Carta
expect(parseHtmlValues(ETIQUETAS_BULTOS, { docEntry: '144153', cantidad: '6', papel: 'A4' })).toMatchObject({ papel: 'Carta' });
});
it('reparte las etiquetas de a 4 por hoja y las numera 1/6… 6/6', () => {
const d = labelsData(ROW, 6) as { paginas: { numero: number; total: number }[][] };
expect(d.paginas.map((p) => p.length)).toEqual([4, 2]);
expect(d.paginas.flat().map((l) => `${l.numero}/${l.total}`)).toEqual(['1/6', '2/6', '3/6', '4/6', '5/6', '6/6']);
expect((labelsData(ROW, 8) as { paginas: unknown[] }).paginas).toHaveLength(2);
});
it('une departamento, provincia y distrito en el lugar, sin partes vacías', () => {
expect((labelsData({ ...ROW, Provincia: null }, 1) as { datos: { lugar: string } }).datos.lugar).toBe('LIMA - SURQUILLO');
});
it('la plantilla dibuja una etiqueta por cada una, con sus datos, y escapa el HTML del cliente', () => {
const out = html(6);
expect(count(out, /class="sticker"/g)).toBe(6);
expect(count(out, /class="pagina"/g)).toBe(2);
expect(out).toContain('1/6');
expect(out).toContain('6/6');
expect(out).toContain('T001-37070');
expect(out).toContain('LIMA - LIMA - SURQUILLO');
expect(out).toContain('CLÍNICA &lt;PLASTIC&gt; &amp; CIA S.A.C.');
expect(out).not.toContain('<PLASTIC>');
});
it('la hoja es Carta horizontal con los márgenes ajustados a las etiquetas precortadas; la fuente va incrustada', () => {
expect(html(1)).toMatch(/size: letter landscape/);
expect(html(1)).toContain('margin: 1.6cm 1.5cm 1.8cm 2.7cm;');
expect(html(1)).toContain('18.19cm');
expect(html(1)).toContain('data:font/ttf;base64,');
expect(html(1)).toContain('line-height: 1.2');
});
it('en pantalla el cuerpo lleva los mismos márgenes para ver las etiquetas como saldrán', () => {
expect(html(1)).toMatch(/@media screen\s*{\s*body { margin: 1\.6cm 1\.5cm 1\.8cm 2\.7cm !important; }/);
});
it('pide la guía a la consulta delivery-labels y rechaza cantidades que no valen o una entrega que no existe', async () => {
const query = vi.fn(async () => [ROW]);
const values = { docEntry: '144153', cantidad: '3' };
const data = (await ETIQUETAS_BULTOS.data(values, { query })) as { paper_size: string };
expect(query).toHaveBeenCalledWith('delivery-labels', { docEntry: 144153 });
expect(data.paper_size).toBe('letter');
for (const cantidad of ['0', '2.5', String(MAX_LABELS + 1)]) await expect(ETIQUETAS_BULTOS.data({ ...values, cantidad }, { query })).rejects.toThrow(BadRequestException);
await expect(ETIQUETAS_BULTOS.data(values, { query: async () => [] })).rejects.toThrow(/No se encontró la entrega 144153/);
});
it('la consulta delivery-labels está registrada para este menú y recibe la entrega como argumento', () => {
const q = SAPDB_QUERIES.get('delivery-labels')!;
expect(q.menu).toBe('/reportes-html/ejecutar');
const b = bindNamed(q.sql.hana!, { docEntry: 144153 });
expect(b.args).toEqual([144153]);
expect(b.sql).not.toContain(':');
});
});
@@ -0,0 +1,59 @@
import { BadRequestException } from '@nestjs/common';
import type { GatewayParam } from '@basic-erp/sap-types';
import type { SapDbRow } from '../sapdb/sapdb.types.js';
import type { HtmlReportDef } from './html-reports.types.js';
/**
* El papel de las etiquetas es siempre Carta precortado: tamaño para `@page` y alto útil de la hoja horizontal (papel menos
* los márgenes superior e inferior de la plantilla). Los márgenes de la plantilla están ajustados a los recortes de la hoja:
* no se tocan sin probar con papel.
*/
const PAPER = { size: 'letter', height: '18.19cm' } as const;
/** Tope de etiquetas por pedido (evita un documento enorme por un error de tecleo). */
export const MAX_LABELS = 500;
const PER_PAGE = 4;
/**
* Lo que ve la plantilla: los datos de la guía y las etiquetas numeradas (`1/6`…), de a 4 por hoja. El lugar une
* departamento, provincia y distrito.
*/
export function labelsData(row: SapDbRow, quantity: number): Record<string, unknown> {
const text = (k: string): string => (row[k] === null || row[k] === undefined ? '' : String(row[k]).trim());
const labels = Array.from({ length: quantity }, (_, i) => ({ numero: i + 1, total: quantity }));
return {
datos: {
cliente: text('CardName'),
direccion: text('Direccion'),
lugar: [text('Departamento'), text('Provincia'), text('Distrito')].filter(Boolean).join(' - '),
documento: text('Guia'),
},
paginas: Array.from({ length: Math.ceil(labels.length / PER_PAGE) }, (_, i) => labels.slice(i * PER_PAGE, (i + 1) * PER_PAGE)),
paper_size: PAPER.size,
pagina_alto: PAPER.height,
};
}
const param = (p: Partial<GatewayParam> & Pick<GatewayParam, 'name' | 'label' | 'kind'>): GatewayParam => ({ type: 'xsd:string', required: false, ...p });
/** Etiquetas de bultos de una guía de remisión (entrega): recibe la entrega y la cantidad de etiquetas a imprimir (papel Carta precortado). */
export const ETIQUETAS_BULTOS: HtmlReportDef = {
code: 'etiquetas-bultos',
title: 'Etiquetas de bultos',
category: 'Ventas',
description: 'Etiquetas de los bultos de una guía de remisión: cliente, dirección, lugar y documento, numeradas (1/6, 2/6…), 4 por hoja.',
template: 'tagumedica/etiquetas-bultos.njk',
params: [
param({ name: 'docEntry', label: 'Entrega', kind: 'lookup', required: true, lookup: 'doc:DeliveryNotes', type: 'xsd:decimal' }),
param({ name: 'cantidad', label: 'Cantidad de etiquetas', kind: 'number', required: true, type: 'xsd:decimal' }),
// el papel es siempre Carta precortado: se muestra pero no se edita
param({ name: 'papel', label: 'Papel', kind: 'select', required: true, defaultValue: 'Carta', readOnly: true, options: [{ value: 'Carta', label: 'Carta' }] }),
],
async data(values, ctx) {
const quantity = Number(values['cantidad']);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_LABELS) throw new BadRequestException(`La cantidad de etiquetas debe ser un entero entre 1 y ${MAX_LABELS}`);
const rows = await ctx.query('delivery-labels', { docEntry: Number(values['docEntry']) });
if (!rows[0]) throw new BadRequestException(`No se encontró la entrega ${values['docEntry']}`);
return labelsData(rows[0], quantity);
},
};
@@ -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,8 @@
import { ETIQUETAS_BULTOS } from './etiquetas-bultos.js';
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[] = [ETIQUETAS_BULTOS];
@@ -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 ?? '');
}
}
@@ -0,0 +1,27 @@
import { SL_RESOURCE_MAP } from '../sl/sl-resources.js';
import { SAPDB_QUERIES } from './sapdb-queries.js';
import { bindNamed } from './sapdb-sql.js';
describe('consultas de TAGUMEDICA', () => {
it('registra las del flujo de lotes y cada una es de solo lectura (el registro lo valida al cargar)', () => {
for (const name of ['lot-stock', 'lot-sources']) {
expect(SAPDB_QUERIES.has(name), name).toBe(true);
}
});
it('el origen de un lote se busca con artículo, lote y almacén como parámetros', () => {
const q = SAPDB_QUERIES.get('lot-sources')!;
const b = bindNamed(q.sql.hana!, { item: 'A1', lot: 'L1', warehouse: '302' });
expect(b.args).toEqual(['A1', 'L1', '302']);
expect(b.sql).not.toContain(':');
});
it('las tres pantallas del flujo pueden leer y modificar las solicitudes de traslado', () => {
const r = SL_RESOURCE_MAP.get('InventoryTransferRequests')!;
for (const p of ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote']) {
expect(r.read as string[], p).toContain(p);
expect(r.write as string[], p).toContain(p);
}
expect(r.write as string[]).toContain('/inventario/solicitudes-traslado');
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { SapDbQuery } from './sapdb.types.js';
const FLOWS = ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes'] as const;
/**
* Consultas a la base de SAP de TAGUMEDICA (HANA): el flujo de lotes (inmovilizar, liberar y confirmar la liberación).
* Dependen de sus tablas, por eso viven solo en esta rama. Los valores de los campos de usuario de la solicitud (movimiento,
* tipo de operación, despacho, motivo) no necesitan consulta propia: salen de `/api/sapdb/user-fields`.
*/
export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = [
{
// Datos de una guía de remisión (entrega) para sus etiquetas de bultos: cliente, dirección de entrega, lugar y documento
// (el número de la guía, o su folio si no lo tiene).
name: 'delivery-labels',
title: 'Datos de una entrega para las etiquetas de bultos',
sql: {
hana: `SELECT TOP 1 T0."CardName" AS "CardName",
CASE WHEN IFNULL(T0."NumAtCard", '') <> '' THEN T0."NumAtCard" ELSE T0."FolioPref" || '-' || T0."FolioNum" END AS "Guia",
T7."StreetS" AS "Direccion", T7."CountyS" AS "Provincia", T14."Name" AS "Departamento", T7."ZipCodeS" AS "Distrito"
FROM "ODLN" T0
LEFT JOIN "DLN12" T7 ON T0."DocEntry" = T7."DocEntry"
LEFT JOIN "OCST" T14 ON T14."Code" = T7."StateS" AND T14."Country" = T7."CountryS"
WHERE T0."DocEntry" = :docEntry`,
},
params: { docEntry: { type: 'number' } },
menu: '/reportes-html/ejecutar',
maxRows: 1,
},
{
// Stock de un lote por número, en cada artículo y almacén (un mismo número de lote puede repetirse en varios artículos).
name: 'lot-stock',
title: 'Stock de un lote por número, artículo y almacén',
sql: {
hana: `SELECT T1."WhsCode" AS "WarehouseCode", T2."WhsName" AS "WarehouseName", T1."Quantity" AS "Quantity",
T1."CommitQty" AS "Committed", T1."ItemCode" AS "ItemCode", T3."ItemName" AS "ItemDescription"
FROM "OBTN" T0
INNER JOIN "OBTQ" T1 ON T1."MdAbsEntry" = T0."AbsEntry"
INNER JOIN "OWHS" T2 ON T2."WhsCode" = T1."WhsCode"
INNER JOIN "OITM" T3 ON T3."ItemCode" = T1."ItemCode"
WHERE T0."DistNumber" = :lot AND T1."Quantity" > 0
ORDER BY T3."ItemName", T2."WhsName"`,
},
params: { lot: { type: 'string' } },
menu: FLOWS,
maxRows: 500,
},
{
// De qué almacenes vino un lote hasta el almacén donde está hoy: transferencias ejecutadas (OWTR, DocType 67) que lo
// ingresaron, con el almacén del que salió cada una. El reparto a prorrata lo hace el frontend.
name: 'lot-sources',
title: 'Almacenes de origen de un lote que llegó a un almacén',
sql: {
hana: `WITH LOT AS (SELECT "AbsEntry" FROM "OBTN" WHERE "ItemCode" = :item AND "DistNumber" = :lot)
SELECT O."Whs" AS "WarehouseCode", SUM(I."Qty") AS "Quantity" FROM
(SELECT T1."DocEntry" AS "DocEntry", MAX(T0."Quantity") AS "Qty"
FROM "ITL1" T0 INNER JOIN "OITL" T1 ON T1."LogEntry" = T0."LogEntry"
WHERE T0."MdAbsEntry" IN (SELECT "AbsEntry" FROM LOT) AND T1."LocCode" = :warehouse
AND T1."StockEff" = 1 AND T0."Quantity" > 0 AND T1."DocType" = 67
GROUP BY T1."DocEntry") I
INNER JOIN
(SELECT T1."DocEntry" AS "DocEntry", MIN(T1."LocCode") AS "Whs"
FROM "ITL1" T0 INNER JOIN "OITL" T1 ON T1."LogEntry" = T0."LogEntry"
WHERE T0."MdAbsEntry" IN (SELECT "AbsEntry" FROM LOT) AND T1."DocType" = 67 AND T0."Quantity" < 0
GROUP BY T1."DocEntry") O ON O."DocEntry" = I."DocEntry"
GROUP BY O."Whs" ORDER BY O."Whs"`,
},
params: { item: { type: 'string' }, lot: { type: 'string' }, warehouse: { type: 'string' } },
menu: '/inventario/confirmar-liberacion-lote',
maxRows: 100,
},
];
+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,21 @@
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[] }>> = {
// Inmovilizar y liberar crean la solicitud de traslado; confirmar la liberación la lee y la modifica.
InventoryTransferRequests: {
read: ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote'],
write: ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote'],
},
};
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.
@@ -0,0 +1,146 @@
{#-
Etiquetas de bultos de una guía de remisión (TAGUMEDICA): 4 etiquetas horizontales por hoja (2 × 2).
Datos: datos.cliente, datos.direccion, datos.lugar, datos.documento · paginas (listas de 4 etiquetas con numero y total) ·
paper_size (letter | A4) · pagina_alto (alto útil de la hoja).
Migrada de mini-erp (templates/reports/stickers.html, Jinja2): misma plantilla con el interlineado fijado (line-height)
para que Chrome imprima igual que WeasyPrint, y la fuente incrustada con asset().
-#}
<!DOCTYPE html>
<html>
<head>
<style>
@font-face {
font-family: 'Orbitron';
src: url('{{ asset("tagumedica/fonts/Orbitron-Bold.ttf") }}') format('truetype');
font-weight: bold;
}
@page {
size: {{ paper_size }} landscape;
margin: 1.6cm 1.5cm 1.8cm 2.7cm;
}
/* En pantalla no hay hoja: el cuerpo lleva los mismos márgenes del @page para ver las etiquetas como saldrán. */
@media screen {
body { margin: 1.6cm 1.5cm 1.8cm 2.7cm !important; }
.pagina { margin-bottom: 0.6cm; }
}
body {
line-height: 1.2;
font-family: Arial, Helvetica, sans-serif;
margin: 0;
padding: 0;
}
.pagina {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
column-gap: calc(0.15in + 0.3cm);
row-gap: 0.15in;
height: {{ pagina_alto }};
page-break-after: always;
}
.pagina:last-child {
page-break-after: auto;
}
.sticker {
height: 100%;
border: 2px solid #000;
padding: 0.15in 0.35in;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
}
.titulo {
color: #E8781E;
font-family: 'Orbitron', 'Arial Black', Arial, sans-serif;
font-size: 20pt;
font-weight: bold;
letter-spacing: 1px;
margin-bottom: 10px;
}
.campo {
margin-bottom: 8px;
}
.campo-direccion {
min-height: 0.5in;
}
.campo-label {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: normal;
}
.campo-valor {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: bold;
}
.contador {
font-family: Arial, Helvetica, sans-serif;
font-size: 38pt;
font-weight: bold;
}
.documento-linea {
position: absolute;
left: 0.35in;
right: 0.35in;
bottom: 0.15in;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.documento-linea .campo {
margin-bottom: 0;
}
</style>
</head>
<body>
{% for pagina in paginas %}
<div class="pagina">
{% for sticker in pagina %}
<div class="sticker">
<div class="titulo">TAGUMEDICA</div>
<div class="campo">
<div class="campo-label">DATOS DEL CLIENTE:</div>
<div class="campo-valor">{{ datos.cliente }}</div>
</div>
<div class="campo campo-direccion">
<div class="campo-label">DIRECCIÓN:</div>
<div class="campo-valor">{{ datos.direccion }}</div>
</div>
<div class="campo">
<div class="campo-label">LUGAR:</div>
<div class="campo-valor">{{ datos.lugar }}</div>
</div>
<div class="documento-linea">
<div class="campo">
<div class="campo-label">DOCUMENTO:</div>
<div class="campo-valor">{{ datos.documento }}</div>
</div>
<div class="contador">{{ sticker.numero }}/{{ sticker.total }}</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</body>
</html>
+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}`));
});
});
+27
View File
@@ -0,0 +1,27 @@
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 = [
{ ...clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/lotes/lote-transfer.page').then((m) => m.LoteTransferPage)), data: { path: '/inventario/inmovilizar-lotes', title: 'Inmovilizar lotes', flow: 'inmovilizar' } },
{ ...clientRoute('/inventario/liberar-lotes', 'Liberar lotes', () => import('./features/client/lotes/lote-transfer.page').then((m) => m.LoteTransferPage)), data: { path: '/inventario/liberar-lotes', title: 'Liberar lotes', flow: 'liberar' } },
clientRoute('/inventario/confirmar-liberacion-lote', 'Confirmar liberación de lote', () => import('./features/client/lotes/lote-release-confirm.page').then((m) => m.LoteReleaseConfirmPage)),
];
/** 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">
@@ -0,0 +1,123 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it } from 'vitest';
import { LoteReleaseConfirmPage } from './lote-release-confirm.page';
const PENDING = [{ DocEntry: 480, DocNum: 4123, DocDate: '2026-10-19T00:00:00Z', U_SIS_MOTEST: 'Aprobado por calidad', U_SIS_COMEST: '', U_SIS_ACCTOM: '' }];
const REQUEST = {
'odata.etag': 'W/"5"',
DocEntry: 480,
DocNum: 4123,
StockTransferLines: [
{ LineNum: 0, ItemCode: 'A1', Quantity: 3, WarehouseCode: '201', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }] },
{ LineNum: 1, ItemCode: 'B2', Quantity: 2, WarehouseCode: '201', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L2', Quantity: 2 }] },
],
};
describe('LoteReleaseConfirmPage', () => {
let fixture: ComponentFixture<LoteReleaseConfirmPage>;
let ctrl: HttpTestingController;
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();
};
const isList = (r: { method: string; url: string }) => r.method === 'GET' && r.url === '/api/sl/InventoryTransferRequests';
async function open() {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [LoteReleaseConfirmPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(LoteReleaseConfirmPage);
await tick();
ctrl.expectOne(isList).flush({ value: PENDING });
await tick();
}
/** Elige la solicitud: se pide completa y, por cada línea, de qué almacenes vino su lote. */
async function select(origins: Record<string, { WarehouseCode: string; Quantity: string }[]> = { A1: [{ WarehouseCode: '207', Quantity: '2.000000' }, { WarehouseCode: '208', Quantity: '1.000000' }], B2: [] }) {
(el().querySelector('.grid tbody tr') as HTMLElement).click();
await tick();
ctrl.expectOne('/api/sl/InventoryTransferRequests/480').flush(REQUEST);
await tick();
for (const item of ['A1', 'B2']) {
const req = ctrl.expectOne((r) => r.url === '/api/sapdb/queries/lot-sources' && r.body.params.item === item);
expect(req.request.body.params).toMatchObject({ warehouse: '302' });
req.flush({ rows: origins[item] ?? [], truncated: false });
}
await tick();
}
beforeEach(open);
it('lista las solicitudes de liberación abiertas (motivo 108), las más recientes primero', async () => {
expect(el().querySelector('.grid tbody tr')?.textContent).toContain('4123');
expect(el().querySelector('.grid tbody tr')?.textContent).toContain('19/10/2026');
await tick();
});
it('al elegir una solicitud propone el reparto por los almacenes de los que vino el lote', async () => {
await select();
const d = page().drafts();
expect(d[0].allocations).toEqual([{ WarehouseCode: '207', Quantity: 2 }, { WarehouseCode: '208', Quantity: 1 }]);
expect(el().textContent).toContain('lote L1');
// la línea de la que no se encontró origen se avisa para agregarla a mano
expect(d[1].allocations).toEqual([]);
expect(el().textContent).toContain('no se encontró de qué almacén vino');
});
it('no deja confirmar si el reparto no suma la cantidad de la línea', async () => {
await select();
expect(page().canConfirm()).toBe(true); // la línea sin filas se quita a propósito
page().setAllocations(0, [{ WarehouseCode: '207', Quantity: 1 }]);
await tick();
expect(page().canConfirm()).toBe(false);
expect(el().textContent).toContain('suman 1 y la línea es de 3');
page().addAllocation(0);
await tick();
expect(page().canConfirm()).toBe(false); // la fila nueva está incompleta
expect(el().textContent).toContain('Complete el almacén y la cantidad de cada fila');
});
it('confirma reemplazando las líneas: la primera de cada una con su LineNum, las demás sin él', async () => {
await select();
page().setAllocations(1, [{ WarehouseCode: '201', Quantity: 2 }]);
await tick();
page().confirm();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/InventoryTransferRequests/480');
expect(req.request.headers.get('If-Match')).toBe('W/"5"');
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
expect(req.request.body).toEqual({
StockTransferLines: [
{ LineNum: 0, ItemCode: 'A1', Quantity: 2, WarehouseCode: '207', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 2 }] },
{ ItemCode: 'A1', Quantity: 1, WarehouseCode: '208', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 1 }] },
{ LineNum: 1, ItemCode: 'B2', Quantity: 2, WarehouseCode: '201', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L2', Quantity: 2 }] },
],
});
req.flush(null);
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Solicitud 4123 actualizada');
// la solicitud confirmada sale de la lista pendiente
ctrl.expectOne(isList).flush({ value: [] });
await tick();
expect(el().textContent).toContain('No hay solicitudes de liberación pendientes');
});
it('un error de SAP al confirmar se muestra y deja el reparto para corregirlo', async () => {
await select();
page().setAllocations(1, [{ WarehouseCode: '201', Quantity: 2 }]);
page().confirm();
await tick();
ctrl.expectOne((r) => r.method === 'PATCH').flush({ message: 'El almacén está bloqueado' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('El almacén está bloqueado');
expect(page().drafts()).not.toBeNull();
});
});
@@ -0,0 +1,189 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { apiErrorMessage } from '../../../core/api';
import { FieldNames } from '../../../core/field-names';
import { formatDate } from '../../../core/format';
import type { DetailColumn } from '../../../shared/grid/grid.types';
import { RowsTable } from '../../../shared/grid/rows-table';
import { SboWindow, type StatusMessage } from '../../../shared/window';
import { WAREHOUSE_LOOKUP } from '../../documents/document-layouts';
import { PENDING_LIBERATION } from './lotes-flujo.config';
import { type DraftLine, allocateByOrigin, confirmLines, draftLineError } from './lotes-flujo';
import { LotesFlujoApi } from './lotes-flujo-api';
type Row = Record<string, unknown>;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/**
* «Confirmar liberación de lote» (TAGUMEDICA): las solicitudes de traslado que dejó «Liberar lotes» (motivo 108) salen del
* almacén de inmovilizados; aquí se confirma a qué almacenes vuelve cada lote. Por cada línea se averigua en la base de SAP
* de qué almacenes vino el lote y se propone el reparto a prorrata; el usuario lo corrige (agrega, quita o edita filas) y
* al confirmar se reemplazan las líneas de la solicitud.
*/
@Component({
selector: 'sbo-lote-release-confirm-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RowsTable, SboWindow],
template: `
<sbo-window title="Confirmar liberación de lote" [status]="status()">
<p class="hint">Solicitudes de traslado de liberación abiertas. Elija una para indicar a qué almacenes vuelve cada lote.</p>
<table class="grid">
<thead><tr><th [attr.title]="'DocNum'">Solicitud@if (names.show()) {<span class="db">DocNum</span>}</th><th [attr.title]="'DocDate'">Fecha@if (names.show()) {<span class="db">DocDate</span>}</th><th [attr.title]="'U_SIS_MOTEST'">Motivo del estado@if (names.show()) {<span class="db">U_SIS_MOTEST</span>}</th><th [attr.title]="'U_SIS_COMEST'">Comentario@if (names.show()) {<span class="db">U_SIS_COMEST</span>}</th><th [attr.title]="'U_SIS_ACCTOM'">Acción a tomar@if (names.show()) {<span class="db">U_SIS_ACCTOM</span>}</th></tr></thead>
<tbody>
@for (r of pending.value() ?? []; track r['DocEntry']) {
<tr tabindex="0" [class.sel]="r['DocEntry'] === docEntry()" (click)="select(+r['DocEntry']!)" (keydown.enter)="select(+r['DocEntry']!)">
<td class="code">{{ r['DocNum'] }}</td><td>{{ date(r['DocDate']) }}</td><td>{{ r['U_SIS_MOTEST'] }}</td><td>{{ r['U_SIS_COMEST'] }}</td><td>{{ r['U_SIS_ACCTOM'] }}</td>
</tr>
} @empty {
<tr class="none"><td colspan="5">{{ pending.isLoading() ? 'Cargando…' : 'No hay solicitudes de liberación pendientes.' }}</td></tr>
}
</tbody>
</table>
@if (drafts(); as lines) {
<h3>Solicitud {{ request()?.['DocNum'] }}: almacenes destino</h3>
@for (line of lines; track line.LineNum; let i = $index) {
<div class="line">
<p class="head">
<strong class="code">{{ line.ItemCode }}</strong> · lote <strong class="code">{{ line.Lot }}</strong> · {{ line.Quantity }} unidades salen del almacén {{ line.FromWarehouseCode }}
@if (noOrigin(i)) {
<span class="muted"> · no se encontró de qué almacén vino: agréguelo a mano</span>
}
</p>
<sbo-rows-table [columns]="columns" [rows]="rowsOf(line)" (rowsChange)="setAllocations(i, $event)" emptyText="Sin almacenes: la línea se quita de la solicitud." />
<p class="row">
<button type="button" class="btn" (click)="addAllocation(i)">+ Añadir almacén</button>
<span class="sum">Reparto: {{ sum(line) }} de {{ line.Quantity }}</span>
@if (lineError(line); as e) {
<span class="err" role="alert">{{ e }}</span>
}
</p>
</div>
}
} @else if (resolving()) {
<p class="hint">Buscando de qué almacenes vino cada lote…</p>
}
@if (drafts()) {
<button sboActions class="btn main" type="button" [disabled]="confirming() || !canConfirm()" (click)="confirm()">Confirmar liberación</button>
}
</sbo-window>
`,
styles: `
:host { display: block; }
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
.hint { margin: 0 0 8px; color: var(--quiet); }
.grid { width: 100%; border-collapse: collapse; }
.grid th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
.grid th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; }
.grid td { padding: 3px 8px; border: 1px solid var(--line-soft); }
.grid tbody tr { cursor: pointer; }
.grid tbody tr.sel td { background: var(--row-sel); }
.grid tr.none td { padding: 14px; text-align: center; color: var(--quiet); cursor: default; }
.code { font-family: var(--mono, monospace); }
.line { margin: 8px 0 14px; }
.head { margin: 0 0 4px; }
.row { display: flex; align-items: center; gap: 12px; margin: 4px 0 0; }
.sum { color: var(--quiet); }
.err { color: var(--err); }
.muted { color: var(--quiet); }
`,
})
export class LoteReleaseConfirmPage {
private readonly api = inject(LotesFlujoApi);
protected readonly names = inject(FieldNames);
protected readonly pending = resource({ loader: () => this.api.pending(PENDING_LIBERATION) });
protected readonly docEntry = signal<number | null>(null);
protected readonly request = signal<Row | null>(null);
protected readonly drafts = signal<DraftLine[] | null>(null);
protected readonly resolving = signal(false);
protected readonly confirming = signal(false);
private readonly message = signal<StatusMessage | null>(null);
/** Líneas para las que la base no encontró de qué almacén vino el lote. */
private readonly withoutOrigin = signal<Set<number>>(new Set());
protected readonly date = formatDate;
protected readonly columns: readonly DetailColumn[] = [
{ key: 'WarehouseCode', title: 'Almacén destino', type: 'lookup', lookup: WAREHOUSE_LOOKUP, width: '180px' },
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px', nameOf: { codeKey: 'WarehouseCode', resource: 'Warehouses', valueField: 'WarehouseCode', labelField: 'WarehouseName' } },
{ key: 'Quantity', title: 'Cantidad', type: 'number', decimals: 0, width: '110px' },
];
protected rowsOf = (line: DraftLine): Row[] => line.allocations as Row[];
protected noOrigin = (i: number): boolean => this.withoutOrigin().has(i) && !(this.drafts()?.[i]?.allocations.length ?? 0);
protected sum = (line: DraftLine): number => line.allocations.reduce((s, a) => s + (Number(a.Quantity) || 0), 0);
protected lineError = draftLineError;
/** Al elegir una solicitud: se pide completa (con los lotes de cada línea) y se propone el reparto con lo que dice la base. */
protected async select(docEntry: number): Promise<void> {
if (this.resolving()) return;
this.docEntry.set(docEntry);
this.drafts.set(null);
this.request.set(null);
this.message.set({ kind: 'info', text: 'Cargando la solicitud…' });
this.resolving.set(true);
try {
const req = await this.api.request(docEntry);
this.request.set(req);
const lines = (req['StockTransferLines'] as Row[]) ?? [];
const drafts: DraftLine[] = await Promise.all(
lines.map(async (l) => {
const lot = text((l['BatchNumbers'] as Row[] | undefined)?.[0]?.['BatchNumber']);
const quantity = Number(l['Quantity']) || 0;
const from = text(l['FromWarehouseCode']);
const origins = lot ? await this.api.sources(text(l['ItemCode']), lot, from) : {};
return {
LineNum: Number(l['LineNum']),
ItemCode: text(l['ItemCode']),
Lot: lot,
FromWarehouseCode: from,
Quantity: quantity,
allocations: allocateByOrigin(origins, quantity).map((a) => ({ WarehouseCode: a.WarehouseCode, Quantity: a.Quantity })),
};
}),
);
this.withoutOrigin.set(new Set(drafts.flatMap((d, i) => (d.allocations.length ? [] : [i]))));
this.drafts.set(drafts);
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.resolving.set(false);
}
}
protected setAllocations(index: number, rows: Row[]): void {
this.drafts.update((d) => d?.map((line, i) => (i === index ? { ...line, allocations: rows as DraftLine['allocations'] } : line)) ?? null);
this.message.set(null);
}
protected addAllocation(index: number): void {
this.setAllocations(index, [...(this.drafts()?.[index]?.allocations ?? []), { WarehouseCode: '', Quantity: '' }]);
}
protected readonly canConfirm = computed(() => {
const d = this.drafts();
return !!d && d.some((l) => l.allocations.length > 0) && d.every((l) => !draftLineError(l));
});
protected async confirm(): Promise<void> {
const req = this.request();
const drafts = this.drafts();
if (!req || !drafts || !this.canConfirm() || this.confirming()) return;
this.confirming.set(true);
try {
await this.api.replaceLines(Number(req['DocEntry']), confirmLines(drafts), text(req['odata.etag']) || undefined);
this.message.set({ kind: 'ok', text: `Solicitud ${req['DocNum']} actualizada con los almacenes destino.` });
this.drafts.set(null);
this.request.set(null);
this.docEntry.set(null);
this.pending.reload();
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.confirming.set(false);
}
}
protected readonly status = computed<StatusMessage>(() => this.message() ?? { kind: 'info', text: this.drafts() ? 'Revise el reparto de cada línea y confirme.' : '' });
}
@@ -0,0 +1,179 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it } from 'vitest';
import { LoteTransferPage } from './lote-transfer.page';
import type { LotFlow } from './lotes-flujo.config';
const field = (alias: string, over: object) => ({ table: 'OWTQ', alias, description: alias, type: 'A', size: 10, validValues: [], linkedTable: null, linkedRows: [], linkedTruncated: false, ...over });
const FIELDS: Record<string, object> = {
SIS_MOVINV: field('SIS_MOVINV', { linkedTable: 'SIS_MOVINV', linkedRows: [{ code: 'XTD', name: 'TRANSFERENCIA' }] }),
EXX_TIPOOPER: field('EXX_TIPOOPER', { linkedTable: 'EXX_TIPOOPER', linkedRows: [{ code: '11', name: '11 - TRANSFERENCIA ENTRE ALMACENES' }] }),
SIS_TIPDESP: field('SIS_TIPDESP', { validValues: [{ value: 'N', description: 'LOCAL' }] }),
SIS_MOTALM: field('SIS_MOTALM', { validValues: [{ value: '107', description: 'INMOVILIZADO DT' }, { value: '108', description: 'LIBERACIÓN DT' }] }),
};
const STOCK = [
{ WarehouseCode: '201', WarehouseName: 'APROBADOS DRO', Quantity: '10.000000', Committed: '0.000000', ItemCode: 'A1', ItemDescription: 'SUTURA' },
{ WarehouseCode: '202', WarehouseName: 'CONTRAMUESTRA', Quantity: '4.000000', Committed: '1.000000', ItemCode: 'A1', ItemDescription: 'SUTURA' },
{ WarehouseCode: '302', WarehouseName: 'INMOVILIZADOS', Quantity: '3.000000', Committed: '0.000000', ItemCode: 'A1', ItemDescription: 'SUTURA' },
];
describe('LoteTransferPage', () => {
let fixture: ComponentFixture<LoteTransferPage>;
let ctrl: HttpTestingController;
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(flow: LotFlow) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [LoteTransferPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: { snapshot: { data: { flow } } } }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(LoteTransferPage);
await tick();
// catálogos de la solicitud (campos de usuario de SAP) y series
for (const alias of Object.keys(FIELDS)) ctrl.expectOne(`/api/sapdb/user-fields/OWTQ/${alias}`).flush(FIELDS[alias]);
ctrl.expectOne('/api/sl/SeriesService_GetDefaultSeries').flush({ Series: 7 });
ctrl.expectOne('/api/sl/SeriesService_GetDocumentSeries').flush({ value: [{ Series: 7, Name: 'TR-01', Locked: 'tNO' }, { Series: 8, Name: 'OLD', Locked: 'tYES' }] });
await tick();
}
async function search(lot: string, rows: object[] = STOCK) {
page().lot.set(lot);
await tick();
(el().querySelector('.row .btn.main') as HTMLButtonElement).click();
await tick();
const req = ctrl.expectOne('/api/sapdb/queries/lot-stock');
expect(req.request.body).toEqual({ params: { lot } });
req.flush({ rows, truncated: false });
await tick();
}
const rowOf = (code: string) => [...el().querySelectorAll('.stock tbody tr')].find((r) => r.querySelectorAll('td')[3]?.textContent?.trim() === code) as HTMLElement;
const check = (code: string) => (rowOf(code).querySelector('input[type=checkbox]') as HTMLInputElement).click();
describe('inmovilizar', () => {
beforeEach(() => open('inmovilizar'));
it('muestra todos los almacenes del lote y no deja mover lo que ya está inmovilizado', async () => {
await search('2090456');
expect([...el().querySelectorAll('.stock tbody tr')].map((r) => r.querySelectorAll('td')[3]?.textContent?.trim())).toEqual(['201', '202', '302']);
expect((rowOf('302').querySelector('input[type=checkbox]') as HTMLInputElement).disabled).toBe(true);
expect(rowOf('302').textContent).toContain('Ya inmovilizado');
});
it('deja elegida la serie por defecto de SAP', async () => {
await search('2090456');
expect(page().form().series).toBe(7);
expect((el().querySelector('sbo-select-field select') as HTMLSelectElement).selectedOptions[0]?.textContent?.trim()).toBe('TR-01');
});
it('al marcar un almacén se precarga todo el stock y no se puede superar', async () => {
await search('2090456');
check('201');
await tick();
const qty = rowOf('201').querySelector('input.qty') as HTMLInputElement;
expect(qty.value).toBe('10');
qty.value = '99';
qty.dispatchEvent(new Event('input'));
await tick();
expect((rowOf('201').querySelector('input.qty') as HTMLInputElement).value).toBe('10');
});
it('no se puede generar sin motivo, comentario y acción; con todo, crea la solicitud con una línea por almacén', async () => {
await search('2090456');
check('201');
check('202');
await tick();
expect(page().problems()).toEqual(expect.arrayContaining(['Indique el motivo del estado', 'Indique el comentario y la acción a tomar']));
page().set({ motivo: 'Queja', comentario: 'Reclamo del cliente', accion: 'Retener' });
page().setQuantity(page().visibleRows()[1], '2');
await tick();
expect(page().problems()).toEqual([]);
(el().querySelector('button[class~="main"][title], button.btn.main:not(.row .btn)') as HTMLButtonElement | null)?.click();
page().confirming.set(true);
await tick();
expect(el().textContent).toContain('¿Crear la solicitud de traslado de 2 almacenes del lote 2090456 al almacén 302?');
page().create();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/InventoryTransferRequests');
expect(req.request.body).toMatchObject({
U_SIS_MOVINV: 'XTD', U_EXX_TIPOOPER: '11', U_SIS_TIPDESP: 'N', U_SIS_MOTALM: '107',
U_SIS_MOTEST: 'Queja', U_SIS_COMEST: 'Reclamo del cliente', U_SIS_ACCTOM: 'Retener',
StockTransferLines: [
{ ItemCode: 'A1', Quantity: 10, WarehouseCode: '302', FromWarehouseCode: '201', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 10 }] },
{ ItemCode: 'A1', Quantity: 2, WarehouseCode: '302', FromWarehouseCode: '202', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 2 }] },
],
});
// la serie por defecto de SAP viaja elegida
expect(req.request.body).toHaveProperty('Series', 7);
req.flush({ DocEntry: 480, DocNum: 4123 });
await tick();
// vuelve a la búsqueda con el aviso de éxito
expect(el().querySelector('.stock')).toBeNull();
expect(el().querySelector('.status')?.textContent).toContain('Doc. 4123');
});
it('un error de SAP deja el panel con lo completado para corregir y reintentar', async () => {
await search('2090456');
check('201');
page().set({ motivo: 'Queja', comentario: 'c', accion: 'a' });
await tick();
page().confirming.set(true);
page().create();
await tick();
ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/InventoryTransferRequests').flush({ message: 'Stock insuficiente' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.stock')).not.toBeNull();
expect(page().form().motivo).toBe('Queja');
expect(el().querySelector('.status')?.textContent).toContain('Stock insuficiente');
});
it('cada campo de la solicitud lleva el nombre de su campo de SAP (botón «Campos»)', async () => {
await search('2090456');
const titles = [...el().querySelectorAll('label')].map((l) => l.getAttribute('title'));
expect(titles).toEqual(expect.arrayContaining(['BatchNumber', 'DocDate', 'Series', 'U_SIS_MOVINV', 'U_EXX_TIPOOPER', 'U_SIS_TIPDESP', 'U_SIS_MOTALM', 'U_SIS_MOTEST', 'U_SIS_COMEST', 'U_SIS_ACCTOM']));
expect([...el().querySelectorAll('.stock th')].map((t) => t.getAttribute('title'))).toEqual(expect.arrayContaining(['ItemCode', 'WarehouseCode', 'Quantity']));
});
it('un lote sin stock se avisa', async () => {
await search('NOEXISTE', []);
expect(el().querySelector('.status')?.textContent).toContain('No se encontró stock para el lote «NOEXISTE»');
expect(el().querySelector('.stock')).toBeNull();
});
});
describe('liberar', () => {
beforeEach(() => open('liberar'));
it('muestra solo el stock del almacén de origen (302 por defecto) y propone el destino 201 y el motivo 108', async () => {
await search('2090456');
expect([...el().querySelectorAll('.stock tbody tr')].map((r) => r.querySelectorAll('td')[3]?.textContent?.trim())).toEqual(['302']);
expect(page().form()).toMatchObject({ toWarehouse: '201', movimientoAlmacen: '108' });
});
it('solo exige el motivo del estado', async () => {
await search('2090456');
check('302');
page().set({ motivo: 'Aprobado por calidad' });
await tick();
expect(page().problems()).toEqual([]);
});
it('un lote que no tiene stock en el almacén de origen se avisa', async () => {
await search('2090456', [STOCK[0]]);
expect(el().querySelector('.status')?.textContent).toContain('no tiene stock en el almacén 302');
});
});
});
@@ -0,0 +1,263 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { apiErrorMessage } from '../../../core/api';
import { FieldNames } from '../../../core/field-names';
import { todayIso } from '../../../core/format';
import { DateField, LookupField, SelectField, TextField, TextareaField } from '../../../shared/form';
import type { VhLookup } from '../../../shared/value-help/value-help.types';
import { SboWindow, type StatusMessage } from '../../../shared/window';
import { WAREHOUSE_LOOKUP } from '../../documents/document-layouts';
import { LOT_FLOWS, type LotFlow, MOTIVO_MAX, TEXT_MAX } from './lotes-flujo.config';
import {
type LotStockRow,
type TransferForm,
clampQuantity,
initialForm,
quantityError,
rowKey,
transferErrors,
transferPayload,
} from './lotes-flujo';
import { LotesFlujoApi } from './lotes-flujo-api';
const WAREHOUSE: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' };
/**
* «Inmovilizar lotes» y «Liberar lotes» (TAGUMEDICA): se busca un lote por su número, se marcan los almacenes con stock y
* las cantidades, y se crea la solicitud de traslado en SAP con una línea por almacén de origen y el lote en cada una. La
* pantalla es la misma; el flujo (`data.flow`) fija el almacén de origen y de destino por defecto y qué textos son obligatorios.
*/
@Component({
selector: 'sbo-lote-transfer-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, LookupField, SboWindow, SelectField, TextField, TextareaField],
template: `
<sbo-window [title]="config.title" [status]="status()">
<p class="hint">{{ config.description }}</p>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-text-field label="Número de lote" dbName="BatchNumber" [value]="lot()" (valueChange)="lot.set($event)" (keydown.enter)="search()" />
</div>
<div class="sbo-col">
@if (config.pickFrom) {
<sbo-lookup-field label="Almacén de origen" dbName="FromWarehouseCode" [lookup]="warehouse" [value]="fromWarehouse()" (valueChange)="fromWarehouse.set($event)" resolveOnLeave />
}
</div>
</div>
<p class="row"><button type="button" class="btn main" [disabled]="searching() || !lot().trim()" (click)="search()">Buscar</button></p>
@if (result(); as r) {
<h3>Stock del lote {{ r.lot }}</h3>
<div class="wrap">
<table class="stock">
<thead>
<tr>
<th class="chk"></th><th [attr.title]="'ItemCode'">Artículo@if (names.show()) {<span class="db">ItemCode</span>}</th><th [attr.title]="'ItemDescription'">Descripción@if (names.show()) {<span class="db">ItemDescription</span>}</th><th [attr.title]="'WarehouseCode'">Almacén@if (names.show()) {<span class="db">WarehouseCode</span>}</th><th [attr.title]="'WarehouseName'">Nombre@if (names.show()) {<span class="db">WarehouseName</span>}</th>
<th class="num" [attr.title]="'Quantity'">En stock@if (names.show()) {<span class="db">Quantity</span>}</th><th class="num" [attr.title]="'CommitQty'">Comprometido@if (names.show()) {<span class="db">CommitQty</span>}</th><th class="num">Cantidad a mover</th>
</tr>
</thead>
<tbody>
@for (row of visibleRows(); track key(row)) {
<tr [class.locked]="isLocked(row)">
<td class="chk"><input type="checkbox" [checked]="isSelected(row)" [disabled]="isLocked(row)" [attr.aria-label]="'Mover ' + row.ItemCode + ' del almacén ' + row.WarehouseCode" (change)="toggle(row)" /></td>
<td class="code">{{ row.ItemCode }}</td>
<td>{{ row.ItemDescription }}</td>
<td class="code">{{ row.WarehouseCode }}</td>
<td>{{ row.WarehouseName }}</td>
<td class="num">{{ row.Quantity }}</td>
<td class="num">{{ row.Committed }}</td>
<td class="num">
@if (isSelected(row)) {
<input class="qty" inputmode="decimal" [attr.aria-label]="'Cantidad de ' + row.ItemCode + ' del almacén ' + row.WarehouseCode" [value]="quantities()[key(row)]" (input)="setQuantity(row, $any($event.target))" />
@if (qtyError(row); as e) {
<span class="err" role="alert">{{ e }}</span>
}
} @else if (isLocked(row)) {
<span class="muted">Ya inmovilizado</span>
}
</td>
</tr>
} @empty {
<tr class="none"><td colspan="8">El lote no tiene stock en el almacén {{ fromWarehouse() }}.</td></tr>
}
</tbody>
</table>
</div>
<h3>Solicitud de traslado</h3>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-date-field label="Fecha" dbName="DocDate" required [value]="form().docDate" (valueChange)="set({ docDate: $event })" />
<sbo-select-field label="Serie" dbName="Series" [options]="series.value() ?? []" placeholder="(por defecto)" [value]="form().series" (valueChange)="set({ series: $event === null || $event === '' ? null : +$event })" />
<sbo-lookup-field label="Almacén destino" dbName="WarehouseCode" required [lookup]="warehouse" [value]="form().toWarehouse" (valueChange)="set({ toWarehouse: $event })" resolveOnLeave />
</div>
<div class="sbo-col">
<sbo-select-field label="Movimiento de inventario" dbName="U_SIS_MOVINV" required [options]="options().movimientoInventario" placeholder="" [value]="form().movimientoInventario" (valueChange)="set({ movimientoInventario: '' + ($event ?? '') })" />
<sbo-select-field label="Tipo de operación" dbName="U_EXX_TIPOOPER" required [options]="options().tipoOperacion" placeholder="" [value]="form().tipoOperacion" (valueChange)="set({ tipoOperacion: '' + ($event ?? '') })" />
<sbo-select-field label="Tipo de despacho" dbName="U_SIS_TIPDESP" required [options]="options().tipoDespacho" placeholder="" [value]="form().tipoDespacho" (valueChange)="set({ tipoDespacho: '' + ($event ?? '') })" />
<sbo-select-field label="Motivo de almacén" dbName="U_SIS_MOTALM" required [options]="options().movimientoAlmacen" placeholder="" [value]="form().movimientoAlmacen" (valueChange)="set({ movimientoAlmacen: '' + ($event ?? '') })" />
</div>
</div>
<sbo-textarea-field label="Motivo del estado" dbName="U_SIS_MOTEST" [rows]="3" required [value]="form().motivo" (valueChange)="set({ motivo: $event })" [error]="over(form().motivo, motivoMax)" />
<sbo-textarea-field label="Comentario del estado" dbName="U_SIS_COMEST" [rows]="3" [required]="config.requireDetail" [value]="form().comentario" (valueChange)="set({ comentario: $event })" [error]="over(form().comentario, textMax)" />
<sbo-textarea-field label="Acción a tomar" dbName="U_SIS_ACCTOM" [rows]="3" [required]="config.requireDetail" [value]="form().accion" (valueChange)="set({ accion: $event })" [error]="over(form().accion, textMax)" />
@if (confirming()) {
<p class="ask" role="alert">
¿Crear la solicitud de traslado de {{ selected().length }} {{ selected().length === 1 ? 'almacén' : 'almacenes' }} del lote {{ r.lot }} al almacén {{ form().toWarehouse }}?
<button type="button" class="btn main" [disabled]="saving()" (click)="create()">Sí, crear</button>
<button type="button" class="btn" [disabled]="saving()" (click)="confirming.set(false)">No</button>
</p>
}
}
@if (result() && !confirming()) {
<button sboActions class="btn main" type="button" [disabled]="saving() || problems().length > 0" [attr.title]="problems()[0] ?? null" (click)="confirming.set(true)">Generar solicitud de traslado</button>
}
</sbo-window>
`,
styles: `
:host { display: block; }
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
.hint { margin: 0 0 8px; color: var(--quiet); }
.row { margin: 4px 0 0; }
.wrap { overflow-x: auto; }
.stock { width: 100%; border-collapse: collapse; }
.stock th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
.stock th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; white-space: nowrap; }
.stock td { padding: 3px 8px; border: 1px solid var(--line-soft); }
.stock .num { text-align: right; }
.stock .chk { width: 28px; text-align: center; }
.stock .code { font-family: var(--mono, monospace); }
.stock tr.locked td { background: var(--field-ro); color: var(--quiet); }
.stock tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
.qty { width: 90px; text-align: right; }
.err { display: block; color: var(--err); font-size: 11.5px; }
.muted { color: var(--quiet); }
.ask { margin: 12px 0 0; padding: 6px 10px; background: var(--warn-bg, var(--panel)); border: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
`,
})
export class LoteTransferPage {
private readonly api = inject(LotesFlujoApi);
protected readonly names = inject(FieldNames);
private readonly flow = inject(ActivatedRoute).snapshot.data['flow'] as LotFlow;
protected readonly config = LOT_FLOWS[this.flow];
protected readonly warehouse = WAREHOUSE;
protected readonly motivoMax = MOTIVO_MAX;
protected readonly textMax = TEXT_MAX;
// --- búsqueda ------------------------------------------------------------------------------------------------------
protected readonly lot = signal('');
protected readonly fromWarehouse = signal(this.config.fromWarehouse ?? '');
protected readonly searching = signal(false);
protected readonly result = signal<{ lot: string; rows: LotStockRow[] } | null>(null);
protected readonly quantities = signal<Record<string, string>>({});
private readonly message = signal<StatusMessage | null>(null);
/** Las filas que se ven: con origen elegido (liberar), solo las de ese almacén; si no, todas. */
protected readonly visibleRows = computed(() => {
const r = this.result();
if (!r) return [];
const from = this.config.pickFrom ? this.fromWarehouse() : null;
return from ? r.rows.filter((x) => x.WarehouseCode === from) : r.rows;
});
protected readonly key = rowKey;
protected async search(): Promise<void> {
const lot = this.lot().trim();
if (!lot || this.searching()) return;
this.searching.set(true);
this.message.set({ kind: 'info', text: 'Buscando el lote…' });
this.result.set(null);
this.quantities.set({});
this.confirming.set(false);
try {
const rows = await this.api.stock(lot);
if (!rows.length) throw new Error(`No se encontró stock para el lote «${lot}»`);
if (this.config.pickFrom && !rows.some((r) => r.WarehouseCode === this.fromWarehouse())) throw new Error(`El lote «${lot}» no tiene stock en el almacén ${this.fromWarehouse()}`);
this.result.set({ lot, rows });
this.form.set(initialForm(this.config, todayIso(), this.defaultSeries.value() ?? null));
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: e instanceof Error && !('status' in e) ? e.message : apiErrorMessage(e) });
} finally {
this.searching.set(false);
}
}
// --- selección -----------------------------------------------------------------------------------------------------
protected isLocked(row: LotStockRow): boolean {
return row.WarehouseCode === this.config.lockedWarehouse;
}
protected isSelected(row: LotStockRow): boolean {
return rowKey(row) in this.quantities();
}
protected toggle(row: LotStockRow): void {
if (this.isLocked(row)) return;
const key = rowKey(row);
this.quantities.update((q) => {
if (key in q) {
const { [key]: _removed, ...rest } = q;
return rest;
}
// al marcar la fila se precarga con todo el stock (se puede bajar, no superar)
return { ...q, [key]: String(row.Quantity) };
});
}
protected setQuantity(row: LotStockRow, source: string | HTMLInputElement): void {
const clamped = clampQuantity(row, typeof source === 'string' ? source : source.value);
// si se recortó, el casillero debe mostrar el valor corregido aunque el valor guardado no haya cambiado
if (typeof source !== 'string') source.value = clamped;
this.quantities.update((q) => ({ ...q, [rowKey(row)]: clamped }));
}
protected qtyError(row: LotStockRow): string {
return quantityError(row, this.quantities()[rowKey(row)] ?? '');
}
protected readonly selected = computed(() => this.visibleRows().filter((r) => this.isSelected(r)).map((row) => ({ row, quantity: this.quantities()[rowKey(row)] ?? '' })));
// --- solicitud -----------------------------------------------------------------------------------------------------
protected readonly form = signal<TransferForm>(initialForm(this.config, todayIso()));
protected set(change: Partial<TransferForm>): void {
this.form.update((f) => ({ ...f, ...change }));
}
protected over = (text: string, max: number): string => (text.length > max ? `Máximo ${max} caracteres` : '');
protected readonly catalogs = resource({ loader: () => this.api.catalogs() });
protected readonly options = computed(() => this.catalogs.value() ?? { movimientoInventario: [], tipoOperacion: [], tipoDespacho: [], movimientoAlmacen: [] });
protected readonly series = resource({ loader: () => this.api.series().catch(() => []) });
/** La serie por defecto de SAP: queda elegida al buscar el lote (se puede cambiar o dejar en blanco). */
private readonly defaultSeries = resource({ loader: () => this.api.defaultSeries().catch(() => null) });
protected readonly problems = computed(() => {
const f = this.form();
const errors = transferErrors(this.config, f, this.selected());
if (f.motivo.length > MOTIVO_MAX || f.comentario.length > TEXT_MAX || f.accion.length > TEXT_MAX) errors.push('Algún texto es demasiado largo');
return errors;
});
protected readonly confirming = signal(false);
protected readonly saving = signal(false);
protected async create(): Promise<void> {
const r = this.result();
if (!r || this.saving() || this.problems().length) return;
this.saving.set(true);
try {
const doc = await this.api.create(transferPayload(r.lot, this.form(), this.selected()));
// éxito: se vuelve a la búsqueda y el aviso queda arriba (el panel del lote desaparece)
this.result.set(null);
this.lot.set('');
this.quantities.set({});
this.message.set({ kind: 'ok', text: `Solicitud de traslado creada en SAP (Doc. ${doc['DocNum'] ?? doc['DocEntry']}).` });
} catch (e) {
// el error deja el panel con todo lo completado para corregir y reintentar
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.confirming.set(false);
this.saving.set(false);
}
}
protected readonly status = computed<StatusMessage>(() => this.message() ?? { kind: 'info', text: this.result() ? 'Marque los almacenes, complete la solicitud y genérela.' : 'Escriba el número de lote y pulse «Buscar».' });
}
@@ -0,0 +1,103 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../../../core/api';
import { SapDbApi } from '../../../core/sl/sapdb-api';
import { SlMasterApi } from '../../../core/sl/sl-master';
import { UserFieldsApi } from '../../../core/sl/user-fields';
import { type LotStockRow, toStockRow } from './lotes-flujo';
export interface Option {
value: string;
label: string;
}
/** Catálogos de la solicitud de traslado: cada uno sale de la definición del campo de usuario en SAP. */
export interface TransferCatalogs {
movimientoInventario: Option[];
tipoOperacion: Option[];
tipoDespacho: Option[];
movimientoAlmacen: Option[];
}
const RESOURCE = 'InventoryTransferRequests';
/** Objeto de SAP de la solicitud de traslado (`BoObjectTypes`), para pedir sus series. */
const OBJECT_TYPE = '1250000001';
type Row = Record<string, unknown>;
/** Habla con SAP para los flujos de lotes: la base (stock y orígenes), los campos de usuario y la solicitud de traslado. */
@Injectable({ providedIn: 'root' })
export class LotesFlujoApi {
private readonly http = inject(HttpClient);
private readonly sapdb = inject(SapDbApi);
private readonly userFields = inject(UserFieldsApi);
private readonly master = inject(SlMasterApi);
/** El stock de un lote por número, en cada artículo y almacén. */
async stock(lot: string): Promise<LotStockRow[]> {
return (await this.sapdb.run('lot-stock', { lot })).rows.map(toStockRow);
}
/** De qué almacenes vino un lote hasta el almacén donde está: almacén → cantidad histórica. */
async sources(item: string, lot: string, warehouse: string): Promise<Record<string, number>> {
const rows = (await this.sapdb.run('lot-sources', { item, lot, warehouse })).rows;
return Object.fromEntries(rows.map((r) => [String(r['WarehouseCode']), Number(r['Quantity'])]));
}
/** Un campo de usuario de la solicitud como lista: su tabla enlazada o sus valores válidos. */
private async options(alias: string): Promise<Option[]> {
const info = await this.userFields.get('OWTQ', alias);
if (!info) return [];
if (info.validValues.length) return info.validValues.map((v) => ({ value: v.value, label: v.description ? `${v.value} · ${v.description}` : v.value }));
return info.linkedRows.map((r) => ({ value: r.code, label: r.name.startsWith(r.code) ? r.name : `${r.code} · ${r.name}` }));
}
async catalogs(): Promise<TransferCatalogs> {
const [movimientoInventario, tipoOperacion, tipoDespacho, movimientoAlmacen] = await Promise.all([
this.options('SIS_MOVINV'),
this.options('EXX_TIPOOPER'),
this.options('SIS_TIPDESP'),
this.options('SIS_MOTALM'),
]);
return { movimientoInventario, tipoOperacion, tipoDespacho, movimientoAlmacen };
}
/** Las series de la solicitud de traslado que no están bloqueadas. */
async series(): Promise<{ value: number; label: string }[]> {
const res = await firstValueFrom(this.http.post<SlListResponse<Row>>(`${API_URL}/sl/SeriesService_GetDocumentSeries`, { DocumentTypeParams: { Document: OBJECT_TYPE } }));
return res.value.filter((r) => r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: String(r['Name'] ?? r['Series']) }));
}
/** La serie que SAP usa por defecto para las solicitudes de traslado, o `null` si no se pudo saber. */
async defaultSeries(): Promise<number | null> {
const res = await firstValueFrom(this.http.post<{ Series?: number }>(`${API_URL}/sl/SeriesService_GetDefaultSeries`, { DocumentTypeParams: { Document: OBJECT_TYPE } }));
return typeof res.Series === 'number' ? res.Series : null;
}
create(body: Row): Promise<Row> {
return this.master.create(RESOURCE, body);
}
/** Las solicitudes abiertas pendientes de confirmar (las más recientes primero). */
async pending(motive: string): Promise<Row[]> {
const params = {
$select: 'DocEntry,DocNum,DocDate,U_SIS_MOTEST,U_SIS_COMEST,U_SIS_ACCTOM',
$filter: `DocumentStatus eq 'bost_Open' and U_SIS_MOTALM eq '${motive}'`,
$orderby: 'DocEntry desc',
$top: 200,
};
return (await firstValueFrom(this.http.get<SlListResponse<Row>>(`${API_URL}/sl/${RESOURCE}`, { params }))).value;
}
/** La solicitud completa (el Service Layer solo trae los lotes de las líneas al pedirla por su clave). */
request(docEntry: number): Promise<Row> {
return firstValueFrom(this.http.get<Row>(`${API_URL}/sl/${RESOURCE}/${docEntry}`));
}
/** Reemplaza las líneas de la solicitud (se pueden agregar y quitar al repartir entre almacenes). */
replaceLines(docEntry: number, lines: Row[], etag: string | undefined): Promise<void> {
return this.master.update(RESOURCE, String(docEntry), { StockTransferLines: lines }, etag, true);
}
}
@@ -0,0 +1,64 @@
/**
* Valores de los flujos de lotes de TAGUMEDICA (almacenes y códigos de los campos de usuario de la solicitud de
* traslado). Son decisiones del negocio, no del código: aquí se cambian sin tocar las pantallas.
*/
/** Los dos flujos que crean una solicitud de traslado desde un lote. */
export type LotFlow = 'inmovilizar' | 'liberar';
export interface LotFlowConfig {
title: string;
description: string;
/** Almacén de origen fijo de la búsqueda: `null` = todos los del lote. */
fromWarehouse: string | null;
/** El origen se puede elegir (liberar) o se muestran todos los almacenes del lote (inmovilizar). */
pickFrom: boolean;
/** Almacén destino por defecto (se puede cambiar). */
toWarehouse: string;
/** El stock de este almacén no se puede volver a mover en este flujo (ya está inmovilizado). */
lockedWarehouse: string | null;
/** `U_SIS_MOTALM` por defecto: 107 «INMOVILIZADO DT», 108 «LIBERACIÓN DT». */
movimientoAlmacen: string;
/** Comentario y acción a tomar obligatorios (inmovilizar); en liberar solo el motivo. */
requireDetail: boolean;
}
/** Almacén de inmovilizados (302 - INMOVILIZADOS DRO). */
export const WAREHOUSE_IMMOBILIZED = '302';
/** Almacén de aprobados (201 - APROBADOS DRO). */
export const WAREHOUSE_APPROVED = '201';
export const LOT_FLOWS: Readonly<Record<LotFlow, LotFlowConfig>> = {
inmovilizar: {
title: 'Inmovilizar lotes',
description: 'Consulta el stock de un lote por almacén y genera la solicitud de traslado al almacén de inmovilizados.',
fromWarehouse: null,
pickFrom: false,
toWarehouse: WAREHOUSE_IMMOBILIZED,
lockedWarehouse: WAREHOUSE_IMMOBILIZED,
movimientoAlmacen: '107',
requireDetail: true,
},
liberar: {
title: 'Liberar lotes',
description: 'Consulta el stock inmovilizado de un lote en un almacén y genera la solicitud de traslado de liberación.',
fromWarehouse: WAREHOUSE_IMMOBILIZED,
pickFrom: true,
toWarehouse: WAREHOUSE_APPROVED,
lockedWarehouse: null,
movimientoAlmacen: '108',
requireDetail: false,
},
};
/** Valores por defecto de `U_SIS_MOVINV`, `U_EXX_TIPOOPER` y `U_SIS_TIPDESP` en las solicitudes de los dos flujos. */
export const DEFAULT_MOVIMIENTO_INVENTARIO = 'XTD';
export const DEFAULT_TIPO_OPERACION = '11';
export const DEFAULT_TIPO_DESPACHO = 'N';
/** `U_SIS_MOTALM` de las solicitudes que esperan la confirmación de la liberación (las que crea «Liberar lotes»). */
export const PENDING_LIBERATION = '108';
/** Largo máximo de los textos (`U_SIS_MOTEST` es de 100 caracteres, los otros dos de 254). */
export const MOTIVO_MAX = 100;
export const TEXT_MAX = 254;
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import { LOT_FLOWS } from './lotes-flujo.config';
import {
type DraftLine,
type LotStockRow,
allocateByOrigin,
clampQuantity,
confirmLines,
draftLineError,
groupByItem,
initialForm,
quantityError,
rowKey,
toStockRow,
transferErrors,
transferPayload,
} from './lotes-flujo';
const row = (over: Partial<LotStockRow> = {}): LotStockRow => ({ ItemCode: 'A1', ItemDescription: 'Sutura', WarehouseCode: '201', WarehouseName: 'APROBADOS', Quantity: 10, Committed: 0, ...over });
describe('stock de un lote', () => {
it('convierte la fila de HANA (los decimales llegan como texto)', () => {
expect(toStockRow({ ItemCode: 'A1', ItemDescription: 'S', WarehouseCode: '100', WarehouseName: 'C', Quantity: '7.500000', Committed: '0.000000' })).toEqual({
ItemCode: 'A1', ItemDescription: 'S', WarehouseCode: '100', WarehouseName: 'C', Quantity: 7.5, Committed: 0,
});
});
it('agrupa por artículo y cada fila es artículo + almacén', () => {
const g = groupByItem([row(), row({ WarehouseCode: '202' }), row({ ItemCode: 'B2' })]);
expect(g.map((x) => [x.ItemCode, x.rows.length])).toEqual([['A1', 2], ['B2', 1]]);
expect(rowKey(row())).toBe('A1::201');
});
it('la cantidad debe ser mayor a 0 y no pasar del stock; lo escrito se recorta al stock', () => {
expect(quantityError(row(), '')).toBe('');
expect(quantityError(row(), '0')).toMatch(/mayor a 0/);
expect(quantityError(row(), '11')).toMatch(/superar el stock/);
expect(quantityError(row(), '10')).toBe('');
expect(clampQuantity(row(), '99')).toBe('10');
expect(clampQuantity(row(), '3')).toBe('3');
});
});
describe('solicitud de inmovilización', () => {
const cfg = LOT_FLOWS.inmovilizar;
const form = { ...initialForm(cfg, '2026-10-20'), motivo: 'Queja', comentario: 'Cliente reclama', accion: 'Retener' };
const sel = [{ row: row(), quantity: '4' }];
it('parte de los valores por defecto del flujo', () => {
expect(initialForm(cfg, '2026-10-20')).toMatchObject({ toWarehouse: '302', movimientoAlmacen: '107', movimientoInventario: 'XTD', tipoOperacion: '11', tipoDespacho: 'N', series: null });
expect(initialForm(LOT_FLOWS.liberar, '2026-10-20')).toMatchObject({ toWarehouse: '201', movimientoAlmacen: '108' });
expect(initialForm(cfg, '2026-10-20', 7).series).toBe(7);
});
it('pide motivo, comentario y acción, al menos un almacén y un destino distinto del origen', () => {
expect(transferErrors(cfg, form, sel)).toEqual([]);
expect(transferErrors(cfg, { ...form, comentario: '', accion: '' }, sel)).toContain('Indique el comentario y la acción a tomar');
expect(transferErrors(cfg, { ...form, motivo: ' ' }, sel)).toContain('Indique el motivo del estado');
expect(transferErrors(cfg, form, [])).toContain('Marque al menos un almacén con stock');
expect(transferErrors(cfg, { ...form, toWarehouse: '201' }, sel)).toContain('El almacén destino debe ser distinto al almacén de origen');
expect(transferErrors(cfg, form, [{ row: row(), quantity: '' }])[0]).toMatch(/Revise la cantidad/);
});
it('liberar solo exige el motivo', () => {
expect(transferErrors(LOT_FLOWS.liberar, { ...initialForm(LOT_FLOWS.liberar, '2026-10-20'), motivo: 'Aprobado' }, [{ row: row({ WarehouseCode: '302' }), quantity: '2' }])).toEqual([]);
});
it('arma la solicitud con una línea por almacén de origen y el lote en cada una', () => {
expect(transferPayload('2090456', { ...form, series: 5 }, [...sel, { row: row({ WarehouseCode: '202' }), quantity: '2' }])).toEqual({
DocDate: '2026-10-20',
Series: 5,
U_SIS_MOVINV: 'XTD',
U_EXX_TIPOOPER: '11',
U_SIS_TIPDESP: 'N',
U_SIS_MOTALM: '107',
U_SIS_MOTEST: 'Queja',
U_SIS_COMEST: 'Cliente reclama',
U_SIS_ACCTOM: 'Retener',
StockTransferLines: [
{ ItemCode: 'A1', Quantity: 4, WarehouseCode: '302', FromWarehouseCode: '201', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 4 }] },
{ ItemCode: 'A1', Quantity: 2, WarehouseCode: '302', FromWarehouseCode: '202', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 2 }] },
],
});
});
it('sin serie elegida no se envía (SAP usa la por defecto) y los textos opcionales vacíos tampoco', () => {
const p = transferPayload('L', { ...form, comentario: '', accion: '' }, sel);
expect(p).not.toHaveProperty('Series');
expect(p).not.toHaveProperty('U_SIS_COMEST');
expect(p).not.toHaveProperty('U_SIS_ACCTOM');
});
});
describe('confirmar la liberación', () => {
it('reparte la cantidad entre los almacenes de origen a prorrata y el último absorbe el redondeo', () => {
expect(allocateByOrigin({ '207': 2, '208': 1 }, 3)).toEqual([{ WarehouseCode: '207', Quantity: 2 }, { WarehouseCode: '208', Quantity: 1 }]);
expect(allocateByOrigin({ '207': 5, '208': 1, '303': 3 }, 9)).toEqual([{ WarehouseCode: '207', Quantity: 5 }, { WarehouseCode: '208', Quantity: 1 }, { WarehouseCode: '303', Quantity: 3 }]);
const parts = allocateByOrigin({ A: 1, B: 1, C: 1 }, 10);
expect(parts.reduce((s, p) => s + p.Quantity, 0)).toBe(10);
expect(allocateByOrigin({}, 5)).toEqual([]);
expect(allocateByOrigin({ A: 0 }, 5)).toEqual([]);
});
const line = (over: Partial<DraftLine> = {}): DraftLine => ({
LineNum: 0, ItemCode: 'A1', Lot: 'L1', FromWarehouseCode: '302', Quantity: 3,
allocations: [{ WarehouseCode: '207', Quantity: 2 }, { WarehouseCode: '208', Quantity: '1' }],
...over,
});
it('una línea es válida si cada fila está completa y suman la cantidad; sin filas se quita a propósito', () => {
expect(draftLineError(line())).toBe('');
expect(draftLineError(line({ allocations: [] }))).toBe('');
expect(draftLineError(line({ allocations: [{ WarehouseCode: '', Quantity: 3 }] }))).toMatch(/Complete/);
expect(draftLineError(line({ allocations: [{ WarehouseCode: '207', Quantity: 2 }] }))).toMatch(/suman 2 y la línea es de 3/);
});
it('reemplaza las líneas: la primera conserva su LineNum, las otras van sin él, y una línea sin filas no se envía', () => {
expect(confirmLines([line(), line({ LineNum: 1, ItemCode: 'B2', allocations: [] })])).toEqual([
{ LineNum: 0, ItemCode: 'A1', Quantity: 2, WarehouseCode: '207', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 2 }] },
{ ItemCode: 'A1', Quantity: 1, WarehouseCode: '208', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 1 }] },
]);
});
});
@@ -0,0 +1,198 @@
import type { LotFlowConfig } from './lotes-flujo.config';
import { DEFAULT_MOVIMIENTO_INVENTARIO, DEFAULT_TIPO_DESPACHO, DEFAULT_TIPO_OPERACION } from './lotes-flujo.config';
/** Una fila de stock de un lote: un artículo en un almacén. */
export interface LotStockRow {
ItemCode: string;
ItemDescription: string;
WarehouseCode: string;
WarehouseName: string;
Quantity: number;
Committed: number;
}
const num = (v: unknown): number => {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
/** La fila de la consulta `lot-stock` (HANA entrega los decimales como texto) como fila de stock. */
export function toStockRow(raw: Record<string, unknown>): LotStockRow {
return {
ItemCode: String(raw['ItemCode'] ?? ''),
ItemDescription: String(raw['ItemDescription'] ?? ''),
WarehouseCode: String(raw['WarehouseCode'] ?? ''),
WarehouseName: String(raw['WarehouseName'] ?? ''),
Quantity: num(raw['Quantity']),
Committed: num(raw['Committed']),
};
}
/** Un mismo número de lote puede repetirse en varios artículos, y un almacén en varios artículos: la fila es artículo + almacén. */
export const rowKey = (r: Pick<LotStockRow, 'ItemCode' | 'WarehouseCode'>): string => `${r.ItemCode}::${r.WarehouseCode}`;
/** Por qué una cantidad escrita no vale, o `''` si vale (o está vacía y todavía no se valida). */
export function quantityError(row: LotStockRow, raw: string): string {
if (raw === '') return '';
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return 'La cantidad debe ser mayor a 0';
if (n > row.Quantity) return `La cantidad no puede superar el stock disponible en el almacén (${row.Quantity})`;
return '';
}
/** Lo escrito no puede pasar del stock de la fila. */
export function clampQuantity(row: LotStockRow, raw: string): string {
const n = Number(raw);
return raw !== '' && Number.isFinite(n) && n > row.Quantity ? String(row.Quantity) : raw;
}
export interface ItemGroup {
ItemCode: string;
ItemDescription: string;
rows: LotStockRow[];
}
/** Las filas agrupadas por artículo, en el orden en que llegan. */
export function groupByItem(rows: readonly LotStockRow[]): ItemGroup[] {
const groups: ItemGroup[] = [];
for (const r of rows) {
let g = groups.find((x) => x.ItemCode === r.ItemCode);
if (!g) groups.push((g = { ItemCode: r.ItemCode, ItemDescription: r.ItemDescription, rows: [] }));
g.rows.push(r);
}
return groups;
}
/** Los datos de la solicitud que escribe el usuario. */
export interface TransferForm {
docDate: string;
series: number | null;
movimientoInventario: string;
tipoOperacion: string;
tipoDespacho: string;
movimientoAlmacen: string;
toWarehouse: string;
motivo: string;
comentario: string;
accion: string;
}
/** Valores iniciales de la solicitud de un flujo (`series`: la que SAP usa por defecto, si se conoce). */
export const initialForm = (config: LotFlowConfig, today: string, series: number | null = null): TransferForm => ({
docDate: today,
series,
movimientoInventario: DEFAULT_MOVIMIENTO_INVENTARIO,
tipoOperacion: DEFAULT_TIPO_OPERACION,
tipoDespacho: DEFAULT_TIPO_DESPACHO,
movimientoAlmacen: config.movimientoAlmacen,
toWarehouse: config.toWarehouse,
motivo: '',
comentario: '',
accion: '',
});
/** Lo que falta o está mal para poder crear la solicitud (vacío = se puede). */
export function transferErrors(config: LotFlowConfig, form: TransferForm, selected: readonly { row: LotStockRow; quantity: string }[]): string[] {
const errors: string[] = [];
if (!selected.length) errors.push('Marque al menos un almacén con stock');
for (const s of selected) {
if (s.quantity === '' || quantityError(s.row, s.quantity)) errors.push(`Revise la cantidad de ${s.row.ItemCode} en el almacén ${s.row.WarehouseCode}`);
if (s.row.WarehouseCode === form.toWarehouse) errors.push('El almacén destino debe ser distinto al almacén de origen');
}
if (!form.toWarehouse) errors.push('Indique el almacén destino');
if (!form.docDate) errors.push('Indique la fecha');
if (!form.movimientoInventario || !form.tipoOperacion || !form.tipoDespacho || !form.movimientoAlmacen) errors.push('Complete los datos de la solicitud');
if (!form.motivo.trim()) errors.push('Indique el motivo del estado');
if (config.requireDetail && (!form.comentario.trim() || !form.accion.trim())) errors.push('Indique el comentario y la acción a tomar');
return [...new Set(errors)];
}
/** El documento que se crea en SAP: la solicitud de traslado con una línea por almacén de origen y el lote en cada una. */
export function transferPayload(
lot: string,
form: TransferForm,
selected: readonly { row: LotStockRow; quantity: string }[],
): Record<string, unknown> {
return {
DocDate: form.docDate,
...(form.series !== null ? { Series: form.series } : {}),
U_SIS_MOVINV: form.movimientoInventario,
U_EXX_TIPOOPER: form.tipoOperacion,
U_SIS_TIPDESP: form.tipoDespacho,
U_SIS_MOTALM: form.movimientoAlmacen,
U_SIS_MOTEST: form.motivo.trim(),
...(form.comentario.trim() ? { U_SIS_COMEST: form.comentario.trim() } : {}),
...(form.accion.trim() ? { U_SIS_ACCTOM: form.accion.trim() } : {}),
StockTransferLines: selected.map(({ row, quantity }) => ({
ItemCode: row.ItemCode,
Quantity: Number(quantity),
WarehouseCode: form.toWarehouse,
FromWarehouseCode: row.WarehouseCode,
BatchNumbers: [{ BatchNumber: lot, Quantity: Number(quantity) }],
})),
};
}
// --- confirmar la liberación ---------------------------------------------------------------------------------------
export interface Allocation {
WarehouseCode: string;
Quantity: number;
}
/**
* Reparte la cantidad de una línea entre los almacenes de los que vino el lote, a prorrata de lo que trajo cada uno
* (`origins`: almacén → cantidad histórica). Cada parte es un entero (los artículos se mueven por unidades completas) y
* el último almacén absorbe el redondeo para que la suma sea exactamente la cantidad.
*/
export function allocateByOrigin(origins: Readonly<Record<string, number>>, quantity: number): Allocation[] {
const entries = Object.entries(origins);
const total = entries.reduce((s, [, q]) => s + q, 0);
if (total <= 0) return [];
const out: Allocation[] = [];
let done = 0;
entries.forEach(([warehouse, historical], i) => {
const last = i === entries.length - 1;
const part = Math.max(last ? Math.round(quantity - done) : Math.round(quantity * (historical / total)), 0);
done += part;
if (part > 0) out.push({ WarehouseCode: warehouse, Quantity: part });
});
return out;
}
/** Una línea de la solicitud pendiente con los almacenes a los que vuelve el lote (editables). */
export interface DraftLine {
LineNum: number;
ItemCode: string;
Lot: string;
FromWarehouseCode: string;
Quantity: number;
/** Las filas de la tabla editable: almacén y cantidad (la tabla guarda número o texto). */
allocations: { WarehouseCode: string; Quantity: number | string }[];
}
/** Por qué una línea no se puede confirmar (vacío = bien). Una línea sin filas se quita del documento a propósito. */
export function draftLineError(line: DraftLine): string {
if (!line.allocations.length) return '';
if (line.allocations.some((a) => !a.WarehouseCode || String(a.Quantity).trim() === '' || !(Number(a.Quantity) > 0))) return 'Complete el almacén y la cantidad de cada fila';
const sum = line.allocations.reduce((s, a) => s + Number(a.Quantity), 0);
if (sum !== line.Quantity) return `Las cantidades suman ${sum} y la línea es de ${line.Quantity}`;
return '';
}
/**
* Las líneas con que se reemplazan las de la solicitud: una por cada fila de reparto. La primera de cada línea conserva su
* `LineNum`; las demás van sin él para que SAP las agregue. Una línea sin filas no se envía (se quita del documento).
*/
export function confirmLines(lines: readonly DraftLine[]): Record<string, unknown>[] {
return lines.flatMap((line) =>
line.allocations.map((a, i) => ({
...(i === 0 ? { LineNum: line.LineNum } : {}),
ItemCode: line.ItemCode,
Quantity: Number(a.Quantity),
WarehouseCode: a.WarehouseCode,
FromWarehouseCode: line.FromWarehouseCode,
BatchNumbers: [{ BatchNumber: line.Lot, Quantity: Number(a.Quantity) }],
})),
);
}
@@ -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);
}
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { masterKeysOf } from '../master-model';
import { MASTER_SCREENS } from '../master-screens';
describe('Lotes de TAGUMEDICA', () => {
const screen = MASTER_SCREENS.find((s) => s.path === '/inventario/lotes')!;
it('suma sus campos de usuario en dos pestañas nuevas, después de las del base', () => {
expect(screen.layout.tabs.map((t) => t.id)).toEqual(['general', 'stock', 'detalles', 'calidad', 'registro']);
const keys = masterKeysOf(screen.layout);
for (const k of ['U_SIS_ESTLOT', 'U_SIS_ESTLIB', 'U_SIS_FECAPROB', 'U_SIS_FECLIB', 'U_SIS_RESPAPROB', 'U_SIS_OBSER', 'U_SIS_REGSAN', 'U_INT_NUMPRO', 'U_SIS_ACTMUE', 'U_SIS_INFENS', 'U_SIS_PROCED', 'U_INT_UBIAPT']) {
expect(keys, k).toContain(k);
}
});
it('la hora de aprobación se ve pero no se edita (la pone el proceso de aprobación)', () => {
const hora = screen.layout.tabs.flatMap((t) => [...t.left, ...t.right]).find((f) => 'key' in f && f.key === 'U_SIS_HORAPROB');
expect(hora && 'readonly' in hora && hora.readonly).toBe(true);
});
it('la lista muestra el estado de calidad y de liberación con su texto', () => {
const cols = Object.fromEntries(screen.columns.map((c) => [c.key, c]));
expect(cols['U_SIS_ESTLOT'].format?.({ U_SIS_ESTLOT: '102' })).toBe('Aprobado');
expect(cols['U_SIS_ESTLIB'].format?.({ U_SIS_ESTLIB: '102' })).toBe('No liberado');
});
});
@@ -0,0 +1,73 @@
import { udfField } from '../../documents/document-layouts';
import type { MasterExtensions } from '../master-extensions';
/** Etiquetas de las columnas de la lista (el formulario lee los valores válidos de SAP): `U_SIS_ESTLOT`. */
const ESTADO_LOTE = [
{ value: '101', label: 'Cuarentena' },
{ value: '102', label: 'Aprobado' },
{ value: '103', label: 'Rechazado' },
{ value: '104', label: 'Inmovilizado' },
];
/** Etiquetas de la lista para `U_SIS_ESTLIB`. */
const ESTADO_LIBERACION = [
{ value: '101', label: 'Liberado' },
{ value: '102', label: 'No liberado' },
];
const label = (options: readonly { value: string; label: string }[], value: unknown): string => options.find((o) => o.value === String(value))?.label ?? String(value ?? '');
/**
* Campos de usuario de TAGUMEDICA en los lotes (`OBTN`, verificados en `CUFD`/`UFD1` de `SBO_TAGUMEDICA_21`). Solo
* están en esta rama; `main` lleva solo campos estándar.
*
* Notas verificadas en la base: `U_SIS_HORAPROB` guarda la hora como entero `hhmm` (1223 = 12:23) y la pone el proceso de
* aprobación, por eso va de solo lectura. Los estados y el registro sanitario salen de su definición en SAP (`udfField`:
* valores válidos y la tabla de usuario `@SIS_REGSAN` enlazada, 357 registros). Los
* campos `U_SIS_MOTEST`, `U_SIS_COMEST` y `U_SIS_ACCTOM` no son del lote sino de la solicitud de traslado (`OWTQ`).
*/
export const LOTES_EXTENSIONS: MasterExtensions = {
'/inventario/lotes': {
newTabs: [
{
id: 'calidad',
label: 'Calidad',
left: [
udfField('OBTN', 'SIS_ESTLOT', 'Estado del lote'),
{ type: 'date', key: 'U_SIS_FECAPROB', label: 'Fecha de aprobación' },
{ type: 'text', key: 'U_SIS_HORAPROB', label: 'Hora de aprobación (hhmm)', readonly: true },
{ type: 'text', key: 'U_SIS_RESPAPROB', label: 'Responsable de aprobación' },
],
right: [
udfField('OBTN', 'SIS_ESTLIB', 'Estado de liberación'),
{ type: 'date', key: 'U_SIS_FECLIB', label: 'Fecha de liberación' },
{ type: 'text', key: 'U_SIS_OBSER', label: 'Observaciones' },
],
},
{
id: 'registro',
label: 'Registro y ensayos',
left: [
udfField('OBTN', 'SIS_REGSAN', 'Registro sanitario'),
{ type: 'text', key: 'U_INT_NUMPRO', label: 'Número de protocolo' },
{ type: 'text', key: 'U_SIS_PROCED', label: 'Procedencia' },
],
right: [
{ type: 'text', key: 'U_SIS_ACTMUE', label: 'Acta de muestreo' },
{ type: 'text', key: 'U_SIS_INFENS', label: 'Informe de ensayo' },
{ type: 'text', key: 'U_INT_UBIAPT', label: 'Ubicación APT' },
],
},
],
listColumns: [
{
key: 'U_SIS_ESTLOT',
title: 'Calidad',
width: '110px',
format: (r) => label(ESTADO_LOTE, r['U_SIS_ESTLOT']),
tone: (r) => (r['U_SIS_ESTLOT'] === '102' ? 'ok' : r['U_SIS_ESTLOT'] === '101' ? 'info' : r['U_SIS_ESTLOT'] === '103' ? 'err' : 'warn'),
},
{ key: 'U_SIS_ESTLIB', title: 'Liberación', width: '110px', format: (r) => label(ESTADO_LIBERACION, r['U_SIS_ESTLIB']), tone: (r) => (r['U_SIS_ESTLIB'] === '101' ? 'ok' : 'muted') },
],
},
};
@@ -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' });
});
});
@@ -15,9 +15,10 @@ const EXT: MasterExtensions = {
};
describe('extensiones por cliente de los maestros', () => {
it('en main no agregan nada', () => {
expect(MASTER_EXTENSIONS).toEqual({});
expect(MASTER_SCREENS).toEqual(BASE_MASTER_SCREENS);
// En `main` esta prueba exige `{}` (el base no lleva campos de cliente); en la rama del cliente se lista lo que agrega.
it('en esta rama solo se extienden las pantallas del cliente', () => {
expect(Object.keys(MASTER_EXTENSIONS)).toEqual(['/inventario/lotes']);
expect(MASTER_SCREENS.filter((s) => s.path !== '/inventario/lotes')).toEqual(BASE_MASTER_SCREENS.filter((s) => s.path !== '/inventario/lotes'));
});
it('agregan campos al final de la cabecera y de las pestañas, una pestaña nueva y columnas de lista', () => {
@@ -1,5 +1,5 @@
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';
@@ -13,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')"
@@ -26,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()"
@@ -37,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()"
@@ -46,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()"
@@ -59,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()"
@@ -67,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()"
@@ -94,6 +104,12 @@ export class MasterField {
readonly saved = input(false);
readonly changed = output<string | number>();
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. */
@@ -101,7 +117,12 @@ export class MasterField {
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,6 +41,11 @@
}
</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"
@@ -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;
@@ -21,7 +21,7 @@ import type { MasterScreen } from './master-types';
[orderby]="screen.orderby"
[baseFilter]="screen.baseFilter"
[rowKey]="screen.keyField"
[createLabel]="screen.createLabel"
[createLabel]="screen.creatable === false ? '' : screen.createLabel"
(open)="open($event)"
(create)="create()"
/>
@@ -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';
@@ -39,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;
};
}
/**
@@ -67,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`). */
@@ -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 });
});
});
@@ -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() });
+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';
@@ -33,6 +33,7 @@ const NAMES = new Map<string, string>();
template: `
<sbo-field
[label]="label()"
[dbName]="dbName()"
[required]="required()"
[error]="error() || missingText()"
action
+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);
}
}
@@ -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);
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more