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:
commit
287b187c14
126 files changed
+20033
No files matched your search
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
release/
|
||||
@@ -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/`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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`. |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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[],
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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&b</Usuario>');
|
||||
expect(xml).toContain('<PWD>x<y>"z'</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 <1234> 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');
|
||||
});
|
||||
});
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
const desescapar = (v: string): string =>
|
||||
v.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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,
|
||||
};
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.e2e-spec.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.spec.ts'],
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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ó. |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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])),
|
||||
],
|
||||
};
|
||||
@@ -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: '' },
|
||||
];
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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`, {}));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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í. Código	Cantidad A00001	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
Reference in new issue
Block a user