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
106 changed files with 4500 additions and 173 deletions

No files matched your search

+28 -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
@@ -58,6 +58,26 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): ejecuta consultas **registradas** de solo lectura directamente en la base de SAP, para lo que el Service Layer no da bien (por ejemplo, el stock de un lote por almacén o el almacén de origen de un traslado). Hoy el motor es **HANA** (`hdb`, JavaScript puro); la interfaz `SapDbAdapter` deja listo sumar **MSSQL** (SAP10 de pruebas) con otro adaptador, y cada consulta declara su SQL por motor. En `main` solo existe `ping`; **las consultas de negocio van en la rama de cada cliente** (`sapdb.extensions.ts`, vacío en `main`) porque dependen de sus tablas y campos de usuario. El frontend nunca manda SQL: pide la consulta por nombre con parámetros tipados (`POST /api/sapdb/queries/:name`); el SQL usa `:nombre` y el servidor lo cambia por `?` (nunca se concatena). Al arrancar se rechaza una consulta que no sea un único `SELECT`/`WITH`. Cola propia (`SAPDB_MAX_CONCURRENCY` 2), tope de filas y tiempo máximo; auditoría `db_query` (nombres, no valores). Variables `SAPDB_*` en el `.env`. Verificado contra `SBO_TAGUMEDICA_21` (conexión, esquema y parámetro). **Aviso:** el `.env` del mini-erp usa el usuario `SYSTEM` de HANA (superusuario): conviene un usuario de la base que solo pueda leer; el módulo igualmente solo ejecuta los `SELECT` registrados.
## Reportes HTML (plantillas por cliente)
Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente define como plantillas **Nunjucks** (la sintaxis de Jinja2: `{{ x }}`, `{% for %}`) rellenadas con datos de la base de SAP y que se **imprimen desde el navegador**. El motor es genérico y vive en `main` (`apps/api/src/htmlreports/`); los reportes los declara la rama del cliente en `html-reports.extensions.ts` (vacío en `main`) y sus plantillas van en `apps/api/templates/<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.
@@ -73,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 `--`).
@@ -82,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).
@@ -95,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; 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.
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.
+9
View File
@@ -63,3 +63,12 @@ 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
+14
View File
@@ -118,3 +118,17 @@ Algunas pantallas necesitan datos que el Service Layer no entrega bien (por ejem
- **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.
+2
View File
@@ -30,6 +30,7 @@
"@nestjs/typeorm": "^12.0.2",
"better-sqlite3": "^12.11.1",
"hdb": "^0.19.12",
"nunjucks": "^3.2.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^1.1.2",
@@ -44,6 +45,7 @@
"@types/better-sqlite3": "^9.6.0",
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"@types/nunjucks": "^3.2.6",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0",
+2 -1
View File
@@ -4,6 +4,7 @@ import { AuthModule } from './auth/auth.module.js';
import { AppConfigModule } from './config/config.module.js';
import { DatabaseModule } from './database/database.module.js';
import { GatewayModule } from './gateway/gateway.module.js';
import { HtmlReportsModule } from './htmlreports/html-reports.module.js';
import { SapDbModule } from './sapdb/sapdb.module.js';
import { HealthController } from './health/health.controller.js';
import { PermissionsModule } from './permissions/permissions.module.js';
@@ -11,7 +12,7 @@ import { SapModule } from './sap/sap.module.js';
import { SlModule } from './sl/sl.module.js';
@Module({
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule],
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule, HtmlReportsModule],
controllers: [HealthController],
})
export class AppModule {}
+7
View File
@@ -88,6 +88,13 @@ export const envSchema = z
SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
// Tope de filas de cualquier consulta; lo que pase se recorta.
SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000),
// Carpeta de las plantillas de los reportes HTML (Nunjucks), relativa a apps/api.
HTML_REPORTS_DIR: z.string().min(1).default('./templates'),
// 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')
@@ -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[];
}
+18
View File
@@ -11,6 +11,24 @@ export const BASE_SAPDB_QUERIES: readonly SapDbQuery[] = [
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. */
+5
View File
@@ -18,6 +18,11 @@ export class SapDbController {
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(
@@ -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');
});
});
+67 -3
View File
@@ -1,7 +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 que agrega la rama de cada cliente (vacío en `main`): el SQL depende de sus tablas y de
* sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú.
* 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[] = [];
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,
},
];
+1
View File
@@ -11,6 +11,7 @@ import { SAPDB_ADAPTER, type SapDbAdapter } from './sapdb.types.js';
@Module({
imports: [AuditModule, PermissionsModule],
controllers: [SapDbController],
exports: [SapDbService],
providers: [
{
provide: SAPDB_ADAPTER,
+3 -1
View File
@@ -79,6 +79,8 @@ describe('SapDbService', () => {
});
it('lista las consultas disponibles', async () => {
expect(await make({ query } as SapDbAdapter).available(user)).toEqual([{ name: 'ping', title: expect.any(String) }]);
// 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']));
});
});
+33 -1
View File
@@ -1,10 +1,11 @@
import { BadGatewayException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException, NotImplementedException } from '@nestjs/common';
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';
@@ -52,6 +53,37 @@ export class SapDbService {
}
}
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);
+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);
}
+3 -1
View File
@@ -15,7 +15,9 @@
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key; let j = $index) {
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required">{{ c.title }}</th>
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required" [attr.title]="isSap(c.key) ? c.key : null">{{ c.title }}@if (names.show() && isSap(c.key)) {
<span class="db">{{ c.key }}</span>
}</th>
@if (hasDetails() && j === detailAt()) {
<th class="det">Lotes / Series</th>
}
@@ -12,6 +12,7 @@ import {
output,
signal,
} from '@angular/core';
import { FieldNames, isSapField } from '../../core/field-names';
import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
@@ -57,6 +58,8 @@ export class LinesGrid {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
protected readonly names = inject(FieldNames);
protected readonly isSap = isSapField;
constructor() {
// las celdas de código con texto propio (la cuenta con su formato) piden los textos de las líneas que se cargan
@@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, resource } from '@angular/core';
import { apiErrorMessage } from '../../core/api';
import { SapDbApi } from '../../core/sl/sapdb-api';
import type { DetailColumn } from './grid.types';
import { RowsTable } from './rows-table';
/** Tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (el stock de un lote por almacén). */
@Component({
selector: 'sbo-query-table',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RowsTable],
template: `
@if (result.error()) {
<p class="err" role="alert">{{ error() }}</p>
} @else if (result.isLoading()) {
<p class="hint">Consultando…</p>
} @else {
<sbo-rows-table [columns]="columns()" [rows]="rows()" [readonly]="true" [removable]="false" [hideEmptyBy]="hideEmptyBy() ?? null" [hideEmptyLabel]="hideEmptyLabel()" emptyText="Sin datos." />
@if (result.value()?.truncated) {
<p class="hint">Hay más filas de las que se muestran.</p>
}
}
`,
styles: `
:host { display: block; }
.hint { margin: 6px 0; color: var(--quiet); }
.err { margin: 6px 0; color: var(--err); }
`,
})
export class QueryTable {
private readonly api = inject(SapDbApi);
readonly query = input.required<string>();
readonly params = input.required<Record<string, unknown>>();
readonly columns = input.required<readonly DetailColumn[]>();
readonly hideEmptyBy = input<readonly string[]>();
readonly hideEmptyLabel = input('Ocultar las filas en 0');
/** La consulta y sus parámetros como texto: quien nos usa puede armar un objeto nuevo en cada ciclo y no por eso se vuelve a consultar. */
private readonly request = computed(() => JSON.stringify({ query: this.query(), params: this.params() }));
protected readonly result = resource({
params: () => this.request(),
loader: ({ params }) => {
const r = JSON.parse(params) as { query: string; params: Record<string, unknown> };
return this.api.run(r.query, r.params);
},
});
protected readonly rows = computed(() => (this.result.hasValue() ? this.result.value().rows : []));
protected readonly error = computed(() => apiErrorMessage(this.result.error()));
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal } from '@angular/core';
import { API_URL } from '../../core/api';
import { FieldNames, isSapField } from '../../core/field-names';
import { formatNumber, parseDecimal } from '../../core/format';
import { SlLookupApi } from '../../core/sl/sl-lookup';
import type { ListRow } from '../list/list.types';
@@ -31,7 +32,9 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
<tr>
<th class="rn">#</th>
@for (c of columns(); track c.key) {
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
<th [style.width]="c.width" [class.req]="c.required" [attr.title]="isSap(c.key) ? c.key : null">{{ c.title }}@if (fieldNames.show() && isSap(c.key)) {
<span class="db">{{ c.key }}</span>
}</th>
}
@if (canRemove()) {
<th class="del"></th>
@@ -145,6 +148,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
.menu button:hover { background: var(--btn-h); }
table { width: 100%; min-width: max-content; border-collapse: collapse; }
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
th .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
th.req::after { content: ' *'; color: var(--err); }
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); white-space: nowrap; }
td.ro { background: var(--field-ro); }
@@ -181,6 +185,8 @@ export class RowsTable {
return rows.filter((r) => keys.some((k) => Number(r[k]) !== 0 && Number.isFinite(Number(r[k]))));
});
protected readonly fieldNames = inject(FieldNames);
protected readonly isSap = isSapField;
private readonly lookupApi = inject(SlLookupApi);
private readonly http = inject(HttpClient);
/** Nombres de los catálogos de las columnas `nameOf`, por recurso. */
@@ -75,3 +75,10 @@ tr.none td {
width: 30px;
text-align: center;
}
th .db {
display: block;
font: 10.5px/1.2 var(--mono, monospace);
font-weight: 400;
color: var(--quiet);
}
+4 -1
View File
@@ -19,10 +19,13 @@
[style.text-align]="align(c)"
[class.pick]="searchable(c)"
[class.active]="c.key === activeColumnKey()"
[attr.title]="searchable(c) ? 'Buscar en esta columna' : null"
[attr.title]="(searchable(c) ? 'Buscar en esta columna' : '') + (searchable(c) && isSap(c.key) ? ' · ' : '') + (isSap(c.key) ? c.key : '') || null"
(click)="columnPicked.emit(c)"
>
{{ c.title }}
@if (names.show() && isSap(c.key)) {
<span class="db">{{ c.key }}</span>
}
</th>
}
</tr>
+5 -1
View File
@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { FieldNames, isSapField } from '../../core/field-names';
import { ColResize } from '../table/col-resize';
import { Badge } from '../window/badge';
import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type Tone } from './list.types';
@@ -15,6 +16,9 @@ import { type ListColumn, type ListRow, alignOf, cellText, isSearchable, type To
styleUrl: './data-table.css',
})
export class DataTable {
protected readonly names = inject(FieldNames);
protected readonly isSap = isSapField;
readonly columns = input.required<readonly ListColumn[]>();
readonly rows = input.required<readonly ListRow[]>();
/** Campo único de cada fila. */
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more