Aplicación web de Devolución a Proveedores para SAP Business One

Reemplaza al addon .NET Framework (SolucionLibros). Genera Devoluciones de mercancías
enlazadas a sus Entradas por compra a partir de un listado pegado desde Excel.

- libs/shared (@dp/shared): modelos y lógica de dominio pura (FIFO, consolidación por
  proveedor, parseo del portapapeles), portada del addon.
- apps/api (NestJS): cliente del Service Layer con cola por sesión, login con la sesión de
  SAP, metadatos (UDT/UDF/UDO) y botón Reconstruir, catálogos, planificaciones (pegar,
  procesar, reprocesar, marcar líneas, crear Devoluciones, cerrar), listado paginado con
  filtros y descarga de PDF de la Guía de despacho desde BES. Sirve también la web
  compilada, con HTTPS opcional.
- apps/web (Angular): login, Configuración → Metadatos y Planificaciones (listado,
  nueva, detalle).
- deploy/windows y scripts/publicar.mjs: despliegue como servicio de Windows con WinSW.

Verificado contra el Service Layer de SQL Server. Sin probar aún: SAP HANA y la
instalación del servicio en Windows (ver CLAUDE.md, sección "Sin verificar").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
georgeandClaude Sonnet 5.5 committed 2026-10-13 08:09:07 -05:00
commit 287b187c14
126 files changed
+20033

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.angular/
coverage/
*.tsbuildinfo
.env
.DS_Store
release/
+294
View File
@@ -0,0 +1,294 @@
# CLAUDE.md
Aplicación web **Devolución a Proveedores** para SAP Business One. Reemplaza al addon
.NET Framework (`../SolucionLibros/`), que **se abandona**: todo pasa a Angular + NestJS.
`../SolucionLibros/` queda solo como referencia de la lógica original — **no modificarlo**.
## Qué hace el proceso
Genera **Devoluciones de mercancías** (ORPD, ObjType 21) enlazadas a sus **Entradas de
mercancías por compra** (OPDN, ObjType 20), a partir de un listado de artículos y
cantidades pegado desde Excel. Cada ejecución se guarda en el UDO `LIB_ODEV`
(cabecera `@LIB_ODEV`, líneas `@LIB_DEV1`).
Pasos de la UI (decididos):
1. **Pegar**: ItemCode + cantidad desde Excel (con el botón *Pegar desde el portapapeles* o
`Ctrl/Cmd+V` en el cuadro). Se valida el stock (`OITW.OnHand`) del almacén elegido. No guarda.
2. **Procesar**: reemplaza a "Selección" del addon. Asignación FIFO contra líneas abiertas
de Entradas de mercancías **del almacén elegido**, ordenadas por `DocDate`. Genera filas
Padre (`F`, artículo) y Hijo (`S`, una por Entrada consumida). El usuario marca o desmarca
qué líneas Hijo devolver.
3. **Crear devoluciones**: agrupa las líneas marcadas sin `TargetEntry` por proveedor y
las parte en lotes de `U_PrintingLines` líneas (11 por defecto). Crea una Devolución por
lote y graba `U_TargetType`/`U_TargetEntry` en las líneas.
4. **Descargar PDF**: por cada Devolución, se pide el PDF de la Guía de despacho (DTE tipo 52)
al web service SOAP de BES, usando las credenciales de `@DTE_CONFC`. Opción de unir todos
los PDF en uno.
## Decisiones tomadas (no volver a discutir)
- Solo se crean **Devoluciones** (ObjType 21), no Solicitudes de devolución. Solo se cargan
las series de Devoluciones.
- `U_Status` de cabecera **eliminado**. El estado usa el `Status` estándar del UDO (`O`/`C`).
- Se usan `U_TipoDespacho` y `U_IndicadorTraslado` en `@LIB_ODEV`. Se envían a la Devolución
como `U_BFE_TipoDespacho` / `U_BFE_IndTraslado`.
- Cantidades y referencias son **enteros** (`db_Numeric`): `Quantity`, `OnHand`, `OpenQty`,
`AllocatedQty`, `BaseEntry`, `BaseLine`, `BaseRef`, `TargetEntry` y `U_Series`.
- Producción en **HANA**; ambiente de pruebas en **MSSQL**. Toda consulta SQL debe tener las
dos variantes, según `DB_TYPE`.
- Sin control de concurrencia: **solo puede haber una planificación abierta**. Para crear
una nueva, todas las anteriores tienen que estar cerradas.
- Después de Procesar, el usuario elige qué líneas devolver. **Una línea con `TargetEntry`
ya no se puede desmarcar** (ver `puedeCambiarSeleccion` en `libs/shared`).
- La planificación se **cierra a mano** (`POST /planificaciones/:id/cerrar`).
- **Reprocesar** (botón *Rehacer FIFO* del Detalle) solo vale para una planificación **abierta y sin
Devoluciones**. Las filas de un UDO no se pueden quitar por Service Layer, así que se crea una nueva
(heredando la cabecera) y, **después** de crearla, se cierra la anterior (si el cierre falla se avisa
para cerrarla a mano).
- **Crear devoluciones**: resultado **parcial por documento**. Cada Devolución se crea aparte; las
buenas graban `U_TargetType`/`U_TargetEntry`, las fallidas `U_ErrCode`/`U_ErrMsg` y se reintentan.
- **Consultas SQL** por `SQLQueries` del Service Layer (no hay conexión directa a la BD). La API
crea/actualiza `DP_Stock` y `DP_EntradasAbiertas` sola (variante según `DB_TYPE`). El analizador
SQL del Service Layer **no admite `FORMAT`** y SAP reescribe el `SqlText`: al cambiar el SQL hay
que subir `version` en `sql-queries.definitions.ts`. Los parámetros van entre comillas simples.
- **Listado** de planificaciones **paginado en SAP** (`$top/$skip/$inlinecount`, OData v3 del SL v1),
más nueva primero, con filtros por estado, almacén y rango de fechas. Por defecto 20 por página,
máximo 100. En la pantalla: botones **Atrás** / **Siguiente**; filtros y página quedan en la URL.
- **PDF**: no se usa el paquete `soap`; `bes-soap.ts` arma el sobre y lee la respuesta a mano y `BesClient`
lo envía con axios (SOAPAction `http://tempuri.org/Consulta_TXTDTE`). Credenciales y URL salen de
`U_DTE_CONFC` y **nunca** se registran ni se devuelven al navegador. Solo se consulta con el
`FolioNumber` de la Devolución: **si todavía no tiene folio no se llama a BES** y esa Devolución informa
"todavía no tiene folio" (el addon usaba el `DocNum` como respaldo; se descartó porque podría traer el DTE
de otro documento). Unir con `pdf-lib`; un fallo en una Devolución no impide las demás.
- Transporte en la Devolución (ORPD): `RutTransporte→U_BFE_RutTrasporte` (sic), `Patente→U_BFE_Patente`,
`RutChofer→U_BFE_RutChofer`, `NombreChofer→U_BFE_NombreChofer`. **ORPD no tiene UDF para fechas
ni hora de salida/llegada**: solo se guardan en `@LIB_ODEV`.
- **Despliegue en Windows**: un solo servicio. La API sirve también la web compilada (mismo origen: sin
CORS y con la cookie `SameSite=strict`), con **HTTPS directo en Node** (`HTTPS_PFX_PATH` o
`HTTPS_KEY_PATH`+`HTTPS_CERT_PATH`) y redirección opcional de HTTP (`HTTP_REDIRECT_PORT`). El servicio
lo administra **WinSW** (elegido sobre NSSM por su configuración en XML versionable); nginx no es
necesario. `npm run publicar` arma `release/devolucion-proveedores/` (API + web + solo dependencias de
producción, ~55 MB). Guía completa: `deploy/windows/DEPLOY.md`.
- **Logs**: la aplicación **no escribe logs a archivo**; usa el `Logger` de Nest por consola. En
producción WinSW captura la salida y la rota (10 MB × 8). Con `NO_COLOR=1` el log sale sin códigos de
color. Solo se registran errores 4xx/5xx, fallos o lentitud del Service Layer y eventos de negocio.
- Aplicación web **independiente**. Login con el usuario de SAP B1 vía **Login del Service Layer**.
- Metadatos (UDT/UDF/UDO) definidos en **JSON** con formato del Service Layer. La web tiene la
pantalla **Configuración → Metadatos** con el botón **Reconstruir**, y NestJS los crea.
Los cambios que borran datos (campo con tipo distinto o campo sobrante) solo se hacen con
autorización explícita.
- **Después de cada login** se verifica que existan las tablas, los campos y el UDO. Si
falta algo, se redirige a Configuración → Metadatos. El login tiene una casilla **"Verificar
los metadatos de SAP al ingresar"** (activa por defecto; se recuerda en `localStorage`).
Desactivada, el guard omite la verificación; Configuración → Metadatos siempre verifica.
## Estructura
```
devolucion-proveedores-web/ npm workspaces
├── libs/shared/ @dp/shared tipos + lógica de dominio pura (ESM, se compila a dist/)
│ └── src/
│ ├── models/ auth, metadata, planificacion, catalogos (catálogos, páginas, PDF)
│ └── domain/ portapapeles.ts (parseo Excel), fifo.ts (FIFO, consolidación)
├── apps/api/ NestJS 12 ESM ("type": "module"), imports con extensión .js
│ ├── .env(.example)
│ └── src/
│ ├── config/ configuration.ts (lee .env)
│ ├── service-layer/ ServiceLayerClient (axios, cola por sesión), SessionStore (memoria)
│ ├── auth/ login/logout/me/info, SessionGuard global + @Public()
│ ├── sql-queries/ SqlQueriesService + definiciones (DP_Stock, DP_EntradasAbiertas)
│ ├── catalogos/ almacenes, series, valores UDF
│ ├── planificaciones/ service, controller, dto, udo.mapper (puro, con tests)
│ ├── pdf/ bes-soap (puro), BesClient, PdfDteService (descarga y unión)
│ ├── metadata/ analyzer (puro), service, controller
│ │ └── definitions/ user-tables.json, user-fields.json, user-objects.json,
│ │ external-dependencies.json ← fuente de verdad
│ ├── health/
│ ├── http-exception.logger.ts filtro global: deja en el log todo error 4xx/5xx
│ ├── web-estatico.ts sirve el build de Angular, fallback a index.html, cabeceras de seguridad
│ └── https.ts lectura del certificado y redirección HTTP → HTTPS
├── scripts/publicar.mjs arma la carpeta de despliegue (release/, ignorada por git)
├── deploy/windows/ WinSW (plantilla XML), instalar/desinstalar-servicio.ps1, DEPLOY.md
└── apps/web/ Angular 22 standalone, signals, zoneless, CSS (no SCSS)
└── src/app/
├── core/ auth.service, metadata.service, planificaciones.service,
│ guards, auth.interceptor, http-error
├── layout/ shell.ts (barra y navegación), stepper.ts (4 pasos)
└── pages/
├── login/ con la casilla de verificar metadatos
├── metadatos/ Configuración → Metadatos
└── planificaciones/ listado (filtros + paginación) · nueva/ (pasos 1-2) · detalle/ (pasos 3-4)
```
### API (prefijo `/api`)
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/health` | público |
| GET | `/auth/info` | público: CompanyDB y DB_TYPE configurados |
| POST | `/auth/login` | `{userName, password}`: Login en el Service Layer y cookie httpOnly `dp_sid` |
| POST | `/auth/logout` | Logout en el Service Layer |
| GET | `/auth/me` | usuario de la sesión |
| GET | `/metadata/status` | compara las definiciones contra la base SAP |
| POST | `/metadata/rebuild` | `{permitirDestructivos: boolean}`: crea y corrige |
| GET | `/catalogos` | almacenes, series de Devoluciones (con la por defecto), valores de `BFE_TipoDespacho`/`BFE_IndTraslado` |
| GET | `/catalogos/almacenes` | solo almacenes (filtros del listado) |
| GET | `/planificaciones?status=&whsCode=&desde=&hasta=&pagina=&tamano=` | **una página** (`{items,total,pagina,tamano,totalPaginas}`), más nueva primero; filtros y salto en SAP. `tamano` máx. 100, por defecto 20 |
| GET | `/planificaciones/abierta` | la abierta (`{abierta: null}` si no hay; siempre JSON) |
| GET | `/planificaciones/:docEntry` | detalle: cabecera y líneas |
| POST | `/planificaciones/pegar` | `{whsCode, texto}`: valida Excel contra stock. No guarda |
| POST | `/planificaciones` | procesa: FIFO + guarda en `LIB_ODEV`. 409 si ya hay una abierta |
| PATCH | `/planificaciones/:id/cabecera` | serie, fecha, despacho, traslado, transporte |
| PATCH | `/planificaciones/:id/lineas/:lineId` | `{applied}`. 409 si la línea ya tiene `TargetEntry` |
| POST | `/planificaciones/:id/devoluciones` | crea las Devoluciones de las líneas marcadas |
| POST | `/planificaciones/:id/reprocesar` | rehace el FIFO: crea una planificación nueva y cierra la anterior. Solo si está **abierta y sin Devoluciones** (409 si no) |
| POST | `/planificaciones/:id/pdf` | `{unir?: boolean}`: pide a BES el PDF de la Guía de despacho (DTE 52) de cada Devolución; devuelve los archivos en base64 y el resultado por Devolución |
| POST | `/planificaciones/:id/cerrar` | cierra la planificación |
La sesión del Service Layer (`B1SESSION`/`ROUTEID`) vive en el servidor; el navegador solo
tiene `dp_sid`. `SessionStore` es en memoria: alcanza para una sola instancia de la API y **se
pierde al reiniciar la API** (hay que volver a iniciar sesión).
### Metadatos (definitions/*.json)
- **Tablas:** `LIB_ODEV` (bott_Document) y `LIB_DEV1` (bott_DocumentLines).
- **UDO:** `LIB_ODEV` (boud_Document), hija `LIB_DEV1`. CanFind/CanClose/CanCancel = tYES,
CanDelete = tNO, CanLog = tYES (`ALIB_ODEV`), sin formulario por defecto. Columnas de búsqueda:
`DocEntry`, `U_WhsCode`, `U_DocDate`.
- **Campos de `@LIB_ODEV`:** WhsCode, DocDate, Series (num), ObjType, PrintingLines (num),
TipoDespacho, IndicadorTraslado, RutTransporte, RutChofer, NombreChofer, FechaSalida,
HoraSalida (st_Time), FechaLlegada, Patente.
- **Campos de `@LIB_DEV1`:** LineType (F/S), ItemCode, ItemDescription, Quantity, OnHand, Status,
BaseType, BaseEntry, BaseLine, BaseRef, BaseDocDate, OpenQty, AllocatedQty, Applied (Y/N),
TargetType, TargetEntry, CardCode, CardName, ErrCode, ErrMsg.
- **Dependencias externas** (solo se verifican, no se crean): `OPDN.U_DOT_TipoRecepcion`,
`OINV.U_BFE_TipoDespacho`, `OINV.U_BFE_IndTraslado` y la tabla `@DTE_CONFC`.
## Preparar el entorno (otro computador)
1. **Node 22 LTS** o superior (`node -v`).
2. **Red con Fortinet** (inspección SSL): npm falla con `SELF_SIGNED_CERT_IN_CHAIN`.
Exportar antes de usar npm u ng:
```sh
export NODE_EXTRA_CA_CERTS="/ruta/a/fortinet-root.crt" # está en ../fortinet-root.crt
# alternativa, si el certificado está en el llavero de macOS:
export NODE_OPTIONS=--use-system-ca
export NG_CLI_ANALYTICS=false # si no, `ng` puede quedar esperando el prompt
```
3. Instalar y configurar:
```sh
npm install
cp apps/api/.env.example apps/api/.env # completar SL_URL, SL_COMPANY_DB, DB_TYPE
```
Variables del `.env` de la API: `PORT` (3000), `WEB_ORIGIN` (CORS), `SL_URL` (con `/b1s/v1`),
`SL_COMPANY_DB`, `SL_REJECT_UNAUTHORIZED` (`false` si el certificado es autofirmado), `DB_TYPE`
(`HANA`/`MSSQL`) y `COOKIE_SECURE` (`true` solo con HTTPS). **El usuario y la contraseña de SAP no van
en el `.env`**: se escriben en el login. Solo para producción: `HTTPS_PFX_PATH`/`HTTPS_PFX_PASSPHRASE` o
`HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`, `HTTP_REDIRECT_PORT` y `WEB_DIST_DIR` (opcional).
4. Desarrollo, en dos terminales:
```sh
npm run dev:api # compila @dp/shared y levanta NestJS en :3000
npm run dev:web # compila @dp/shared y levanta Angular en :4200 (proxy /api → :3000)
```
5. Verificación:
```sh
npm test # shared + api + web
npm run test:e2e -w api
npm run lint -w api
npm run build
npm run publicar # carpeta de despliegue en release/ (ver deploy/windows/DEPLOY.md)
```
Reglas:
- `@dp/shared` se consume **compilado** (`dist/`). Si se cambia, ejecutar `npm run build:shared`
**y reiniciar `ng serve`**: Vite sirve una copia vieja y la página falla con "does not provide an
export named ..." (los tests no lo detectan).
- En la API, los tipos que aparecen en firmas con decoradores se importan con `import type`
(por `isolatedModules` + `emitDecoratorMetadata`). Los DTO (clases) sí se importan como valor.
- Al cambiar código del backend con `start:dev` la API se reinicia y **se pierden las sesiones**.
- Los tests del frontend no usan zone.js: tras un `flush` hay que esperar con
`await new Promise((r) => setTimeout(r))` y luego `fixture.detectChanges()` (`whenStable` no basta).
- Para ver la UI sin la extensión de Chrome se puede abrir Chrome sin cabeza con
`--remote-debugging-port` y manejarlo por DevTools Protocol (`WebSocket` de Node 22).
## Estado al 2026-10-12
**Hecho y verificado contra el Service Layer real (SBOLibros, MSSQL):**
- Login, Metadatos y **Reconstruir** (creación de tablas, campos y UDO desde cero).
- `libs/shared`: modelos y dominio portado del .NET. 14 tests.
- `apps/api`: Service Layer, autenticación, metadatos, catálogos, planificaciones y PDF. 81 tests
unitarios + 6 e2e (incluye la cookie de sesión: `HttpOnly`, `Secure`, `SameSite=Strict`), lint limpio. Prueba de extremo a extremo manual OK: pegar → procesar (FIFO) →
cabecera (fecha/hora) → marcar/desmarcar → crear Devolución (ORPD con base en OPDN) → `TargetEntry`
grabado → cerrar. **Varios proveedores** OK contra SAP (una planificación con 4 líneas generó 3
Devoluciones, con un proveedor reuniendo dos líneas). Las reglas 409 (una sola abierta, línea con
destino, cerrada) OK. **Listado
paginado y filtrado** (total, saltos, estado/almacén/fechas) OK. **PDF** contra BES (certificación)
con 4 Devoluciones reales: individuales y unificado (3 páginas) OK.
- `apps/web`: login, guards, Metadatos y Planificaciones completas (listado con filtros y paginación,
Nueva, Detalle con crear devoluciones, Rehacer FIFO, PDF y cerrar). 38 tests. Verificado en Chrome
real (sin cabeza): login, listado con filtros, selector de almacenes, validación de pegado y la
descarga de PDF del Detalle.
**Aprendido del Service Layer real:**
- **Una sesión no admite peticiones simultáneas**: las procesa en serie y cada una espera ~7 s tras
la anterior (seis en paralelo = ~40 s; una a una = ~40 ms). `ServiceLayerClient.request` las
**encola por sesión**; no lo saltes con otro cliente HTTP. Avisa en el log si una tarda > 2 s.
- `CanFind = tYES` exige `UserObjectMD_FindColumns`; sin ellas SAP lo deja en `tNO` en silencio.
- `GET UserFieldsMD` informa los campos de hora como `db_Numeric/st_Time` (el analizador lo normaliza).
- `PATCH` de `LIB_DEV1Collection` actualiza las filas por `LineId` sin tocar las demás; **no se pueden
quitar filas** de un UDO.
- Los campos numéricos sin llenar del UDO vuelven como `0`: el mapeo los trata como `null`.
- `POST /LIB_ODEV(n)/Close` cierra el UDO. El UDO no se puede borrar (`CanDelete = tNO`).
- Si no hay resultado, el Service Layer responde cuerpo vacío: por eso `/planificaciones/abierta`
devuelve siempre un JSON (`{abierta: null}`).
**Sin verificar:**
- **HANA**: el addon .NET ya funcionaba en HANA, y las consultas son un port de las suyas, así que
su lógica está probada. Lo que no se ha probado es **cómo las recibe el Service Layer de HANA**
(`SQLQueries`: su analizador es más estricto que el `DoQuery` del addon; en MSSQL rechazó `FORMAT`) y los
dos cambios respecto al addon: el filtro por almacén (`PDN1.WhsCode`) y los parámetros con nombre
(`:item`, `:whs`). Tampoco el paginado (`$inlinecount`/`odata.count` es de `/b1s/v1`; en `/b1s/v2` sería
`$count`) ni los tipos que HANA informa en los metadatos.
- **Reprocesar** solo con tests (no contra SAP).
- Fallo parcial al crear Devoluciones (solo se probó el caso feliz). El corte en lotes de
`U_PrintingLines` líneas está probado con tests del algoritmo portado del addon, no contra SAP.
- El `PATCH` de Reconstruir sobre un UDO ya existente con columnas de búsqueda.
- Paginación con varias páginas en la interfaz (hoy hay pocas planificaciones; sí está probada con la
API real y con tests de la pantalla).
- Recorrido con clics de **Procesar** en un navegador real.
- **PDF en la base de pruebas**: las Devoluciones de `SBOLibros` no tienen `FolioNum`, así que ahora su
descarga informa el error de folio. El PDF contra BES se probó antes del cambio, usando el `DocNum`.
Para repetirlo hace falta una Devolución con folio (la que emite el addon de facturación electrónica).
- **Despliegue en Windows**: la carpeta publicada se probó en un Mac (API con HTTPS autofirmado, web
servida por la API, rutas de Angular, 404, cabeceras, redirección 301, log sin colores). **No se ha
ejecutado en Windows**: los `.ps1` solo pasaron la comprobación de sintaxis y la generación del XML de
WinSW; falta instalar el servicio de verdad. Las banderas de la cookie de sesión están comprobadas en un
test e2e; solo falta verlo en un navegador real sobre HTTPS (iniciar sesión en el sitio desplegado).
- **Estilos:** son CSS porque, en el Mac con macOS 12, el compilador nativo de Sass exige
macOS 14 o superior. En Sequoia se puede volver a SCSS si se prefiere.
## Siguientes pasos
1. **Validar en HANA** (ver "Sin verificar"): `DB_TYPE=HANA`, Reconstruir, repetir la prueba de extremo a extremo.
2. Probar los casos sin verificar (fallo parcial y reprocesar contra SAP).
3. **Probar el despliegue en un servidor Windows** (de pruebas) siguiendo `deploy/windows/DEPLOY.md`. El
botón de pegar desde el portapapeles solo funciona en `localhost` o HTTPS.
4. Archivar `../SolucionLibros/` cuando HANA esté validado (ninguna carpeta está bajo `git`).
## Preguntas abiertas para el usuario
1. ¿Se agregan UDF de fecha/hora de salida y llegada a la Devolución (ORPD)? Hoy no existen.
2. Tamaño de `U_TipoDespacho`/`U_IndicadorTraslado` en `@LIB_ODEV` (10) frente a ORPD (1 y 2).
## Referencias en el addon .NET (`../SolucionLibros/DevolucionProveedores/`)
Ya está todo portado; sirve para comparar resultados (sobre todo al validar en HANA).
- `FormularioDevolucionProveedores.Negocio.cs`: parseo, consultas OnHand/FIFO, consolidación.
- `FormularioDevolucionProveedores.Botones.cs`: Crear devoluciones (BOM y `Add`), descarga de PDF.
- `FormularioDevolucionProveedores.Bom.cs`: guardado del UDO (`GeneralService`) y BOM de la Devolución.
- `WebServices/ConsultaDTE.cs`, `WebServices/PdfUtils.cs`: SOAP BES y unión de PDF.
- `ESPECIFICACION.md`: especificación original del proceso.
- `bin/x64/Debug/UserFields.xml` (UTF-16): versión XML anterior de los metadatos, ya
reemplazada por los JSON de `apps/api/src/metadata/definitions/`.
+37
View File
@@ -0,0 +1,37 @@
# Puerto de la API NestJS
PORT=3000
# Origen del frontend Angular (CORS). En desarrollo ng serve usa proxy, no hace falta.
WEB_ORIGIN=http://localhost:4200
# Service Layer de SAP Business One (con /b1s/v1 al final)
SL_URL=https://servidor-sap:50000/b1s/v1
# Base de datos de la sociedad (CompanyDB del Login)
SL_COMPANY_DB=SBODEMOCL
# false si el Service Layer usa un certificado autofirmado
SL_REJECT_UNAUTHORIZED=false
# Motor de la base de datos: HANA (producción) o MSSQL (pruebas)
DB_TYPE=HANA
# true cuando la API se publica por HTTPS (cookie de sesión con Secure)
COOKIE_SECURE=false
# ---------------------------------------------------------------------------------------------
# Despliegue (producción en Windows). En desarrollo se dejan vacías.
# ---------------------------------------------------------------------------------------------
# La API sirve también la web compilada. Si se omite, usa apps/web/dist/web/browser cuando existe.
# En la carpeta publicada (release) ya queda en su sitio y no hace falta configurarla.
# WEB_DIST_DIR=
# HTTPS directo en la API. Use UNA de estas dos formas (las rutas son absolutas).
# Con un .pfx:
# HTTPS_PFX_PATH=C:\apps\devolucion-proveedores\certificados\sitio.pfx
# HTTPS_PFX_PASSPHRASE=
# O con clave privada y certificado en PEM:
# HTTPS_KEY_PATH=C:\apps\devolucion-proveedores\certificados\sitio.key
# HTTPS_CERT_PATH=C:\apps\devolucion-proveedores\certificados\sitio.crt
# Con HTTPS: puerto HTTP que redirige al HTTPS (normalmente 80). Vacío = no se abre.
# HTTP_REDIRECT_PORT=80
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "error"
},
"env": {
"node": true
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+138
View File
@@ -0,0 +1,138 @@
# API — Devolución a Proveedores
Backend en **NestJS 12** (ESM) de la aplicación Devolución a Proveedores para SAP Business One.
No tiene base de datos propia: habla con el **Service Layer** de SAP B1 en nombre del usuario que
inició sesión, y guarda cada ejecución en el UDO `LIB_ODEV`.
La documentación general del proyecto (decisiones, flujo, estado) está en
[`../../CLAUDE.md`](../../CLAUDE.md). Este archivo cubre solo cómo ejecutar y mantener la API.
## Requisitos
- Node 22 LTS o superior.
- Acceso de red al Service Layer de SAP B1 (por defecto `https://servidor:50000/b1s/v1`).
- Red con Fortinet (inspección SSL): exportar `NODE_OPTIONS=--use-system-ca` (o `NODE_EXTRA_CA_CERTS`)
antes de usar `npm`.
## Puesta en marcha
Desde la raíz del monorepo (`devolucion-proveedores-web/`):
```bash
npm install
cp apps/api/.env.example apps/api/.env # y completar los valores
npm run dev:api # compila @dp/shared y levanta la API en :3000
```
### Variables de `.env`
| Variable | Descripción |
|---|---|
| `PORT` | Puerto de la API (3000). |
| `WEB_ORIGIN` | Origen del frontend para CORS. En desarrollo `ng serve` usa proxy y no hace falta. |
| `SL_URL` | URL del Service Layer, **con `/b1s/v1` al final**. |
| `SL_COMPANY_DB` | Base de la sociedad (CompanyDB del Login). |
| `SL_REJECT_UNAUTHORIZED` | `false` si el Service Layer usa un certificado autofirmado. |
| `DB_TYPE` | `HANA` (producción) o `MSSQL` (pruebas). Elige la variante de cada consulta SQL. |
| `COOKIE_SECURE` | `true` solo cuando la API se publica por HTTPS. |
| `HTTPS_PFX_PATH` / `HTTPS_PFX_PASSPHRASE` | Solo producción: certificado `.pfx` y su clave. La API escucha por HTTPS. |
| `HTTPS_KEY_PATH` / `HTTPS_CERT_PATH` | Alternativa al `.pfx`: clave privada y certificado en PEM (no mezclar con el `.pfx`). |
| `HTTP_REDIRECT_PORT` | Con HTTPS: puerto HTTP que redirige (301) al HTTPS, normalmente 80. |
| `WEB_DIST_DIR` | Carpeta con la web compilada. Por defecto `apps/web/dist/web/browser` si existe. |
El **usuario y la contraseña de SAP no se configuran aquí**: se escriben en la pantalla de login y se
usan para el `Login` del Service Layer. La API reinicia sin conservar sesiones.
## Scripts
Se ejecutan con `npm run <script> -w api` desde la raíz, o directamente desde `apps/api`.
| Script | Qué hace |
|---|---|
| `start:dev` | Modo watch (recompila y reinicia; **se pierden las sesiones** en cada reinicio). |
| `build` / `start:prod` | Compila a `dist/` y ejecuta `node dist/main`. |
| `test` | Tests unitarios (Vitest). |
| `test:e2e` | Tests e2e (`test/`). |
| `test:cov` | Cobertura. |
| `lint` | `oxlint --type-aware src/ test/`. |
## Endpoints (prefijo `/api`)
Todos exigen sesión (cookie `dp_sid`) salvo `/health`, `/auth/info`, `/auth/login` y `/auth/logout`.
| Área | Rutas |
|---|---|
| Salud y sesión | `GET /health` · `GET /auth/info` · `POST /auth/login` · `POST /auth/logout` · `GET /auth/me` |
| Metadatos | `GET /metadata/status` · `POST /metadata/rebuild` |
| Catálogos | `GET /catalogos` · `GET /catalogos/almacenes` |
| Planificaciones | `GET /planificaciones` (paginado, con filtros) · `GET /planificaciones/abierta` · `GET /planificaciones/:id` |
| | `POST /planificaciones/pegar` · `POST /planificaciones` · `PATCH /planificaciones/:id/cabecera` · `PATCH /planificaciones/:id/lineas/:lineId` |
| | `POST /planificaciones/:id/devoluciones` · `POST /planificaciones/:id/reprocesar` · `POST /planificaciones/:id/cerrar` |
| PDF | `POST /planificaciones/:id/pdf` |
El detalle de cada ruta (parámetros y reglas) está en la tabla de [`CLAUDE.md`](../../CLAUDE.md#api-prefijo-api).
Ejemplo del listado: `GET /api/planificaciones?status=C&whsCode=01&desde=2026-10-01&pagina=2&tamano=20`.
## Producción
En producción la API **sirve también la web compilada** (si existe `apps/web/dist/web/browser`): todo queda
en el mismo origen y en un solo puerto. Las rutas de Angular (sin extensión y fuera de `/api`) devuelven
`index.html`; los archivos con hash se cachean un año; se agregan cabeceras de seguridad (y HSTS con HTTPS).
```bash
npm run publicar # desde la raíz: arma release/devolucion-proveedores/
```
Instalación en Windows (servicio con WinSW, HTTPS, firewall, actualizaciones): [`../../deploy/windows/DEPLOY.md`](../../deploy/windows/DEPLOY.md).
**Logs:** la API no escribe archivos; usa el `Logger` de Nest por consola. Como servicio, WinSW captura
esa salida y la rota. Con `NO_COLOR=1` el log sale sin códigos de color.
## Estructura
```
src/
├── config/ lectura y validación del .env
├── service-layer/ ServiceLayerClient (axios) y SessionStore (en memoria)
├── auth/ login/logout/me, SessionGuard global y @Public()
├── sql-queries/ consultas guardadas (SQLQueries) con variantes HANA y MSSQL
├── catalogos/ almacenes, series y valores válidos de los UDF
├── planificaciones/ FIFO, UDO LIB_ODEV, Devoluciones, reprocesar y listado
├── pdf/ SOAP de BES (Consulta_TXTDTE) y unión de PDF
├── metadata/ definiciones JSON de tablas/campos/UDO y su reconstrucción
├── health/
├── http-exception.logger.ts registra en el log todo error 4xx/5xx
├── web-estatico.ts sirve la web compilada, fallback a index.html y cabeceras de seguridad
└── https.ts lee el certificado y redirige HTTP → HTTPS
```
La lógica de dominio pura (FIFO, consolidación por proveedor, parseo del texto de Excel) vive en
`libs/shared` (`@dp/shared`), que se consume **compilada** (`dist/`): tras cambiarla,
`npm run build:shared`.
## Cosas que conviene saber
- **Una sesión del Service Layer no admite peticiones simultáneas**: las procesa en serie y cada una
espera unos 7 s. `ServiceLayerClient.request` las encola por sesión; no uses otro cliente HTTP para
hablar con SAP. Una petición que tarde más de 2 s queda avisada en el log.
- **Consultas SQL**: se guardan en SAP como `SQLQueries` (`DP_Stock`, `DP_EntradasAbiertas`) y la API las
crea o actualiza sola. SAP reescribe el SQL al guardarlo, por lo que **al modificar una consulta hay
que subir su `version`** en `sql-queries/sql-queries.definitions.ts`. El analizador SQL no admite
funciones como `FORMAT`; los parámetros van entre comillas simples.
- **Credenciales del web service DTE** (`@DTE_CONFC`): se leen en cada descarga de PDF, no se registran
en el log y no se devuelven al navegador.
- **Metadatos**: la fuente de verdad son los JSON de `src/metadata/definitions/`. *Reconstruir* crea lo
que falta; borrar y recrear un campo solo ocurre con autorización explícita.
- Los tipos que aparecen en firmas con decoradores se importan con `import type` (por
`isolatedModules` + `emitDecoratorMetadata`); los DTO, que son clases, se importan como valor.
- **HANA**: las variantes HANA de las consultas están escritas pero no se han ejecutado todavía.
## Diagnóstico rápido
| Síntoma | Causa probable |
|---|---|
| `No se pudo conectar con el Service Layer (EHOSTUNREACH)` | Proceso de la API con un estado de red viejo (cambio de red o VPN). Reiniciarla. |
| 401 `Debe iniciar sesión` tras tocar el código | La API se reinició y perdió las sesiones. Volver a iniciar sesión. |
| Respuestas de 20-40 s | Peticiones en paralelo sobre una misma sesión (ver arriba). Mirar los avisos `Petición lenta` del log. |
| `Invalid SQL syntax ... Cannot support this function` | El analizador SQL del Service Layer rechazó una función en una consulta guardada. |
| `SELF_SIGNED_CERT_IN_CHAIN` al instalar | Falta `NODE_OPTIONS=--use-system-ca` o `NODE_EXTRA_CA_CERTS`. |
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "oxlint --type-aware src/ test/",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@dp/shared": "*",
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1",
"axios": "^1.20.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"pdf-lib": "^1.17.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^5.0.3",
"oxlint": "^1.58.0",
"oxlint-tsgolint": "^7.0.2001",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"typescript": "^6.0.2",
"vitest": "^5.0.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CatalogosModule } from './catalogos/catalogos.module.js';
import { AuthModule } from './auth/auth.module.js';
import configuration from './config/configuration.js';
import { HealthController } from './health/health.controller.js';
import { MetadataModule } from './metadata/metadata.module.js';
import { PdfModule } from './pdf/pdf.module.js';
import { PlanificacionesModule } from './planificaciones/planificaciones.module.js';
import { ServiceLayerModule } from './service-layer/service-layer.module.js';
import { SqlQueriesModule } from './sql-queries/sql-queries.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
ServiceLayerModule,
SqlQueriesModule,
AuthModule,
MetadataModule,
CatalogosModule,
PlanificacionesModule,
PdfModule,
],
controllers: [HealthController],
})
export class AppModule {}
+64
View File
@@ -0,0 +1,64 @@
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { InfoConexion, UsuarioSesion } from '@dp/shared';
import type { Request, Response } from 'express';
import { AppConfig } from '../config/configuration.js';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import { SessionStore } from '../service-layer/session.store.js';
import type { SapSession } from '../service-layer/session.store.js';
import { LoginDto } from './dto/login.dto.js';
import { CurrentSession, Public, SESSION_COOKIE } from './session.guard.js';
function aUsuario(sesion: SapSession): UsuarioSesion {
return { userName: sesion.userName, companyDb: sesion.companyDb, version: sesion.version };
}
@Controller('auth')
export class AuthController {
constructor(
private readonly serviceLayer: ServiceLayerClient,
private readonly sessions: SessionStore,
private readonly config: ConfigService<AppConfig, true>,
) {}
@Public()
@Get('info')
info(): InfoConexion {
return {
companyDb: this.config.get('serviceLayer', { infer: true }).companyDb,
dbType: this.config.get('dbType', { infer: true }),
};
}
@Public()
@Post('login')
@HttpCode(200)
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response): Promise<UsuarioSesion> {
const sesion = await this.serviceLayer.login(dto.userName, dto.password);
res.cookie(SESSION_COOKIE, sesion.id, {
httpOnly: true,
sameSite: 'strict',
secure: this.config.get('cookieSecure', { infer: true }),
path: '/',
});
return aUsuario(sesion);
}
@Public()
@Post('logout')
@HttpCode(204)
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
const sesion = this.sessions.obtener(req.cookies?.[SESSION_COOKIE] as string | undefined);
if (sesion) {
await this.serviceLayer.logout(sesion);
}
res.clearCookie(SESSION_COOKIE, { path: '/' });
}
@Get('me')
me(@CurrentSession() sesion: SapSession): UsuarioSesion {
return aUsuario(sesion);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller.js';
import { SessionGuard } from './session.guard.js';
@Module({
controllers: [AuthController],
providers: [{ provide: APP_GUARD, useClass: SessionGuard }],
})
export class AuthModule {}
+12
View File
@@ -0,0 +1,12 @@
import { LoginRequest } from '@dp/shared';
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto implements LoginRequest {
@IsString()
@IsNotEmpty()
userName: string;
@IsString()
@IsNotEmpty()
password: string;
}
+42
View File
@@ -0,0 +1,42 @@
import { CanActivate, ExecutionContext, Injectable, SetMetadata, UnauthorizedException, createParamDecorator } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { SapSession, SessionStore } from '../service-layer/session.store.js';
export const SESSION_COOKIE = 'dp_sid';
const IS_PUBLIC = 'isPublic';
/** Marca un endpoint como accesible sin sesión de SAP. */
export const Public = () => SetMetadata(IS_PUBLIC, true);
type RequestConSesion = Request & { sapSession?: SapSession };
/** Inyecta la sesión del Service Layer del usuario que hace la petición. */
export const CurrentSession = createParamDecorator((_: unknown, ctx: ExecutionContext): SapSession => {
return ctx.switchToHttp().getRequest<RequestConSesion>().sapSession!;
});
/** Guard global: exige la cookie de sesión salvo en endpoints marcados con @Public(). */
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionStore,
) {}
canActivate(context: ExecutionContext): boolean {
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [context.getHandler(), context.getClass()])) {
return true;
}
const request = context.switchToHttp().getRequest<RequestConSesion>();
const sesion = this.sessions.obtener(request.cookies?.[SESSION_COOKIE] as string | undefined);
if (!sesion) {
throw new UnauthorizedException('Debe iniciar sesión.');
}
request.sapSession = sesion;
return true;
}
}
@@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import type { Almacen, CatalogosDevolucion } from '@dp/shared';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CatalogosService } from './catalogos.service.js';
@Controller('catalogos')
export class CatalogosController {
constructor(private readonly catalogos: CatalogosService) {}
/** Solo los almacenes: más liviano que el catálogo completo (para filtros). */
@Get('almacenes')
almacenes(@CurrentSession() sesion: SapSession): Promise<Almacen[]> {
return this.catalogos.almacenes(sesion);
}
/** Almacenes, series de Devoluciones y valores válidos de despacho/traslado. */
@Get()
todos(@CurrentSession() sesion: SapSession): Promise<CatalogosDevolucion> {
return this.catalogos.todos(sesion);
}
}
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { CatalogosController } from './catalogos.controller.js';
import { CatalogosService } from './catalogos.service.js';
@Module({ controllers: [CatalogosController], providers: [CatalogosService], exports: [CatalogosService] })
export class CatalogosModule {}
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import type { Almacen, CatalogosDevolucion, OpcionUdf, SerieDocumento } from '@dp/shared';
import { OBJ_TYPE_DEVOLUCION } from '@dp/shared';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
interface SlAlmacen {
WarehouseCode: string;
WarehouseName: string;
}
interface SlSerie {
Series: number;
Name: string;
NextNumber: number | null;
Locked: string;
}
interface SlUdf {
ValidValuesMD?: { Value: string; Description: string }[];
}
const DOCUMENTO = { DocumentTypeParams: { Document: String(OBJ_TYPE_DEVOLUCION) } };
@Injectable()
export class CatalogosService {
constructor(private readonly sl: ServiceLayerClient) {}
async todos(sesion: SapSession): Promise<CatalogosDevolucion> {
const [almacenes, series, tiposDespacho, indicadoresTraslado] = await Promise.all([
this.almacenes(sesion),
this.series(sesion),
this.valoresUdf(sesion, 'BFE_TipoDespacho'),
this.valoresUdf(sesion, 'BFE_IndTraslado'),
]);
return { almacenes, series, tiposDespacho, indicadoresTraslado };
}
async almacenes(sesion: SapSession): Promise<Almacen[]> {
const filas = await this.sl.getAll<SlAlmacen>(
sesion,
`Warehouses?$select=WarehouseCode,WarehouseName&$filter=${encodeURIComponent("Inactive eq 'tNO'")}&$orderby=WarehouseCode`,
);
return filas.map((a) => ({ code: a.WarehouseCode, name: a.WarehouseName }));
}
/** Solo las series de Devoluciones (ObjType 21) que no están bloqueadas. */
async series(sesion: SapSession): Promise<SerieDocumento[]> {
const [lista, porDefecto] = await Promise.all([
this.sl.post<{ value: SlSerie[] }>(sesion, 'SeriesService_GetDocumentSeries', DOCUMENTO),
this.sl.post<{ Series: number }>(sesion, 'SeriesService_GetDefaultSeries', DOCUMENTO).catch(() => undefined),
]);
return lista.value
.filter((s) => s.Locked !== 'tYES')
.map((s) => ({ series: s.Series, name: s.Name, nextNumber: s.NextNumber, esPorDefecto: s.Series === porDefecto?.Series }));
}
/** Valores válidos de un UDF de la Devolución (ORPD). */
async valoresUdf(sesion: SapSession, campo: string): Promise<OpcionUdf[]> {
const filtro = encodeURIComponent(`TableName eq 'ORPD' and Name eq '${campo}'`);
const [udf] = await this.sl.getAll<SlUdf>(sesion, `UserFieldsMD?$filter=${filtro}`);
return (udf?.ValidValuesMD ?? []).map((v) => ({ value: v.Value, description: v.Description }));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
/** Rutas de los archivos de certificado (se leen al arrancar). Con `pfx` o con `key` + `cert`. */
export interface HttpsConfig {
pfxPath?: string;
passphrase?: string;
keyPath?: string;
certPath?: string;
}
export interface AppConfig {
port: number;
webOrigin: string;
serviceLayer: {
url: string;
companyDb: string;
rejectUnauthorized: boolean;
};
dbType: 'HANA' | 'MSSQL';
cookieSecure: boolean;
/** Carpeta con el build de Angular que la API sirve; null si no existe (desarrollo con `ng serve`). */
webDistDir: string | null;
/** null = la API escucha en HTTP. */
https: HttpsConfig | null;
/** Si hay HTTPS: puerto HTTP que redirige al HTTPS (por ejemplo 80). null = no se abre. */
httpRedirectPort: number | null;
}
const vacio = (v: string | undefined): string | undefined => (v && v.trim() !== '' ? v.trim() : undefined);
function leerHttps(): HttpsConfig | null {
const pfxPath = vacio(process.env.HTTPS_PFX_PATH);
const keyPath = vacio(process.env.HTTPS_KEY_PATH);
const certPath = vacio(process.env.HTTPS_CERT_PATH);
if (!pfxPath && !keyPath && !certPath) return null;
if (pfxPath && (keyPath || certPath)) {
throw new Error('Configure el certificado con HTTPS_PFX_PATH o con HTTPS_KEY_PATH + HTTPS_CERT_PATH, no con ambos.');
}
if (!pfxPath && !(keyPath && certPath)) {
throw new Error('Falta HTTPS_KEY_PATH o HTTPS_CERT_PATH: ambos son necesarios.');
}
return { pfxPath, passphrase: vacio(process.env.HTTPS_PFX_PASSPHRASE), keyPath, certPath };
}
/** WEB_DIST_DIR, o el build de Angular junto a la API (apps/web/dist/web/browser) si existe. */
function carpetaWeb(): string | null {
const configurada = vacio(process.env.WEB_DIST_DIR);
const ruta = configurada ? resolve(configurada) : resolve(import.meta.dirname, '../../../web/dist/web/browser');
if (existsSync(resolve(ruta, 'index.html'))) return ruta;
if (configurada) throw new Error(`WEB_DIST_DIR no contiene un index.html: ${ruta}`);
return null;
}
export default (): AppConfig => {
const dbType = (process.env.DB_TYPE ?? 'HANA').toUpperCase();
if (dbType !== 'HANA' && dbType !== 'MSSQL') {
throw new Error(`DB_TYPE inválido: ${dbType}. Valores permitidos: HANA, MSSQL.`);
}
const url = process.env.SL_URL;
const companyDb = process.env.SL_COMPANY_DB;
if (!url || !companyDb) {
throw new Error('Faltan SL_URL y/o SL_COMPANY_DB en la configuración (.env).');
}
return {
port: Number(process.env.PORT ?? 3000),
webOrigin: process.env.WEB_ORIGIN ?? 'http://localhost:4200',
serviceLayer: {
url: url.replace(/\/+$/, ''),
companyDb,
rejectUnauthorized: process.env.SL_REJECT_UNAUTHORIZED !== 'false',
},
dbType,
cookieSecure: process.env.COOKIE_SECURE === 'true',
webDistDir: carpetaWeb(),
https: leerHttps(),
httpRedirectPort: Number(vacio(process.env.HTTP_REDIRECT_PORT)) || null,
};
};
+11
View File
@@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/session.guard.js';
@Controller('health')
export class HealthController {
@Public()
@Get()
health(): { status: 'ok' } {
return { status: 'ok' };
}
}
+17
View File
@@ -0,0 +1,17 @@
import { ArgumentsHost, Catch, HttpException, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import type { Request } from 'express';
/** Deja en el log toda respuesta de error (4xx/5xx) y las excepciones no controladas. */
@Catch()
export class HttpExceptionLogger extends BaseExceptionFilter {
private readonly logger = new Logger('Http');
override catch(exception: unknown, host: ArgumentsHost): void {
const req = host.switchToHttp().getRequest<Request>();
const detalle = exception instanceof HttpException ? JSON.stringify(exception.getResponse()) : (exception as Error)?.stack;
const status = exception instanceof HttpException ? exception.getStatus() : 500;
this.logger.error(`${req.method} ${req.originalUrl} -> ${status} ${detalle}`);
super.catch(exception, host);
}
}
+120
View File
@@ -0,0 +1,120 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { get } from 'node:http';
import configuration from './config/configuration.js';
import { cargarHttps, crearRedireccionHttp, destinoHttps } from './https.js';
const ENV_BASE = { SL_URL: 'https://sl:50000/b1s/v1', SL_COMPANY_DB: 'X', DB_TYPE: 'MSSQL' };
const CLAVES = ['HTTPS_PFX_PATH', 'HTTPS_PFX_PASSPHRASE', 'HTTPS_KEY_PATH', 'HTTPS_CERT_PATH', 'WEB_DIST_DIR', 'HTTP_REDIRECT_PORT'];
describe('configuración de despliegue', () => {
const respaldo = { ...process.env };
beforeEach(() => {
for (const k of CLAVES) delete process.env[k];
Object.assign(process.env, ENV_BASE);
});
afterEach(() => {
process.env = { ...respaldo };
});
it('sin variables HTTPS la API escucha en HTTP', () => {
expect(configuration().https).toBeNull();
expect(configuration().httpRedirectPort).toBeNull();
});
it('acepta un .pfx con su contraseña', () => {
process.env.HTTPS_PFX_PATH = 'C:\\certs\\sitio.pfx';
process.env.HTTPS_PFX_PASSPHRASE = 'clave';
expect(configuration().https).toMatchObject({ pfxPath: 'C:\\certs\\sitio.pfx', passphrase: 'clave' });
});
it('acepta key + cert', () => {
process.env.HTTPS_KEY_PATH = '/k.pem';
process.env.HTTPS_CERT_PATH = '/c.pem';
expect(configuration().https).toMatchObject({ keyPath: '/k.pem', certPath: '/c.pem' });
});
it('rechaza mezclar .pfx con key/cert', () => {
process.env.HTTPS_PFX_PATH = '/a.pfx';
process.env.HTTPS_KEY_PATH = '/k.pem';
expect(() => configuration()).toThrow(/no con ambos/);
});
it('rechaza key sin cert', () => {
process.env.HTTPS_KEY_PATH = '/k.pem';
expect(() => configuration()).toThrow(/ambos son necesarios/);
});
it('ignora las variables vacías (como las deja un .env con "VAR=")', () => {
process.env.HTTPS_PFX_PATH = ' ';
process.env.HTTP_REDIRECT_PORT = '';
expect(configuration().https).toBeNull();
expect(configuration().httpRedirectPort).toBeNull();
});
it('lee el puerto de la redirección HTTP', () => {
process.env.HTTP_REDIRECT_PORT = '80';
expect(configuration().httpRedirectPort).toBe(80);
});
it('WEB_DIST_DIR que no tiene index.html es un error claro', () => {
process.env.WEB_DIST_DIR = join(tmpdir(), 'no-existe-dp');
expect(() => configuration()).toThrow(/WEB_DIST_DIR no contiene un index.html/);
});
it('WEB_DIST_DIR válido se usa tal cual', () => {
const dir = mkdtempSync(join(tmpdir(), 'dp-dist-'));
writeFileSync(join(dir, 'index.html'), 'x');
process.env.WEB_DIST_DIR = dir;
expect(configuration().webDistDir).toBe(dir);
rmSync(dir, { recursive: true, force: true });
});
});
describe('cargarHttps', () => {
it('lee los archivos del certificado', () => {
const dir = mkdtempSync(join(tmpdir(), 'dp-cert-'));
writeFileSync(join(dir, 'k.pem'), 'KEY');
writeFileSync(join(dir, 'c.pem'), 'CERT');
const r = cargarHttps({ keyPath: join(dir, 'k.pem'), certPath: join(dir, 'c.pem') }) as { key: Buffer; cert: Buffer };
expect(r.key.toString()).toBe('KEY');
expect(r.cert.toString()).toBe('CERT');
rmSync(dir, { recursive: true, force: true });
});
it('si falta un archivo dice cuál variable lo pide y la ruta', () => {
expect(() => cargarHttps({ pfxPath: '/no/existe.pfx' })).toThrow(/HTTPS_PFX_PATH \(\/no\/existe\.pfx\)/);
});
});
describe('redirección HTTP → HTTPS', () => {
it.each([
['sitio.local', '/planificaciones?x=1', 443, 'https://sitio.local/planificaciones?x=1'],
['sitio.local:80', '/', 8443, 'https://sitio.local:8443/'],
['10.0.0.5:8080', '/login', 443, 'https://10.0.0.5/login'],
[undefined, '/', 443, 'https://localhost/'],
])('%s %s → %s', (host, url, puerto, esperado) => {
expect(destinoHttps(host, url, puerto)).toBe(esperado);
});
it('el servidor responde 301 con el Location correcto', async () => {
const servidor = crearRedireccionHttp(8443);
await new Promise<void>((r) => servidor.listen(0, '127.0.0.1', r));
const puerto = (servidor.address() as AddressInfo).port;
const cabecera = await new Promise<{ status?: number; location?: string }>((resolver) => {
get({ host: '127.0.0.1', port: puerto, path: '/login', headers: { host: `miservidor:${puerto}` } }, (res) => {
resolver({ status: res.statusCode, location: res.headers.location });
res.resume();
});
});
await new Promise((r) => servidor.close(r));
expect(cabecera).toEqual({ status: 301, location: 'https://miservidor:8443/login' });
});
});
+32
View File
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import type { Server } from 'node:http';
import type { HttpsConfig } from './config/configuration.js';
/** Lee el certificado. Falla al arrancar, con un mensaje claro, si una ruta no existe. */
export function cargarHttps(cfg: HttpsConfig): { pfx: Buffer; passphrase?: string } | { key: Buffer; cert: Buffer } {
const leer = (ruta: string, nombre: string): Buffer => {
try {
return readFileSync(ruta);
} catch (error) {
throw new Error(`No se pudo leer ${nombre} (${ruta}): ${(error as Error).message}`);
}
};
if (cfg.pfxPath) return { pfx: leer(cfg.pfxPath, 'HTTPS_PFX_PATH'), passphrase: cfg.passphrase };
return { key: leer(cfg.keyPath!, 'HTTPS_KEY_PATH'), cert: leer(cfg.certPath!, 'HTTPS_CERT_PATH') };
}
/** Destino de la redirección: mismo host, esquema https y el puerto de la API (se omite el 443). */
export function destinoHttps(hostHeader: string | undefined, url: string, puertoHttps: number): string {
const host = (hostHeader ?? 'localhost').replace(/:\d+$/, '').replace(/^\[(.*)\]$/, '[$1]');
return `https://${host}${puertoHttps === 443 ? '' : `:${puertoHttps}`}${url}`;
}
/** Servidor HTTP que solo redirige (301) a la misma dirección por HTTPS. */
export function crearRedireccionHttp(puertoHttps: number): Server {
return createServer((req, res) => {
res.writeHead(301, { Location: destinoHttps(req.headers.host, req.url ?? '/', puertoHttps) });
res.end();
});
}
+40
View File
@@ -0,0 +1,40 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost, NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module.js';
import configuration from './config/configuration.js';
import { HttpExceptionLogger } from './http-exception.logger.js';
import { cargarHttps, crearRedireccionHttp } from './https.js';
import { cabecerasSeguridad, servirWeb } from './web-estatico.js';
async function bootstrap() {
// El .env ya está cargado en process.env: ConfigModule.forRoot corre al importar AppModule.
const cfg = configuration();
const logger = new Logger('Arranque');
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
httpsOptions: cfg.https ? cargarHttps(cfg.https) : undefined,
});
app.setGlobalPrefix('api');
app.use(cabecerasSeguridad(cfg.https !== null));
app.use(cookieParser());
app.useGlobalFilters(new HttpExceptionLogger(app.get(HttpAdapterHost).httpAdapter));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
app.enableCors({ origin: cfg.webOrigin, credentials: true });
if (cfg.webDistDir) servirWeb(app, cfg.webDistDir);
await app.listen(cfg.port);
const esquema = cfg.https ? 'https' : 'http';
logger.log(`Escuchando en ${esquema}://0.0.0.0:${cfg.port} · SAP ${cfg.serviceLayer.companyDb} (${cfg.dbType})`);
logger.log(cfg.webDistDir ? `Sirviendo la web desde ${cfg.webDistDir}` : 'No se sirve la web (no hay build de Angular): use ng serve.');
if (cfg.cookieSecure && !cfg.https) {
logger.warn('COOKIE_SECURE=true sin HTTPS: el navegador solo guardará la cookie de sesión si hay un proxy HTTPS delante.');
}
if (cfg.https && cfg.httpRedirectPort) {
crearRedireccionHttp(cfg.port).listen(cfg.httpRedirectPort, () => logger.log(`Redirigiendo http:${cfg.httpRedirectPort} → ${esquema}:${cfg.port}`));
}
}
await bootstrap();
@@ -0,0 +1,25 @@
[
{
"tipo": "campo",
"tabla": "OPDN",
"campo": "DOT_TipoRecepcion",
"descripcion": "Tipo de recepción de la Entrada de mercancías (el FIFO solo toma las de tipo 'C')"
},
{
"tipo": "campo",
"tabla": "OINV",
"campo": "BFE_TipoDespacho",
"descripcion": "Valores válidos del Tipo de despacho"
},
{
"tipo": "campo",
"tabla": "OINV",
"campo": "BFE_IndTraslado",
"descripcion": "Valores válidos del Indicador de traslado"
},
{
"tipo": "tabla",
"tabla": "@DTE_CONFC",
"descripcion": "Configuración del web service DTE (RUT, usuario, contraseña y URL) para descargar PDF"
}
]
@@ -0,0 +1,325 @@
[
{
"TableName": "@LIB_ODEV",
"Name": "WhsCode",
"Description": "LIB: Almacen",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "DocDate",
"Description": "LIB: Fecha contabilizacion",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "Series",
"Description": "LIB: Serie electrónica devolucion",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "ObjType",
"Description": "LIB: Objeto destino",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "PrintingLines",
"Description": "LIB: Cantidad maxima lineas",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "TipoDespacho",
"Description": "LIB: Tipo despacho",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "IndicadorTraslado",
"Description": "LIB: Indicador traslado",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "RutTransporte",
"Description": "LIB: RUT Transportista",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "RutChofer",
"Description": "LIB: RUT Chofer",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "NombreChofer",
"Description": "LIB: Nombre Chofer",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 100,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "FechaSalida",
"Description": "LIB: Fecha salida",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "HoraSalida",
"Description": "LIB: Hora salida",
"Type": "db_Date",
"SubType": "st_Time",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "FechaLlegada",
"Description": "LIB: Fecha llegada",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "Patente",
"Description": "LIB: Patente",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "LineType",
"Description": "LIB: Tipo fila",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 1,
"Mandatory": "tNO",
"DefaultValue": "F",
"ValidValuesMD": [
{
"Value": "F",
"Description": "Padre"
},
{
"Value": "S",
"Description": "Hijo"
}
]
},
{
"TableName": "@LIB_DEV1",
"Name": "ItemCode",
"Description": "LIB: Codigo articulo",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 50,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "ItemDescription",
"Description": "LIB: Descripcion articulo",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 254,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "Quantity",
"Description": "LIB: Cantidad a devolver",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "OnHand",
"Description": "LIB: Cantidad disponible",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "Status",
"Description": "LIB: Resultado de la comparacion",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseType",
"Description": "LIB: Tipo base",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseEntry",
"Description": "LIB: Documento base",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseLine",
"Description": "LIB: Linea base",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseRef",
"Description": "LIB: Numero documento",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseDocDate",
"Description": "LIB: Fecha Entrada",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "OpenQty",
"Description": "LIB: Cantidad abierta entrada",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "AllocatedQty",
"Description": "LIB: Cantidad asignada",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "Applied",
"Description": "LIB: Aplica devolucion",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 1,
"Mandatory": "tNO",
"DefaultValue": "Y",
"ValidValuesMD": [
{
"Value": "Y",
"Description": "Si"
},
{
"Value": "N",
"Description": "No"
}
]
},
{
"TableName": "@LIB_DEV1",
"Name": "TargetType",
"Description": "LIB: Tipo documento destino",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "TargetEntry",
"Description": "LIB: Documento destino",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "CardCode",
"Description": "LIB: Proveedor",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "CardName",
"Description": "LIB: Nombre",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 200,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "ErrCode",
"Description": "LIB: Codigo de error",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "ErrMsg",
"Description": "LIB: Mensaje de error",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 254,
"Mandatory": "tNO"
}
]
@@ -0,0 +1,27 @@
[
{
"Code": "LIB_ODEV",
"Name": "Devolucion proveedores",
"ObjectType": "boud_Document",
"TableName": "LIB_ODEV",
"CanFind": "tYES",
"CanClose": "tYES",
"CanCancel": "tYES",
"CanDelete": "tNO",
"CanLog": "tYES",
"LogTableName": "ALIB_ODEV",
"CanCreateDefaultForm": "tNO",
"ManageSeries": "tNO",
"UserObjectMD_ChildTables": [
{
"TableName": "LIB_DEV1",
"ObjectName": "LIB_DEV1"
}
],
"UserObjectMD_FindColumns": [
{ "ColumnAlias": "DocEntry", "ColumnDescription": "Nº documento" },
{ "ColumnAlias": "U_WhsCode", "ColumnDescription": "Almacén" },
{ "ColumnAlias": "U_DocDate", "ColumnDescription": "Fecha" }
]
}
]
@@ -0,0 +1,14 @@
[
{
"TableName": "LIB_ODEV",
"TableDescription": "LIB: Devolucion proveedores",
"TableType": "bott_Document",
"Archivable": "tNO"
},
{
"TableName": "LIB_DEV1",
"TableDescription": "LIB: Detalle devolucion",
"TableType": "bott_DocumentLines",
"Archivable": "tNO"
}
]
@@ -0,0 +1,104 @@
import { CampoReal, MetadatosReales, ObjetoReal, analizarMetadatos, claveDependencia } from './metadata.analyzer.js';
import { DEFINICIONES } from './metadata.definitions.js';
function baseCompleta(): MetadatosReales {
const campos: CampoReal[] = DEFINICIONES.campos.map((c, i) => ({
TableName: c.TableName,
FieldID: i,
Name: c.Name,
Type: c.Type,
SubType: c.SubType,
EditSize: c.EditSize ?? 8,
}));
const objetos: ObjetoReal[] = DEFINICIONES.objetos.map((o) => ({ ...o }));
return {
tablas: DEFINICIONES.tablas.map((t) => ({ TableName: t.TableName, TableType: t.TableType })),
campos,
objetos,
dependenciasPresentes: new Set(DEFINICIONES.dependencias.map((d) => claveDependencia(d.tipo, d.tabla, d.campo))),
};
}
describe('definiciones', () => {
it('incluyen las 2 tablas, 34 campos y el UDO LIB_ODEV', () => {
expect(DEFINICIONES.tablas.map((t) => t.TableName)).toEqual(['LIB_ODEV', 'LIB_DEV1']);
expect(DEFINICIONES.campos).toHaveLength(34);
expect(DEFINICIONES.objetos[0].Code).toBe('LIB_ODEV');
expect(DEFINICIONES.campos.find((c) => c.Name === 'Series')?.Type).toBe('db_Numeric');
expect(DEFINICIONES.campos.some((c) => c.TableName === '@LIB_ODEV' && c.Name === 'Status')).toBe(false);
});
});
describe('analizarMetadatos', () => {
it('base al día: completo y sin acciones', () => {
const { estado, acciones } = analizarMetadatos(DEFINICIONES, baseCompleta());
expect(estado.completo).toBe(true);
expect(acciones).toEqual([]);
});
it('base vacía: crea tablas, luego campos y al final el objeto', () => {
const { estado, acciones } = analizarMetadatos(DEFINICIONES, {
tablas: [],
campos: [],
objetos: [],
dependenciasPresentes: new Set(),
});
expect(estado.completo).toBe(false);
const tipos = acciones.map((a) => a.tipo);
expect(tipos.slice(0, 2)).toEqual(['crear-tabla', 'crear-tabla']);
expect(tipos.filter((t) => t === 'crear-campo')).toHaveLength(34);
expect(tipos.at(-1)).toBe('crear-objeto');
expect(estado.elementos.filter((e) => e.tipo === 'dependencia').every((e) => e.estado === 'FALTA')).toBe(true);
});
it('campo alfanumérico que pasó a numérico: eliminar y volver a crear (destructivo)', () => {
const real = baseCompleta();
const qty = real.campos.find((c) => c.TableName === '@LIB_DEV1' && c.Name === 'Quantity')!;
qty.Type = 'db_Alpha';
const { estado, acciones } = analizarMetadatos(DEFINICIONES, real);
expect(estado.completo).toBe(false);
expect(acciones.map((a) => [a.tipo, a.nombre, a.destructivo])).toEqual([
['eliminar-campo', '@LIB_DEV1.U_Quantity', true],
['crear-campo', '@LIB_DEV1.U_Quantity', false],
]);
});
it('campo alfanumérico más chico: se amplía sin borrar', () => {
const real = baseCompleta();
real.campos.find((c) => c.Name === 'TipoDespacho')!.EditSize = 5;
const { acciones } = analizarMetadatos(DEFINICIONES, real);
expect(acciones).toEqual([expect.objectContaining({ tipo: 'ampliar-campo', editSize: 10, destructivo: false })]);
});
it('campo sobrante en tabla propia (U_Status de cabecera): se informa y se puede eliminar', () => {
const real = baseCompleta();
real.campos.push({ TableName: '@LIB_ODEV', FieldID: 99, Name: 'Status', Type: 'db_Alpha', SubType: 'st_None', EditSize: 1 });
real.campos.push({ TableName: 'OPDN', FieldID: 5, Name: 'Otro', Type: 'db_Alpha', SubType: 'st_None', EditSize: 1 });
const { estado, acciones } = analizarMetadatos(DEFINICIONES, real);
expect(estado.completo).toBe(true);
expect(estado.elementos.filter((e) => e.estado === 'SOBRANTE').map((e) => e.nombre)).toEqual(['@LIB_ODEV.U_Status']);
expect(acciones).toEqual([expect.objectContaining({ tipo: 'eliminar-campo', fieldId: 99, destructivo: true })]);
});
it('UDO sin CanClose: se actualiza la propiedad', () => {
const real = baseCompleta();
real.objetos[0].CanClose = 'tNO';
const { acciones } = analizarMetadatos(DEFINICIONES, real);
expect(acciones).toEqual([expect.objectContaining({ tipo: 'actualizar-objeto', cambios: { CanClose: 'tYES' } })]);
});
it('UDO sin la tabla hija: se informa para corregir a mano', () => {
const real = baseCompleta();
real.objetos[0].UserObjectMD_ChildTables = [];
const { estado, acciones } = analizarMetadatos(DEFINICIONES, real);
expect(acciones).toEqual([]);
expect(estado.elementos.find((e) => e.tipo === 'objeto')).toMatchObject({ estado: 'DIFERENTE', destructivo: true });
});
});
+196
View File
@@ -0,0 +1,196 @@
import type { DefinicionCampo, DefinicionObjeto, DefinicionTabla, EstadoMetadato, EstadoMetadatos } from '@dp/shared';
import { DefinicionesMetadatos } from './metadata.definitions.js';
// Compara las definiciones contra lo que hay en la base SAP y arma el plan de acciones
// para dejarla al día. Es lógica pura: la lectura y la ejecución están en MetadataService.
export interface TablaReal {
TableName: string;
TableType: string;
}
export interface CampoReal {
TableName: string;
FieldID: number;
Name: string;
Type: string;
SubType: string;
EditSize: number;
}
export interface ObjetoReal extends Partial<Omit<DefinicionObjeto, 'UserObjectMD_ChildTables'>> {
Code: string;
UserObjectMD_ChildTables?: { TableName: string }[];
}
export interface MetadatosReales {
tablas: TablaReal[];
campos: CampoReal[];
objetos: ObjetoReal[];
/** Claves "campo:OPDN.DOT_TipoRecepcion" / "tabla:@DTE_CONFC" de las dependencias encontradas. */
dependenciasPresentes: Set<string>;
}
/** El Service Layer informa los campos de hora (db_Date/st_Time) como db_Numeric/st_Time. */
function tipoReal(c: { Type: string; SubType: string }): string {
const tipo = c.SubType === 'st_Time' && c.Type === 'db_Numeric' ? 'db_Date' : c.Type;
return `${tipo}/${c.SubType}`;
}
export type Accion =
| { tipo: 'crear-tabla'; nombre: string; destructivo: false; tabla: DefinicionTabla }
| { tipo: 'crear-campo'; nombre: string; destructivo: false; campo: DefinicionCampo }
| { tipo: 'ampliar-campo'; nombre: string; destructivo: false; tableName: string; fieldId: number; editSize: number }
| { tipo: 'eliminar-campo'; nombre: string; destructivo: true; tableName: string; fieldId: number }
| { tipo: 'crear-objeto'; nombre: string; destructivo: false; objeto: DefinicionObjeto }
| { tipo: 'actualizar-objeto'; nombre: string; destructivo: false; code: string; cambios: Partial<DefinicionObjeto> };
export interface Analisis {
estado: EstadoMetadatos;
acciones: Accion[];
}
const PROPIEDADES_OBJETO = ['CanFind', 'CanClose', 'CanCancel', 'CanDelete', 'CanLog', 'ManageSeries'] as const;
export function claveDependencia(tipo: 'tabla' | 'campo', tabla: string, campo?: string): string {
return tipo === 'tabla' ? `tabla:${tabla}` : `campo:${tabla}.${campo}`;
}
export function analizarMetadatos(def: DefinicionesMetadatos, real: MetadatosReales): Analisis {
const elementos: EstadoMetadato[] = [];
const acciones: Accion[] = [];
// Tablas
for (const tabla of def.tablas) {
const existente = real.tablas.find((t) => t.TableName === tabla.TableName);
const base = { tipo: 'tabla' as const, nombre: tabla.TableName, descripcion: tabla.TableDescription };
if (!existente) {
elementos.push({ ...base, estado: 'FALTA' });
acciones.push({ tipo: 'crear-tabla', nombre: tabla.TableName, destructivo: false, tabla });
} else if (existente.TableType !== tabla.TableType) {
elementos.push({
...base,
estado: 'DIFERENTE',
detalle: `Tipo ${existente.TableType}, se esperaba ${tabla.TableType}. Debe corregirse manualmente en SAP.`,
destructivo: true,
});
} else {
elementos.push({ ...base, estado: 'OK' });
}
}
// Campos
const tablasPropias = new Set(def.tablas.map((t) => `@${t.TableName}`));
for (const campo of def.campos) {
const nombre = `${campo.TableName}.U_${campo.Name}`;
const base = { tipo: 'campo' as const, nombre, descripcion: campo.Description };
const existente = real.campos.find((c) => c.TableName === campo.TableName && c.Name === campo.Name);
if (!existente) {
elementos.push({ ...base, estado: 'FALTA' });
acciones.push({ tipo: 'crear-campo', nombre, destructivo: false, campo });
continue;
}
if (tipoReal(existente) !== tipoReal(campo)) {
elementos.push({
...base,
estado: 'DIFERENTE',
detalle: `Tipo ${existente.Type}/${existente.SubType}, se esperaba ${campo.Type}/${campo.SubType}. Se borra y se vuelve a crear (se pierden sus datos).`,
destructivo: true,
});
acciones.push({ tipo: 'eliminar-campo', nombre, destructivo: true, tableName: campo.TableName, fieldId: existente.FieldID });
acciones.push({ tipo: 'crear-campo', nombre, destructivo: false, campo });
continue;
}
if (campo.Type === 'db_Alpha' && campo.EditSize && existente.EditSize < campo.EditSize) {
elementos.push({ ...base, estado: 'DIFERENTE', detalle: `Tamaño ${existente.EditSize}, se esperaba ${campo.EditSize}.` });
acciones.push({
tipo: 'ampliar-campo',
nombre,
destructivo: false,
tableName: campo.TableName,
fieldId: existente.FieldID,
editSize: campo.EditSize,
});
continue;
}
elementos.push({ ...base, estado: 'OK' });
}
for (const sobrante of real.campos) {
const definido = def.campos.some((c) => c.TableName === sobrante.TableName && c.Name === sobrante.Name);
if (!definido && tablasPropias.has(sobrante.TableName)) {
const nombre = `${sobrante.TableName}.U_${sobrante.Name}`;
elementos.push({
tipo: 'campo',
nombre,
descripcion: 'Campo que ya no se utiliza',
estado: 'SOBRANTE',
detalle: 'Se elimina de la base (se pierden sus datos).',
destructivo: true,
});
acciones.push({ tipo: 'eliminar-campo', nombre, destructivo: true, tableName: sobrante.TableName, fieldId: sobrante.FieldID });
}
}
// Objetos de usuario
for (const objeto of def.objetos) {
const base = { tipo: 'objeto' as const, nombre: objeto.Code, descripcion: objeto.Name };
const existente = real.objetos.find((o) => o.Code === objeto.Code);
if (!existente) {
elementos.push({ ...base, estado: 'FALTA' });
acciones.push({ tipo: 'crear-objeto', nombre: objeto.Code, destructivo: false, objeto });
continue;
}
const hijasReales = new Set((existente.UserObjectMD_ChildTables ?? []).map((h) => h.TableName));
const faltanHijas = objeto.UserObjectMD_ChildTables.filter((h) => !hijasReales.has(h.TableName)).map((h) => h.TableName);
if (existente.TableName !== objeto.TableName || existente.ObjectType !== objeto.ObjectType || faltanHijas.length > 0) {
elementos.push({
...base,
estado: 'DIFERENTE',
detalle: `Tabla ${existente.TableName} (${existente.ObjectType})${faltanHijas.length ? `, faltan tablas hijas ${faltanHijas.join(', ')}` : ''}. Debe corregirse manualmente en SAP.`,
destructivo: true,
});
continue;
}
const cambios: Partial<DefinicionObjeto> = {};
for (const prop of PROPIEDADES_OBJETO) {
if (existente[prop] !== objeto[prop]) cambios[prop] = objeto[prop];
}
if (cambios.CanLog === 'tYES') cambios.LogTableName = objeto.LogTableName;
if (cambios.CanFind === 'tYES') cambios.UserObjectMD_FindColumns = objeto.UserObjectMD_FindColumns;
const diferencias = Object.keys(cambios).filter((k) => k !== 'LogTableName' && k !== 'UserObjectMD_FindColumns');
if (diferencias.length > 0) {
elementos.push({ ...base, estado: 'DIFERENTE', detalle: `Propiedades distintas: ${diferencias.join(', ')}.` });
acciones.push({ tipo: 'actualizar-objeto', nombre: objeto.Code, destructivo: false, code: objeto.Code, cambios });
} else {
elementos.push({ ...base, estado: 'OK' });
}
}
// Dependencias externas (solo se verifican, no se crean)
for (const dep of def.dependencias) {
const nombre = dep.tipo === 'tabla' ? dep.tabla : `${dep.tabla}.U_${dep.campo}`;
const presente = real.dependenciasPresentes.has(claveDependencia(dep.tipo, dep.tabla, dep.campo));
elementos.push({
tipo: 'dependencia',
nombre,
descripcion: dep.descripcion,
estado: presente ? 'OK' : 'FALTA',
detalle: presente ? undefined : 'Lo crea otro addon o la localización; esta aplicación no lo genera.',
});
}
const completo = elementos.every((e) => e.estado === 'OK' || e.estado === 'SOBRANTE');
return { estado: { completo, elementos }, acciones };
}
@@ -0,0 +1,29 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoReconstruccion } from '@dp/shared';
import { IsBoolean } from 'class-validator';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { MetadataService } from './metadata.service.js';
class ReconstruirDto implements OpcionesReconstruccion {
@IsBoolean()
permitirDestructivos: boolean;
}
@Controller('metadata')
export class MetadataController {
constructor(private readonly metadata: MetadataService) {}
/** Compara tablas, campos y objeto de usuario esperados contra la base SAP. */
@Get('status')
estado(@CurrentSession() sesion: SapSession): Promise<EstadoMetadatos> {
return this.metadata.estado(sesion);
}
/** Crea o corrige lo que falta. Lo que borra datos solo se hace con permitirDestructivos. */
@Post('rebuild')
@HttpCode(200)
reconstruir(@CurrentSession() sesion: SapSession, @Body() dto: ReconstruirDto): Promise<ResultadoReconstruccion> {
return this.metadata.reconstruir(sesion, dto);
}
}
@@ -0,0 +1,25 @@
import type { DefinicionCampo, DefinicionObjeto, DefinicionTabla, DependenciaExterna } from '@dp/shared';
import dependencias from './definitions/external-dependencies.json' with { type: 'json' };
import campos from './definitions/user-fields.json' with { type: 'json' };
import objetos from './definitions/user-objects.json' with { type: 'json' };
import tablas from './definitions/user-tables.json' with { type: 'json' };
/**
* Metadatos que el proceso necesita en la base SAP. Los JSON de ./definitions son la
* fuente de verdad (reemplazan a UserTables.xml / UserFields.xml del addon .NET) y usan
* el mismo formato que los endpoints UserTablesMD / UserFieldsMD / UserObjectsMD del
* Service Layer.
*/
export interface DefinicionesMetadatos {
tablas: DefinicionTabla[];
campos: DefinicionCampo[];
objetos: DefinicionObjeto[];
dependencias: DependenciaExterna[];
}
export const DEFINICIONES: DefinicionesMetadatos = {
tablas: tablas as DefinicionTabla[],
campos: campos as DefinicionCampo[],
objetos: objetos as DefinicionObjeto[],
dependencias: dependencias as DependenciaExterna[],
};
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MetadataController } from './metadata.controller.js';
import { MetadataService } from './metadata.service.js';
@Module({
controllers: [MetadataController],
providers: [MetadataService],
})
export class MetadataModule {}
+124
View File
@@ -0,0 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoPaso, ResultadoReconstruccion } from '@dp/shared';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import { SapSession } from '../service-layer/session.store.js';
import { Accion, CampoReal, MetadatosReales, ObjetoReal, TablaReal, analizarMetadatos, claveDependencia } from './metadata.analyzer.js';
import { DEFINICIONES } from './metadata.definitions.js';
/** Arma "Entidad?$filter=...&$select=..." con los valores codificados para la URL. */
function odata(entidad: string, query: Record<string, string>): string {
const params = Object.entries(query).map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
return `${entidad}?${params.join('&')}`;
}
const DESCRIPCION_ACCION: Record<Accion['tipo'], string> = {
'crear-tabla': 'Crear tabla',
'crear-campo': 'Crear campo',
'ampliar-campo': 'Ampliar campo',
'eliminar-campo': 'Eliminar campo',
'crear-objeto': 'Crear objeto',
'actualizar-objeto': 'Actualizar objeto',
};
@Injectable()
export class MetadataService {
private readonly logger = new Logger(MetadataService.name);
constructor(private readonly sl: ServiceLayerClient) {}
async estado(sesion: SapSession): Promise<EstadoMetadatos> {
return analizarMetadatos(DEFINICIONES, await this.leerReales(sesion)).estado;
}
async reconstruir(sesion: SapSession, opciones: OpcionesReconstruccion): Promise<ResultadoReconstruccion> {
const { acciones } = analizarMetadatos(DEFINICIONES, await this.leerReales(sesion));
const pasos: ResultadoPaso[] = [];
// Campos cuyo borrado no se hizo: tampoco se intenta volver a crearlos.
const sinEliminar = new Set<string>();
for (const accion of acciones) {
const paso: ResultadoPaso = { accion: DESCRIPCION_ACCION[accion.tipo], nombre: accion.nombre, ok: false };
if (accion.destructivo && !opciones.permitirDestructivos) {
paso.mensaje = 'Omitido: requiere autorizar cambios que borran datos.';
sinEliminar.add(accion.nombre);
} else if (accion.tipo === 'crear-campo' && sinEliminar.has(accion.nombre)) {
paso.mensaje = 'Omitido: el campo existente no se eliminó.';
} else {
try {
await this.ejecutar(sesion, accion);
paso.ok = true;
} catch (error) {
paso.mensaje = (error as Error).message;
if (accion.tipo === 'eliminar-campo') sinEliminar.add(accion.nombre);
this.logger.warn(`${paso.accion} ${paso.nombre}: ${paso.mensaje}`);
}
}
pasos.push(paso);
}
return { pasos, estado: await this.estado(sesion) };
}
private async ejecutar(sesion: SapSession, accion: Accion): Promise<void> {
switch (accion.tipo) {
case 'crear-tabla':
await this.sl.post(sesion, 'UserTablesMD', accion.tabla);
return;
case 'crear-campo':
await this.sl.post(sesion, 'UserFieldsMD', accion.campo);
return;
case 'ampliar-campo':
await this.sl.patch(sesion, `UserFieldsMD(TableName='${accion.tableName}',FieldID=${accion.fieldId})`, {
EditSize: accion.editSize,
});
return;
case 'eliminar-campo':
await this.sl.delete(sesion, `UserFieldsMD(TableName='${accion.tableName}',FieldID=${accion.fieldId})`);
return;
case 'crear-objeto':
await this.sl.post(sesion, 'UserObjectsMD', accion.objeto);
return;
case 'actualizar-objeto':
await this.sl.patch(sesion, `UserObjectsMD('${accion.code}')`, accion.cambios);
return;
}
}
private async leerReales(sesion: SapSession): Promise<MetadatosReales> {
const filtroTablas = DEFINICIONES.tablas.map((t) => `TableName eq '${t.TableName}'`).join(' or ');
const filtroCampos = DEFINICIONES.tablas.map((t) => `TableName eq '@${t.TableName}'`).join(' or ');
const filtroObjetos = DEFINICIONES.objetos.map((o) => `Code eq '${o.Code}'`).join(' or ');
const [tablas, campos, objetos, dependenciasPresentes] = await Promise.all([
this.sl.getAll<TablaReal>(sesion, odata('UserTablesMD', { $filter: filtroTablas, $select: 'TableName,TableType' })),
this.sl.getAll<CampoReal>(
sesion,
odata('UserFieldsMD', { $filter: filtroCampos, $select: 'TableName,FieldID,Name,Type,SubType,EditSize' }),
),
this.sl.getAll<ObjetoReal>(sesion, odata('UserObjectsMD', { $filter: filtroObjetos })),
this.leerDependencias(sesion),
]);
return { tablas, campos, objetos, dependenciasPresentes };
}
private async leerDependencias(sesion: SapSession): Promise<Set<string>> {
const presentes = new Set<string>();
await Promise.all(
DEFINICIONES.dependencias.map(async (dep) => {
const consulta =
dep.tipo === 'tabla'
? odata('UserTablesMD', { $filter: `TableName eq '${dep.tabla.replace(/^@/, '')}'`, $select: 'TableName' })
: odata('UserFieldsMD', { $filter: `TableName eq '${dep.tabla}' and Name eq '${dep.campo}'`, $select: 'FieldID' });
const encontrados = await this.sl.getAll<unknown>(sesion, consulta);
if (encontrados.length > 0) presentes.add(claveDependencia(dep.tipo, dep.tabla, dep.campo));
}),
);
return presentes;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { ACCION_CONSULTA_TXTDTE, construirSobre, interpretarRespuesta, normalizarUrl } from './bes-soap.js';
describe('construirSobre', () => {
it('lleva los 5 parámetros en el espacio de nombres de BES', () => {
const xml = construirSobre({ rutEmpresa: '78137716-3', tipoDocto: '52', folioDocto: '1234', usuario: 'U', pwd: 'P' });
expect(xml).toContain('<Consulta_TXTDTE xmlns="http://tempuri.org/">');
expect(xml).toContain('<RutEmpresa>78137716-3</RutEmpresa>');
expect(xml).toContain('<TipoDocto>52</TipoDocto>');
expect(xml).toContain('<FolioDocto>1234</FolioDocto>');
expect(xml).toContain('<Usuario>U</Usuario>');
expect(xml).toContain('<PWD>P</PWD>');
expect(ACCION_CONSULTA_TXTDTE).toBe('"http://tempuri.org/Consulta_TXTDTE"');
});
it('escapa los caracteres especiales (una contraseña con < & " no rompe el XML)', () => {
const xml = construirSobre({ rutEmpresa: '1', tipoDocto: '52', folioDocto: '1', usuario: 'a&b', pwd: `x<y>"z'` });
expect(xml).toContain('<Usuario>a&amp;b</Usuario>');
expect(xml).toContain('<PWD>x&lt;y&gt;&quot;z&apos;</PWD>');
});
});
describe('normalizarUrl', () => {
it.each([
['https://bes/wsconsulta/avanzado.asmx?WSDL', 'https://bes/wsconsulta/avanzado.asmx'],
['https://bes/wsconsulta/avanzado.asmx?wsdl', 'https://bes/wsconsulta/avanzado.asmx'],
['https://bes/wsconsulta/avanzado.asmx', 'https://bes/wsconsulta/avanzado.asmx'],
[' https://bes/x.asmx?WSDL ', 'https://bes/x.asmx'],
])('%s', (entrada, esperado) => {
expect(normalizarUrl(entrada)).toBe(esperado);
});
});
describe('interpretarRespuesta', () => {
const sobre = (cuerpo: string) =>
`<?xml version="1.0"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body>` +
`<Consulta_TXTDTEResponse xmlns="http://tempuri.org/"><Consulta_TXTDTEResult>${cuerpo}</Consulta_TXTDTEResult></Consulta_TXTDTEResponse>` +
`</soap:Body></soap:Envelope>`;
it('extrae el PDF en base64 y no lo confunde con PDFCedible', () => {
const pdf = Buffer.from('%PDF-1.4 contenido');
const cedible = Buffer.from('otro');
const r = interpretarRespuesta(
sobre(`<PDF>${pdf.toString('base64')}</PDF><PDFCedible>${cedible.toString('base64')}</PDFCedible><XML /><Estatus>0</Estatus><MsgEstatus>OK</MsgEstatus>`),
);
expect(r.pdf?.toString()).toBe('%PDF-1.4 contenido');
expect(r.estatus).toBe(0);
expect(r.mensaje).toBe('OK');
});
it('tolera saltos de línea dentro del base64', () => {
const b64 = Buffer.from('%PDF-1.7 hola mundo').toString('base64');
const r = interpretarRespuesta(sobre(`<PDF>${b64.slice(0, 8)}\n${b64.slice(8)}</PDF>`));
expect(r.pdf?.toString()).toBe('%PDF-1.7 hola mundo');
});
it('sin PDF devuelve el mensaje de estado del servicio', () => {
const r = interpretarRespuesta(sobre('<PDF /><Estatus>5</Estatus><MsgEstatus>Documento &lt;1234&gt; no existe</MsgEstatus>'));
expect(r.pdf).toBeUndefined();
expect(r.estatus).toBe(5);
expect(r.mensaje).toBe('Documento <1234> no existe');
});
it('una falla SOAP se informa', () => {
const r = interpretarRespuesta(
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>Usuario inválido</faultstring></soap:Fault></soap:Body></soap:Envelope>',
);
expect(r.pdf).toBeUndefined();
expect(r.mensaje).toBe('Falla del servicio: Usuario inválido');
});
});
+73
View File
@@ -0,0 +1,73 @@
// Cliente SOAP mínimo de Consulta_TXTDTE (avanzado.asmx de BES). Portado de
// WebServices/ConsultaDTE.cs y AvanzadoWS.cs (.NET), que usaba un proxy generado con wsdl.exe.
// Solo se arma el sobre y se lee la respuesta; el envío HTTP está en BesClient.
export const NS_BES = 'http://tempuri.org/';
export const ACCION_CONSULTA_TXTDTE = `"${NS_BES}Consulta_TXTDTE"`;
export interface ParametrosConsulta {
rutEmpresa: string;
tipoDocto: string;
folioDocto: string;
usuario: string;
pwd: string;
}
export interface RespuestaBes {
pdf?: Buffer;
estatus?: number;
mensaje?: string;
}
const escapar = (v: string): string =>
v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
const desescapar = (v: string): string =>
v.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
export function construirSobre(p: ParametrosConsulta): string {
return (
'<?xml version="1.0" encoding="utf-8"?>' +
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
'<soap:Body>' +
`<Consulta_TXTDTE xmlns="${NS_BES}">` +
`<RutEmpresa>${escapar(p.rutEmpresa)}</RutEmpresa>` +
`<TipoDocto>${escapar(p.tipoDocto)}</TipoDocto>` +
`<FolioDocto>${escapar(p.folioDocto)}</FolioDocto>` +
`<Usuario>${escapar(p.usuario)}</Usuario>` +
`<PWD>${escapar(p.pwd)}</PWD>` +
'</Consulta_TXTDTE>' +
'</soap:Body>' +
'</soap:Envelope>'
);
}
/**
* U_ConsultaTXTDTEWSDL guarda la dirección de descripción del servicio (termina en ?WSDL); el
* endpoint a invocar es la misma dirección sin ese sufijo.
*/
export function normalizarUrl(url: string): string {
const pos = url.search(/\?wsdl/i);
return (pos >= 0 ? url.slice(0, pos) : url).trim();
}
/** Contenido de la primera etiqueta con ese nombre local (ignora prefijo de espacio de nombres). */
function contenido(xml: string, etiqueta: string): string | undefined {
const re = new RegExp(`<(?:[\\w.-]+:)?${etiqueta}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${etiqueta}>`);
return re.exec(xml)?.[1];
}
export function interpretarRespuesta(xml: string): RespuestaBes {
const falla = contenido(xml, 'faultstring');
if (falla !== undefined) return { mensaje: `Falla del servicio: ${desescapar(falla.trim())}` };
const base64 = contenido(xml, 'PDF')?.replace(/\s+/g, '');
const estatus = contenido(xml, 'Estatus');
const mensaje = contenido(xml, 'MsgEstatus')?.trim();
return {
pdf: base64 ? Buffer.from(base64, 'base64') : undefined,
estatus: estatus === undefined ? undefined : Number(estatus),
mensaje: mensaje ? desescapar(mensaje) : undefined,
};
}
+55
View File
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { ACCION_CONSULTA_TXTDTE, construirSobre, interpretarRespuesta, normalizarUrl } from './bes-soap.js';
/** Fila única de @DTE_CONFC: credenciales y dirección del web service. Nunca se registra ni se devuelve al navegador. */
export interface ConfiguracionDte {
rutEmpresa: string;
usuario: string;
password: string;
wsdlUrl: string;
}
export interface ResultadoConsultaBes {
pdf?: Buffer;
error?: string;
}
const TIMEOUT_MS = 60_000;
@Injectable()
export class BesClient {
/** Llama a Consulta_TXTDTE. Se considera éxito que el servicio devuelva contenido en PDF. */
async consultarPdf(cfg: ConfiguracionDte, tipoDocto: string, folio: number): Promise<ResultadoConsultaBes> {
const url = normalizarUrl(cfg.wsdlUrl);
if (!/^https?:\/\//i.test(url)) return { error: 'La URL del web service en @DTE_CONFC no es válida.' };
const sobre = construirSobre({
rutEmpresa: cfg.rutEmpresa,
tipoDocto,
folioDocto: String(folio),
usuario: cfg.usuario,
pwd: cfg.password,
});
let cuerpo: string;
try {
const res = await axios.post<string>(url, sobre, {
headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: ACCION_CONSULTA_TXTDTE },
timeout: TIMEOUT_MS,
responseType: 'text',
// Las fallas SOAP llegan con HTTP 500 pero traen un cuerpo que se interpreta abajo.
validateStatus: () => true,
});
cuerpo = res.data;
} catch (error) {
// Solo el mensaje: el objeto de error de axios incluye la petición completa (con la contraseña).
return { error: `Error al consultar el servicio: ${(error as Error).message}` };
}
const respuesta = interpretarRespuesta(cuerpo);
if (respuesta.pdf && respuesta.pdf.length > 0) return { pdf: respuesta.pdf };
return { error: respuesta.mensaje ?? 'El servicio no devolvió un PDF para el documento consultado.' };
}
}
+154
View File
@@ -0,0 +1,154 @@
import { PDFDocument } from 'pdf-lib';
import type { SapSession } from '../service-layer/session.store.js';
import { PdfDteService } from './pdf-dte.service.js';
const sesion = { id: 's', userName: 'manager' } as SapSession;
const unaPagina = async (): Promise<Buffer> => {
const doc = await PDFDocument.create();
doc.addPage();
return Buffer.from(await doc.save());
};
const paginas = async (base64: string): Promise<number> => (await PDFDocument.load(Buffer.from(base64, 'base64'))).getPageCount();
const linea = (targetEntry: number | null) => ({ tipoFila: 'S', targetEntry }) as never;
function crear(opciones: {
lineas?: unknown[];
config?: object | null;
folios?: Record<number, { DocNum: number; FolioNumber: number | null }>;
respuestas?: Record<number, { pdf?: Buffer; error?: string }>;
}) {
const planificaciones = {
obtener: vi.fn(async () => ({ cabecera: { docNum: 7 }, lineas: opciones.lineas ?? [linea(33), linea(33), linea(34)] })),
};
const sl = {
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
if (path.startsWith('U_DTE_CONFC')) {
return { value: opciones.config === null ? [] : [opciones.config ?? { U_RutEmpresa: '1-9', U_UsuarioDTE: 'u', U_PasswordDTE: 'p', U_ConsultaTXTDTEWSDL: 'https://bes/x.asmx?WSDL' }] };
}
const docEntry = Number(/PurchaseReturns\((\d+)\)/.exec(path)![1]);
return { DocEntry: docEntry, ...(opciones.folios?.[docEntry] ?? { DocNum: docEntry, FolioNumber: null }) };
}),
};
const bes = {
consultarPdf: vi.fn(async (_c: unknown, _t: string, folio: number) => opciones.respuestas?.[folio] ?? { error: `sin respuesta para ${folio}` }),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { servicio: new PdfDteService(sl as any, planificaciones as any, bes as any), sl, bes };
}
describe('PdfDteService.generar', () => {
it('pide cada Devolución una sola vez, con el tipo 52 y su folio, y devuelve un archivo por cada una', async () => {
const pdf = await unaPagina();
const { servicio, bes } = crear({
folios: { 33: { DocNum: 33, FolioNumber: 5001 }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5001: { pdf }, 5002: { pdf } },
});
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).toHaveBeenCalledTimes(2);
expect(bes.consultarPdf.mock.calls.map((c) => [c[1], c[2]])).toEqual([['52', 5001], ['52', 5002]]);
expect(r.unificado).toBe(false);
expect(r.archivos.map((a) => [a.nombre, a.folios])).toEqual([['DTE_5001.pdf', [5001]], ['DTE_5002.pdf', [5002]]]);
expect(r.devoluciones).toEqual([{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002 }]);
});
it('con unir junta las páginas de todos en un solo PDF', async () => {
const pdf = await unaPagina();
const { servicio } = crear({
folios: { 33: { DocNum: 33, FolioNumber: 5001 }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5001: { pdf }, 5002: { pdf } },
});
const r = await servicio.generar(sesion, 7, true);
expect(r.unificado).toBe(true);
expect(r.archivos).toHaveLength(1);
expect(r.archivos[0]).toMatchObject({ nombre: 'DTE_Unificado_7.pdf', folios: [5001, 5002] });
expect(await paginas(r.archivos[0].base64)).toBe(2);
});
it('con un solo PDF no hay nada que unir', async () => {
const { servicio } = crear({
lineas: [linea(33)],
folios: { 33: { DocNum: 33, FolioNumber: 5001 } },
respuestas: { 5001: { pdf: await unaPagina() } },
});
const r = await servicio.generar(sesion, 7, true);
expect(r.unificado).toBe(false);
expect(r.archivos.map((a) => a.nombre)).toEqual(['DTE_5001.pdf']);
});
it('si una Devolución falla, las demás igual se descargan y el error queda en la suya', async () => {
const { servicio } = crear({
folios: { 33: { DocNum: 33, FolioNumber: 5001 }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5001: { error: 'Documento no emitido' }, 5002: { pdf: await unaPagina() } },
});
const r = await servicio.generar(sesion, 7, true);
expect(r.devoluciones).toEqual([{ docEntry: 33, folio: 5001, error: 'Documento no emitido' }, { docEntry: 34, folio: 5002 }]);
expect(r.archivos.map((a) => a.nombre)).toEqual(['DTE_5002.pdf']);
});
it('sin FolioNum no consulta a BES y lo informa en esa Devolución', async () => {
const { servicio, bes } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 12, FolioNumber: null } } });
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).not.toHaveBeenCalled();
expect(r.archivos).toEqual([]);
expect(r.devoluciones).toEqual([{ docEntry: 33, error: 'La Devolución Nº 12 todavía no tiene folio (FolioNum): no se puede consultar su PDF.' }]);
});
it('un FolioNum en 0 tampoco cuenta como folio', async () => {
const { servicio, bes } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 12, FolioNumber: 0 } } });
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).not.toHaveBeenCalled();
expect(r.devoluciones[0].error).toContain('todavía no tiene folio');
});
it('una Devolución sin folio no impide descargar las que sí lo tienen', async () => {
const { servicio, bes } = crear({
folios: { 33: { DocNum: 33, FolioNumber: null }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5002: { pdf: await unaPagina() } },
});
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).toHaveBeenCalledTimes(1);
expect(bes.consultarPdf.mock.calls[0][2]).toBe(5002);
expect(r.archivos.map((a) => a.nombre)).toEqual(['DTE_5002.pdf']);
expect(r.devoluciones[0].error).toContain('todavía no tiene folio');
});
it('rechaza lo que el servicio devuelve si no es un PDF', async () => {
const { servicio } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 33, FolioNumber: 5001 } }, respuestas: { 5001: { pdf: Buffer.from('<html>error</html>') } } });
const r = await servicio.generar(sesion, 7, false);
expect(r.archivos).toEqual([]);
expect(r.devoluciones[0].error).toContain('no es un PDF');
});
it('sin Devoluciones creadas avisa y no consulta nada', async () => {
const { servicio, bes } = crear({ lineas: [linea(null)] });
await expect(servicio.generar(sesion, 7, true)).rejects.toThrow(/No hay Devoluciones creadas/);
expect(bes.consultarPdf).not.toHaveBeenCalled();
});
it('sin configuración en @DTE_CONFC avisa y no consulta nada', async () => {
const { servicio, bes } = crear({ config: null });
await expect(servicio.generar(sesion, 7, true)).rejects.toThrow(/@DTE_CONFC/);
expect(bes.consultarPdf).not.toHaveBeenCalled();
});
it('la contraseña de @DTE_CONFC no aparece en el resultado', async () => {
const { servicio } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 33, FolioNumber: 5001 } }, respuestas: { 5001: { pdf: await unaPagina() } } });
const r = await servicio.generar(sesion, 7, false);
expect(JSON.stringify(r)).not.toContain('"p"');
expect(JSON.stringify(r)).not.toMatch(/password|pwd|U_Password/i);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { ArchivoPdf, ResultadoPdf, ResultadoPdfDevolucion } from '@dp/shared';
import { PDFDocument } from 'pdf-lib';
import { PlanificacionesService } from '../planificaciones/planificaciones.service.js';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import { BesClient } from './bes.client.js';
import type { ConfiguracionDte } from './bes.client.js';
/** Guía de despacho electrónica (DTE tipo 52). */
export const TIPO_GUIA_DESPACHO = '52';
interface FilaConfiguracion {
U_RutEmpresa: string | null;
U_UsuarioDTE: string | null;
U_PasswordDTE: string | null;
U_ConsultaTXTDTEWSDL: string | null;
}
interface DevolucionSl {
DocEntry: number;
DocNum: number;
FolioNumber: number | null;
}
const esPdf = (b: Buffer): boolean => b.subarray(0, 5).toString('latin1') === '%PDF-';
@Injectable()
export class PdfDteService {
private readonly logger = new Logger(PdfDteService.name);
constructor(
private readonly sl: ServiceLayerClient,
private readonly planificaciones: PlanificacionesService,
private readonly bes: BesClient,
) {}
/**
* Descarga el PDF de la Guía de despacho de cada Devolución creada por la planificación y,
* si `unir` y hay más de uno, los junta en un solo PDF. Se pide de a uno (como el addon) y un
* fallo en uno no impide los demás: cada Devolución informa su propio resultado.
*/
async generar(sesion: SapSession, docEntry: number, unir: boolean): Promise<ResultadoPdf> {
const plan = await this.planificaciones.obtener(sesion, docEntry);
const devoluciones = [...new Set(plan.lineas.map((l) => l.targetEntry).filter((e): e is number => e != null))];
if (devoluciones.length === 0) throw new BadRequestException('No hay Devoluciones creadas para descargar su PDF.');
const cfg = await this.configuracion(sesion);
if (!cfg) {
throw new BadRequestException('No se encontró configuración en la tabla @DTE_CONFC (RUT, usuario, contraseña y URL del web service).');
}
const resultados: ResultadoPdfDevolucion[] = [];
const descargados: { folio: number; pdf: Buffer }[] = [];
for (const dev of devoluciones) {
const resultado: ResultadoPdfDevolucion = { docEntry: dev };
resultados.push(resultado);
try {
const { docNum, folio } = await this.folioDe(sesion, dev);
if (!folio) {
// Sin folio no hay DTE emitido: consultar con otro número podría traer el PDF de otro documento.
resultado.error = `La Devolución Nº ${docNum} todavía no tiene folio (FolioNum): no se puede consultar su PDF.`;
continue;
}
resultado.folio = folio;
const respuesta = await this.bes.consultarPdf(cfg, TIPO_GUIA_DESPACHO, folio);
if (!respuesta.pdf) resultado.error = respuesta.error;
else if (!esPdf(respuesta.pdf)) resultado.error = 'El servicio devolvió un archivo que no es un PDF.';
else descargados.push({ folio: resultado.folio, pdf: respuesta.pdf });
} catch (error) {
resultado.error = (error as Error).message;
}
}
const fallidas = resultados.filter((r) => r.error).length;
this.logger.log(`PDF de la planificación ${docEntry} (${sesion.userName}): ${descargados.length} de ${devoluciones.length} descargados${fallidas ? `, ${fallidas} con error` : ''}.`);
const unificar = unir && descargados.length > 1;
const archivos: ArchivoPdf[] = unificar
? [await this.unificar(descargados, `DTE_Unificado_${plan.cabecera.docNum ?? docEntry}.pdf`)]
: descargados.map((d) => ({ nombre: `DTE_${d.folio}.pdf`, base64: d.pdf.toString('base64'), folios: [d.folio] }));
return { devoluciones: resultados, archivos, unificado: unificar };
}
/** Lee la fila única de @DTE_CONFC; null si la tabla está vacía. */
private async configuracion(sesion: SapSession): Promise<ConfiguracionDte | null> {
const columnas = 'U_RutEmpresa,U_UsuarioDTE,U_PasswordDTE,U_ConsultaTXTDTEWSDL';
const { value } = await this.sl.get<{ value: FilaConfiguracion[] }>(sesion, `U_DTE_CONFC?$select=${columnas}&$top=1`);
const fila = value[0];
if (!fila) return null;
return {
rutEmpresa: fila.U_RutEmpresa ?? '',
usuario: fila.U_UsuarioDTE ?? '',
password: fila.U_PasswordDTE ?? '',
wsdlUrl: fila.U_ConsultaTXTDTEWSDL ?? '',
};
}
/** FolioNum de la Devolución (indefinido si todavía no lo tiene) y su Nº de documento, para el mensaje. */
private async folioDe(sesion: SapSession, docEntry: number): Promise<{ docNum: number; folio?: number }> {
const dev = await this.sl.get<DevolucionSl>(sesion, `PurchaseReturns(${docEntry})?$select=DocEntry,DocNum,FolioNumber`);
return { docNum: dev.DocNum, folio: dev.FolioNumber || undefined };
}
private async unificar(archivos: { folio: number; pdf: Buffer }[], nombre: string): Promise<ArchivoPdf> {
const salida = await PDFDocument.create();
for (const { pdf } of archivos) {
const origen = await PDFDocument.load(pdf);
for (const pagina of await salida.copyPages(origen, origen.getPageIndices())) salida.addPage(pagina);
}
return {
nombre,
base64: Buffer.from(await salida.save()).toString('base64'),
folios: archivos.map((a) => a.folio),
};
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Body, Controller, HttpCode, Param, ParseIntPipe, Post } from '@nestjs/common';
import type { ResultadoPdf } from '@dp/shared';
import { IsBoolean, IsOptional } from 'class-validator';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { PdfDteService } from './pdf-dte.service.js';
class PdfDto {
/** Unir todos los PDF en uno solo (por defecto sí). */
@IsOptional() @IsBoolean() unir?: boolean;
}
@Controller('planificaciones')
export class PdfController {
constructor(private readonly pdf: PdfDteService) {}
/** Paso 4: descarga el PDF de la Guía de despacho de cada Devolución de la planificación. */
@Post(':docEntry/pdf')
@HttpCode(200)
generar(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Body() dto: PdfDto,
): Promise<ResultadoPdf> {
return this.pdf.generar(sesion, docEntry, dto.unir ?? true);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PlanificacionesModule } from '../planificaciones/planificaciones.module.js';
import { BesClient } from './bes.client.js';
import { PdfController } from './pdf.controller.js';
import { PdfDteService } from './pdf-dte.service.js';
@Module({ imports: [PlanificacionesModule], controllers: [PdfController], providers: [PdfDteService, BesClient] })
export class PdfModule {}
@@ -0,0 +1,49 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
import { TAMANO_PAGINA_MAXIMO } from '@dp/shared';
const FECHA_ISO = /^\d{4}-\d{2}-\d{2}$/;
/** Fecha/hora opcionales: la cadena vacía borra el valor guardado. */
const FECHA_OPCIONAL = /^(\d{4}-\d{2}-\d{2})?$/;
const HORA_OPCIONAL = /^(([01]\d|2[0-3]):[0-5]\d)?$/;
/** Campos de cabecera que el usuario puede cambiar mientras la planificación está abierta. */
export class CabeceraDto {
@IsOptional() @Matches(FECHA_ISO, { message: 'docDate debe tener formato yyyy-MM-dd' }) docDate?: string;
@IsOptional() @IsInt() @Min(0) series?: number;
@IsOptional() @IsInt() @Min(1) printingLines?: number;
@IsOptional() @IsString() @MaxLength(10) tipoDespacho?: string;
@IsOptional() @IsString() @MaxLength(10) indicadorTraslado?: string;
@IsOptional() @IsString() @MaxLength(20) rutTransporte?: string;
@IsOptional() @IsString() @MaxLength(20) rutChofer?: string;
@IsOptional() @IsString() @MaxLength(100) nombreChofer?: string;
@IsOptional() @Matches(FECHA_OPCIONAL, { message: 'fechaSalida debe tener formato yyyy-MM-dd' }) fechaSalida?: string;
@IsOptional() @Matches(HORA_OPCIONAL, { message: 'horaSalida debe tener formato HH:mm' }) horaSalida?: string;
@IsOptional() @Matches(FECHA_OPCIONAL, { message: 'fechaLlegada debe tener formato yyyy-MM-dd' }) fechaLlegada?: string;
@IsOptional() @IsString() @MaxLength(20) patente?: string;
}
export class PegarDto {
@IsString() @MaxLength(10) whsCode!: string;
@IsString() texto!: string;
}
export class ProcesarDto extends CabeceraDto {
@IsString() @MaxLength(10) whsCode!: string;
@Matches(FECHA_ISO, { message: 'docDate debe tener formato yyyy-MM-dd' }) declare docDate: string;
@IsString() texto!: string;
}
export class SeleccionDto {
@IsBoolean() applied!: boolean;
}
/** Parámetros de GET /planificaciones. Llegan como texto en la URL: `@Type` los convierte a número. */
export class ListarDto {
@IsOptional() @IsIn(['O', 'C'], { message: 'status debe ser O (abierta) o C (cerrada)' }) status?: 'O' | 'C';
@IsOptional() @IsString() @MaxLength(10) whsCode?: string;
@IsOptional() @Matches(FECHA_ISO, { message: 'desde debe tener formato yyyy-MM-dd' }) desde?: string;
@IsOptional() @Matches(FECHA_ISO, { message: 'hasta debe tener formato yyyy-MM-dd' }) hasta?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) pagina?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(TAMANO_PAGINA_MAXIMO) tamano?: number;
}
@@ -0,0 +1,96 @@
import { Body, Controller, Get, HttpCode, Param, ParseIntPipe, Patch, Post, Query, ValidationPipe } from '@nestjs/common';
import type {
PaginaPlanificaciones,
Planificacion,
RespuestaAbierta,
ResultadoCrearDevoluciones,
ResultadoPegadoApi,
ResultadoProcesar,
} from '@dp/shared';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CabeceraDto, ListarDto, PegarDto, ProcesarDto, SeleccionDto } from './dto/planificaciones.dto.js';
import { PlanificacionesService } from './planificaciones.service.js';
@Controller('planificaciones')
export class PlanificacionesController {
constructor(private readonly planificaciones: PlanificacionesService) {}
/** Una página de planificaciones, con filtros opcionales por estado, almacén y fechas. */
@Get()
listar(
@CurrentSession() sesion: SapSession,
@Query(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) filtros: ListarDto,
): Promise<PaginaPlanificaciones> {
return this.planificaciones.listar(sesion, filtros);
}
/** La planificación abierta (`{ abierta: null }` si no hay ninguna). Siempre responde un JSON. */
@Get('abierta')
async abierta(@CurrentSession() sesion: SapSession): Promise<RespuestaAbierta> {
return { abierta: await this.planificaciones.abierta(sesion) };
}
/** Paso 1: valida el texto pegado desde Excel contra el stock del almacén. No guarda. */
@Post('pegar')
@HttpCode(200)
pegar(@CurrentSession() sesion: SapSession, @Body() dto: PegarDto): Promise<ResultadoPegadoApi> {
return this.planificaciones.validarPegado(sesion, dto.whsCode, dto.texto);
}
/** Paso 2: asigna FIFO y guarda la planificación. */
@Post()
procesar(@CurrentSession() sesion: SapSession, @Body() dto: ProcesarDto): Promise<ResultadoProcesar> {
return this.planificaciones.procesar(sesion, dto);
}
/** Rehace el FIFO de una planificación abierta sin Devoluciones: crea una nueva y cierra la anterior. */
@Post(':docEntry/reprocesar')
reprocesar(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Body() dto: ProcesarDto,
): Promise<ResultadoProcesar> {
return this.planificaciones.reprocesar(sesion, docEntry, dto);
}
@Get(':docEntry')
obtener(@CurrentSession() sesion: SapSession, @Param('docEntry', ParseIntPipe) docEntry: number): Promise<Planificacion> {
return this.planificaciones.obtener(sesion, docEntry);
}
@Patch(':docEntry/cabecera')
actualizarCabecera(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Body() dto: CabeceraDto,
): Promise<Planificacion> {
return this.planificaciones.actualizarCabecera(sesion, docEntry, dto);
}
@Patch(':docEntry/lineas/:lineId')
cambiarSeleccion(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Param('lineId', ParseIntPipe) lineId: number,
@Body() dto: SeleccionDto,
): Promise<Planificacion> {
return this.planificaciones.cambiarSeleccion(sesion, docEntry, lineId, dto.applied);
}
/** Paso 3: crea las Devoluciones de las líneas marcadas que aún no tienen documento. */
@Post(':docEntry/devoluciones')
@HttpCode(200)
crearDevoluciones(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
): Promise<ResultadoCrearDevoluciones> {
return this.planificaciones.crearDevoluciones(sesion, docEntry);
}
@Post(':docEntry/cerrar')
@HttpCode(200)
cerrar(@CurrentSession() sesion: SapSession, @Param('docEntry', ParseIntPipe) docEntry: number): Promise<Planificacion> {
return this.planificaciones.cerrar(sesion, docEntry);
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CatalogosModule } from '../catalogos/catalogos.module.js';
import { PlanificacionesController } from './planificaciones.controller.js';
import { PlanificacionesService } from './planificaciones.service.js';
@Module({ imports: [CatalogosModule], controllers: [PlanificacionesController], providers: [PlanificacionesService], exports: [PlanificacionesService] })
export class PlanificacionesModule {}
@@ -0,0 +1,168 @@
import { PlanificacionesService, construirFiltro } from './planificaciones.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js';
import type { SapSession } from '../service-layer/session.store.js';
const sesion = { id: 's', userName: 'manager' } as SapSession;
const udo = (docEntry: number, status: 'O' | 'C', lineas: Record<string, unknown>[] = []) => ({
DocEntry: docEntry,
DocNum: docEntry,
Status: status,
U_WhsCode: '01',
U_DocDate: '2026-10-12T00:00:00Z',
U_Series: 15,
U_PrintingLines: 11,
U_TipoDespacho: '1',
U_IndicadorTraslado: '7',
U_Patente: 'AB1234',
LIB_DEV1Collection: lineas,
});
const filaHijo = (extra: Record<string, unknown> = {}) => ({ LineId: 2, U_LineType: 'S', U_ItemCode: 'A1', U_Applied: 'Y', ...extra });
const dto = (texto = 'A1\t5'): ProcesarDto => ({ whsCode: '01', docDate: '2026-10-13', texto }) as ProcesarDto;
function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[]; fallaCierre?: boolean } = {}) {
const llamadas: string[] = [];
const sl = {
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
if (path.startsWith('LIB_ODEV(')) return anterior;
throw new Error(`GET inesperado ${path}`);
}),
post: vi.fn(async (_s: unknown, path: string, body: unknown) => {
llamadas.push(path);
if (path === 'LIB_ODEV') return { ...udo(4, 'O'), ...(body as object) , DocEntry: 4, DocNum: 4, Status: 'O' };
if (path.endsWith('/Close')) {
if (opciones.fallaCierre) throw new Error('no se pudo cerrar');
return {};
}
throw new Error(`POST inesperado ${path}`);
}),
};
const sql = {
consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => {
if (consulta.codigo === 'DP_Stock') return opciones.stock ?? [{ ItemCode: 'A1', ItemName: 'Libro', OnHand: 100 }];
return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenQty: 10 }];
}),
};
const catalogos = { series: vi.fn(async () => [{ series: 15, name: 'Primario', nextNumber: 1, esPorDefecto: true }]) };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const servicio = new PlanificacionesService(sl as any, sql as any, catalogos as any);
return { servicio, sl, llamadas };
}
describe('PlanificacionesService.reprocesar', () => {
it('crea la nueva planificación heredando la cabecera y luego cierra la anterior', async () => {
const { servicio, sl, llamadas } = crear(udo(3, 'O', [filaHijo()]));
const r = await servicio.reprocesar(sesion, 3, dto());
expect(llamadas).toEqual(['LIB_ODEV', 'LIB_ODEV(3)/Close']);
const cuerpo = sl.post.mock.calls[0][2] as Record<string, unknown>;
expect(cuerpo).toMatchObject({ U_WhsCode: '01', U_DocDate: '2026-10-13', U_Series: 15, U_TipoDespacho: '1', U_IndicadorTraslado: '7', U_Patente: 'AB1234' });
expect(cuerpo['LIB_DEV1Collection']).toHaveLength(2); // Padre + 1 Hijo
expect(r.planificacion.cabecera.docEntry).toBe(4);
expect(r.reemplazada).toEqual({ docNum: 3, errorCierre: undefined });
});
it('no deja reprocesar si ya hay una Devolución creada', async () => {
const { servicio, llamadas } = crear(udo(3, 'O', [filaHijo({ U_TargetEntry: 33, U_TargetType: '21' })]));
await expect(servicio.reprocesar(sesion, 3, dto())).rejects.toThrow(/ya tiene Devoluciones creadas/);
expect(llamadas).toEqual([]);
});
it('no deja reprocesar una planificación cerrada', async () => {
const { servicio, llamadas } = crear(udo(3, 'C'));
await expect(servicio.reprocesar(sesion, 3, dto())).rejects.toThrow(/está cerrada/);
expect(llamadas).toEqual([]);
});
it('si el listado nuevo no valida, no crea nada ni cierra la anterior', async () => {
const { servicio, llamadas } = crear(udo(3, 'O', [filaHijo()]), { stock: [] });
await expect(servicio.reprocesar(sesion, 3, dto('X9\t1'))).rejects.toThrow(/no existe/);
expect(llamadas).toEqual([]);
});
it('si la nueva se creó pero no se pudo cerrar la anterior, lo informa', async () => {
const { servicio } = crear(udo(3, 'O', [filaHijo()]), { fallaCierre: true });
const r = await servicio.reprocesar(sesion, 3, dto());
expect(r.planificacion.cabecera.docEntry).toBe(4);
expect(r.reemplazada?.errorCierre).toBe('no se pudo cerrar');
});
});
describe('PlanificacionesService.procesar', () => {
it('con una planificación abierta no crea otra', async () => {
const { servicio, sl } = crear(udo(3, 'O'));
sl.get.mockImplementation(async (_s: unknown, path: string) => {
if (path.includes('$filter')) return { value: [{ DocEntry: 3, DocNum: 3, Status: 'O', U_WhsCode: '01', U_DocDate: '2026-10-12T00:00:00Z' }] };
return udo(3, 'O');
});
await expect(servicio.procesar(sesion, dto())).rejects.toThrow(/Ya hay una planificación abierta/);
expect(sl.post).not.toHaveBeenCalled();
});
});
describe('construirFiltro', () => {
it('sin filtros queda vacío', () => {
expect(construirFiltro({})).toBe('');
});
it('combina estado, almacén y rango de fechas con "and"', () => {
expect(construirFiltro({ status: 'O', whsCode: '01', desde: '2026-10-01', hasta: '2026-10-31' })).toBe(
"Status eq 'O' and U_WhsCode eq '01' and U_DocDate ge '2026-10-01' and U_DocDate le '2026-10-31'",
);
});
it('escapa las comillas simples del texto', () => {
expect(construirFiltro({ whsCode: "a'b" })).toBe("U_WhsCode eq 'a''b'");
});
});
describe('PlanificacionesService.listar', () => {
const fila = (n: number) => ({ DocEntry: n, DocNum: n, Status: 'O', U_WhsCode: '01', U_DocDate: '2026-10-12T00:00:00Z' });
function crearConLista(respuesta: object) {
const sl = { request: vi.fn(async () => respuesta) };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { servicio: new PlanificacionesService(sl as any, {} as any, {} as any), sl };
}
it('pide a SAP solo la página pedida, con filtros y total', async () => {
const { servicio, sl } = crearConLista({ value: [fila(41), fila(40)], 'odata.count': '45' });
const r = await servicio.listar(sesion, { status: 'C', pagina: 3, tamano: 20, desde: '2026-10-01' });
const [, metodo, ruta, , cabeceras] = sl.request.mock.calls[0] as unknown as [unknown, string, string, unknown, Record<string, string>];
expect(metodo).toBe('GET');
expect(ruta).toContain('$top=20');
expect(ruta).toContain('$skip=40');
expect(ruta).toContain('$inlinecount=allpages');
expect(decodeURIComponent(ruta)).toContain("$filter=Status eq 'C' and U_DocDate ge '2026-10-01'");
expect(decodeURIComponent(ruta)).toContain('$orderby=DocEntry desc');
expect(cabeceras).toEqual({ Prefer: 'odata.maxpagesize=20' });
expect(r).toMatchObject({ total: 45, pagina: 3, tamano: 20, totalPaginas: 3 });
expect(r.items.map((i) => i.docEntry)).toEqual([41, 40]);
});
it('por defecto: página 1 de 20, sin $filter', async () => {
const { servicio, sl } = crearConLista({ value: [], 'odata.count': 0 });
const r = await servicio.listar(sesion);
const ruta = (sl.request.mock.calls[0] as unknown as [unknown, string, string])[2];
expect(ruta).toContain('$top=20');
expect(ruta).toContain('$skip=0');
expect(ruta).not.toContain('$filter');
expect(r).toMatchObject({ total: 0, pagina: 1, totalPaginas: 1, items: [] });
});
it('limita el tamaño de página al máximo', async () => {
const { servicio, sl } = crearConLista({ value: [], 'odata.count': 0 });
await servicio.listar(sesion, { tamano: 5000 });
expect((sl.request.mock.calls[0] as unknown as [unknown, string, string])[2]).toContain('$top=100');
});
});
@@ -0,0 +1,416 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import type {
ArticuloADevolver,
CabeceraEditable,
CabeceraPlanificacion,
FiltrosPlanificaciones,
PaginaPlanificaciones,
EntradaAbierta,
Planificacion,
ResultadoCrearDevoluciones,
ResultadoDevolucion,
ResultadoPegadoApi,
ResultadoProcesar,
ResumenPlanificacion,
} from '@dp/shared';
import {
MAX_LINEAS_POR_DOCUMENTO_DEFECTO,
TAMANO_PAGINA_DEFECTO,
TAMANO_PAGINA_MAXIMO,
OBJ_TYPE_DEVOLUCION,
OBJ_TYPE_ENTRADA_MERCANCIAS,
asignarFifo,
consolidarDocumentos,
crearLineaPadre,
extraerLineasAplicables,
parsearPortapapeles,
puedeCambiarSeleccion,
} from '@dp/shared';
import { CatalogosService } from '../catalogos/catalogos.service.js';
import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js';
import { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js';
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
import type { UdoSl } from './udo.mapper.js';
interface FilaStock {
ItemCode: string;
ItemName: string | null;
OnHand: number | string;
}
interface FilaEntrada {
DocNum: number;
CardCode: string;
CardName: string;
DocDate: string;
DocEntry: number;
LineNum: number;
OpenQty: number | string;
}
interface DocumentoCreado {
DocEntry: number;
DocNum: number;
}
interface ResumenUdoSl {
DocEntry: number;
DocNum: number;
Status: string;
U_WhsCode: string;
U_DocDate: string;
}
const CONCURRENCIA_CONSULTAS = 8;
const COLUMNAS_RESUMEN = 'DocEntry,DocNum,Status,U_WhsCode,U_DocDate';
/** "2026-10-12T00:00:00Z" o "20261012" → "2026-10-12". */
const fechaIso = (valor: string): string =>
valor.includes('-') ? valor.slice(0, 10) : `${valor.slice(0, 4)}-${valor.slice(4, 6)}-${valor.slice(6, 8)}`;
/** Ejecuta `fn` sobre cada elemento con a lo sumo `limite` llamadas simultáneas, conservando el orden. */
async function enLotes<T, R>(items: T[], limite: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const resultados = Array.from<R>({ length: items.length });
let siguiente = 0;
const trabajadores = Array.from({ length: Math.min(limite, items.length) }, async () => {
while (siguiente < items.length) {
const i = siguiente++;
resultados[i] = await fn(items[i]);
}
});
await Promise.all(trabajadores);
return resultados;
}
@Injectable()
export class PlanificacionesService {
private readonly logger = new Logger(PlanificacionesService.name);
constructor(
private readonly sl: ServiceLayerClient,
private readonly sql: SqlQueriesService,
private readonly catalogos: CatalogosService,
) {}
/** Paso 1: parsea lo pegado desde Excel y completa descripción y stock del almacén. No guarda nada. */
async validarPegado(sesion: SapSession, whsCode: string, texto: string): Promise<ResultadoPegadoApi> {
const { filas, errores } = parsearPortapapeles(texto);
const lineas = [];
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => ({
fila,
stock: (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, { whs: whsCode, item: fila.itemCode }))[0],
}));
for (const { fila, stock } of consultas) {
if (!stock) {
errores.push(`${fila.itemCode}: el artículo no existe o no tiene stock registrado en el almacén ${whsCode}.`);
continue;
}
const articulo: ArticuloADevolver = {
itemCode: stock.ItemCode,
itemName: stock.ItemName ?? '',
quantity: fila.quantity,
onHand: Math.floor(Number(stock.OnHand)),
};
lineas.push(crearLineaPadre(articulo));
}
return { lineas, errores };
}
/**
* Una página de planificaciones, de la más nueva a la más vieja. Los filtros se aplican en SAP
* ($filter) y la paginación con $top/$skip; el total sale de $inlinecount (OData v3 del SL v1).
*/
async listar(sesion: SapSession, filtros: FiltrosPlanificaciones = {}): Promise<PaginaPlanificaciones> {
const tamano = Math.min(Math.max(filtros.tamano ?? TAMANO_PAGINA_DEFECTO, 1), TAMANO_PAGINA_MAXIMO);
const pagina = Math.max(filtros.pagina ?? 1, 1);
const filtro = construirFiltro(filtros);
const consulta = [
`$select=${COLUMNAS_RESUMEN}`,
`$orderby=${encodeURIComponent('DocEntry desc')}`,
`$top=${tamano}`,
`$skip=${(pagina - 1) * tamano}`,
'$inlinecount=allpages',
...(filtro ? [`$filter=${encodeURIComponent(filtro)}`] : []),
].join('&');
const respuesta = await this.sl.request<{ value: ResumenUdoSl[]; 'odata.count'?: string | number }>(
sesion,
'GET',
`LIB_ODEV?${consulta}`,
undefined,
{ Prefer: `odata.maxpagesize=${tamano}` },
);
const total = Number(respuesta['odata.count'] ?? respuesta.value.length);
return { items: respuesta.value.map(aResumen), total, pagina, tamano, totalPaginas: Math.max(Math.ceil(total / tamano), 1) };
}
/** La planificación abierta (solo puede haber una) o null. */
async abierta(sesion: SapSession): Promise<ResumenPlanificacion | null> {
const filtro = encodeURIComponent("Status eq 'O'");
const { value } = await this.sl.get<{ value: ResumenUdoSl[] }>(sesion, `LIB_ODEV?$select=${COLUMNAS_RESUMEN}&$filter=${filtro}&$top=1`);
return value[0] ? aResumen(value[0]) : null;
}
async obtener(sesion: SapSession, docEntry: number): Promise<Planificacion> {
return planificacionDesdeUdo(await this.sl.get<UdoSl>(sesion, `LIB_ODEV(${docEntry})`));
}
/** Paso 2: valida, asigna FIFO contra Entradas abiertas del almacén y guarda la planificación. */
async procesar(sesion: SapSession, dto: ProcesarDto): Promise<ResultadoProcesar> {
const existente = await this.abierta(sesion);
if (existente) {
throw new ConflictException(`Ya hay una planificación abierta (Nº ${existente.docNum}). Ciérrela antes de crear otra.`);
}
const { payload, lineas, advertencias } = await this.construir(sesion, dto);
const creada = await this.sl.post<UdoSl>(sesion, 'LIB_ODEV', payload);
this.logger.log(`Planificación ${creada.DocEntry} creada por ${sesion.userName}: ${lineas} líneas.`);
return { planificacion: planificacionDesdeUdo(creada), advertencias };
}
/**
* Rehace el FIFO de una planificación abierta que aún no tiene Devoluciones. Las filas de un UDO
* no se pueden quitar por Service Layer, así que se crea una planificación nueva (heredando los
* datos de cabecera) y, solo si salió bien, se cierra la anterior.
*/
async reprocesar(sesion: SapSession, docEntry: number, dto: ProcesarDto): Promise<ResultadoProcesar> {
const anterior = await this.exigirAbierta(sesion, docEntry);
if (anterior.lineas.some((l) => l.targetEntry != null)) {
throw new ConflictException(
`La planificación ${anterior.cabecera.docNum} ya tiene Devoluciones creadas y no se puede reprocesar. Ciérrela y cree una nueva con lo pendiente.`,
);
}
const { payload, lineas, advertencias } = await this.construir(sesion, dto, anterior.cabecera);
const creada = await this.sl.post<UdoSl>(sesion, 'LIB_ODEV', payload);
this.logger.log(
`Planificación ${creada.DocEntry} creada por ${sesion.userName} reemplazando a ${anterior.cabecera.docNum}: ${lineas} líneas.`,
);
let errorCierre: string | undefined;
try {
await this.sl.post(sesion, `LIB_ODEV(${docEntry})/Close`, {});
} catch (error) {
errorCierre = (error as Error).message;
this.logger.error(`No se pudo cerrar la planificación ${anterior.cabecera.docNum} tras reemplazarla: ${errorCierre}`);
}
return { planificacion: planificacionDesdeUdo(creada), advertencias, reemplazada: { docNum: anterior.cabecera.docNum ?? docEntry, errorCierre } };
}
/**
* Valida lo pegado, asigna el FIFO y arma el cuerpo del POST a LIB_ODEV. `base` aporta los datos de
* cabecera que el usuario ya había completado en la planificación que se reemplaza.
*/
private async construir(
sesion: SapSession,
dto: ProcesarDto,
base?: CabeceraPlanificacion,
): Promise<{ payload: Record<string, unknown>; lineas: number; advertencias: string[] }> {
const pegado = await this.validarPegado(sesion, dto.whsCode, dto.texto);
if (pegado.errores.length > 0) throw new BadRequestException({ message: pegado.errores.join(' '), errores: pegado.errores });
if (pegado.lineas.length === 0) throw new BadRequestException('No hay artículos para procesar.');
const articulos: ArticuloADevolver[] = pegado.lineas.map((l) => ({
itemCode: l.itemCode,
itemName: l.itemDescription,
quantity: l.quantity ?? 0,
onHand: l.onHand ?? 0,
}));
const entradasPorItem = new Map<string, EntradaAbierta[]>();
await enLotes(
articulos.filter((a) => a.quantity <= a.onHand),
CONCURRENCIA_CONSULTAS,
async (a) => {
const filas = await this.sql.consultar<FilaEntrada>(sesion, CONSULTA_ENTRADAS_ABIERTAS, { whs: dto.whsCode, item: a.itemCode });
entradasPorItem.set(
a.itemCode.toUpperCase(),
filas.map((f) => ({
docEntry: f.DocEntry,
lineNum: f.LineNum,
docNum: f.DocNum,
docDate: fechaIso(String(f.DocDate)),
cardCode: f.CardCode,
cardName: f.CardName,
openQty: Math.floor(Number(f.OpenQty)),
})),
);
},
);
const { lineas, advertencias } = asignarFifo(articulos, entradasPorItem);
if (lineas.length === 0) throw new BadRequestException({ message: 'Ningún artículo tiene stock suficiente.', advertencias });
const { texto: _texto, ...cabecera } = dto;
const heredada = base && {
series: base.series,
printingLines: base.printingLines,
tipoDespacho: base.tipoDespacho,
indicadorTraslado: base.indicadorTraslado,
rutTransporte: base.rutTransporte,
rutChofer: base.rutChofer,
nombreChofer: base.nombreChofer,
fechaSalida: base.fechaSalida,
horaSalida: base.horaSalida,
fechaLlegada: base.fechaLlegada,
patente: base.patente,
};
const combinada = { ...heredada, ...cabecera };
const series = combinada.series || ((await this.catalogos.series(sesion)).find((s) => s.esPorDefecto)?.series ?? 0);
return {
payload: {
...cabeceraAUdo(
{ ...combinada, series, printingLines: combinada.printingLines || MAX_LINEAS_POR_DOCUMENTO_DEFECTO },
OBJ_TYPE_DEVOLUCION,
),
LIB_DEV1Collection: lineas.map(lineaAUdo),
},
lineas: lineas.length,
advertencias,
};
}
async actualizarCabecera(sesion: SapSession, docEntry: number, cambios: CabeceraEditable): Promise<Planificacion> {
await this.exigirAbierta(sesion, docEntry);
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, cabeceraAUdo(cambios));
return this.obtener(sesion, docEntry);
}
/** Marca o desmarca una fila Hijo. Una fila con Devolución ya creada no se puede cambiar. */
async cambiarSeleccion(sesion: SapSession, docEntry: number, lineId: number, applied: boolean): Promise<Planificacion> {
const plan = await this.exigirAbierta(sesion, docEntry);
const linea = plan.lineas.find((l) => l.lineId === lineId);
if (!linea) throw new BadRequestException(`La línea ${lineId} no existe en la planificación.`);
if (!puedeCambiarSeleccion(linea)) {
throw new ConflictException('Solo las filas Hijo sin Devolución creada pueden marcarse o desmarcarse.');
}
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, { LIB_DEV1Collection: [{ LineId: lineId, U_Applied: applied ? 'Y' : 'N' }] });
return this.obtener(sesion, docEntry);
}
/**
* Paso 3: crea una Devolución por proveedor y lote de U_PrintingLines líneas. Cada documento se
* crea por separado: los que salen bien graban TargetEntry en sus líneas, los que fallan
* graban ErrCode/ErrMsg y quedan listos para reintentar.
*/
async crearDevoluciones(sesion: SapSession, docEntry: number): Promise<ResultadoCrearDevoluciones> {
const plan = await this.exigirAbierta(sesion, docEntry);
const cab = plan.cabecera;
const faltantes = [
!cab.docDate && 'fecha',
!cab.series && 'serie',
!cab.tipoDespacho && 'tipo de despacho',
!cab.indicadorTraslado && 'indicador de traslado',
].filter(Boolean);
if (faltantes.length > 0) throw new BadRequestException(`Falta completar: ${faltantes.join(', ')}.`);
const aplicables = extraerLineasAplicables(plan.lineas);
if (aplicables.length === 0) throw new BadRequestException('No hay líneas marcadas pendientes de devolver.');
const documentos = consolidarDocumentos(aplicables, cab.printingLines);
const devoluciones: ResultadoDevolucion[] = [];
const cambiosLineas: Record<string, unknown>[] = [];
for (const doc of documentos) {
const base = { numero: doc.numero, cardCode: doc.cardCode, cardName: doc.cardName, lineas: doc.lineas.length };
try {
const creado = await this.sl.post<DocumentoCreado>(sesion, 'PurchaseReturns', {
CardCode: doc.cardCode,
Series: cab.series,
DocDate: cab.docDate,
TaxDate: cab.docDate,
DocDueDate: cab.docDate,
U_BFE_TipoDespacho: cab.tipoDespacho,
U_BFE_IndTraslado: cab.indicadorTraslado,
...(cab.rutTransporte && { U_BFE_RutTrasporte: cab.rutTransporte }),
...(cab.patente && { U_BFE_Patente: cab.patente }),
...(cab.rutChofer && { U_BFE_RutChofer: cab.rutChofer }),
...(cab.nombreChofer && { U_BFE_NombreChofer: cab.nombreChofer }),
DocumentLines: doc.lineas.map((l) => ({
Quantity: l.allocatedQty,
BaseType: OBJ_TYPE_ENTRADA_MERCANCIAS,
BaseEntry: l.baseEntry,
BaseLine: l.baseLine,
})),
});
devoluciones.push({ ...base, docEntry: creado.DocEntry, docNum: creado.DocNum });
for (const l of doc.lineas) {
cambiosLineas.push({
LineId: l.lineId,
U_TargetType: String(OBJ_TYPE_DEVOLUCION),
U_TargetEntry: creado.DocEntry,
U_ErrCode: '',
U_ErrMsg: '',
});
}
} catch (error) {
const mensaje = (error as Error).message;
const codigo = error instanceof ServiceLayerException ? String(error.sapCode ?? 'ERR') : 'ERR';
this.logger.warn(`Devolución ${doc.numero} (${doc.cardCode}) falló: ${mensaje}`);
devoluciones.push({ ...base, error: mensaje });
for (const l of doc.lineas) {
cambiosLineas.push({ LineId: l.lineId, U_ErrCode: codigo.slice(0, 10), U_ErrMsg: mensaje.slice(0, 254) });
}
}
}
let errorGuardado: string | undefined;
try {
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, { LIB_DEV1Collection: cambiosLineas });
} catch (error) {
errorGuardado = (error as Error).message;
this.logger.error(`Devoluciones creadas pero no se pudo guardar el detalle de la planificación ${docEntry}: ${errorGuardado}`);
}
const planificacion = await this.obtener(sesion, docEntry).catch(() => plan);
return { devoluciones, errorGuardado, planificacion };
}
async cerrar(sesion: SapSession, docEntry: number): Promise<Planificacion> {
await this.exigirAbierta(sesion, docEntry);
await this.sl.post(sesion, `LIB_ODEV(${docEntry})/Close`, {});
return this.obtener(sesion, docEntry);
}
private async exigirAbierta(sesion: SapSession, docEntry: number): Promise<Planificacion> {
const plan = await this.obtener(sesion, docEntry);
if (plan.cabecera.status === 'C') throw new ConflictException(`La planificación ${plan.cabecera.docNum} está cerrada.`);
return plan;
}
}
/** Arma el $filter de OData a partir de los filtros; los textos van entre comillas simples escapadas. */
export function construirFiltro(f: FiltrosPlanificaciones): string {
const texto = (v: string) => `'${v.replace(/'/g, "''")}'`;
return [
f.status && `Status eq ${texto(f.status)}`,
f.whsCode && `U_WhsCode eq ${texto(f.whsCode)}`,
f.desde && `U_DocDate ge ${texto(f.desde)}`,
f.hasta && `U_DocDate le ${texto(f.hasta)}`,
]
.filter(Boolean)
.join(' and ');
}
function aResumen(u: ResumenUdoSl): ResumenPlanificacion {
return {
docEntry: u.DocEntry,
docNum: u.DocNum,
status: u.Status === 'C' ? 'C' : 'O',
whsCode: u.U_WhsCode ?? '',
docDate: typeof u.U_DocDate === 'string' ? u.U_DocDate.slice(0, 10) : '',
};
}
@@ -0,0 +1,89 @@
import type { LineaPlanificacion } from '@dp/shared';
import { cabeceraAUdo, cabeceraDesdeUdo, horaDesdeSl, lineaAUdo, lineaDesdeUdo, planificacionDesdeUdo } from './udo.mapper.js';
const hijo: LineaPlanificacion = {
tipoFila: 'S',
itemCode: 'A1',
itemDescription: 'Libro',
quantity: null,
onHand: null,
status: null,
baseType: 20,
baseEntry: 15,
baseLine: 2,
baseRef: 101,
baseDocDate: '2026-01-31',
openQty: 10,
allocatedQty: 4,
applied: true,
cardCode: 'P001',
cardName: 'Proveedor',
targetType: null,
targetEntry: null,
errCode: '',
errMsg: '',
};
describe('horaDesdeSl', () => {
it.each([
['14:30:00', '14:30'],
['9:05:00', '09:05'],
[1430, '14:30'],
['905', '09:05'],
[null, ''],
['', ''],
])('%s → %s', (entrada, esperado) => {
expect(horaDesdeSl(entrada)).toBe(esperado);
});
});
describe('cabecera', () => {
it('convierte fechas ISO, hora y estado', () => {
const cab = cabeceraDesdeUdo({
DocEntry: 3,
DocNum: 3,
Status: 'O',
U_WhsCode: '01',
U_DocDate: '2026-10-12T00:00:00Z',
U_Series: 15,
U_PrintingLines: 11,
U_HoraSalida: '08:05:00',
U_FechaSalida: null,
});
expect(cab).toMatchObject({ docEntry: 3, status: 'O', whsCode: '01', docDate: '2026-10-12', series: 15, horaSalida: '08:05', fechaSalida: '' });
});
it('al escribir solo manda lo que viene y la hora con segundos', () => {
expect(cabeceraAUdo({ patente: 'AB1234', horaSalida: '08:05', fechaSalida: '' })).toEqual({
U_Patente: 'AB1234',
U_HoraSalida: '08:05:00',
U_FechaSalida: null,
});
});
});
describe('líneas', () => {
it('ida y vuelta de una fila Hijo', () => {
const udo = lineaAUdo(hijo);
expect(udo).toMatchObject({ U_LineType: 'S', U_BaseType: '20', U_BaseEntry: 15, U_AllocatedQty: 4, U_Applied: 'Y' });
expect(udo).not.toHaveProperty('U_Quantity');
expect(lineaDesdeUdo(udo)).toEqual(hijo);
});
it('un TargetEntry en 0 (campo sin llenar) se lee como null', () => {
const l = lineaDesdeUdo({ LineId: 1, U_LineType: 'S', U_TargetEntry: 0, U_TargetType: '' });
expect(l.targetEntry).toBeNull();
expect(l.targetType).toBeNull();
});
it('una fila Padre conserva cantidad, stock y estado', () => {
const padre = lineaDesdeUdo({ LineId: 1, U_LineType: 'F', U_ItemCode: 'A1', U_Quantity: 5, U_OnHand: 8, U_Status: 'OK', U_BaseEntry: 0 });
expect(padre).toMatchObject({ tipoFila: 'F', quantity: 5, onHand: 8, status: 'OK', baseEntry: null });
});
it('planificacionDesdeUdo arma cabecera y líneas', () => {
const p = planificacionDesdeUdo({ DocEntry: 1, DocNum: 1, Status: 'C', LIB_DEV1Collection: [{ LineId: 1, U_LineType: 'F' }] });
expect(p.cabecera.status).toBe('C');
expect(p.lineas).toHaveLength(1);
});
});
+160
View File
@@ -0,0 +1,160 @@
import type { CabeceraPlanificacion, LineaPlanificacion, Planificacion } from '@dp/shared';
// Traducción entre el modelo de dominio (@dp/shared) y el UDO LIB_ODEV tal como lo expone el
// Service Layer (cabecera con prefijo U_ y líneas en LIB_DEV1Collection).
export interface UdoLineaSl {
LineId?: number;
[campo: string]: unknown;
}
export interface UdoSl {
DocEntry: number;
DocNum: number;
Status: string;
LIB_DEV1Collection?: UdoLineaSl[];
[campo: string]: unknown;
}
type Escalar = string | number | boolean | null | undefined;
const texto = (v: unknown): string => (v == null ? '' : String(v as Escalar));
const entero = (v: unknown): number | null => {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/** Referencias a documentos: el UDO devuelve 0 cuando el campo numérico no se llenó. */
const referencia = (v: unknown): number | null => {
const n = entero(v);
return n === 0 ? null : n;
};
/** "2026-10-12T00:00:00Z" → "2026-10-12". */
const fechaDesdeSl = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
/** El Service Layer entrega la hora como "14:30:00" o como número (1430): se normaliza a "HH:mm". */
export function horaDesdeSl(v: unknown): string {
if (v == null || v === '') return '';
const s = String(v as Escalar);
const hhmm = /^(\d{1,2}):(\d{2})/.exec(s);
if (hhmm) return `${hhmm[1].padStart(2, '0')}:${hhmm[2]}`;
if (/^\d{3,4}$/.test(s)) return `${s.padStart(4, '0').slice(0, 2)}:${s.padStart(4, '0').slice(2)}`;
return '';
}
/** "14:30" → "14:30:00" (formato que acepta el Service Layer para db_Date/st_Time). */
const horaASl = (v: string): string => (/^\d{2}:\d{2}$/.test(v) ? `${v}:00` : v);
export function cabeceraDesdeUdo(u: UdoSl): CabeceraPlanificacion {
return {
docEntry: u.DocEntry,
docNum: u.DocNum,
status: u.Status === 'C' ? 'C' : 'O',
whsCode: texto(u.U_WhsCode),
docDate: fechaDesdeSl(u.U_DocDate),
series: entero(u.U_Series) ?? 0,
printingLines: entero(u.U_PrintingLines) ?? 0,
tipoDespacho: texto(u.U_TipoDespacho),
indicadorTraslado: texto(u.U_IndicadorTraslado),
rutTransporte: texto(u.U_RutTransporte),
rutChofer: texto(u.U_RutChofer),
nombreChofer: texto(u.U_NombreChofer),
fechaSalida: fechaDesdeSl(u.U_FechaSalida),
horaSalida: horaDesdeSl(u.U_HoraSalida),
fechaLlegada: fechaDesdeSl(u.U_FechaLlegada),
patente: texto(u.U_Patente),
};
}
export function lineaDesdeUdo(l: UdoLineaSl): LineaPlanificacion {
const esPadre = l.U_LineType !== 'S';
const estado = texto(l.U_Status);
return {
lineId: l.LineId,
tipoFila: esPadre ? 'F' : 'S',
itemCode: texto(l.U_ItemCode),
itemDescription: texto(l.U_ItemDescription),
quantity: esPadre ? entero(l.U_Quantity) : null,
onHand: esPadre ? entero(l.U_OnHand) : null,
status: esPadre && (estado === 'OK' || estado === 'SIN_STOCK') ? estado : null,
baseType: esPadre ? null : referencia(l.U_BaseType),
baseEntry: esPadre ? null : referencia(l.U_BaseEntry),
baseLine: esPadre ? null : entero(l.U_BaseLine),
baseRef: esPadre ? null : referencia(l.U_BaseRef),
baseDocDate: esPadre ? null : fechaDesdeSl(l.U_BaseDocDate) || null,
openQty: esPadre ? null : entero(l.U_OpenQty),
allocatedQty: esPadre ? null : entero(l.U_AllocatedQty),
applied: l.U_Applied === 'Y',
cardCode: texto(l.U_CardCode),
cardName: texto(l.U_CardName),
targetType: referencia(l.U_TargetType),
targetEntry: referencia(l.U_TargetEntry),
errCode: texto(l.U_ErrCode),
errMsg: texto(l.U_ErrMsg),
};
}
export function planificacionDesdeUdo(u: UdoSl): Planificacion {
return {
cabecera: cabeceraDesdeUdo(u),
lineas: (u.LIB_DEV1Collection ?? []).map(lineaDesdeUdo),
};
}
/** Quita los valores indefinidos para no mandar campos vacíos al Service Layer. */
function sinIndefinidos(o: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
}
/** Cabecera → propiedades U_ del UDO. Solo incluye los campos presentes (sirve para PATCH). */
export function cabeceraAUdo(c: Partial<CabeceraPlanificacion>, objType?: number): Record<string, unknown> {
const fecha = (v: string | undefined) => (v === undefined ? undefined : v === '' ? null : v);
return sinIndefinidos({
U_WhsCode: c.whsCode,
U_DocDate: fecha(c.docDate),
U_Series: c.series,
U_ObjType: objType === undefined ? undefined : String(objType),
U_PrintingLines: c.printingLines,
U_TipoDespacho: c.tipoDespacho,
U_IndicadorTraslado: c.indicadorTraslado,
U_RutTransporte: c.rutTransporte,
U_RutChofer: c.rutChofer,
U_NombreChofer: c.nombreChofer,
U_FechaSalida: fecha(c.fechaSalida),
U_HoraSalida: c.horaSalida === undefined ? undefined : c.horaSalida === '' ? null : horaASl(c.horaSalida),
U_FechaLlegada: fecha(c.fechaLlegada),
U_Patente: c.patente,
});
}
/** Línea → propiedades U_ de LIB_DEV1. Con lineId sirve para actualizar esa fila por PATCH. */
export function lineaAUdo(l: LineaPlanificacion): Record<string, unknown> {
const esPadre = l.tipoFila === 'F';
return sinIndefinidos({
LineId: l.lineId,
U_LineType: l.tipoFila,
U_ItemCode: l.itemCode,
U_ItemDescription: l.itemDescription,
U_Quantity: esPadre ? (l.quantity ?? undefined) : undefined,
U_OnHand: esPadre ? (l.onHand ?? undefined) : undefined,
U_Status: esPadre ? (l.status ?? undefined) : undefined,
U_BaseType: l.baseType == null ? undefined : String(l.baseType),
U_BaseEntry: l.baseEntry ?? undefined,
U_BaseLine: l.baseLine ?? undefined,
U_BaseRef: l.baseRef ?? undefined,
U_BaseDocDate: l.baseDocDate ?? undefined,
U_OpenQty: l.openQty ?? undefined,
U_AllocatedQty: l.allocatedQty ?? undefined,
U_Applied: l.applied ? 'Y' : 'N',
U_CardCode: l.cardCode,
U_CardName: l.cardName,
U_TargetType: l.targetType == null ? undefined : String(l.targetType),
U_TargetEntry: l.targetEntry ?? undefined,
U_ErrCode: l.errCode,
U_ErrMsg: l.errMsg,
});
}
@@ -0,0 +1,70 @@
import type { ConfigService } from '@nestjs/config';
import type { AppConfig } from '../config/configuration.js';
import { ServiceLayerClient } from './service-layer.client.js';
import { SessionStore } from './session.store.js';
import type { SapSession } from './session.store.js';
// Reemplaza a axios: cuenta cuántas peticiones están en vuelo a la vez.
const estado = vi.hoisted(() => ({ enVuelo: 0, maximo: 0, orden: [] as string[], fallar: new Set<string>() }));
vi.mock('axios', async (importOriginal) => {
const original = await importOriginal<typeof import('axios')>();
return {
...original,
default: {
create: () => ({
request: async ({ url }: { url: string }) => {
estado.enVuelo++;
estado.maximo = Math.max(estado.maximo, estado.enVuelo);
estado.orden.push(url);
await new Promise((r) => setTimeout(r, 5));
estado.enVuelo--;
if (estado.fallar.has(url)) throw new Error('falló');
return { data: { url } };
},
}),
},
};
});
const config = { get: () => ({ url: 'https://sl/b1s/v1', companyDb: 'X', rejectUnauthorized: false }) } as unknown as ConfigService<AppConfig, true>;
const sesion = (id: string): SapSession => ({
id, b1Session: 'b', userName: 'u', companyDb: 'X', version: '1', timeoutMinutes: 30, expiresAt: Date.now() + 60_000,
});
describe('ServiceLayerClient: cola por sesión', () => {
let client: ServiceLayerClient;
let store: SessionStore;
beforeEach(() => {
Object.assign(estado, { enVuelo: 0, maximo: 0, orden: [], fallar: new Set<string>() });
store = new SessionStore();
client = new ServiceLayerClient(config, store);
});
afterEach(() => store.onModuleDestroy());
it('las peticiones simultáneas de una sesión se envían de a una y en orden', async () => {
const s = sesion('a');
const resultados = await Promise.all(['uno', 'dos', 'tres', 'cuatro'].map((p) => client.get<{ url: string }>(s, p)));
expect(estado.maximo).toBe(1);
expect(estado.orden).toEqual(['uno', 'dos', 'tres', 'cuatro']);
expect(resultados.map((r) => r.url)).toEqual(['uno', 'dos', 'tres', 'cuatro']);
});
it('sesiones distintas sí pueden ir en paralelo', async () => {
await Promise.all([client.get(sesion('a'), 'x'), client.get(sesion('b'), 'y')]);
expect(estado.maximo).toBe(2);
});
it('una petición que falla no bloquea a las siguientes', async () => {
estado.fallar.add('malo');
const s = sesion('a');
const [malo, bueno] = await Promise.allSettled([client.get(s, 'malo'), client.get<{ url: string }>(s, 'bueno')]);
expect(malo.status).toBe('rejected');
expect(bueno).toMatchObject({ status: 'fulfilled', value: { url: 'bueno' } });
});
});
@@ -0,0 +1,182 @@
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance, Method } from 'axios';
import { Agent } from 'node:https';
import { AppConfig } from '../config/configuration.js';
import { SapSession, SessionStore } from './session.store.js';
/** Una petición que tarda más que esto se avisa en el log. */
const UMBRAL_LENTA_MS = 2000;
interface SlErrorBody {
error?: { code?: number | string; message?: string | { value?: string } };
}
interface SlLoginResponse {
SessionId: string;
Version: string;
SessionTimeout: number;
}
interface SlCollection<T> {
value: T[];
'odata.nextLink'?: string;
'@odata.nextLink'?: string;
}
/** Error devuelto por el Service Layer, con el código SAP para mostrarlo al usuario. */
export class ServiceLayerException extends HttpException {
constructor(status: number, message: string, readonly sapCode?: number | string) {
super({ statusCode: status, message, sapCode }, status);
}
}
@Injectable()
export class ServiceLayerClient {
private readonly logger = new Logger(ServiceLayerClient.name);
/** Última petición en curso de cada sesión (id de sesión → promesa), para enviarlas en serie. */
private readonly colas = new Map<string, Promise<unknown>>();
private readonly http: AxiosInstance;
private readonly companyDb: string;
constructor(
config: ConfigService<AppConfig, true>,
private readonly sessions: SessionStore,
) {
const sl = config.get('serviceLayer', { infer: true });
this.companyDb = sl.companyDb;
this.http = axios.create({
baseURL: `${sl.url}/`,
timeout: 120_000,
httpsAgent: new Agent({ rejectUnauthorized: sl.rejectUnauthorized }),
headers: { 'Content-Type': 'application/json' },
});
}
async login(userName: string, password: string): Promise<SapSession> {
try {
const res = await this.http.post<SlLoginResponse>('Login', {
CompanyDB: this.companyDb,
UserName: userName,
Password: password,
});
const cookies = res.headers['set-cookie'] ?? [];
const leer = (nombre: string) =>
cookies.map((c) => c.split(';')[0]).find((c) => c.startsWith(`${nombre}=`))?.slice(nombre.length + 1);
return this.sessions.crear({
b1Session: leer('B1SESSION') ?? res.data.SessionId,
routeId: leer('ROUTEID'),
userName,
companyDb: this.companyDb,
version: res.data.Version,
timeoutMinutes: res.data.SessionTimeout || 30,
});
} catch (error) {
throw this.traducirError(error, 'Login');
}
}
async logout(sesion: SapSession): Promise<void> {
try {
await this.http.post('Logout', undefined, { headers: this.cabeceras(sesion) });
} catch (error) {
this.logger.warn(`Logout de ${sesion.userName} falló: ${(error as Error).message}`);
} finally {
this.sessions.eliminar(sesion.id);
}
}
/**
* Las peticiones de una misma sesión se envían de a una. El Service Layer no atiende bien las
* simultáneas: las procesa en serie y cada una espera ~7 s tras la anterior, así que seis
* consultas en paralelo tardaban ~40 s en lugar de ~0,3 s.
*/
request<T>(sesion: SapSession, method: Method, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> {
const anterior = this.colas.get(sesion.id) ?? Promise.resolve();
const actual = anterior.then(
() => this.enviar<T>(sesion, method, path, data, headers),
() => this.enviar<T>(sesion, method, path, data, headers),
);
this.colas.set(sesion.id, actual);
const limpiar = () => {
if (this.colas.get(sesion.id) === actual) this.colas.delete(sesion.id);
};
actual.then(limpiar, limpiar);
return actual;
}
private async enviar<T>(sesion: SapSession, method: Method, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> {
const inicio = performance.now();
try {
const res = await this.http.request<T>({ method, url: path, data, headers: { ...this.cabeceras(sesion), ...headers } });
this.sessions.renovar(sesion);
const ms = Math.round(performance.now() - inicio);
if (ms > UMBRAL_LENTA_MS) this.logger.warn(`Petición lenta al Service Layer: ${method.toUpperCase()} ${path.slice(0, 120)} (${ms} ms)`);
return res.data;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 401) {
this.sessions.eliminar(sesion.id);
throw new UnauthorizedException('La sesión de SAP Business One expiró. Ingrese nuevamente.');
}
throw this.traducirError(error, `${method.toUpperCase()} ${path}`);
}
}
get<T>(sesion: SapSession, path: string): Promise<T> {
return this.request<T>(sesion, 'GET', path);
}
post<T>(sesion: SapSession, path: string, data: unknown): Promise<T> {
return this.request<T>(sesion, 'POST', path, data);
}
patch(sesion: SapSession, path: string, data: unknown): Promise<void> {
return this.request<void>(sesion, 'PATCH', path, data);
}
delete(sesion: SapSession, path: string): Promise<void> {
return this.request<void>(sesion, 'DELETE', path);
}
/** Lee una colección completa siguiendo los nextLink de paginación. */
async getAll<T>(sesion: SapSession, path: string): Promise<T[]> {
const resultado: T[] = [];
let siguiente: string | undefined = path;
while (siguiente) {
const pagina: SlCollection<T> = await this.request<SlCollection<T>>(sesion, 'GET', siguiente, undefined, {
Prefer: 'odata.maxpagesize=500',
});
resultado.push(...pagina.value);
siguiente = (pagina['@odata.nextLink'] ?? pagina['odata.nextLink'])?.replace(/^.*\/b1s\/v\d+\//, '');
}
return resultado;
}
private cabeceras(sesion: SapSession): Record<string, string> {
const cookie = [`B1SESSION=${sesion.b1Session}`, sesion.routeId ? `ROUTEID=${sesion.routeId}` : undefined]
.filter(Boolean)
.join('; ');
return { Cookie: cookie };
}
private traducirError(error: unknown, operacion: string): HttpException {
if (error instanceof AxiosError) {
if (error.response) {
const body = error.response.data as SlErrorBody | undefined;
const mensaje = typeof body?.error?.message === 'string' ? body.error.message : body?.error?.message?.value;
const status = error.response.status >= 500 ? HttpStatus.BAD_GATEWAY : error.response.status;
this.logger.error(`${operacion}: HTTP ${error.response.status} ${mensaje ?? JSON.stringify(body)}`);
return new ServiceLayerException(status, mensaje ?? `Error del Service Layer en ${operacion}.`, body?.error?.code);
}
this.logger.error(`${operacion}: ${error.code} ${error.message}`);
return new ServiceLayerException(HttpStatus.BAD_GATEWAY, `No se pudo conectar con el Service Layer (${error.code ?? error.message}).`);
}
return error instanceof HttpException ? error : new HttpException((error as Error).message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ServiceLayerClient } from './service-layer.client.js';
import { SessionStore } from './session.store.js';
@Global()
@Module({
providers: [SessionStore, ServiceLayerClient],
exports: [SessionStore, ServiceLayerClient],
})
export class ServiceLayerModule {}
@@ -0,0 +1,62 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { randomBytes } from 'node:crypto';
/** Sesión del Service Layer de un usuario; el navegador solo conoce el id. */
export interface SapSession {
id: string;
b1Session: string;
routeId?: string;
userName: string;
companyDb: string;
version: string;
/** Timeout de inactividad devuelto por el Login, en minutos. */
timeoutMinutes: number;
expiresAt: number;
}
/**
* Guarda en memoria las sesiones del Service Layer. Suficiente para una sola instancia de
* la API; con varias instancias habría que llevarlo a Redis o similar.
*/
@Injectable()
export class SessionStore implements OnModuleDestroy {
private readonly sesiones = new Map<string, SapSession>();
private readonly limpieza = setInterval(() => this.purgarVencidas(), 60_000);
crear(datos: Omit<SapSession, 'id' | 'expiresAt'>): SapSession {
const sesion: SapSession = { ...datos, id: randomBytes(32).toString('hex'), expiresAt: 0 };
this.renovar(sesion);
this.sesiones.set(sesion.id, sesion);
return sesion;
}
obtener(id: string | undefined): SapSession | undefined {
if (!id) return undefined;
const sesion = this.sesiones.get(id);
if (!sesion) return undefined;
if (sesion.expiresAt < Date.now()) {
this.sesiones.delete(id);
return undefined;
}
return sesion;
}
renovar(sesion: SapSession): void {
sesion.expiresAt = Date.now() + sesion.timeoutMinutes * 60_000;
}
eliminar(id: string): void {
this.sesiones.delete(id);
}
onModuleDestroy(): void {
clearInterval(this.limpieza);
}
private purgarVencidas(): void {
const ahora = Date.now();
for (const [id, sesion] of this.sesiones) {
if (sesion.expiresAt < ahora) this.sesiones.delete(id);
}
}
}
@@ -0,0 +1,48 @@
/**
* Consultas guardadas (SQLQueries del Service Layer) que usa la API. Cada una tiene su
* variante MSSQL (pruebas) y HANA (producción). Los parámetros se escriben ":nombre".
*
* El analizador SQL del Service Layer no admite funciones como FORMAT: las fechas se devuelven
* tal cual y se normalizan en el código.
*
* SAP reescribe el SqlText al guardarlo (por ejemplo OITM → [OITM]), así que no se puede
* comparar el texto para saber si cambió: al modificar el SQL hay que subir `version`.
*/
export interface ConsultaGuardada {
codigo: string;
version: number;
mssql: string;
hana: string;
}
export const CONSULTA_STOCK: ConsultaGuardada = {
codigo: 'DP_Stock',
version: 1,
mssql:
'SELECT T0.[ItemCode], T0.[ItemName], T1.[OnHand] ' +
'FROM OITM T0 INNER JOIN OITW T1 ON T1.[ItemCode] = T0.[ItemCode] ' +
'WHERE T1.[WhsCode] = :whs AND T0.[ItemCode] = :item',
hana:
'SELECT T0."ItemCode", T0."ItemName", T1."OnHand" ' +
'FROM OITM T0 INNER JOIN OITW T1 ON T1."ItemCode" = T0."ItemCode" ' +
'WHERE T1."WhsCode" = :whs AND T0."ItemCode" = :item',
};
export const CONSULTA_ENTRADAS_ABIERTAS: ConsultaGuardada = {
codigo: 'DP_EntradasAbiertas',
version: 2,
mssql:
'SELECT T0.[DocNum], T0.[CardCode], T0.[CardName], T0.[DocDate], ' +
'T1.[DocEntry], T1.[LineNum], T1.[OpenQty] ' +
'FROM OPDN T0 INNER JOIN PDN1 T1 ON T1.[DocEntry] = T0.[DocEntry] ' +
"WHERE T0.[U_DOT_TipoRecepcion] = 'C' AND T1.[LineStatus] = 'O' " +
'AND T1.[ItemCode] = :item AND T1.[WhsCode] = :whs ' +
'ORDER BY T0.[DocDate], T1.[DocEntry], T1.[LineNum]',
hana:
'SELECT T0."DocNum", T0."CardCode", T0."CardName", T0."DocDate", ' +
'T1."DocEntry", T1."LineNum", T1."OpenQty" ' +
'FROM OPDN T0 INNER JOIN PDN1 T1 ON T1."DocEntry" = T0."DocEntry" ' +
"WHERE T0.\"U_DOT_TipoRecepcion\" = 'C' AND T1.\"LineStatus\" = 'O' " +
'AND T1."ItemCode" = :item AND T1."WhsCode" = :whs ' +
'ORDER BY T0."DocDate", T1."DocEntry", T1."LineNum"',
};
@@ -0,0 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { SqlQueriesService } from './sql-queries.service.js';
@Global()
@Module({ providers: [SqlQueriesService], exports: [SqlQueriesService] })
export class SqlQueriesModule {}
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppConfig } from '../config/configuration.js';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import type { ConsultaGuardada } from './sql-queries.definitions.js';
interface SqlQueryExistente {
SqlName: string;
}
/** Texto de un parámetro para la URL del Service Layer: entre comillas simples y escapado. */
export function parametroSl(valor: string): string {
return encodeURIComponent(`'${valor.replace(/'/g, "''")}'`);
}
@Injectable()
export class SqlQueriesService {
private readonly dbType: AppConfig['dbType'];
/** Consultas ya verificadas en esta ejecución de la API ("codigo:version"). */
private readonly verificadas = new Set<string>();
constructor(
private readonly sl: ServiceLayerClient,
config: ConfigService<AppConfig, true>,
) {
this.dbType = config.get('dbType', { infer: true });
}
/** Ejecuta la consulta (la crea o actualiza en SAP si hace falta) y devuelve todas las filas. */
async consultar<T>(sesion: SapSession, consulta: ConsultaGuardada, parametros: Record<string, string>): Promise<T[]> {
await this.asegurar(sesion, consulta);
const query = Object.entries(parametros)
.map(([nombre, valor]) => `${nombre}=${parametroSl(valor)}`)
.join('&');
return this.sl.getAll<T>(sesion, `SQLQueries('${consulta.codigo}')/List${query ? `?${query}` : ''}`);
}
private async asegurar(sesion: SapSession, consulta: ConsultaGuardada): Promise<void> {
const clave = `${consulta.codigo}:${consulta.version}`;
if (this.verificadas.has(clave)) return;
const nombre = `Devolución proveedores ${consulta.codigo} v${consulta.version}`;
const sqlText = this.dbType === 'HANA' ? consulta.hana : consulta.mssql;
const ruta = `SQLQueries('${consulta.codigo}')`;
const filtro = encodeURIComponent(`SqlCode eq '${consulta.codigo}'`);
const [existente] = await this.sl.getAll<SqlQueryExistente>(sesion, `SQLQueries?$filter=${filtro}&$select=SqlName`);
if (!existente) {
await this.sl.post(sesion, 'SQLQueries', { SqlCode: consulta.codigo, SqlName: nombre, SqlText: sqlText });
} else if (existente.SqlName !== nombre) {
await this.sl.patch(sesion, ruta, { SqlName: nombre, SqlText: sqlText });
}
this.verificadas.add(clave);
}
}
+98
View File
@@ -0,0 +1,98 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Controller, Get } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { cabecerasSeguridad, servirWeb } from './web-estatico.js';
@Controller('health')
class SaludFalsa {
@Get()
salud() {
return { status: 'ok' };
}
}
describe('servirWeb', () => {
let app: NestExpressApplication;
let carpeta: string;
beforeAll(async () => {
carpeta = mkdtempSync(join(tmpdir(), 'dp-web-'));
writeFileSync(join(carpeta, 'index.html'), '<html>INDEX</html>');
writeFileSync(join(carpeta, 'main-ABCD1234.js'), 'console.log(1)');
writeFileSync(join(carpeta, 'favicon.ico'), 'ico');
mkdirSync(join(carpeta, 'assets'));
const modulo = await Test.createTestingModule({ controllers: [SaludFalsa] }).compile();
app = modulo.createNestApplication<NestExpressApplication>();
app.setGlobalPrefix('api');
app.use(cabecerasSeguridad(false));
servirWeb(app, carpeta);
await app.init();
});
afterAll(async () => {
await app.close();
rmSync(carpeta, { recursive: true, force: true });
});
it('la raíz devuelve index.html sin caché', async () => {
const r = await request(app.getHttpServer()).get('/').expect(200);
expect(r.text).toContain('INDEX');
expect(r.headers['cache-control']).toBe('no-cache');
});
it('una ruta de Angular sin extensión devuelve index.html (para que el router la resuelva)', async () => {
const r = await request(app.getHttpServer()).get('/planificaciones/5').expect(200);
expect(r.text).toContain('INDEX');
});
it('los archivos con hash se cachean un año', async () => {
const r = await request(app.getHttpServer()).get('/main-ABCD1234.js').expect(200);
expect(r.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('un archivo sin hash no se cachea', async () => {
const r = await request(app.getHttpServer()).get('/favicon.ico').expect(200);
expect(r.headers['cache-control']).toBe('no-cache');
});
it('un archivo que no existe responde 404, no index.html', async () => {
const r = await request(app.getHttpServer()).get('/no-existe.js').expect(404);
expect(r.text).not.toContain('INDEX');
});
it('la API sigue respondiendo y una ruta /api inexistente es 404, no index.html', async () => {
await request(app.getHttpServer()).get('/api/health').expect(200).expect({ status: 'ok' });
const r = await request(app.getHttpServer()).get('/api/no-existe').expect(404);
expect(r.text).not.toContain('INDEX');
});
it('un POST a una ruta de la web no devuelve index.html', async () => {
const r = await request(app.getHttpServer()).post('/planificaciones').expect(404);
expect(r.text).not.toContain('INDEX');
});
it('agrega las cabeceras de seguridad y no manda HSTS por HTTP', async () => {
const r = await request(app.getHttpServer()).get('/').expect(200);
expect(r.headers['x-content-type-options']).toBe('nosniff');
expect(r.headers['x-frame-options']).toBe('DENY');
expect(r.headers['strict-transport-security']).toBeUndefined();
});
});
describe('cabecerasSeguridad con HTTPS', () => {
it('agrega Strict-Transport-Security', () => {
const cabeceras: Record<string, string> = {};
const res = { setHeader: (k: string, v: string) => (cabeceras[k] = v) };
const siguiente = vi.fn();
cabecerasSeguridad(true)({} as never, res as never, siguiente);
expect(cabeceras['Strict-Transport-Security']).toBe('max-age=31536000');
expect(siguiente).toHaveBeenCalled();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { extname, join } from 'node:path';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { NextFunction, Request, Response } from 'express';
/** Nombres con hash de Angular (main-HFVHQMEH.js, styles-RS7YTGMB.css): no cambian, se pueden cachear un año. */
const CON_HASH = /-[A-Z0-9]{8}\.(js|css)$/;
/**
* Cabeceras de seguridad básicas. HSTS solo si la API atiende por HTTPS: con HTTP obligaría al
* navegador a usar un HTTPS que no existe.
*/
export function cabecerasSeguridad(https: boolean) {
return (_req: Request, res: Response, next: NextFunction): void => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'same-origin');
if (https) res.setHeader('Strict-Transport-Security', 'max-age=31536000');
next();
};
}
/**
* La API sirve también la web compilada, así que todo queda en el mismo origen (sin CORS y con la
* cookie de sesión SameSite=strict). Las rutas de Angular (sin extensión y fuera de /api) devuelven
* index.html; un archivo que no existe responde 404 en lugar de index.html.
*/
export function servirWeb(app: NestExpressApplication, carpeta: string): void {
app.useStaticAssets(carpeta, {
index: false,
setHeaders: (res, ruta) => {
res.setHeader('Cache-Control', CON_HASH.test(ruta) ? 'public, max-age=31536000, immutable' : 'no-cache');
},
});
app.use((req: Request, res: Response, next: NextFunction) => {
const esRutaDeLaApp = (req.method === 'GET' || req.method === 'HEAD') && !req.path.startsWith('/api') && extname(req.path) === '';
if (!esRutaDeLaApp) return next();
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(join(carpeta, 'index.html'), (error) => error && next(error));
});
}
+43
View File
@@ -0,0 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { AppModule } from './../src/app.module.js';
describe('API (e2e)', () => {
let app: INestApplication<App>;
beforeAll(() => {
process.env.SL_URL = 'https://localhost:50000/b1s/v1';
process.env.SL_COMPANY_DB = 'TEST';
process.env.DB_TYPE = 'MSSQL';
});
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.use(cookieParser());
await app.init();
});
it('/api/health es público', () => {
return request(app.getHttpServer()).get('/api/health').expect(200).expect({ status: 'ok' });
});
it('/api/auth/info devuelve la sociedad configurada', () => {
return request(app.getHttpServer()).get('/api/auth/info').expect(200).expect({ companyDb: 'TEST', dbType: 'MSSQL' });
});
it('/api/metadata/status exige sesión', () => {
return request(app.getHttpServer()).get('/api/metadata/status').expect(401);
});
afterEach(async () => {
await app.close();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { AppModule } from './../src/app.module.js';
import { ServiceLayerClient } from './../src/service-layer/service-layer.client.js';
import { SessionStore } from './../src/service-layer/session.store.js';
// El Service Layer se reemplaza por uno falso que acepta cualquier login (y registra la sesión en el
// almacén real): lo que se comprueba es la cookie de sesión que la API entrega al navegador, no SAP.
describe('Cookie de sesión (e2e)', () => {
let app: INestApplication<App>;
async function crear(cookieSecure: boolean) {
process.env.SL_URL = 'https://localhost:50000/b1s/v1';
process.env.SL_COMPANY_DB = 'TEST';
process.env.DB_TYPE = 'MSSQL';
process.env.COOKIE_SECURE = String(cookieSecure);
const modulo = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(ServiceLayerClient)
.useFactory({
inject: [SessionStore],
factory: (sesiones: SessionStore) => ({
login: async () => sesiones.crear({ b1Session: 'b', userName: 'u', companyDb: 'X', version: '1', timeoutMinutes: 30 }),
logout: async () => undefined,
}),
})
.compile();
app = modulo.createNestApplication();
app.setGlobalPrefix('api');
app.use(cookieParser());
await app.init();
}
const loguear = async () => {
const r = await request(app.getHttpServer()).post('/api/auth/login').send({ userName: 'u', password: 'p' }).expect(200);
return (r.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('dp_sid=')) ?? '';
};
afterEach(async () => {
await app.close();
delete process.env.COOKIE_SECURE;
});
it('con COOKIE_SECURE=true la cookie es HttpOnly, Secure, SameSite=Strict y de toda la aplicación', async () => {
await crear(true);
const cookie = await loguear();
expect(cookie).toMatch(/^dp_sid=[0-9a-f]{64};/);
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('Secure');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).toContain('Path=/');
});
it('con COOKIE_SECURE=false (desarrollo por HTTP) no lleva Secure, pero sigue HttpOnly y Strict', async () => {
await crear(false);
const cookie = await loguear();
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).not.toContain('Secure');
});
it('con esa cookie el navegador queda autenticado; sin ella, no', async () => {
await crear(true);
const cookie = await loguear();
await request(app.getHttpServer()).get('/api/auth/me').expect(401);
await request(app.getHttpServer()).get('/api/auth/me').set('Cookie', cookie.split(';')[0]).expect(200);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"types": [
"vitest/globals",
"node"
],
"resolveJsonModule": true
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+44
View File
@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+92
View File
@@ -0,0 +1,92 @@
# Web — Devolución a Proveedores
Frontend en **Angular 22** (componentes standalone, signals, sin zone.js, CSS) de la aplicación
Devolución a Proveedores para SAP Business One. Consume la API NestJS de [`../api`](../api/README.md).
La documentación general del proyecto (decisiones, flujo, estado) está en
[`../../CLAUDE.md`](../../CLAUDE.md). Este archivo cubre solo cómo ejecutar y mantener la web.
## Puesta en marcha
Desde la raíz del monorepo (`devolucion-proveedores-web/`), con la API ya levantada:
```bash
npm install
npm run dev:web # compila @dp/shared y levanta Angular en http://localhost:4200
```
`ng serve` redirige `/api` a `http://localhost:3000` (ver `src/proxy.conf.json`), así que no hace falta
configurar CORS en desarrollo. Con Fortinet, exportar antes `NODE_OPTIONS=--use-system-ca` y
`NG_CLI_ANALYTICS=false`.
> **Importante:** `@dp/shared` se consume compilado. Si lo cambia, ejecute `npm run build:shared`
> **y reinicie `ng serve`**: Vite sirve una copia vieja y la página falla con *"does not provide an
> export named …"*. Los tests no lo detectan.
## Scripts
| Comando | Qué hace |
|---|---|
| `ng serve` / `npm start` | Servidor de desarrollo con recarga. |
| `ng build` | Compila a `dist/web`. |
| `ng test` | Tests (Vitest + jsdom). Para una sola pasada: `npx ng test --watch=false`. |
## En producción
La web **no se despliega por separado**: `ng build` genera `dist/web/browser` y la API (NestJS) la sirve
junto con `/api`, por HTTPS y en el mismo origen. Por eso las llamadas son siempre relativas (`/api/...`).
`npm run publicar` (en la raíz) arma la carpeta de despliegue; ver
[`../../deploy/windows/DEPLOY.md`](../../deploy/windows/DEPLOY.md).
## Pantallas
| Ruta | Pantalla |
|---|---|
| `/login` | Usuario y contraseña de SAP B1, con la casilla **"Verificar los metadatos de SAP al ingresar"**. |
| `/planificaciones` | Listado con filtros (estado, almacén, fechas) y paginación (**Atrás** / **Siguiente**, filas por página). Los filtros y la página quedan en la URL. |
| `/planificaciones/nueva` | Pasos 1 y 2: elegir almacén y fecha, pegar el listado desde Excel (botón *Pegar desde el portapapeles* o `Ctrl/Cmd+V`), validar y procesar (FIFO). Con `?reemplaza=<id>` rehace una planificación. |
| `/planificaciones/:docEntry` | Pasos 3 y 4: datos de las Devoluciones y transporte, grilla Padre/Hijo con casillas, **Crear devoluciones**, **Rehacer FIFO**, **Descargar PDF** y **Cerrar planificación**. |
| `/configuracion/metadatos` | Estado de tablas, campos y objeto de usuario en SAP, y el botón **Reconstruir**. |
Flujo de rutas: `authGuard` exige sesión; `metadataGuard` verifica los metadatos una vez por sesión y,
si falta algo, lleva a *Configuración → Metadatos* (se omite si el usuario desmarcó la casilla del login).
Una respuesta 401 en cualquier llamada devuelve al login.
## Estructura
```
src/app/
├── core/ servicios HTTP (auth, metadata, planificaciones), guards, interceptor y errores
├── layout/ shell (barra superior) y stepper de los 4 pasos
└── pages/
├── login/
├── metadatos/
└── planificaciones/ listado · nueva/ · detalle/
```
Cada página tiene su `.ts`, `.html` y `.css`; los tests (`*.spec.ts`) están junto a lo que prueban.
La lógica de negocio pura (FIFO, qué líneas se pueden marcar, consolidación por proveedor) se importa
de `@dp/shared`, la misma que usa la API.
## Cosas que conviene saber
- **El navegador solo conoce la cookie `dp_sid`**; la sesión de SAP vive en el servidor y se pierde si
la API se reinicia (habrá que volver a iniciar sesión).
- **Descarga de PDF**: la API devuelve los archivos en base64; la pantalla arma `Blob`s y enlaces
(`URL.createObjectURL`). Con un solo archivo lo descarga directo; con varios deja un enlace por archivo.
- **Pegar desde el portapapeles** usa `navigator.clipboard.readText()`: solo funciona en `localhost` o
HTTPS, y el navegador pide permiso. Si lo niega, se puede pegar a mano dentro del cuadro de texto.
- **Estilos en CSS**, no SCSS: en macOS 12 el compilador nativo de Sass exige macOS 14 o superior. Las
variables de color y espaciado están en `src/styles.css`.
- **Tests sin zone.js**: tras un `flush` de `HttpTestingController` hay que esperar con
`await new Promise((r) => setTimeout(r))` y llamar a `fixture.detectChanges()`; `whenStable()` no
alcanza para las promesas de `firstValueFrom`.
## Diagnóstico rápido
| Síntoma | Causa probable |
|---|---|
| Pantalla en blanco con *"does not provide an export named …"* en la consola | `@dp/shared` cambió y `ng serve` quedó con la copia vieja. Reiniciarlo. |
| Vuelve al login sin avisar | Sesión de SAP vencida o API reiniciada. |
| `http proxy error: /api/… ECONNREFUSED` en la terminal | La API no está levantada (o se está reiniciando). |
| La lista de almacenes sale vacía | Mirar el recuadro rojo de la pantalla: indica qué llamada falló. |
+81
View File
@@ -0,0 +1,81 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "css"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "css",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "web:build:production"
},
"development": {
"buildTarget": "web:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "src/proxy.conf.json"
}
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@10.9.9",
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"@dp/shared": "*"
},
"devDependencies": {
"@angular/build": "^22.2.2",
"@angular/cli": "^22.2.2",
"@angular/compiler-cli": "^22.2.0",
"jsdom": "^30.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^5.0.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -0,0 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
+31
View File
@@ -0,0 +1,31 @@
import { Routes } from '@angular/router';
import { authGuard, guestGuard, metadataGuard } from './core/guards';
import { Shell } from './layout/shell';
import { LoginPage } from './pages/login/login';
import { MetadatosPage } from './pages/metadatos/metadatos';
import { PlanificacionDetallePage } from './pages/planificaciones/detalle/detalle';
import { NuevaPlanificacionPage } from './pages/planificaciones/nueva/nueva';
import { PlanificacionesPage } from './pages/planificaciones/planificaciones';
export const routes: Routes = [
{ path: 'login', component: LoginPage, canActivate: [guestGuard] },
{
path: '',
component: Shell,
canActivate: [authGuard],
children: [
{ path: 'configuracion/metadatos', component: MetadatosPage },
{
path: '',
canActivate: [metadataGuard],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'planificaciones' },
{ path: 'planificaciones', component: PlanificacionesPage },
{ path: 'planificaciones/nueva', component: NuevaPlanificacionPage },
{ path: 'planificaciones/:docEntry', component: PlanificacionDetallePage },
],
},
],
},
{ path: '**', redirectTo: '' },
];
+76
View File
@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { App } from './app';
import { routes } from './app.routes';
import { MetadataService } from './core/metadata.service';
const tick = () => new Promise((r) => setTimeout(r));
describe('App', () => {
let http: HttpTestingController;
let router: Router;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter(routes), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
});
it('sin sesión redirige al login', async () => {
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ message: 'Debe iniciar sesión.' }, { status: 401, statusText: 'Unauthorized' });
await navegacion;
expect(router.url).toBe('/login');
});
it('con sesión y metadatos incompletos lleva a Configuración', async () => {
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
await tick();
http.expectOne('/api/metadata/status').flush({ completo: false, elementos: [] });
await navegacion;
expect(router.url).toBe('/configuracion/metadatos');
});
it('con la verificación de metadatos desactivada entra sin consultarla', async () => {
TestBed.inject(MetadataService).fijarVerificacionAlIngresar(false);
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
await navegacion;
http.expectNone('/api/metadata/status');
expect(router.url).toBe('/planificaciones');
});
it('desactivada pero con un estado ya conocido incompleto, igual lleva a Configuración', async () => {
const metadata = TestBed.inject(MetadataService);
metadata.fijarVerificacionAlIngresar(false);
metadata.estado.set({ completo: false, elementos: [] });
TestBed.createComponent(App);
const navegacion = router.navigateByUrl('/planificaciones');
await tick();
http.expectOne('/api/auth/me').flush({ userName: 'manager', companyDb: 'TEST', version: '1000' });
await navegacion;
expect(router.url).toBe('/configuracion/metadatos');
});
it('la elección del login se recuerda en el navegador', () => {
const metadata = TestBed.inject(MetadataService);
expect(metadata.verificarAlIngresar()).toBe(true);
metadata.fijarVerificacionAlIngresar(false);
expect(localStorage.getItem('dp.verificarMetadatos')).toBe('false');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: '<router-outlet />',
})
export class App {}
+25
View File
@@ -0,0 +1,25 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
import { MetadataService } from './metadata.service';
/** Si la sesión de SAP expiró (401) en cualquier llamada, vuelve al login. */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const metadata = inject(MetadataService);
const router = inject(Router);
return next(req).pipe(
catchError((error: unknown) => {
const esAuth = req.url.startsWith('/api/auth/');
if (error instanceof HttpErrorResponse && error.status === 401 && !esAuth) {
auth.limpiar();
metadata.olvidar();
void router.navigateByUrl('/login');
}
return throwError(() => error);
}),
);
};
+48
View File
@@ -0,0 +1,48 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import type { InfoConexion, LoginRequest, UsuarioSesion } from '@dp/shared';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private verificada = false;
readonly usuario = signal<UsuarioSesion | null>(null);
readonly autenticado = computed(() => this.usuario() !== null);
info(): Promise<InfoConexion> {
return firstValueFrom(this.http.get<InfoConexion>('/api/auth/info'));
}
async login(datos: LoginRequest): Promise<void> {
this.usuario.set(await firstValueFrom(this.http.post<UsuarioSesion>('/api/auth/login', datos)));
this.verificada = true;
}
async logout(): Promise<void> {
try {
await firstValueFrom(this.http.post<void>('/api/auth/logout', {}));
} finally {
this.limpiar();
}
}
/** Recupera la sesión existente (cookie) la primera vez; después usa la que está en memoria. */
async asegurarSesion(): Promise<boolean> {
if (!this.verificada) {
this.verificada = true;
try {
this.usuario.set(await firstValueFrom(this.http.get<UsuarioSesion>('/api/auth/me')));
} catch {
this.usuario.set(null);
}
}
return this.autenticado();
}
limpiar(): void {
this.usuario.set(null);
this.verificada = true;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
import { MetadataService } from './metadata.service';
/** Exige sesión de SAP; si no hay, al login. */
export const authGuard: CanActivateFn = async () => {
const router = inject(Router);
return (await inject(AuthService).asegurarSesion()) || router.parseUrl('/login');
};
/** Con sesión activa no tiene sentido volver al login. */
export const guestGuard: CanActivateFn = async () => {
const router = inject(Router);
return !(await inject(AuthService).asegurarSesion()) || router.parseUrl('/');
};
/**
* Antes de usar el proceso, verifica que existan en SAP las tablas, campos y objeto de
* usuario. Se consulta una vez por sesión (y otra vez tras reconstruir); si falta algo,
* lleva a la pantalla de metadatos. Si el usuario desactivó la verificación en el login y
* todavía no hay un estado conocido, se omite (Configuración → Metadatos sigue disponible).
*/
export const metadataGuard: CanActivateFn = async () => {
const router = inject(Router);
const metadata = inject(MetadataService);
if (!metadata.estado() && !metadata.verificarAlIngresar()) return true;
try {
const estado = metadata.estado() ?? (await metadata.verificar());
return estado.completo || router.parseUrl('/configuracion/metadatos');
} catch {
return router.parseUrl('/configuracion/metadatos');
}
};
+13
View File
@@ -0,0 +1,13 @@
import { HttpErrorResponse } from '@angular/common/http';
/** Mensaje legible de un error de la API (incluye el código SAP si viene). */
export function mensajeDeError(error: unknown): string {
if (error instanceof HttpErrorResponse) {
if (error.status === 0) return 'No se pudo conectar con el servidor.';
const body = error.error as { message?: string | string[]; sapCode?: number | string } | null;
const mensaje = Array.isArray(body?.message) ? body.message.join(' ') : body?.message;
if (mensaje) return body?.sapCode != null ? `${mensaje} (SAP ${body.sapCode})` : mensaje;
return `Error ${error.status}: ${error.statusText}`;
}
return error instanceof Error ? error.message : 'Error inesperado.';
}
+41
View File
@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoReconstruccion } from '@dp/shared';
import { firstValueFrom } from 'rxjs';
const CLAVE_VERIFICAR = 'dp.verificarMetadatos';
@Injectable({ providedIn: 'root' })
export class MetadataService {
private readonly http = inject(HttpClient);
/**
* Si es true (por defecto), tras cada login se verifica en SAP que existan tablas, campos y objeto.
* El usuario lo elige en el login; se recuerda en este navegador.
*/
readonly verificarAlIngresar = signal(localStorage.getItem(CLAVE_VERIFICAR) !== 'false');
/** Último estado leído de la base SAP; null hasta la primera verificación de la sesión. */
readonly estado = signal<EstadoMetadatos | null>(null);
async verificar(): Promise<EstadoMetadatos> {
const estado = await firstValueFrom(this.http.get<EstadoMetadatos>('/api/metadata/status'));
this.estado.set(estado);
return estado;
}
async reconstruir(opciones: OpcionesReconstruccion): Promise<ResultadoReconstruccion> {
const resultado = await firstValueFrom(this.http.post<ResultadoReconstruccion>('/api/metadata/rebuild', opciones));
this.estado.set(resultado.estado);
return resultado;
}
fijarVerificacionAlIngresar(valor: boolean): void {
this.verificarAlIngresar.set(valor);
localStorage.setItem(CLAVE_VERIFICAR, String(valor));
}
olvidar(): void {
this.estado.set(null);
}
}
@@ -0,0 +1,84 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type {
Almacen,
CabeceraEditable,
CatalogosDevolucion,
FiltrosPlanificaciones,
PaginaPlanificaciones,
Planificacion,
RespuestaAbierta,
ResultadoCrearDevoluciones,
ResultadoPdf,
ResultadoPegadoApi,
ResultadoProcesar,
ResumenPlanificacion,
} from '@dp/shared';
import { firstValueFrom } from 'rxjs';
/** Llamadas a la API de planificaciones y catálogos. */
@Injectable({ providedIn: 'root' })
export class PlanificacionesService {
private readonly http = inject(HttpClient);
catalogos(): Promise<CatalogosDevolucion> {
return firstValueFrom(this.http.get<CatalogosDevolucion>('/api/catalogos'));
}
/** Solo los almacenes (más liviano que el catálogo completo). */
almacenes(): Promise<Almacen[]> {
return firstValueFrom(this.http.get<Almacen[]>('/api/catalogos/almacenes'));
}
/** Una página del listado; los filtros vacíos no se envían. */
listar(filtros: FiltrosPlanificaciones = {}): Promise<PaginaPlanificaciones> {
let params = new HttpParams();
for (const [clave, valor] of Object.entries(filtros)) {
if (valor !== undefined && valor !== '') params = params.set(clave, String(valor));
}
return firstValueFrom(this.http.get<PaginaPlanificaciones>('/api/planificaciones', { params }));
}
/** La planificación abierta o null si no hay ninguna. */
async abierta(): Promise<ResumenPlanificacion | null> {
return (await firstValueFrom(this.http.get<RespuestaAbierta>('/api/planificaciones/abierta'))).abierta;
}
obtener(docEntry: number): Promise<Planificacion> {
return firstValueFrom(this.http.get<Planificacion>(`/api/planificaciones/${docEntry}`));
}
pegar(whsCode: string, texto: string): Promise<ResultadoPegadoApi> {
return firstValueFrom(this.http.post<ResultadoPegadoApi>('/api/planificaciones/pegar', { whsCode, texto }));
}
procesar(datos: { whsCode: string; docDate: string; texto: string }): Promise<ResultadoProcesar> {
return firstValueFrom(this.http.post<ResultadoProcesar>('/api/planificaciones', datos));
}
/** Rehace el FIFO de una planificación abierta sin Devoluciones: crea una nueva y cierra la anterior. */
reprocesar(docEntry: number, datos: { whsCode: string; docDate: string; texto: string }): Promise<ResultadoProcesar> {
return firstValueFrom(this.http.post<ResultadoProcesar>(`/api/planificaciones/${docEntry}/reprocesar`, datos));
}
actualizarCabecera(docEntry: number, cambios: CabeceraEditable): Promise<Planificacion> {
return firstValueFrom(this.http.patch<Planificacion>(`/api/planificaciones/${docEntry}/cabecera`, cambios));
}
cambiarSeleccion(docEntry: number, lineId: number, applied: boolean): Promise<Planificacion> {
return firstValueFrom(this.http.patch<Planificacion>(`/api/planificaciones/${docEntry}/lineas/${lineId}`, { applied }));
}
crearDevoluciones(docEntry: number): Promise<ResultadoCrearDevoluciones> {
return firstValueFrom(this.http.post<ResultadoCrearDevoluciones>(`/api/planificaciones/${docEntry}/devoluciones`, {}));
}
/** Pide a BES el PDF de la Guía de despacho de cada Devolución de la planificación. */
pdf(docEntry: number, unir: boolean): Promise<ResultadoPdf> {
return firstValueFrom(this.http.post<ResultadoPdf>(`/api/planificaciones/${docEntry}/pdf`, { unir }));
}
cerrar(docEntry: number): Promise<Planificacion> {
return firstValueFrom(this.http.post<Planificacion>(`/api/planificaciones/${docEntry}/cerrar`, {}));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Component, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AuthService } from '../core/auth.service';
import { MetadataService } from '../core/metadata.service';
@Component({
selector: 'app-shell',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<header class="barra">
<strong class="marca">Devolución a Proveedores</strong>
<nav>
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
</nav>
@if (auth.usuario(); as u) {
<div class="usuario">
<span>{{ u.userName }}</span>
<span class="muted">· {{ u.companyDb }}</span>
<button class="btn" type="button" (click)="salir()">Salir</button>
</div>
}
</header>
<main class="contenido">
<router-outlet />
</main>
`,
styles: `
.barra {
display: flex;
align-items: center;
gap: 24px;
height: 56px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.marca {
font-size: 15px;
}
nav {
display: flex;
gap: 4px;
flex: 1;
}
nav a {
padding: 6px 12px;
border-radius: 6px;
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
}
nav a:hover {
background: var(--surface-alt);
}
nav a.activo {
background: var(--primary-soft);
color: var(--primary);
}
.usuario {
display: flex;
align-items: center;
gap: 8px;
}
.contenido {
max-width: 1280px;
margin: 0 auto;
padding: 24px;
}
`,
})
export class Shell {
protected readonly auth = inject(AuthService);
private readonly metadata = inject(MetadataService);
private readonly router = inject(Router);
async salir(): Promise<void> {
await this.auth.logout();
this.metadata.olvidar();
await this.router.navigateByUrl('/login');
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Component, input } from '@angular/core';
export const PASOS_PROCESO = ['Pegar', 'Procesar', 'Crear devoluciones', 'Descargar PDF'] as const;
/** Indicador de los 4 pasos del proceso. `actual` va de 1 a 4; los anteriores salen como hechos. */
@Component({
selector: 'app-stepper',
template: `
<ol>
@for (paso of pasos; track paso; let i = $index) {
<li [class.hecho]="i + 1 < actual()" [class.actual]="i + 1 === actual()">
<span class="numero">{{ i + 1 < actual() ? '✓' : i + 1 }}</span>
<span>{{ paso }}</span>
</li>
}
</ol>
`,
styles: `
ol {
list-style: none;
display: flex;
gap: 8px;
margin: 0;
padding: 0;
}
li {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text-muted);
font-weight: 500;
}
.numero {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--neutral-soft);
font-size: 12px;
font-weight: 600;
}
li.actual {
border-color: var(--primary);
color: var(--primary);
}
li.actual .numero {
background: var(--primary);
color: #fff;
}
li.hecho {
color: var(--ok);
}
li.hecho .numero {
background: var(--ok-soft);
}
`,
})
export class Stepper {
readonly actual = input.required<number>();
protected readonly pasos = PASOS_PROCESO;
}
+49
View File
@@ -0,0 +1,49 @@
.pantalla {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
}
form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 16px;
padding: 28px;
}
header p {
margin: 4px 0 0;
}
.sociedad {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
background: var(--surface-alt);
border: 1px solid var(--border);
}
.btn {
height: 40px;
}
.opcion {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
input {
margin-top: 3px;
}
small {
display: block;
font-size: 12px;
}
}
+42
View File
@@ -0,0 +1,42 @@
<main class="pantalla">
<form class="card" [formGroup]="form" (ngSubmit)="ingresar()">
<header>
<h1>Devolución a Proveedores</h1>
<p class="muted">Ingrese con su usuario de SAP Business One</p>
</header>
@if (info(); as i) {
<div class="sociedad">
<span class="muted">Sociedad</span>
<strong>{{ i.companyDb }}</strong>
<span class="chip neutral">{{ i.dbType }}</span>
</div>
}
<div class="field">
<label for="userName">Usuario</label>
<input id="userName" formControlName="userName" autocomplete="username" autofocus />
</div>
<div class="field">
<label for="password">Contraseña</label>
<input id="password" type="password" formControlName="password" autocomplete="current-password" />
</div>
<label class="opcion">
<input type="checkbox" formControlName="verificarMetadatos" />
<span>
Verificar los metadatos de SAP al ingresar
<small class="muted">Desactivarlo agiliza el ingreso. Puede verificarlos cuando quiera en Configuración → Metadatos.</small>
</span>
</label>
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
</button>
</form>
</main>
+57
View File
@@ -0,0 +1,57 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import type { InfoConexion } from '@dp/shared';
import { AuthService } from '../../core/auth.service';
import { mensajeDeError } from '../../core/http-error';
import { MetadataService } from '../../core/metadata.service';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.css',
})
export class LoginPage implements OnInit {
private readonly auth = inject(AuthService);
private readonly metadata = inject(MetadataService);
private readonly router = inject(Router);
protected readonly info = signal<InfoConexion | null>(null);
protected readonly error = signal<string | null>(null);
protected readonly enviando = signal(false);
protected readonly form = inject(FormBuilder).nonNullable.group({
userName: ['', Validators.required],
password: ['', Validators.required],
verificarMetadatos: [this.metadata.verificarAlIngresar()],
});
async ngOnInit(): Promise<void> {
try {
this.info.set(await this.auth.info());
} catch (e) {
this.error.set(mensajeDeError(e));
}
}
async ingresar(): Promise<void> {
if (this.form.invalid || this.enviando()) return;
this.enviando.set(true);
this.error.set(null);
try {
const { userName, password, verificarMetadatos } = this.form.getRawValue();
await this.auth.login({ userName, password });
// Salvo que el usuario lo desactive, tras cada login se verifica que existan tablas, campos y objeto en SAP.
this.metadata.fijarVerificacionAlIngresar(verificarMetadatos);
this.metadata.olvidar();
await this.router.navigateByUrl('/');
} catch (e) {
this.error.set(mensajeDeError(e));
this.form.controls.password.reset();
} finally {
this.enviando.set(false);
}
}
}
@@ -0,0 +1,39 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
.acciones {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
p {
margin: 4px 0 8px;
}
}
.check {
display: flex;
align-items: center;
gap: 8px;
color: var(--error);
font-weight: 500;
}
.card h2 {
margin-bottom: 12px;
}
@@ -0,0 +1,101 @@
<section class="encabezado">
<div>
<h1>Metadatos de SAP</h1>
<p class="muted">Tablas, campos y objeto de usuario que el proceso necesita en la base de SAP Business One.</p>
</div>
<button class="btn" type="button" (click)="verificar()" [disabled]="cargando() || reconstruyendo()">
{{ cargando() ? 'Verificando…' : 'Volver a verificar' }}
</button>
</section>
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (estado(); as est) {
@if (est.completo) {
<div class="alert ok">
La base está al día.
<a routerLink="/planificaciones">Ir a Planificaciones</a>
</div>
} @else {
<div class="alert warn">
Faltan elementos o hay diferencias en la base SAP. El proceso no se puede usar hasta corregirlos.
@if (faltanDependencias()) {
<br />Hay dependencias externas faltantes: las crea otro addon o la localización, no esta aplicación.
}
</div>
}
@if (pendientes().length > 0) {
<section class="card acciones">
<div>
<h2>Reconstruir</h2>
<p class="muted">
Crea en SAP lo que falta y corrige las diferencias ({{ pendientes().length }} elemento(s)).
</p>
@if (hayDestructivos()) {
<label class="check">
<input type="checkbox" [ngModel]="permitirDestructivos()" (ngModelChange)="permitirDestructivos.set($event)" />
Autorizar cambios que borran datos (campos con tipo distinto y campos sobrantes)
</label>
}
</div>
<button class="btn primary" type="button" (click)="reconstruir()" [disabled]="reconstruyendo() || cargando()">
{{ reconstruyendo() ? 'Reconstruyendo…' : 'Reconstruir' }}
</button>
</section>
}
@if (pasos(); as ps) {
<section class="card">
<h2>Resultado</h2>
@if (ps.length === 0) {
<p class="muted">No había nada que hacer.</p>
} @else {
<table class="grid">
<thead>
<tr><th>Acción</th><th>Elemento</th><th>Resultado</th></tr>
</thead>
<tbody>
@for (p of ps; track $index) {
<tr>
<td>{{ p.accion }}</td>
<td><code>{{ p.nombre }}</code></td>
<td>
<span class="chip" [class.ok]="p.ok" [class.falta]="!p.ok">{{ p.ok ? 'OK' : 'Error' }}</span>
@if (p.mensaje) {
<span class="muted"> {{ p.mensaje }}</span>
}
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
@for (g of grupos(); track g.tipo) {
<section class="card">
<h2>{{ g.titulo }}</h2>
<table class="grid">
<thead>
<tr><th>Nombre</th><th>Descripción</th><th>Estado</th><th>Detalle</th></tr>
</thead>
<tbody>
@for (e of g.elementos; track e.nombre) {
<tr>
<td><code>{{ e.nombre }}</code></td>
<td>{{ e.descripcion }}</td>
<td><span class="chip" [class]="claseEstado(e)">{{ e.estado }}</span></td>
<td class="muted">{{ e.detalle }}</td>
</tr>
}
</tbody>
</table>
</section>
}
} @else if (cargando()) {
<p class="muted">Verificando la base SAP…</p>
}
@@ -0,0 +1,87 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import type { EstadoMetadato, ResultadoPaso } from '@dp/shared';
import { mensajeDeError } from '../../core/http-error';
import { MetadataService } from '../../core/metadata.service';
const GRUPOS: { tipo: EstadoMetadato['tipo']; titulo: string }[] = [
{ tipo: 'tabla', titulo: 'Tablas de usuario' },
{ tipo: 'campo', titulo: 'Campos de usuario' },
{ tipo: 'objeto', titulo: 'Objeto de usuario' },
{ tipo: 'dependencia', titulo: 'Dependencias externas (solo verificación)' },
];
@Component({
selector: 'app-metadatos',
imports: [FormsModule, RouterLink],
templateUrl: './metadatos.html',
styleUrl: './metadatos.css',
})
export class MetadatosPage implements OnInit {
private readonly metadata = inject(MetadataService);
protected readonly estado = this.metadata.estado;
protected readonly cargando = signal(false);
protected readonly reconstruyendo = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly pasos = signal<ResultadoPaso[] | null>(null);
protected readonly permitirDestructivos = signal(false);
protected readonly grupos = computed(() => {
const elementos = this.estado()?.elementos ?? [];
return GRUPOS.map((g) => ({ ...g, elementos: elementos.filter((e) => e.tipo === g.tipo) })).filter(
(g) => g.elementos.length > 0,
);
});
protected readonly pendientes = computed(() =>
(this.estado()?.elementos ?? []).filter((e) => e.estado !== 'OK' && e.tipo !== 'dependencia'),
);
protected readonly hayDestructivos = computed(() => this.pendientes().some((e) => e.destructivo));
protected readonly faltanDependencias = computed(() =>
(this.estado()?.elementos ?? []).some((e) => e.tipo === 'dependencia' && e.estado !== 'OK'),
);
async ngOnInit(): Promise<void> {
await this.verificar();
}
async verificar(): Promise<void> {
this.cargando.set(true);
this.error.set(null);
try {
await this.metadata.verificar();
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.cargando.set(false);
}
}
async reconstruir(): Promise<void> {
const destructivo = this.permitirDestructivos() && this.hayDestructivos();
const pregunta = destructivo
? 'Se crearán los elementos que faltan y se BORRARÁN y volverán a crear los campos marcados. Los datos de esas columnas se perderán. ¿Desea continuar?'
: 'Se crearán y corregirán en SAP los elementos que faltan. ¿Desea continuar?';
if (!confirm(pregunta)) return;
this.reconstruyendo.set(true);
this.error.set(null);
this.pasos.set(null);
try {
const resultado = await this.metadata.reconstruir({ permitirDestructivos: this.permitirDestructivos() });
this.pasos.set(resultado.pasos);
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.reconstruyendo.set(false);
}
}
protected claseEstado(e: EstadoMetadato): string {
return e.estado.toLowerCase();
}
}
@@ -0,0 +1,108 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
a.btn {
text-decoration: none;
}
form.card {
display: flex;
flex-direction: column;
gap: 12px;
}
h3 {
font-size: 14px;
margin-top: 4px;
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px 16px;
}
.nota {
margin: 0;
font-size: 12px;
}
.acciones {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.titulo-grilla {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.scroll {
overflow-x: auto;
}
tr.padre td {
background: var(--surface-alt);
font-weight: 600;
}
tr.hijo.deshabilitada td {
color: var(--text-muted);
}
.centro {
text-align: center;
width: 40px;
}
.texto-error {
color: var(--error);
}
.pie {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.alert ul {
margin: 4px 0 0;
padding-left: 20px;
}
.barra-pdf {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.opcion-pdf {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.nota a.btn {
display: inline-flex;
}
@@ -0,0 +1,289 @@
@if (cargando()) {
<p class="muted">Cargando…</p>
}
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (cabecera(); as c) {
<section class="encabezado">
<div>
<h1>Planificación Nº {{ c.docNum }}</h1>
<p class="muted">
Almacén {{ c.whsCode }} ·
<span class="chip" [class.neutral]="c.status === 'O'" [class.ok]="c.status === 'C'">
{{ c.status === 'O' ? 'Abierta' : 'Cerrada' }}
</span>
</p>
</div>
<a class="btn" routerLink="/planificaciones">Volver</a>
</section>
<app-stepper [actual]="paso()" />
@if (advertencias().length > 0) {
<div class="alert warn">
<strong>Avisos del procesamiento:</strong>
<ul>
@for (a of advertencias(); track $index) {
<li>{{ a }}</li>
}
</ul>
</div>
}
@if (aviso(); as a) {
<div class="alert ok">{{ a }}</div>
}
<form class="card" [formGroup]="form" (ngSubmit)="guardarCabecera()">
<h2>Datos de las Devoluciones</h2>
<div class="rejilla">
<div class="field">
<label for="docDate">Fecha</label>
<input id="docDate" type="date" formControlName="docDate" />
</div>
<div class="field">
<label for="series">Serie</label>
<select id="series" formControlName="series">
<option [ngValue]="0" disabled>Seleccione…</option>
@for (s of catalogos()?.series ?? []; track s.series) {
<option [ngValue]="s.series">{{ s.name }}</option>
}
</select>
</div>
<div class="field">
<label for="tipoDespacho">Tipo de despacho</label>
<select id="tipoDespacho" formControlName="tipoDespacho">
<option value="" disabled>Seleccione…</option>
@for (o of catalogos()?.tiposDespacho ?? []; track o.value) {
<option [value]="o.value">{{ o.value }} · {{ o.description }}</option>
}
</select>
</div>
<div class="field">
<label for="indicadorTraslado">Indicador de traslado</label>
<select id="indicadorTraslado" formControlName="indicadorTraslado">
<option value="" disabled>Seleccione…</option>
@for (o of catalogos()?.indicadoresTraslado ?? []; track o.value) {
<option [value]="o.value">{{ o.value }} · {{ o.description }}</option>
}
</select>
</div>
<div class="field">
<label for="printingLines">Líneas por documento</label>
<input id="printingLines" type="number" min="1" formControlName="printingLines" />
</div>
</div>
<h3>Transporte</h3>
<div class="rejilla">
<div class="field">
<label for="rutTransporte">RUT transportista</label>
<input id="rutTransporte" type="text" formControlName="rutTransporte" maxlength="20" />
</div>
<div class="field">
<label for="patente">Patente</label>
<input id="patente" type="text" formControlName="patente" maxlength="20" />
</div>
<div class="field">
<label for="rutChofer">RUT chofer</label>
<input id="rutChofer" type="text" formControlName="rutChofer" maxlength="20" />
</div>
<div class="field">
<label for="nombreChofer">Nombre chofer</label>
<input id="nombreChofer" type="text" formControlName="nombreChofer" maxlength="100" />
</div>
<div class="field">
<label for="fechaSalida">Fecha salida</label>
<input id="fechaSalida" type="date" formControlName="fechaSalida" />
</div>
<div class="field">
<label for="horaSalida">Hora salida</label>
<input id="horaSalida" type="time" formControlName="horaSalida" />
</div>
<div class="field">
<label for="fechaLlegada">Fecha llegada</label>
<input id="fechaLlegada" type="date" formControlName="fechaLlegada" />
</div>
</div>
<p class="muted nota">
RUT transportista, patente y datos del chofer se envían a la Devolución. Las fechas y la hora solo se guardan en la
planificación.
</p>
@if (abierta()) {
<div class="acciones">
<button class="btn" type="submit" [disabled]="form.invalid || ocupado()">Guardar cabecera</button>
</div>
}
</form>
<section class="card">
<div class="titulo-grilla">
<h2>Líneas</h2>
<span class="muted">
{{ aplicables().length }} marcada(s) pendiente(s) · {{ conDestino() }} con Devolución
@if (conError() > 0) {
· <span class="texto-error">{{ conError() }} con error</span>
}
</span>
</div>
<div class="scroll">
<table class="grid">
<thead>
<tr>
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Stock</th>
<th>Entrada</th><th>Fecha entrada</th><th class="num">Abierta</th><th class="num">Asignada</th>
<th>Proveedor</th><th>Devolución</th>
</tr>
</thead>
<tbody>
@for (l of lineas(); track l.lineId) {
@if (l.tipoFila === 'F') {
<tr class="padre">
<td></td>
<td colspan="1"><code>{{ l.itemCode }}</code> {{ l.itemDescription }}</td>
<td class="num">{{ l.quantity }}</td>
<td class="num">{{ l.onHand }}</td>
<td colspan="6">
<span class="chip" [class.ok]="l.status === 'OK'" [class.falta]="l.status === 'SIN_STOCK'">
{{ l.status === 'SIN_STOCK' ? 'Sin stock' : 'OK' }}
</span>
</td>
</tr>
} @else {
<tr class="hijo" [class.deshabilitada]="!l.applied">
<td class="centro">
<input
type="checkbox"
[checked]="l.applied"
[disabled]="!puedeMarcar(l)"
[attr.aria-label]="'Devolver la línea de la Entrada ' + l.baseRef"
(change)="alternar(l, $any($event.target).checked)"
/>
</td>
<td></td>
<td></td>
<td></td>
<td>Nº {{ l.baseRef }}</td>
<td>{{ l.baseDocDate }}</td>
<td class="num">{{ l.openQty }}</td>
<td class="num">{{ l.allocatedQty }}</td>
<td>{{ l.cardCode }} <span class="muted">{{ l.cardName }}</span></td>
<td>
@if (l.targetEntry != null) {
<span class="chip ok">Devolución {{ l.targetEntry }}</span>
} @else if (l.errMsg) {
<span class="texto-error" [title]="l.errMsg">{{ l.errCode }}: {{ l.errMsg }}</span>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
@if (resultado(); as r) {
<section class="card">
<h2>Resultado de la creación</h2>
@if (r.errorGuardado) {
<div class="alert error" role="alert">
Las Devoluciones se crearon, pero no se pudo guardar el detalle en la planificación: {{ r.errorGuardado }}
</div>
}
<table class="grid">
<thead>
<tr><th>#</th><th>Proveedor</th><th class="num">Líneas</th><th>Resultado</th></tr>
</thead>
<tbody>
@for (d of r.devoluciones; track d.numero) {
<tr>
<td>{{ d.numero }}</td>
<td>{{ d.cardCode }} <span class="muted">{{ d.cardName }}</span></td>
<td class="num">{{ d.lineas }}</td>
<td>
@if (d.docEntry != null) {
<span class="chip ok">Devolución Nº {{ d.docNum }}</span>
} @else {
<span class="texto-error">{{ d.error }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</section>
}
@if (conDestino() > 0) {
<section class="card">
<h2>Descargar PDF</h2>
<p class="muted nota">
Guía de despacho electrónica (DTE 52) de cada Devolución creada, desde el web service de BES.
</p>
<div class="barra-pdf">
<label class="opcion-pdf">
<input type="checkbox" [checked]="pdfUnir()" (change)="pdfUnir.set($any($event.target).checked)" [disabled]="pdfCargando()" />
Unir todos los PDF en uno solo
</label>
<button class="btn primary" type="button" (click)="descargarPdf()" [disabled]="pdfCargando()">
{{ pdfCargando() ? 'Consultando BES…' : 'Descargar PDF' }}
</button>
</div>
@if (pdfResultado(); as r) {
<table class="grid">
<thead>
<tr><th>Devolución</th><th>Folio consultado</th><th>Resultado</th></tr>
</thead>
<tbody>
@for (d of r.devoluciones; track d.docEntry) {
<tr>
<td>{{ d.docEntry }}</td>
<td>{{ d.folio ?? '—' }}</td>
<td>
@if (d.error) {
<span class="texto-error">{{ d.error }}</span>
} @else if (r.unificado) {
<span class="chip ok">Incluida en el PDF unificado</span>
} @else if (urlDe(d.folio); as url) {
<a [href]="url" [attr.download]="nombreDe(d.folio)">Descargar {{ nombreDe(d.folio) }}</a>
}
</td>
</tr>
}
</tbody>
</table>
@if (r.unificado && pdfDescargas()[0]; as u) {
<p class="nota">
<a class="btn primary" [href]="u.url" [attr.download]="u.nombre">Descargar {{ u.nombre }}</a>
</p>
}
@if (r.archivos.length === 0) {
<div class="alert error" role="alert">No se pudo descargar ningún PDF. Revise los mensajes de cada Devolución.</div>
} @else if (r.devoluciones.some((d) => d.error)) {
<div class="alert warn">Algunas Devoluciones no tienen PDF (ver detalle). Puede volver a intentarlo.</div>
}
}
</section>
}
<section class="pie">
<span></span>
@if (abierta()) {
<div class="acciones">
@if (conDestino() === 0) {
<a class="btn" routerLink="/planificaciones/nueva" [queryParams]="{ reemplaza: c.docEntry }">Rehacer FIFO</a>
}
<button class="btn" type="button" (click)="cerrar()" [disabled]="ocupado()">Cerrar planificación</button>
<button class="btn primary" type="button" (click)="crearDevoluciones()" [disabled]="!puedeCrear()">
{{ ocupado() ? 'Procesando…' : 'Crear devoluciones' }}
</button>
</div>
}
</section>
}
@@ -0,0 +1,234 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { PlanificacionDetallePage } from './detalle';
const linea = (extra: Record<string, unknown>) => ({
tipoFila: 'S', itemCode: 'A1', itemDescription: 'Libro', quantity: null, onHand: null, status: null,
baseType: 20, baseEntry: 15, baseLine: 0, baseRef: 101, baseDocDate: '2026-01-31', openQty: 10, allocatedQty: 4,
applied: true, cardCode: 'P1', cardName: 'Proveedor', targetType: null, targetEntry: null, errCode: '', errMsg: '',
...extra,
});
const plan = (status: 'O' | 'C', lineas: unknown[]) => ({
cabecera: {
docEntry: 5, docNum: 5, status, whsCode: '01', docDate: '2026-10-12', series: 15, printingLines: 11,
tipoDespacho: '1', indicadorTraslado: '7',
},
lineas,
});
const CATALOGOS = {
almacenes: [],
series: [{ series: 15, name: 'Primario', nextNumber: 33, esPorDefecto: true }],
tiposDespacho: [{ value: '1', description: 'Por cuenta del receptor' }],
indicadoresTraslado: [{ value: '7', description: 'Guía de devolución' }],
};
const tick = () => new Promise((r) => setTimeout(r));
describe('PlanificacionDetallePage', () => {
let http: HttpTestingController;
async function crear(p: object) {
await TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['docEntry', '5']]) } } },
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(PlanificacionDetallePage);
fixture.detectChanges();
http.expectOne('/api/planificaciones/5').flush(p);
http.expectOne('/api/catalogos').flush(CATALOGOS);
await tick();
fixture.detectChanges();
return fixture;
}
it('una línea con Devolución ya creada tiene el checkbox bloqueado', async () => {
const fixture = await crear(
plan('O', [
linea({ lineId: 1 }),
linea({ lineId: 2, targetType: 21, targetEntry: 33 }),
]),
);
const cajas = (fixture.nativeElement as HTMLElement).querySelectorAll<HTMLInputElement>('tbody input[type=checkbox]');
expect(cajas).toHaveLength(2);
expect(cajas[0].disabled).toBe(false);
expect(cajas[1].disabled).toBe(true);
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Devolución 33');
});
it('desmarcar una línea llama a la API con applied=false', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
const caja = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('tbody input[type=checkbox]')!;
caja.checked = false;
caja.dispatchEvent(new Event('change'));
const req = http.expectOne('/api/planificaciones/5/lineas/1');
expect(req.request.method).toBe('PATCH');
expect(req.request.body).toEqual({ applied: false });
req.flush(plan('O', [linea({ lineId: 1, applied: false })]));
await tick();
fixture.detectChanges();
expect(caja.checked).toBe(false);
});
it('crear devoluciones guarda la cabecera y luego pide crearlas', async () => {
vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
const el: HTMLElement = fixture.nativeElement;
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Crear devoluciones'))!.click();
http.expectOne((r) => r.method === 'PATCH' && r.url === '/api/planificaciones/5/cabecera').flush(plan('O', [linea({ lineId: 1 })]));
await tick();
http.expectOne('/api/planificaciones/5/devoluciones').flush({
devoluciones: [{ numero: 1, cardCode: 'P1', cardName: 'Proveedor', lineas: 1, docEntry: 33, docNum: 33 }],
planificacion: plan('O', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]),
});
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('Devolución Nº 33');
});
it('una planificación cerrada no permite cambios', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
const el: HTMLElement = fixture.nativeElement;
const textos = [...el.querySelectorAll('button')].map((b) => b.textContent);
expect(textos.some((t) => t?.includes('Crear devoluciones'))).toBe(false);
expect(textos.some((t) => t?.includes('Cerrar planificación'))).toBe(false);
expect(el.querySelector<HTMLInputElement>('#docDate')!.disabled).toBe(true);
});
const botonRehacer = (fixture: Awaited<ReturnType<typeof crear>>) =>
[...(fixture.nativeElement as HTMLElement).querySelectorAll('a')].find((a) => a.textContent?.includes('Rehacer FIFO'));
it('abierta y sin Devoluciones: ofrece Rehacer FIFO', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
expect(botonRehacer(fixture)).toBeTruthy();
});
it('con una Devolución ya creada no ofrece Rehacer FIFO', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
expect(botonRehacer(fixture)).toBeFalsy();
});
it('cerrada no ofrece Rehacer FIFO', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1 })]));
expect(botonRehacer(fixture)).toBeFalsy();
});
describe('descarga de PDF (paso 4)', () => {
const conDevolucion = () => plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]);
const botonPdf = (el: HTMLElement) => [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Descargar PDF'));
const pdfB64 = btoa('%PDF-1.4 prueba');
let clics: string[];
beforeEach(() => {
clics = [];
URL.createObjectURL = vi.fn(() => 'blob:prueba');
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clics.push(this.download);
});
});
it('sin Devoluciones creadas no se ofrece', async () => {
const fixture = await crear(plan('O', [linea({ lineId: 1 })]));
expect(botonPdf(fixture.nativeElement)).toBeUndefined();
});
it('con Devoluciones se ofrece, incluso con la planificación cerrada', async () => {
const fixture = await crear(conDevolucion());
expect(botonPdf(fixture.nativeElement)).toBeDefined();
});
it('un solo PDF: lo pide con unir=true y lo descarga directo', async () => {
const fixture = await crear(conDevolucion());
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
const req = http.expectOne('/api/planificaciones/5/pdf');
expect(req.request.body).toEqual({ unir: true });
req.flush({ devoluciones: [{ docEntry: 33, folio: 5001 }], archivos: [{ nombre: 'DTE_5001.pdf', base64: pdfB64, folios: [5001] }], unificado: false });
await tick();
fixture.detectChanges();
expect(clics).toEqual(['DTE_5001.pdf']);
expect(el.textContent).toContain('Descargar DTE_5001.pdf');
});
it('varios PDF sin unir: no descarga solo, deja un enlace por Devolución y muestra el error de la que falló', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1, targetEntry: 33 }), linea({ lineId: 2, targetEntry: 34 })]));
const el: HTMLElement = fixture.nativeElement;
el.querySelector<HTMLInputElement>('.opcion-pdf input')!.click();
botonPdf(el)!.click();
const req = http.expectOne('/api/planificaciones/5/pdf');
expect(req.request.body).toEqual({ unir: false });
req.flush({
devoluciones: [{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002, error: 'Documento no emitido' }],
archivos: [{ nombre: 'DTE_5001.pdf', base64: pdfB64, folios: [5001] }],
unificado: false,
});
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('Documento no emitido');
expect(el.textContent).toContain('Algunas Devoluciones no tienen PDF');
expect(el.querySelectorAll('table.grid a[download]')).toHaveLength(1);
});
it('unificado: un único enlace con todos los folios', async () => {
const fixture = await crear(plan('C', [linea({ lineId: 1, targetEntry: 33 }), linea({ lineId: 2, targetEntry: 34 })]));
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
http.expectOne('/api/planificaciones/5/pdf').flush({
devoluciones: [{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002 }],
archivos: [{ nombre: 'DTE_Unificado_5.pdf', base64: pdfB64, folios: [5001, 5002] }],
unificado: true,
});
await tick();
fixture.detectChanges();
expect(clics).toEqual(['DTE_Unificado_5.pdf']);
expect(el.textContent).toContain('Incluida en el PDF unificado');
});
it('si el servicio no entrega ningún PDF lo dice', async () => {
const fixture = await crear(conDevolucion());
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
http.expectOne('/api/planificaciones/5/pdf').flush({ devoluciones: [{ docEntry: 33, folio: 5001, error: 'Usuario inválido' }], archivos: [], unificado: false });
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('No se pudo descargar ningún PDF');
expect(clics).toEqual([]);
});
it('un error de la API (por ejemplo sin configuración DTE) se muestra', async () => {
const fixture = await crear(conDevolucion());
const el: HTMLElement = fixture.nativeElement;
botonPdf(el)!.click();
http.expectOne('/api/planificaciones/5/pdf').flush({ message: 'No se encontró configuración en la tabla @DTE_CONFC' }, { status: 400, statusText: 'Bad Request' });
await tick();
fixture.detectChanges();
expect(el.querySelector('.alert.error')?.textContent).toContain('@DTE_CONFC');
});
});
});
@@ -0,0 +1,224 @@
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import type {
CatalogosDevolucion,
LineaPlanificacion,
Planificacion,
ResultadoCrearDevoluciones,
ResultadoPdf,
} from '@dp/shared';
import { consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from '@dp/shared';
import { mensajeDeError } from '../../../core/http-error';
import { PlanificacionesService } from '../../../core/planificaciones.service';
import { Stepper } from '../../../layout/stepper';
interface DescargaPdf {
nombre: string;
folios: number[];
url: string;
}
function base64ABytes(base64: string): Uint8Array<ArrayBuffer> {
const binario = atob(base64);
const bytes = new Uint8Array(binario.length);
for (let i = 0; i < binario.length; i++) bytes[i] = binario.charCodeAt(i);
return bytes;
}
function iniciarDescarga(d: DescargaPdf): void {
const a = document.createElement('a');
a.href = d.url;
a.download = d.nombre;
a.click();
}
@Component({
selector: 'app-planificacion-detalle',
imports: [ReactiveFormsModule, RouterLink, Stepper],
templateUrl: './detalle.html',
styleUrl: './detalle.css',
})
export class PlanificacionDetallePage implements OnInit, OnDestroy {
private readonly api = inject(PlanificacionesService);
private readonly route = inject(ActivatedRoute);
private docEntry = 0;
protected readonly plan = signal<Planificacion | null>(null);
protected readonly catalogos = signal<CatalogosDevolucion | null>(null);
protected readonly cargando = signal(true);
protected readonly ocupado = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly aviso = signal<string | null>(null);
protected readonly advertencias = signal<string[]>([]);
protected readonly resultado = signal<ResultadoCrearDevoluciones | null>(null);
// Paso 4: descarga de PDF
protected readonly pdfUnir = signal(true);
protected readonly pdfCargando = signal(false);
protected readonly pdfResultado = signal<ResultadoPdf | null>(null);
protected readonly pdfDescargas = signal<DescargaPdf[]>([]);
protected readonly form = inject(FormBuilder).nonNullable.group({
docDate: ['', Validators.required],
series: [0, [Validators.required, Validators.min(1)]],
tipoDespacho: ['', Validators.required],
indicadorTraslado: ['', Validators.required],
printingLines: [11, [Validators.required, Validators.min(1)]],
rutTransporte: [''],
patente: [''],
rutChofer: [''],
nombreChofer: [''],
fechaSalida: [''],
horaSalida: [''],
fechaLlegada: [''],
});
protected readonly cabecera = computed(() => this.plan()?.cabecera ?? null);
protected readonly abierta = computed(() => this.cabecera()?.status === 'O');
protected readonly lineas = computed(() => this.plan()?.lineas ?? []);
protected readonly aplicables = computed(() => extraerLineasAplicables(this.lineas()));
protected readonly nDocumentos = computed(() =>
consolidarDocumentos(this.aplicables(), this.cabecera()?.printingLines ?? 0).length,
);
protected readonly conDestino = computed(() => this.lineas().filter((l) => l.tipoFila === 'S' && l.targetEntry != null).length);
protected readonly conError = computed(() => this.aplicables().filter((l) => l.errMsg).length);
/** 3 mientras falten Devoluciones por crear; 4 (PDF) cuando ya no queda nada pendiente. */
protected readonly paso = computed(() => (this.conDestino() > 0 && this.aplicables().length === 0 ? 4 : 3));
protected readonly puedeCrear = computed(
() => this.abierta() && this.aplicables().length > 0 && this.form.valid && !this.ocupado(),
);
ngOnDestroy(): void {
this.liberarDescargas();
}
async ngOnInit(): Promise<void> {
this.docEntry = Number(this.route.snapshot.paramMap.get('docEntry'));
this.advertencias.set((history.state as { advertencias?: string[] } | null)?.advertencias ?? []);
try {
const [plan, catalogos] = await Promise.all([this.api.obtener(this.docEntry), this.api.catalogos()]);
this.catalogos.set(catalogos);
this.mostrar(plan);
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.cargando.set(false);
}
}
/** Reemplaza el estado en pantalla con lo que devolvió la API. */
private mostrar(plan: Planificacion): void {
this.plan.set(plan);
const c = plan.cabecera;
this.form.reset({
docDate: c.docDate,
series: c.series,
tipoDespacho: c.tipoDespacho,
indicadorTraslado: c.indicadorTraslado,
printingLines: c.printingLines || 11,
rutTransporte: c.rutTransporte ?? '',
patente: c.patente ?? '',
rutChofer: c.rutChofer ?? '',
nombreChofer: c.nombreChofer ?? '',
fechaSalida: c.fechaSalida ?? '',
horaSalida: c.horaSalida ?? '',
fechaLlegada: c.fechaLlegada ?? '',
});
if (plan.cabecera.status === 'C') this.form.disable();
else this.form.enable();
}
protected puedeMarcar(l: LineaPlanificacion): boolean {
return this.abierta() && puedeCambiarSeleccion(l) && !this.ocupado();
}
protected async alternar(l: LineaPlanificacion, marcada: boolean): Promise<void> {
if (l.lineId == null) return;
await this.ejecutar(async () => this.mostrar(await this.api.cambiarSeleccion(this.docEntry, l.lineId!, marcada)));
}
protected async guardarCabecera(): Promise<void> {
if (this.form.invalid) return;
await this.ejecutar(async () => {
this.mostrar(await this.api.actualizarCabecera(this.docEntry, this.form.getRawValue()));
this.aviso.set('Cabecera guardada.');
});
}
protected async crearDevoluciones(): Promise<void> {
if (!this.puedeCrear()) return;
const lineas = this.aplicables().length;
const docs = this.nDocumentos();
if (!confirm(`Se crearán ${docs} Devolución(es) con ${lineas} línea(s) marcada(s) en SAP. ¿Desea continuar?`)) return;
await this.ejecutar(async () => {
// Se guarda primero la cabecera para que las Devoluciones salgan con lo que se ve en pantalla.
await this.api.actualizarCabecera(this.docEntry, this.form.getRawValue());
const r = await this.api.crearDevoluciones(this.docEntry);
this.resultado.set(r);
this.mostrar(r.planificacion);
});
}
/** Pide los PDF de las Devoluciones. Con un solo archivo lo descarga directo; con varios deja un enlace por archivo. */
protected async descargarPdf(): Promise<void> {
this.pdfCargando.set(true);
this.error.set(null);
this.liberarDescargas();
this.pdfResultado.set(null);
try {
const r = await this.api.pdf(this.docEntry, this.pdfUnir());
this.pdfResultado.set(r);
const descargas = r.archivos.map((a) => ({
nombre: a.nombre,
folios: a.folios,
url: URL.createObjectURL(new Blob([base64ABytes(a.base64)], { type: 'application/pdf' })),
}));
this.pdfDescargas.set(descargas);
if (descargas.length === 1) iniciarDescarga(descargas[0]);
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.pdfCargando.set(false);
}
}
protected urlDe(folio: number | undefined): string | null {
return this.pdfDescargas().find((d) => folio != null && d.folios.length === 1 && d.folios[0] === folio)?.url ?? null;
}
protected nombreDe(folio: number | undefined): string {
return this.pdfDescargas().find((d) => folio != null && d.folios.length === 1 && d.folios[0] === folio)?.nombre ?? '';
}
private liberarDescargas(): void {
for (const d of this.pdfDescargas()) URL.revokeObjectURL(d.url);
this.pdfDescargas.set([]);
}
protected async cerrar(): Promise<void> {
const pendientes = this.aplicables().length;
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
if (!confirm(`Se cerrará la planificación y ya no se podrá modificar.${aviso} ¿Desea continuar?`)) return;
await this.ejecutar(async () => this.mostrar(await this.api.cerrar(this.docEntry)));
}
private async ejecutar(accion: () => Promise<void>): Promise<void> {
this.ocupado.set(true);
this.error.set(null);
this.aviso.set(null);
try {
await accion();
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.ocupado.set(false);
}
}
}
@@ -0,0 +1,87 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
a.btn {
text-decoration: none;
}
.formulario {
display: flex;
flex-direction: column;
gap: 16px;
}
.fila {
display: flex;
gap: 16px;
flex-wrap: wrap;
.field {
min-width: 240px;
}
}
select,
textarea {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
color: var(--text);
background: var(--surface);
&:focus {
outline: 2px solid var(--primary-soft);
border-color: var(--primary);
}
}
select {
height: 38px;
}
textarea {
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
resize: vertical;
}
.ayuda {
font-size: 12px;
}
.acciones,
.acciones-finales {
display: flex;
gap: 8px;
}
.acciones-finales {
justify-content: flex-end;
}
.num {
text-align: right;
}
.nota {
margin: 12px 0 0;
}
.alert ul {
margin: 4px 0 0;
padding-left: 20px;
}
@@ -0,0 +1,123 @@
<section class="encabezado">
<div>
<h1>{{ reemplaza() ? 'Rehacer planificación Nº ' + reemplazaNumero() : 'Nueva planificación' }}</h1>
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida el stock del almacén antes de procesar.</p>
</div>
<a class="btn" routerLink="/planificaciones">Volver</a>
</section>
<app-stepper [actual]="paso()" />
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (reemplaza()) {
<div class="alert warn">
Se creará una planificación nueva con este listado y la Nº {{ reemplazaNumero() }} se cerrará. Se conservan la serie, el
despacho, el traslado y los datos de transporte ya cargados. Edite el listado, o el almacén, antes de procesar.
</div>
}
@if (bloqueada() && abierta(); as a) {
<div class="alert warn">
Hay una planificación abierta (Nº {{ a.docNum }}). Ciérrela antes de crear otra.
<a [routerLink]="['/planificaciones', a.docEntry]">Ir a la planificación</a>
</div>
}
<section class="card formulario">
<div class="fila">
<div class="field">
<label for="almacen">Almacén</label>
<select id="almacen" [ngModel]="whsCode()" (ngModelChange)="cambiarAlmacen($event)" [disabled]="cargando() || bloqueada()">
<option value="" disabled>Seleccione…</option>
@for (a of almacenes(); track a.code) {
<option [value]="a.code">{{ a.code }} · {{ a.name }}</option>
}
</select>
</div>
<div class="field">
<label for="fecha">Fecha del documento</label>
<input id="fecha" type="date" [ngModel]="docDate()" (ngModelChange)="docDate.set($event)" [disabled]="bloqueada()" />
</div>
</div>
<div class="field">
<label for="texto">Artículos (código y cantidad, separados por tabulador)</label>
<textarea
id="texto"
rows="8"
spellcheck="false"
placeholder="Copie las columnas desde Excel y péguelas aquí.&#10;Código&#9;Cantidad&#10;A00001&#9;5"
[ngModel]="texto()"
(ngModelChange)="cambiarTexto($event)"
[disabled]="bloqueada()"
></textarea>
<span class="muted ayuda">
Con 2 columnas se toma la segunda como cantidad; con 3 o más, la tercera (la segunda se ignora). Una primera fila sin
cantidad numérica se toma como encabezado.
</span>
</div>
<div class="acciones">
<button class="btn" type="button" (click)="pegarDesdePortapapeles()" [disabled]="bloqueada() || validando() || procesando()">
Pegar desde el portapapeles
</button>
<button class="btn" type="button" (click)="validar()" [disabled]="!puedeValidar()">
{{ validando() ? 'Validando…' : 'Validar' }}
</button>
</div>
</section>
@if (resultado(); as r) {
@if (r.errores.length > 0) {
<div class="alert error" role="alert">
<strong>Corrija antes de procesar:</strong>
<ul>
@for (e of r.errores; track $index) {
<li>{{ e }}</li>
}
</ul>
</div>
}
@if (r.lineas.length > 0) {
<section class="card">
<h2>Artículos validados</h2>
<table class="grid">
<thead>
<tr>
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Stock</th><th>Estado</th>
</tr>
</thead>
<tbody>
@for (l of r.lineas; track l.itemCode) {
<tr>
<td><code>{{ l.itemCode }}</code></td>
<td>{{ l.itemDescription }}</td>
<td class="num">{{ l.quantity }}</td>
<td class="num">{{ l.onHand }}</td>
<td>
<span class="chip" [class.ok]="l.status === 'OK'" [class.falta]="l.status === 'SIN_STOCK'">
{{ l.status === 'OK' ? 'OK' : 'Sin stock' }}
</span>
</td>
</tr>
}
</tbody>
</table>
@if (sinStock() > 0) {
<p class="muted nota">
{{ sinStock() }} artículo(s) sin stock suficiente quedarán fuera al procesar.
</p>
}
</section>
}
<section class="acciones-finales">
<button class="btn primary" type="button" (click)="procesar()" [disabled]="!puedeProcesar()">
{{ procesando() ? 'Procesando…' : reemplaza() ? 'Procesar y reemplazar' : 'Procesar (asignación FIFO)' }}
</button>
</section>
}
@@ -0,0 +1,222 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { NuevaPlanificacionPage } from './nueva';
const CATALOGOS = {
almacenes: [{ code: '01', name: 'Almacén general' }],
series: [],
tiposDespacho: [],
indicadoresTraslado: [],
};
const padre = (itemCode: string, status: 'OK' | 'SIN_STOCK') => ({
tipoFila: 'F', itemCode, itemDescription: 'Libro', quantity: 5, onHand: 10, status,
baseType: null, baseEntry: null, baseLine: null, baseRef: null, baseDocDate: null, openQty: null, allocatedQty: null,
applied: false, cardCode: '', cardName: '', targetType: null, targetEntry: null, errCode: '', errMsg: '',
});
const tick = () => new Promise((r) => setTimeout(r));
describe('NuevaPlanificacionPage', () => {
let http: HttpTestingController;
async function crear(abierta: object | null = null, reemplaza?: { docEntry: number; plan: object }) {
await TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
...(reemplaza
? [{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({ reemplaza: String(reemplaza.docEntry) }) } } }]
: []),
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(NuevaPlanificacionPage);
fixture.detectChanges();
http.expectOne('/api/catalogos').flush(CATALOGOS);
http.expectOne('/api/planificaciones/abierta').flush({ abierta });
if (reemplaza) http.expectOne(`/api/planificaciones/${reemplaza.docEntry}`).flush(reemplaza.plan);
await tick();
fixture.detectChanges();
return fixture;
}
async function escribir(fixture: Awaited<ReturnType<typeof crear>>, texto: string) {
const el: HTMLElement = fixture.nativeElement;
const almacen = el.querySelector<HTMLSelectElement>('#almacen')!;
almacen.value = '01';
almacen.dispatchEvent(new Event('change'));
const area = el.querySelector<HTMLTextAreaElement>('#texto')!;
area.value = texto;
area.dispatchEvent(new Event('input'));
await tick();
fixture.detectChanges();
return el;
}
it('valida lo pegado y habilita Procesar solo si no hay errores', async () => {
const fixture = await crear();
const el = await escribir(fixture, 'A1\t5');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
const req = http.expectOne('/api/planificaciones/pegar');
expect(req.request.body).toEqual({ whsCode: '01', texto: 'A1\t5' });
req.flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('A1');
const procesar = [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'));
expect(procesar?.disabled).toBe(false);
});
it('el botón Pegar lee el portapapeles y valida', async () => {
const leer = vi.fn().mockResolvedValue('A1\t5');
Object.defineProperty(navigator, 'clipboard', { value: { readText: leer }, configurable: true });
const fixture = await crear();
const el = await escribir(fixture, '');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Pegar'))!.click();
await tick();
const req = http.expectOne('/api/planificaciones/pegar');
expect(req.request.body).toEqual({ whsCode: '01', texto: 'A1\t5' });
req.flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
expect(leer).toHaveBeenCalled();
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('A1\t5');
expect(el.textContent).toContain('A1');
});
it('si el navegador niega el portapapeles muestra cómo pegar a mano', async () => {
Object.defineProperty(navigator, 'clipboard', { value: { readText: vi.fn().mockRejectedValue(new Error('denied')) }, configurable: true });
const fixture = await crear();
const el = await escribir(fixture, '');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Pegar'))!.click();
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('No se pudo leer el portapapeles');
http.expectNone('/api/planificaciones/pegar');
});
it('con errores de validación no deja procesar', async () => {
const fixture = await crear();
const el = await escribir(fixture, 'X9\t5');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [], errores: ['X9: el artículo no existe.'] });
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('X9: el artículo no existe.');
const procesar = [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'));
expect(procesar?.disabled).toBe(true);
});
it('procesa y navega al detalle de la planificación creada', async () => {
const fixture = await crear();
const router = TestBed.inject(Router);
const navegar = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const el = await escribir(fixture, 'A1\t5');
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar'))!.click();
const req = http.expectOne('/api/planificaciones');
expect(req.request.body).toMatchObject({ whsCode: '01', texto: 'A1\t5' });
req.flush({ planificacion: { cabecera: { docEntry: 7 }, lineas: [] }, advertencias: ['aviso'] });
await tick();
expect(navegar).toHaveBeenCalledWith(['/planificaciones', 7], { state: { advertencias: ['aviso'] } });
});
it('si falla la consulta de la abierta, los almacenes igual se muestran', async () => {
await TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(NuevaPlanificacionPage);
fixture.detectChanges();
http.expectOne('/api/catalogos').flush(CATALOGOS);
http.expectOne('/api/planificaciones/abierta').flush({ message: 'boom' }, { status: 500, statusText: 'Error' });
await tick();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelectorAll('#almacen option')).toHaveLength(2);
expect(el.textContent).toContain('No se pudo consultar la planificación abierta');
});
it('con una planificación abierta bloquea el formulario', async () => {
const fixture = await crear({ docEntry: 3, docNum: 3, status: 'O', whsCode: '01', docDate: '2026-10-12' });
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('Hay una planificación abierta (Nº 3)');
expect(el.querySelector<HTMLSelectElement>('#almacen')!.disabled).toBe(true);
});
describe('rehacer una planificación (?reemplaza=)', () => {
const resumen = { docEntry: 3, docNum: 3, status: 'O', whsCode: '02', docDate: '2026-10-10' };
const planAbierta = (extra: Record<string, unknown> = {}) => ({
cabecera: { docEntry: 3, docNum: 3, status: 'O', whsCode: '02', docDate: '2026-10-10' },
lineas: [padre('A1', 'OK'), { ...padre('', 'OK'), tipoFila: 'S', ...extra }],
});
it('precarga almacén, fecha y listado, y no la bloquea la propia planificación abierta', async () => {
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta() });
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('Rehacer planificación Nº 3');
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('A1\t5');
expect(el.querySelector<HTMLSelectElement>('#almacen')!.disabled).toBe(false);
expect(el.textContent).not.toContain('Ciérrela antes de crear otra');
});
it('procesar llama a /reprocesar y avisa que la anterior quedó cerrada', async () => {
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta() });
const navegar = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
const el: HTMLElement = fixture.nativeElement;
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK')], errores: [] });
await tick();
fixture.detectChanges();
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Procesar y reemplazar'))!.click();
const req = http.expectOne('/api/planificaciones/3/reprocesar');
expect(req.request.body).toMatchObject({ texto: 'A1\t5' });
req.flush({ planificacion: { cabecera: { docEntry: 4 }, lineas: [] }, advertencias: [], reemplazada: { docNum: 3 } });
await tick();
expect(navegar).toHaveBeenCalledWith(['/planificaciones', 4], {
state: { advertencias: ['Esta planificación reemplaza a la Nº 3, que quedó cerrada.'] },
});
});
it('si la anterior ya tiene una Devolución, no se puede rehacer', async () => {
const fixture = await crear(resumen, { docEntry: 3, plan: planAbierta({ targetEntry: 33, targetType: 21 }) });
const el: HTMLElement = fixture.nativeElement;
expect(el.textContent).toContain('ya no se puede rehacer');
expect(el.textContent).not.toContain('Rehacer planificación');
expect(el.querySelector<HTMLTextAreaElement>('#texto')!.value).toBe('');
});
it('si la anterior está cerrada, no se puede rehacer', async () => {
const plan = { ...planAbierta(), cabecera: { docEntry: 3, docNum: 3, status: 'C', whsCode: '02', docDate: '2026-10-10' } };
const fixture = await crear(null, { docEntry: 3, plan });
expect((fixture.nativeElement as HTMLElement).textContent).toContain('ya no se puede rehacer');
});
});
});
@@ -0,0 +1,178 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { Almacen, Planificacion, ResultadoPegadoApi, ResumenPlanificacion } from '@dp/shared';
import { mensajeDeError } from '../../../core/http-error';
import { PlanificacionesService } from '../../../core/planificaciones.service';
import { Stepper } from '../../../layout/stepper';
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
function hoy(): string {
const d = new Date();
const dos = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
}
@Component({
selector: 'app-nueva-planificacion',
imports: [FormsModule, RouterLink, Stepper],
templateUrl: './nueva.html',
styleUrl: './nueva.css',
})
export class NuevaPlanificacionPage implements OnInit {
private readonly api = inject(PlanificacionesService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
protected readonly almacenes = signal<Almacen[]>([]);
protected readonly abierta = signal<ResumenPlanificacion | null>(null);
protected readonly cargando = signal(true);
/** docEntry de la planificación que se está rehaciendo (query param `reemplaza`), si aplica. */
protected readonly reemplaza = signal<number | null>(null);
protected readonly reemplazaNumero = signal<number | null>(null);
/** Hay otra planificación abierta distinta de la que se rehace: no se puede continuar. */
protected readonly bloqueada = computed(() => {
const a = this.abierta();
return !!a && a.docEntry !== this.reemplaza();
});
protected readonly whsCode = signal('');
protected readonly docDate = signal(hoy());
protected readonly texto = signal('');
protected readonly resultado = signal<ResultadoPegadoApi | null>(null);
protected readonly validando = signal(false);
protected readonly procesando = signal(false);
protected readonly error = signal<string | null>(null);
protected readonly paso = computed(() => (this.resultado() ? 2 : 1));
protected readonly puedeValidar = computed(
() => !!this.whsCode() && this.texto().trim().length > 0 && !this.validando() && !this.procesando() && !this.bloqueada(),
);
protected readonly conStock = computed(() => (this.resultado()?.lineas ?? []).filter((l) => l.status === 'OK').length);
protected readonly sinStock = computed(() => (this.resultado()?.lineas ?? []).filter((l) => l.status === 'SIN_STOCK').length);
protected readonly puedeProcesar = computed(() => {
const r = this.resultado();
return !!r && r.errores.length === 0 && this.conStock() > 0 && !this.procesando() && !this.bloqueada();
});
async ngOnInit(): Promise<void> {
const reemplaza = Number(this.route.snapshot.queryParamMap.get('reemplaza')) || null;
this.reemplaza.set(reemplaza);
// Cada carga va por separado: si falla una, la otra se muestra igual y el error dice cuál.
const [catalogos, abierta, anterior] = await Promise.allSettled([
this.api.catalogos(),
this.api.abierta(),
reemplaza ? this.api.obtener(reemplaza) : Promise.resolve(null),
]);
const errores: string[] = [];
if (catalogos.status === 'fulfilled') this.almacenes.set(catalogos.value.almacenes);
else errores.push(`No se pudieron cargar los almacenes: ${mensajeDeError(catalogos.reason)}`);
if (abierta.status === 'fulfilled') this.abierta.set(abierta.value);
else errores.push(`No se pudo consultar la planificación abierta: ${mensajeDeError(abierta.reason)}`);
if (anterior.status === 'fulfilled' && anterior.value) this.precargar(anterior.value);
else if (anterior.status === 'rejected') errores.push(`No se pudo leer la planificación a rehacer: ${mensajeDeError(anterior.reason)}`);
if (errores.length > 0) this.error.set(errores.join(' '));
this.cargando.set(false);
}
/** Deja listo el formulario con el almacén, la fecha y el listado de la planificación que se rehace. */
private precargar(plan: Planificacion): void {
if (plan.cabecera.status !== 'O' || plan.lineas.some((l) => l.targetEntry != null)) {
this.reemplaza.set(null);
this.error.set(`La planificación Nº ${plan.cabecera.docNum} ya no se puede rehacer (está cerrada o tiene Devoluciones creadas).`);
return;
}
this.reemplazaNumero.set(plan.cabecera.docNum ?? null);
this.whsCode.set(plan.cabecera.whsCode);
this.docDate.set(plan.cabecera.docDate || hoy());
this.texto.set(
plan.lineas
.filter((l) => l.tipoFila === 'F')
.map((l) => `${l.itemCode}\t${l.quantity}`)
.join('\n'),
);
}
/** Cualquier cambio en el almacén o en el texto invalida la validación anterior. */
protected cambiarAlmacen(codigo: string): void {
this.whsCode.set(codigo);
this.resultado.set(null);
}
protected cambiarTexto(texto: string): void {
this.texto.set(texto);
this.resultado.set(null);
}
/** Lee el portapapeles del navegador, lo deja en el área de texto y lo valida. */
async pegarDesdePortapapeles(): Promise<void> {
if (!this.whsCode()) {
this.error.set('Seleccione primero el almacén.');
return;
}
this.error.set(null);
let texto: string;
try {
texto = await navigator.clipboard.readText();
} catch {
this.error.set(
'No se pudo leer el portapapeles. Permita el acceso cuando el navegador lo pida, o pegue con Ctrl/Cmd+V dentro del cuadro de texto.',
);
return;
}
if (texto.trim().length === 0) {
this.error.set('El portapapeles está vacío. Copie primero las celdas desde Excel.');
return;
}
this.cambiarTexto(texto);
await this.validar();
}
async validar(): Promise<void> {
if (!this.puedeValidar()) return;
this.validando.set(true);
this.error.set(null);
try {
this.resultado.set(await this.api.pegar(this.whsCode(), this.texto()));
} catch (e) {
this.resultado.set(null);
this.error.set(mensajeDeError(e));
} finally {
this.validando.set(false);
}
}
async procesar(): Promise<void> {
if (!this.puedeProcesar()) return;
this.procesando.set(true);
this.error.set(null);
try {
const datos = { whsCode: this.whsCode(), docDate: this.docDate(), texto: this.texto() };
const anterior = this.reemplaza();
const { planificacion, advertencias, reemplazada } = anterior
? await this.api.reprocesar(anterior, datos)
: await this.api.procesar(datos);
const avisos = [...advertencias];
if (reemplazada?.errorCierre) {
avisos.push(
`La planificación Nº ${reemplazada.docNum} no se pudo cerrar (${reemplazada.errorCierre}). Ciérrela a mano para poder crear otras.`,
);
} else if (reemplazada) {
avisos.push(`Esta planificación reemplaza a la Nº ${reemplazada.docNum}, que quedó cerrada.`);
}
await this.router.navigate(['/planificaciones', planificacion.cabecera.docEntry], { state: { advertencias: avisos } });
} catch (e) {
this.error.set(mensajeDeError(e));
} finally {
this.procesando.set(false);
}
}
}
@@ -0,0 +1,86 @@
:host {
display: flex;
flex-direction: column;
gap: 16px;
}
.encabezado {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
p {
margin: 4px 0 0;
}
}
a.btn {
text-decoration: none;
}
.filtros {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 16px;
.field {
min-width: 170px;
}
}
.limpiar {
margin-left: auto;
}
.derecha {
text-align: right;
}
table.recargando {
opacity: 0.55;
}
.paginacion {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.controles {
display: flex;
align-items: center;
gap: 6px;
}
.tamano {
display: flex;
align-items: center;
gap: 6px;
margin-right: 8px;
select {
height: 36px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
background: var(--surface);
}
}
.btn.numero {
min-width: 36px;
padding: 0 8px;
&.activo {
background: var(--primary-soft);
border-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
}
@@ -0,0 +1,120 @@
<section class="encabezado">
<div>
<h1>Planificaciones</h1>
<p class="muted">Devoluciones a proveedores agrupadas por ejecución. Solo puede haber una abierta a la vez.</p>
</div>
@if (abierta()) {
<button class="btn primary" type="button" disabled title="Cierre la planificación abierta para crear otra">
Nueva planificación
</button>
} @else {
<a class="btn primary" routerLink="nueva">Nueva planificación</a>
}
</section>
@if (error(); as e) {
<div class="alert error" role="alert">{{ e }}</div>
}
@if (abierta(); as a) {
<div class="alert warn">
Hay una planificación abierta (Nº {{ a.docNum }}).
<a [routerLink]="[a.docEntry]">Continuar con ella</a> o ciérrela para crear otra.
</div>
}
<section class="card filtros" aria-label="Filtros">
<div class="field">
<label for="f-estado">Estado</label>
<select id="f-estado" [ngModel]="status()" (ngModelChange)="status.set($event); filtrar()">
<option value="">Todas</option>
<option value="O">Abiertas</option>
<option value="C">Cerradas</option>
</select>
</div>
<div class="field">
<label for="f-almacen">Almacén</label>
<select id="f-almacen" [ngModel]="whsCode()" (ngModelChange)="whsCode.set($event); filtrar()">
<option value="">Todos</option>
@for (a of almacenes(); track a.code) {
<option [value]="a.code">{{ a.code }} · {{ a.name }}</option>
}
</select>
</div>
<div class="field">
<label for="f-desde">Fecha desde</label>
<input id="f-desde" type="date" [ngModel]="desde()" [max]="hasta() || null" (ngModelChange)="desde.set($event); filtrar()" />
</div>
<div class="field">
<label for="f-hasta">Fecha hasta</label>
<input id="f-hasta" type="date" [ngModel]="hasta()" [min]="desde() || null" (ngModelChange)="hasta.set($event); filtrar()" />
</div>
<div class="limpiar">
<button class="btn" type="button" (click)="limpiar()" [disabled]="!hayFiltros()">Limpiar filtros</button>
</div>
</section>
<section class="card">
@if (cargando() && !datos()) {
<p class="muted">Cargando…</p>
} @else if (filas().length === 0) {
<p class="muted">
{{ hayFiltros() ? 'Ninguna planificación cumple los filtros.' : 'Todavía no hay planificaciones.' }}
</p>
} @else {
<table class="grid" [class.recargando]="cargando()">
<thead>
<tr><th>Nº</th><th>Fecha</th><th>Almacén</th><th>Estado</th><th></th></tr>
</thead>
<tbody>
@for (p of filas(); track p.docEntry) {
<tr>
<td>{{ p.docNum }}</td>
<td>{{ p.docDate }}</td>
<td>{{ p.whsCode }}</td>
<td>
<span class="chip" [class.ok]="p.status === 'C'" [class.neutral]="p.status === 'O'">
{{ p.status === 'O' ? 'Abierta' : 'Cerrada' }}
</span>
</td>
<td class="derecha"><a [routerLink]="[p.docEntry]">Ver</a></td>
</tr>
}
</tbody>
</table>
}
@if (rango(); as r) {
<nav class="paginacion" aria-label="Paginación">
<span class="muted resumen">Mostrando {{ r.primero }}–{{ r.ultimo }} de {{ r.total }}</span>
<div class="controles">
<label class="tamano muted" for="f-tamano">
Por página
<select id="f-tamano" [ngModel]="tamano()" (ngModelChange)="cambiarTamano($event)">
@for (t of tamanos; track t) {
<option [ngValue]="t">{{ t }}</option>
}
</select>
</label>
<button class="btn" type="button" (click)="irA(pagina() - 1)" [disabled]="pagina() <= 1 || cargando()">Atrás</button>
@for (n of numeros(); track n) {
<button
class="btn numero"
type="button"
[class.activo]="n === pagina()"
[attr.aria-current]="n === pagina() ? 'page' : null"
(click)="irA(n)"
[disabled]="cargando()"
>
{{ n }}
</button>
}
<button class="btn" type="button" (click)="irA(pagina() + 1)" [disabled]="pagina() >= totalPaginas() || cargando()">
Siguiente
</button>
</div>
</nav>
}
</section>
@@ -0,0 +1,174 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, TestRequest, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { PlanificacionesPage } from './planificaciones';
const tick = () => new Promise((r) => setTimeout(r));
const fila = (n: number, status: 'O' | 'C' = 'C') => ({ docEntry: n, docNum: n, status, whsCode: '01', docDate: '2026-10-12' });
const pagina = (pag: number, total: number, tamano = 20) => ({
items: Array.from({ length: Math.min(tamano, total - (pag - 1) * tamano) }, (_, i) => fila(total - (pag - 1) * tamano - i)),
total,
pagina: pag,
tamano,
totalPaginas: Math.max(Math.ceil(total / tamano), 1),
});
describe('PlanificacionesPage (listado)', () => {
let http: HttpTestingController;
async function crear(primera: object = pagina(1, 45)) {
await TestBed.configureTestingModule({
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(PlanificacionesPage);
fixture.detectChanges();
http.expectOne('/api/catalogos/almacenes').flush([{ code: '01', name: 'Almacén general' }, { code: '02', name: 'Bodega Secundaria' }]);
http.expectOne('/api/planificaciones/abierta').flush({ abierta: null });
peticion().flush(primera);
await tick();
fixture.detectChanges();
return fixture;
}
/** La petición pendiente al listado (se espera exactamente una). */
const peticion = (): TestRequest => http.expectOne((r) => r.url === '/api/planificaciones');
const boton = (el: HTMLElement, texto: string) =>
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === texto)!;
async function responder(fixture: Awaited<ReturnType<typeof crear>>, cuerpo: object): Promise<TestRequest> {
const req = peticion();
req.flush(cuerpo);
await tick();
fixture.detectChanges();
return req;
}
it('carga la primera página y muestra el rango', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelectorAll('tbody tr')).toHaveLength(20);
expect(el.textContent).toContain('Mostrando 1–20 de 45');
expect(boton(el, 'Atrás').disabled).toBe(true);
expect(boton(el, 'Siguiente').disabled).toBe(false);
});
it('el pedido inicial lleva pagina=1 y tamano=20 y no manda filtros vacíos', async () => {
await TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
http = TestBed.inject(HttpTestingController);
TestBed.createComponent(PlanificacionesPage).detectChanges();
http.expectOne('/api/catalogos/almacenes').flush([]);
http.expectOne('/api/planificaciones/abierta').flush({ abierta: null });
const { params } = peticion().request;
expect(params.get('pagina')).toBe('1');
expect(params.get('tamano')).toBe('20');
expect(params.has('status')).toBe(false);
expect(params.has('whsCode')).toBe(false);
});
it('Siguiente pide la página 2 y Atrás vuelve a la 1', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
const req = await responder(fixture, pagina(2, 45));
expect(req.request.params.get('pagina')).toBe('2');
expect(el.textContent).toContain('Mostrando 21–40 de 45');
expect(boton(el, 'Atrás').disabled).toBe(false);
boton(el, 'Atrás').click();
const atras = await responder(fixture, pagina(1, 45));
expect(atras.request.params.get('pagina')).toBe('1');
});
it('en la última página Siguiente queda deshabilitado', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
await responder(fixture, pagina(2, 45));
boton(el, 'Siguiente').click();
await responder(fixture, pagina(3, 45));
expect(el.textContent).toContain('Mostrando 41–45 de 45');
expect(boton(el, 'Siguiente').disabled).toBe(true);
expect(el.querySelectorAll('tbody tr')).toHaveLength(5);
});
it('al cambiar un filtro vuelve a la página 1 con ese filtro', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
await responder(fixture, pagina(2, 45));
const estado = el.querySelector<HTMLSelectElement>('#f-estado')!;
estado.value = 'C';
estado.dispatchEvent(new Event('change'));
const req = await responder(fixture, pagina(1, 7));
expect(req.request.params.get('status')).toBe('C');
expect(req.request.params.get('pagina')).toBe('1');
expect(el.textContent).toContain('Mostrando 1–7 de 7');
});
it('filtra por almacén y por rango de fechas', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
const almacen = el.querySelector<HTMLSelectElement>('#f-almacen')!;
almacen.value = '02';
almacen.dispatchEvent(new Event('change'));
await responder(fixture, pagina(1, 3));
const desde = el.querySelector<HTMLInputElement>('#f-desde')!;
desde.value = '2026-10-01';
desde.dispatchEvent(new Event('input'));
const req = await responder(fixture, pagina(1, 2));
expect(req.request.params.get('whsCode')).toBe('02');
expect(req.request.params.get('desde')).toBe('2026-10-01');
});
it('sin resultados con filtros lo dice, y Limpiar filtros los quita', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
const estado = el.querySelector<HTMLSelectElement>('#f-estado')!;
estado.value = 'O';
estado.dispatchEvent(new Event('change'));
await responder(fixture, pagina(1, 0));
expect(el.textContent).toContain('Ninguna planificación cumple los filtros');
expect(el.querySelector('.paginacion')).toBeNull();
boton(el, 'Limpiar filtros').click();
const req = await responder(fixture, pagina(1, 45));
expect(req.request.params.has('status')).toBe(false);
expect(el.textContent).toContain('Mostrando 1–20 de 45');
});
it('si la página pedida quedó fuera de rango, va a la última', async () => {
const fixture = await crear();
const el: HTMLElement = fixture.nativeElement;
boton(el, 'Siguiente').click();
// SAP responde vacío para una página que ya no existe (total 25 → 2 páginas).
const vacia = { items: [], total: 25, pagina: 3, tamano: 20, totalPaginas: 2 };
peticion().flush(vacia);
await tick();
const req = peticion();
expect(req.request.params.get('pagina')).toBe('2');
req.flush(pagina(2, 25));
await tick();
fixture.detectChanges();
expect(el.textContent).toContain('Mostrando 21–25 de 25');
});
});
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more