Compare commits
10
Commits
93aa1568b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e343a18da1 | ||
|
|
29dcc8ccef | ||
|
|
7efdcce113 | ||
|
|
b6c553fed3 | ||
|
|
691aa454ca | ||
|
|
67a80e18db | ||
|
|
cd748bb4ef | ||
|
|
2182147adb | ||
|
|
3f6395bfb0 | ||
|
|
b6e34276ce |
No files matched your search
@@ -14,7 +14,7 @@ cantidades pegado desde Excel. Cada ejecución se guarda en el UDO `LIB_ODEV`
|
|||||||
Pasos de la UI (decididos):
|
Pasos de la UI (decididos):
|
||||||
|
|
||||||
1. **Pegar**: ItemCode + cantidad desde Excel (con el botón *Pegar desde el portapapeles* o
|
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.
|
`Ctrl/Cmd+V` en el cuadro). Se valida contra la **Consignación** del almacén elegido (ver Decisiones). No guarda.
|
||||||
2. **Procesar**: reemplaza a "Selección" del addon. Asignación FIFO contra líneas abiertas
|
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
|
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
|
Padre (`F`, artículo) y Hijo (`S`, una por Entrada consumida). El usuario marca o desmarca
|
||||||
@@ -48,6 +48,13 @@ Pasos de la UI (decididos):
|
|||||||
para cerrarla a mano).
|
para cerrarla a mano).
|
||||||
- **Crear devoluciones**: resultado **parcial por documento**. Cada Devolución se crea aparte; las
|
- **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.
|
buenas graban `U_TargetType`/`U_TargetEntry`, las fallidas `U_ErrCode`/`U_ErrMsg` y se reintentan.
|
||||||
|
- **El "stock" que se muestra y valida es la Consignación**, no `OITW.OnHand`: `DP_Stock` (v3) suma
|
||||||
|
`PDN1.OpenInvQty` de las líneas abiertas de Entradas con `U_DOT_TipoRecepcion = 'C'` del almacén, o sea lo
|
||||||
|
mismo que consume el FIFO y lo único que se puede devolver. Sin consignación no hay fila: entonces `DP_Articulo`
|
||||||
|
distingue artículo existente (0, queda "Sin stock") de inexistente (error). **El analizador SQL del Service Layer
|
||||||
|
no admite `COALESCE` ni subconsultas** (error `Cannot support this function or expression`): solo JOIN + GROUP BY. La columna se sigue llamando `OnHand` en la consulta, el modelo y
|
||||||
|
el UDO (`U_OnHand`); en pantalla y en Excel el encabezado es **Consignación**. Las planificaciones guardadas
|
||||||
|
antes de la 2.0.0 conservan el valor viejo hasta rehacer el FIFO.
|
||||||
- **Consultas SQL** por `SQLQueries` del Service Layer (no hay conexión directa a la BD). La API
|
- **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
|
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
|
SQL del Service Layer **no admite `FORMAT`** y SAP reescribe el `SqlText`: al cambiar el SQL hay
|
||||||
@@ -63,6 +70,13 @@ Pasos de la UI (decididos):
|
|||||||
`FolioNumber` de la Devolución: **si todavía no tiene folio no se llama a BES** y esa Devolución informa
|
`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
|
"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.
|
de otro documento). Unir con `pdf-lib`; un fallo en una Devolución no impide las demás.
|
||||||
|
- **Exportar a Excel** (solo en el navegador, sin pasar por la API): "Artículos validados" (Nueva) sale tal cual
|
||||||
|
la tabla; "Líneas" (Detalle) sale con una fila por Hijo (`S`) que repite los datos de su Padre (`F`); un Padre
|
||||||
|
sin Hijos sale solo. `core/excel.ts` arma el .xlsx a mano (ZIP sin compresión, sin dependencias).
|
||||||
|
- **Valores por defecto de la cabecera** (Detalle, solo con la planificación abierta y el campo vacío): fecha,
|
||||||
|
fecha de salida y de llegada = hoy; hora de salida = hora actual; tipo de despacho `1`; indicador de traslado
|
||||||
|
`7` (Guía de devolución), estos dos solo si existen en el catálogo. Lo pone la web; se guardan con *Guardar
|
||||||
|
cabecera* o al crear las Devoluciones. Lo ya guardado no se pisa.
|
||||||
- Transporte en la Devolución (ORPD): `RutTransporte→U_BFE_RutTrasporte` (sic), `Patente→U_BFE_Patente`,
|
- 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
|
`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`.
|
ni hora de salida/llegada**: solo se guardan en `@LIB_ODEV`.
|
||||||
@@ -73,6 +87,16 @@ Pasos de la UI (decididos):
|
|||||||
nginx no es necesario. NSSM 2.24 estable puede no arrancar en Windows recientes: usar la 2.24-101. `npm run publicar` arma `release/devolucion-proveedores/` (API + web + solo dependencias de
|
nginx no es necesario. NSSM 2.24 estable puede no arrancar en Windows recientes: usar la 2.24-101. `npm run publicar` arma `release/devolucion-proveedores/` (API + web + solo dependencias de
|
||||||
producción, ~55 MB, **sin enlaces simbólicos**: `@dp/shared` va como copia real, porque la API lo
|
producción, ~55 MB, **sin enlaces simbólicos**: `@dp/shared` va como copia real, porque la API lo
|
||||||
importa al ejecutarse y un enlace no sobrevive a copiar de Mac a Windows). Guía completa: `deploy/windows/DEPLOY.md`.
|
importa al ejecutarse y un enlace no sobrevive a copiar de Mac a Windows). Guía completa: `deploy/windows/DEPLOY.md`.
|
||||||
|
- **Versión**: el número es el `version` del `package.json` raíz. **Cada release sube la versión** y el
|
||||||
|
número lo decide el usuario (preguntarle antes): `npm run version:parche|menor|mayor` y luego
|
||||||
|
`npm run publicar`, que deja `app/version.json` (número, fecha y commit). La API lo lee al arrancar
|
||||||
|
(`src/version.ts`), lo informa en `/auth/info` y `/auth/me`, y la web lo muestra en el login y en la barra
|
||||||
|
superior (en desarrollo dice "desarrollo").
|
||||||
|
- **Repositorio**: Gitea, remoto `origin` = `https://unasgeorge.vaquita-hadar.ts.net/george/devolucion_proveedores.git`
|
||||||
|
(rama `main`; reemplaza al anterior `http://100.68.242.27:30000/...`). Cada release lleva un **tag SemVer
|
||||||
|
anotado `vX.Y.Z`** igual al `version` del `package.json` (`git tag -a vX.Y.Z` + `git push origin main --tags`),
|
||||||
|
hecho **antes** de `npm run publicar` para que `version.json` lleve el commit correcto. El primero es
|
||||||
|
`v2.0.1`. El procedimiento para el usuario está en `README.md` ("Generar un release").
|
||||||
- **Logs**: la aplicación **no escribe logs a archivo**; usa el `Logger` de Nest por consola. En
|
- **Logs**: la aplicación **no escribe logs a archivo**; usa el `Logger` de Nest por consola. En
|
||||||
producción NSSM captura la salida y la rota a los 10 MB (no limita cuántos archivos conserva: hay que
|
producción NSSM captura la salida y la rota a los 10 MB (no limita cuántos archivos conserva: hay que
|
||||||
limpiar los viejos). Con `NO_COLOR=1` el log sale sin códigos de
|
limpiar los viejos). Con `NO_COLOR=1` el log sale sin códigos de
|
||||||
@@ -113,7 +137,7 @@ devolucion-proveedores-web/ npm workspaces
|
|||||||
│ ├── web-estatico.ts sirve el build de Angular, fallback a index.html, cabeceras de seguridad
|
│ ├── 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
|
│ └── https.ts lectura del certificado y redirección HTTP → HTTPS
|
||||||
├── scripts/publicar.mjs arma la carpeta de despliegue (release/, ignorada por git)
|
├── scripts/publicar.mjs arma la carpeta de despliegue (release/, ignorada por git)
|
||||||
├── deploy/windows/ NSSM: instalar/desinstalar-servicio.ps1, DEPLOY.md
|
├── deploy/windows/ NSSM: instalar/desinstalar-servicio.ps1, DEPLOY.md (van al release)
|
||||||
└── apps/web/ Angular 22 standalone, signals, zoneless, CSS (no SCSS)
|
└── apps/web/ Angular 22 standalone, signals, zoneless, CSS (no SCSS)
|
||||||
└── src/app/
|
└── src/app/
|
||||||
├── core/ auth.service, metadata.service, planificaciones.service,
|
├── core/ auth.service, metadata.service, planificaciones.service,
|
||||||
@@ -192,9 +216,17 @@ pierde al reiniciar la API** (hay que volver a iniciar sesión).
|
|||||||
`HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`, `HTTP_REDIRECT_PORT` y `WEB_DIST_DIR` (opcional).
|
`HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`, `HTTP_REDIRECT_PORT` y `WEB_DIST_DIR` (opcional).
|
||||||
4. Desarrollo, en dos terminales:
|
4. Desarrollo, en dos terminales:
|
||||||
```sh
|
```sh
|
||||||
npm run dev:api # compila @dp/shared y levanta NestJS en :3000
|
npm run dev:api # compila @dp/shared y levanta NestJS en el PORT del .env (3015 en el servidor de pruebas)
|
||||||
npm run dev:web # compila @dp/shared y levanta Angular en :4200 (proxy /api → :3000)
|
npm run dev:web # compila @dp/shared y levanta Angular en :4215 (proxy /api → :3015)
|
||||||
```
|
```
|
||||||
|
El puerto de `ng serve` está en `apps/web/angular.json` y el destino del proxy en
|
||||||
|
`apps/web/src/proxy.conf.json`: si cambia `PORT`, hay que cambiar también el proxy. En el servidor
|
||||||
|
Windows de pruebas el servicio instalado usa el **mismo 3015**: detener uno antes de arrancar el otro.
|
||||||
|
En Windows, `nest start --watch` a veces no logra terminar el proceso anterior al recompilar y se cae:
|
||||||
|
basta con volver a ejecutar `npm run dev:api`.
|
||||||
|
`dev:web` usa `ng serve --poll 1500`: en el Windows Server de pruebas (Node 24) el vigilante nativo
|
||||||
|
(`@parcel/watcher`) hace caer `ng serve` con `0xC0000005` justo después de "Watch mode enabled".
|
||||||
|
Solo afecta al desarrollo; producción sirve la web compilada y no vigila archivos.
|
||||||
5. Verificación:
|
5. Verificación:
|
||||||
```sh
|
```sh
|
||||||
npm test # shared + api + web
|
npm test # shared + api + web
|
||||||
@@ -216,9 +248,11 @@ Reglas:
|
|||||||
- Para ver la UI sin la extensión de Chrome se puede abrir Chrome sin cabeza con
|
- 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).
|
`--remote-debugging-port` y manejarlo por DevTools Protocol (`WebSocket` de Node 22).
|
||||||
|
|
||||||
## Estado al 2026-10-12
|
## Estado al 2026-10-13
|
||||||
|
|
||||||
**Hecho y verificado contra el Service Layer real (SBOLibros, MSSQL):**
|
**Hecho y verificado contra el Service Layer real (SBOLibros, MSSQL):**
|
||||||
|
- **Validación contra Consignación** (2026-10-19, 2.0.1): `DP_Stock` v3 (`SUM` + `GROUP BY`) y `DP_Articulo` funcionan
|
||||||
|
contra el Service Layer real (confirmado por el usuario; la variante probada fue HANA, la de la v2 rechazada).
|
||||||
- Login, Metadatos y **Reconstruir** (creación de tablas, campos y UDO desde cero).
|
- Login, Metadatos y **Reconstruir** (creación de tablas, campos y UDO desde cero).
|
||||||
- `libs/shared`: modelos y dominio portado del .NET. 14 tests.
|
- `libs/shared`: modelos y dominio portado del .NET. 14 tests.
|
||||||
- `apps/api`: Service Layer, autenticación, metadatos, catálogos, planificaciones y PDF. 81 tests
|
- `apps/api`: Service Layer, autenticación, metadatos, catálogos, planificaciones y PDF. 81 tests
|
||||||
@@ -230,7 +264,8 @@ Reglas:
|
|||||||
paginado y filtrado** (total, saltos, estado/almacén/fechas) OK. **PDF** contra BES (certificación)
|
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.
|
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,
|
- `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
|
Nueva, Detalle con crear devoluciones, Rehacer FIFO, PDF, exportar a Excel y cerrar). 46 tests. El .xlsx se
|
||||||
|
abrió en Excel 16 (servidor de pruebas) sin aviso de reparación. Verificado en Chrome
|
||||||
real (sin cabeza): login, listado con filtros, selector de almacenes, validación de pegado y la
|
real (sin cabeza): login, listado con filtros, selector de almacenes, validación de pegado y la
|
||||||
descarga de PDF del Detalle.
|
descarga de PDF del Detalle.
|
||||||
|
|
||||||
@@ -243,6 +278,9 @@ Reglas:
|
|||||||
- `PATCH` de `LIB_DEV1Collection` actualiza las filas por `LineId` sin tocar las demás; **no se pueden
|
- `PATCH` de `LIB_DEV1Collection` actualiza las filas por `LineId` sin tocar las demás; **no se pueden
|
||||||
quitar filas** de un UDO.
|
quitar filas** de un UDO.
|
||||||
- Los campos numéricos sin llenar del UDO vuelven como `0`: el mapeo los trata como `null`.
|
- Los campos numéricos sin llenar del UDO vuelven como `0`: el mapeo los trata como `null`.
|
||||||
|
- **Lo que se puede devolver de una Entrada es `PDN1.OpenInvQty`, no `OpenQty`**: `OpenQty` solo baja con
|
||||||
|
las Devoluciones; `OpenInvQty` también descuenta lo facturado. Con `OpenQty` SAP rechaza la Devolución con
|
||||||
|
`-10 Quantity cannot exceed the quantity in the base document` (visto en HANA, `SBOTSTLIBROS`).
|
||||||
- `POST /LIB_ODEV(n)/Close` cierra el UDO. El UDO no se puede borrar (`CanDelete = tNO`).
|
- `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`
|
- Si no hay resultado, el Service Layer responde cuerpo vacío: por eso `/planificaciones/abierta`
|
||||||
devuelve siempre un JSON (`{abierta: null}`).
|
devuelve siempre un JSON (`{abierta: null}`).
|
||||||
@@ -262,11 +300,12 @@ Reglas:
|
|||||||
- **PDF en la base de pruebas**: las Devoluciones de `SBOLibros` no tienen `FolioNum`, así que ahora su
|
- **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`.
|
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).
|
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
|
- **Despliegue en Windows**: el usuario instaló el servicio `DevolucionProveedores` (NSSM) en el servidor
|
||||||
servida por la API, rutas de Angular, 404, cabeceras, redirección 301, log sin colores). **No se ha
|
de pruebas, en `C:\tmp\devolucion-proveedores` (`PORT=3015`, `SBOTSTLIBROS`, HANA), con un release
|
||||||
ejecutado en Windows**: los `.ps1` solo pasaron la comprobación de sintaxis y la lista de comandos de
|
publicado desde el Mac. `npm run publicar` funciona también en Windows (2026-10-13). Falta verificar
|
||||||
NSSM que generan (`-SoloMostrar`); falta instalar el servicio de verdad. Las banderas de la cookie de sesión están comprobadas en un
|
la cookie de sesión en un navegador real sobre HTTPS.
|
||||||
test e2e; solo falta verlo en un navegador real sobre HTTPS (iniciar sesión en el sitio desplegado).
|
- **`OpenInvQty`** (2026-10-13): la consulta `DP_EntradasAbiertas` v3 todavía no se ha ejecutado contra el
|
||||||
|
Service Layer de HANA. Se prueba con *Rehacer FIFO* de la planificación 7 de `SBOTSTLIBROS`.
|
||||||
- **Estilos:** son CSS porque, en el Mac con macOS 12, el compilador nativo de Sass exige
|
- **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.
|
macOS 14 o superior. En Sequoia se puede volver a SCSS si se prefiere.
|
||||||
|
|
||||||
@@ -274,9 +313,10 @@ Reglas:
|
|||||||
|
|
||||||
1. **Validar en HANA** (ver "Sin verificar"): `DB_TYPE=HANA`, Reconstruir, repetir la prueba de extremo a extremo.
|
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).
|
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
|
3. **Actualizar el servicio de pruebas** con el release que trae `OpenInvQty` (DEPLOY.md, sección 11) y
|
||||||
botón de pegar desde el portapapeles solo funciona en `localhost` o HTTPS.
|
rehacer el FIFO de la planificación 7. El botón de pegar desde el portapapeles solo funciona en
|
||||||
4. Archivar `../SolucionLibros/` cuando HANA esté validado (ninguna carpeta está bajo `git`).
|
`localhost` o HTTPS.
|
||||||
|
4. Archivar `../SolucionLibros/` cuando HANA esté validado (`../SolucionLibros/` no está bajo `git`).
|
||||||
|
|
||||||
## Preguntas abiertas para el usuario
|
## Preguntas abiertas para el usuario
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Devolución a Proveedores
|
||||||
|
|
||||||
|
Aplicación web (Angular + NestJS) para SAP Business One que genera **Devoluciones de mercancías**
|
||||||
|
(ORPD) enlazadas a sus **Entradas de mercancías por compra** (OPDN), a partir de un listado de
|
||||||
|
artículos y cantidades pegado desde Excel. Reemplaza al addon .NET anterior.
|
||||||
|
|
||||||
|
## Cómo funciona
|
||||||
|
|
||||||
|
1. **Pegar**: ItemCode + cantidad desde Excel. Se valida contra la **Consignación** del almacén elegido.
|
||||||
|
2. **Procesar**: asignación FIFO contra las líneas abiertas de Entradas de mercancías del almacén,
|
||||||
|
ordenadas por fecha. El usuario marca qué líneas devolver.
|
||||||
|
3. **Crear devoluciones**: agrupa las líneas marcadas por proveedor y crea una Devolución por lote
|
||||||
|
(11 líneas por defecto).
|
||||||
|
4. **Descargar PDF**: pide al web service de BES el PDF de la Guía de despacho (DTE 52) de cada
|
||||||
|
Devolución, con opción de unirlos en uno.
|
||||||
|
|
||||||
|
Cada ejecución (planificación) se guarda en el UDO `LIB_ODEV`. Solo puede haber una planificación
|
||||||
|
abierta a la vez; se cierra a mano.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Node.js 22 LTS o superior.
|
||||||
|
- SAP Business One con **Service Layer** (`/b1s/v1`), base **HANA** (producción) o **MSSQL** (pruebas).
|
||||||
|
- El usuario de SAP inicia sesión en la web; sus credenciales no se guardan en la configuración.
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
libs/shared/ @dp/shared tipos y lógica de dominio (parseo de Excel, FIFO)
|
||||||
|
apps/api/ NestJS API REST (/api) sobre el Service Layer; en producción sirve también la web
|
||||||
|
apps/web/ Angular interfaz
|
||||||
|
scripts/ publicar.mjs arma la carpeta de despliegue en release/
|
||||||
|
deploy/windows/ instalación como servicio de Windows (NSSM)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Puesta en marcha (desarrollo)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
cp apps/api/.env.example apps/api/.env # completar SL_URL, SL_COMPANY_DB y DB_TYPE
|
||||||
|
npm run dev:api # API NestJS (puerto PORT del .env)
|
||||||
|
npm run dev:web # Angular en http://localhost:4215 (proxy /api → API)
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables principales del `.env`: `PORT`, `SL_URL`, `SL_COMPANY_DB`, `SL_REJECT_UNAUTHORIZED`,
|
||||||
|
`DB_TYPE` (`HANA`/`MSSQL`) y `COOKIE_SECURE`. Para producción con HTTPS: `HTTPS_PFX_PATH` y
|
||||||
|
`HTTPS_PFX_PASSPHRASE` (o `HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`) y `HTTP_REDIRECT_PORT`.
|
||||||
|
Ver `apps/api/.env.example`.
|
||||||
|
|
||||||
|
En el primer ingreso, **Configuración → Metadatos → Reconstruir** crea las tablas, los campos y
|
||||||
|
el UDO necesarios en SAP.
|
||||||
|
|
||||||
|
## Pruebas y compilación
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test # shared + api + web
|
||||||
|
npm run test:e2e -w api
|
||||||
|
npm run lint -w api
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generar un release
|
||||||
|
|
||||||
|
La versión es el `version` del `package.json` raíz y sigue SemVer (`MAYOR.MENOR.PARCHE`). Cada
|
||||||
|
release sube la versión y lleva un tag `vX.Y.Z` en git.
|
||||||
|
|
||||||
|
1. **Subir la versión** (elegir uno; no crea commit ni tag):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run version:parche # 2.0.1 → 2.0.2 correcciones
|
||||||
|
npm run version:menor # 2.0.1 → 2.1.0 funciones nuevas compatibles
|
||||||
|
npm run version:mayor # 2.0.1 → 3.0.0 cambios incompatibles
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Commit y tag**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A
|
||||||
|
git commit -m "Versión X.Y.Z"
|
||||||
|
git tag -a vX.Y.Z -m "Versión X.Y.Z"
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Armar la carpeta de despliegue** (después del commit, para que el release lleve el commit
|
||||||
|
correcto):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run publicar # compila shared + api + web y arma la carpeta
|
||||||
|
npm run publicar -- --sin-build # reutiliza los builds ya hechos (más rápido)
|
||||||
|
```
|
||||||
|
|
||||||
|
El resultado queda en `release/devolucion-proveedores/` (ignorada por git): la API compilada,
|
||||||
|
la web compilada, solo las dependencias de producción y los scripts de instalación. Incluye
|
||||||
|
`app/version.json` con el número, la fecha y el commit (si hay cambios sin commit, el commit
|
||||||
|
aparece con `-modificado`). La versión se muestra en el login y en la barra superior.
|
||||||
|
|
||||||
|
4. **Instalar o actualizar en el servidor**: copiar esa carpeta al servidor Windows. Se instala
|
||||||
|
como un único servicio (NSSM) con HTTPS directo en Node. Instalación nueva: secciones 1 a 8 de
|
||||||
|
[`deploy/windows/DEPLOY.md`](deploy/windows/DEPLOY.md); actualizar una instalación existente
|
||||||
|
sin tocar el `.env`: sección 11.
|
||||||
@@ -5,12 +5,13 @@ import type { Request, Response } from 'express';
|
|||||||
import { AppConfig } from '../config/configuration.js';
|
import { AppConfig } from '../config/configuration.js';
|
||||||
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
|
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
|
||||||
import { SessionStore } from '../service-layer/session.store.js';
|
import { SessionStore } from '../service-layer/session.store.js';
|
||||||
|
import { VERSION_APP } from '../version.js';
|
||||||
import type { SapSession } from '../service-layer/session.store.js';
|
import type { SapSession } from '../service-layer/session.store.js';
|
||||||
import { LoginDto } from './dto/login.dto.js';
|
import { LoginDto } from './dto/login.dto.js';
|
||||||
import { CurrentSession, Public, SESSION_COOKIE } from './session.guard.js';
|
import { CurrentSession, Public, SESSION_COOKIE } from './session.guard.js';
|
||||||
|
|
||||||
function aUsuario(sesion: SapSession): UsuarioSesion {
|
function aUsuario(sesion: SapSession): UsuarioSesion {
|
||||||
return { userName: sesion.userName, companyDb: sesion.companyDb, version: sesion.version };
|
return { userName: sesion.userName, companyDb: sesion.companyDb, version: sesion.version, app: VERSION_APP };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -27,6 +28,7 @@ export class AuthController {
|
|||||||
return {
|
return {
|
||||||
companyDb: this.config.get('serviceLayer', { infer: true }).companyDb,
|
companyDb: this.config.get('serviceLayer', { infer: true }).companyDb,
|
||||||
dbType: this.config.get('dbType', { infer: true }),
|
dbType: this.config.get('dbType', { infer: true }),
|
||||||
|
app: VERSION_APP,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
|
|||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import configuration from './config/configuration.js';
|
import configuration from './config/configuration.js';
|
||||||
import { HttpExceptionLogger } from './http-exception.logger.js';
|
import { HttpExceptionLogger } from './http-exception.logger.js';
|
||||||
|
import { VERSION_APP } from './version.js';
|
||||||
import { cargarHttps, crearRedireccionHttp } from './https.js';
|
import { cargarHttps, crearRedireccionHttp } from './https.js';
|
||||||
import { cabecerasSeguridad, servirWeb } from './web-estatico.js';
|
import { cabecerasSeguridad, servirWeb } from './web-estatico.js';
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ async function bootstrap() {
|
|||||||
await app.listen(cfg.port);
|
await app.listen(cfg.port);
|
||||||
|
|
||||||
const esquema = cfg.https ? 'https' : 'http';
|
const esquema = cfg.https ? 'https' : 'http';
|
||||||
|
const v = VERSION_APP;
|
||||||
|
logger.log(`Versión ${v.version} · ${v.publicado ? `publicada ${v.publicado}${v.commit ? ` · commit ${v.commit}` : ''}` : 'desarrollo'}`);
|
||||||
logger.log(`Escuchando en ${esquema}://0.0.0.0:${cfg.port} · SAP ${cfg.serviceLayer.companyDb} (${cfg.dbType})`);
|
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.');
|
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) {
|
if (cfg.cookieSecure && !cfg.https) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const filaHijo = (extra: Record<string, unknown> = {}) => ({ LineId: 2, U_LineTy
|
|||||||
|
|
||||||
const dto = (texto = 'A1\t5'): ProcesarDto => ({ whsCode: '01', docDate: '2026-10-13', texto }) as ProcesarDto;
|
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 } = {}) {
|
function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[]; articulo?: unknown[]; fallaCierre?: boolean } = {}) {
|
||||||
const llamadas: string[] = [];
|
const llamadas: string[] = [];
|
||||||
const sl = {
|
const sl = {
|
||||||
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
|
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
|
||||||
@@ -42,6 +42,7 @@ function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[];
|
|||||||
const sql = {
|
const sql = {
|
||||||
consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => {
|
consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => {
|
||||||
if (consulta.codigo === 'DP_Stock') return opciones.stock ?? [{ ItemCode: 'A1', ItemName: 'Libro', OnHand: 100 }];
|
if (consulta.codigo === 'DP_Stock') return opciones.stock ?? [{ ItemCode: 'A1', ItemName: 'Libro', OnHand: 100 }];
|
||||||
|
if (consulta.codigo === 'DP_Articulo') return opciones.articulo ?? [];
|
||||||
return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenInvQty: 10 }];
|
return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenInvQty: 10 }];
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -166,3 +167,18 @@ describe('PlanificacionesService.listar', () => {
|
|||||||
expect((sl.request.mock.calls[0] as unknown as [unknown, string, string])[2]).toContain('$top=100');
|
expect((sl.request.mock.calls[0] as unknown as [unknown, string, string])[2]).toContain('$top=100');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PlanificacionesService.validarPegado', () => {
|
||||||
|
it('un artículo que existe pero no tiene consignación abierta queda con 0 (Sin stock)', async () => {
|
||||||
|
const { servicio } = crear(udo(3, 'O'), { stock: [], articulo: [{ ItemCode: 'A1', ItemName: 'Libro' }] });
|
||||||
|
const r = await servicio.validarPegado(sesion, 'B1', 'A1\t5');
|
||||||
|
expect(r.errores).toEqual([]);
|
||||||
|
expect(r.lineas[0]).toMatchObject({ itemCode: 'A1', onHand: 0, status: 'SIN_STOCK' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un artículo inexistente es un error', async () => {
|
||||||
|
const { servicio } = crear(udo(3, 'O'), { stock: [], articulo: [] });
|
||||||
|
const r = await servicio.validarPegado(sesion, 'B1', 'X9\t5');
|
||||||
|
expect(r.errores[0]).toMatch(/no existe/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { CatalogosService } from '../catalogos/catalogos.service.js';
|
import { CatalogosService } from '../catalogos/catalogos.service.js';
|
||||||
import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js';
|
import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js';
|
||||||
import type { SapSession } from '../service-layer/session.store.js';
|
import type { SapSession } from '../service-layer/session.store.js';
|
||||||
import { CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js';
|
import { CONSULTA_ARTICULO, CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js';
|
||||||
import { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
|
import { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
|
||||||
import type { ProcesarDto } from './dto/planificaciones.dto.js';
|
import type { ProcesarDto } from './dto/planificaciones.dto.js';
|
||||||
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
|
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
|
||||||
@@ -38,7 +38,8 @@ import type { UdoSl } from './udo.mapper.js';
|
|||||||
interface FilaStock {
|
interface FilaStock {
|
||||||
ItemCode: string;
|
ItemCode: string;
|
||||||
ItemName: string | null;
|
ItemName: string | null;
|
||||||
OnHand: number | string;
|
/** Ausente cuando la fila viene de DP_Articulo (sin consignación). */
|
||||||
|
OnHand?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilaEntrada {
|
interface FilaEntrada {
|
||||||
@@ -100,21 +101,24 @@ export class PlanificacionesService {
|
|||||||
const { filas, errores } = parsearPortapapeles(texto);
|
const { filas, errores } = parsearPortapapeles(texto);
|
||||||
const lineas = [];
|
const lineas = [];
|
||||||
|
|
||||||
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => ({
|
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => {
|
||||||
fila,
|
const params = { whs: whsCode, item: fila.itemCode };
|
||||||
stock: (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, { whs: whsCode, item: fila.itemCode }))[0],
|
const stock = (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, params))[0];
|
||||||
}));
|
// Sin consignación abierta no hay fila: el artículo puede existir con 0 o no existir.
|
||||||
|
const articulo = stock ?? (await this.sql.consultar<FilaStock>(sesion, CONSULTA_ARTICULO, { item: fila.itemCode }))[0];
|
||||||
|
return { fila, articulo };
|
||||||
|
});
|
||||||
|
|
||||||
for (const { fila, stock } of consultas) {
|
for (const { fila, articulo: datos } of consultas) {
|
||||||
if (!stock) {
|
if (!datos) {
|
||||||
errores.push(`${fila.itemCode}: el artículo no existe o no tiene stock registrado en el almacén ${whsCode}.`);
|
errores.push(`${fila.itemCode}: el artículo no existe.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const articulo: ArticuloADevolver = {
|
const articulo: ArticuloADevolver = {
|
||||||
itemCode: stock.ItemCode,
|
itemCode: datos.ItemCode,
|
||||||
itemName: stock.ItemName ?? '',
|
itemName: datos.ItemName ?? '',
|
||||||
quantity: fila.quantity,
|
quantity: fila.quantity,
|
||||||
onHand: Math.floor(Number(stock.OnHand)),
|
onHand: Math.floor(Number(datos.OnHand ?? 0)),
|
||||||
};
|
};
|
||||||
lineas.push(crearLineaPadre(articulo));
|
lineas.push(crearLineaPadre(articulo));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,45 @@ export interface ConsultaGuardada {
|
|||||||
hana: string;
|
hana: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Stock" = lo que realmente se puede devolver: la suma de PDN1.OpenInvQty de las líneas abiertas de
|
||||||
|
* Entradas de mercancías de Consignación (U_DOT_TipoRecepcion = 'C') del almacén, las mismas que usa el
|
||||||
|
* FIFO. No es OITW.OnHand: el stock físico incluye mercancía que no es devolvible por esta vía.
|
||||||
|
* Sin filas si el artículo no tiene consignación abierta (el servicio lo distingue con DP_Articulo).
|
||||||
|
* El analizador del Service Layer no admite COALESCE ni subconsultas: por eso solo JOIN + GROUP BY.
|
||||||
|
* La columna se llama OnHand porque así se guarda en el UDO (U_OnHand).
|
||||||
|
*/
|
||||||
export const CONSULTA_STOCK: ConsultaGuardada = {
|
export const CONSULTA_STOCK: ConsultaGuardada = {
|
||||||
codigo: 'DP_Stock',
|
codigo: 'DP_Stock',
|
||||||
version: 1,
|
version: 3,
|
||||||
mssql:
|
mssql:
|
||||||
'SELECT T0.[ItemCode], T0.[ItemName], T1.[OnHand] ' +
|
'SELECT T2.[ItemCode], T0.[ItemName], SUM(T2.[OpenInvQty]) AS [OnHand] ' +
|
||||||
'FROM OITM T0 INNER JOIN OITW T1 ON T1.[ItemCode] = T0.[ItemCode] ' +
|
'FROM OPDN T1 INNER JOIN PDN1 T2 ON T2.[DocEntry] = T1.[DocEntry] ' +
|
||||||
'WHERE T1.[WhsCode] = :whs AND T0.[ItemCode] = :item',
|
'INNER JOIN OITM T0 ON T0.[ItemCode] = T2.[ItemCode] ' +
|
||||||
|
"WHERE T1.[U_DOT_TipoRecepcion] = 'C' AND T2.[LineStatus] = 'O' " +
|
||||||
|
'AND T2.[ItemCode] = :item AND T2.[WhsCode] = :whs ' +
|
||||||
|
'GROUP BY T2.[ItemCode], T0.[ItemName]',
|
||||||
hana:
|
hana:
|
||||||
'SELECT T0."ItemCode", T0."ItemName", T1."OnHand" ' +
|
'SELECT T2."ItemCode", T0."ItemName", SUM(T2."OpenInvQty") AS "OnHand" ' +
|
||||||
'FROM OITM T0 INNER JOIN OITW T1 ON T1."ItemCode" = T0."ItemCode" ' +
|
'FROM OPDN T1 INNER JOIN PDN1 T2 ON T2."DocEntry" = T1."DocEntry" ' +
|
||||||
'WHERE T1."WhsCode" = :whs AND T0."ItemCode" = :item',
|
'INNER JOIN OITM T0 ON T0."ItemCode" = T2."ItemCode" ' +
|
||||||
|
"WHERE T1.\"U_DOT_TipoRecepcion\" = 'C' AND T2.\"LineStatus\" = 'O' " +
|
||||||
|
'AND T2."ItemCode" = :item AND T2."WhsCode" = :whs ' +
|
||||||
|
'GROUP BY T2."ItemCode", T0."ItemName"',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Solo para saber si el artículo existe (y su nombre) cuando DP_Stock no devuelve filas. */
|
||||||
|
export const CONSULTA_ARTICULO: ConsultaGuardada = {
|
||||||
|
codigo: 'DP_Articulo',
|
||||||
|
version: 1,
|
||||||
|
mssql: 'SELECT T0.[ItemCode], T0.[ItemName] FROM OITM T0 WHERE T0.[ItemCode] = :item',
|
||||||
|
hana: 'SELECT T0."ItemCode", T0."ItemName" FROM OITM T0 WHERE T0."ItemCode" = :item',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenInvQty, no OpenQty: OpenQty solo baja con las Devoluciones, mientras que OpenInvQty también
|
||||||
|
* descuenta lo facturado, y SAP rechaza (-10) devolver contra la Entrada más que eso.
|
||||||
|
*/
|
||||||
export const CONSULTA_ENTRADAS_ABIERTAS: ConsultaGuardada = {
|
export const CONSULTA_ENTRADAS_ABIERTAS: ConsultaGuardada = {
|
||||||
codigo: 'DP_EntradasAbiertas',
|
codigo: 'DP_EntradasAbiertas',
|
||||||
version: 3,
|
version: 3,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { leerVersion } from './version.js';
|
||||||
|
|
||||||
|
describe('leerVersion', () => {
|
||||||
|
let raiz: string;
|
||||||
|
let dist: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Igual que el release: app/package.json (raíz) y la API en app/apps/api/dist, con su propio package.json.
|
||||||
|
raiz = mkdtempSync(join(tmpdir(), 'dp-version-'));
|
||||||
|
dist = join(raiz, 'apps', 'api', 'dist');
|
||||||
|
mkdirSync(dist, { recursive: true });
|
||||||
|
writeFileSync(join(raiz, 'package.json'), JSON.stringify({ name: 'devolucion-proveedores-web', version: '1.2.3' }));
|
||||||
|
writeFileSync(join(raiz, 'apps', 'api', 'package.json'), JSON.stringify({ name: 'api', version: '0.0.1' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => rmSync(raiz, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('en desarrollo: el número del package.json raíz, sin fecha de publicación', () => {
|
||||||
|
expect(leerVersion(dist)).toEqual({ version: '1.2.3', publicado: null, commit: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('en un release: agrega la fecha y el commit de version.json', () => {
|
||||||
|
writeFileSync(join(raiz, 'version.json'), JSON.stringify({ version: '1.2.3', publicado: '2026-10-15T20:00:00.000Z', commit: 'abc1234' }));
|
||||||
|
expect(leerVersion(dist)).toEqual({ version: '1.2.3', publicado: '2026-10-15T20:00:00.000Z', commit: 'abc1234' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('el repositorio real tiene un número de versión', () => {
|
||||||
|
expect(leerVersion().version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import type { VersionApp } from '@dp/shared';
|
||||||
|
|
||||||
|
/** Nombre del `package.json` raíz del proyecto; está igual en el repositorio y en el release (`app/`). */
|
||||||
|
const NOMBRE_RAIZ = 'devolucion-proveedores-web';
|
||||||
|
|
||||||
|
/** Lo que `npm run publicar` deja en `app/version.json`, junto al `package.json` raíz. */
|
||||||
|
const ARCHIVO_PUBLICACION = 'version.json';
|
||||||
|
|
||||||
|
function leerJson(ruta: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(ruta, 'utf8')) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca hacia arriba desde `desde` la carpeta raíz del proyecto y arma la versión: el número sale del
|
||||||
|
* `package.json` raíz; la fecha y el commit, del `version.json` del release (en desarrollo no existe).
|
||||||
|
*/
|
||||||
|
export function leerVersion(desde = dirname(fileURLToPath(import.meta.url))): VersionApp {
|
||||||
|
for (let carpeta = desde; ; carpeta = dirname(carpeta)) {
|
||||||
|
const paquete = leerJson(join(carpeta, 'package.json'));
|
||||||
|
if (paquete?.name === NOMBRE_RAIZ) {
|
||||||
|
const publicacion = existsSync(join(carpeta, ARCHIVO_PUBLICACION)) ? leerJson(join(carpeta, ARCHIVO_PUBLICACION)) : null;
|
||||||
|
const texto = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
||||||
|
return {
|
||||||
|
version: texto(paquete.version) ?? '0.0.0',
|
||||||
|
publicado: texto(publicacion?.publicado),
|
||||||
|
commit: texto(publicacion?.commit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (dirname(carpeta) === carpeta) return { version: '0.0.0', publicado: null, commit: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Se lee una vez al arrancar: cambiar de versión implica reiniciar el servicio. */
|
||||||
|
export const VERSION_APP: VersionApp = leerVersion();
|
||||||
@@ -30,7 +30,13 @@ describe('API (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('/api/auth/info devuelve la sociedad configurada', () => {
|
it('/api/auth/info devuelve la sociedad configurada', () => {
|
||||||
return request(app.getHttpServer()).get('/api/auth/info').expect(200).expect({ companyDb: 'TEST', dbType: 'MSSQL' });
|
return request(app.getHttpServer())
|
||||||
|
.get('/api/auth/info')
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body).toMatchObject({ companyDb: 'TEST', dbType: 'MSSQL', app: { publicado: null } });
|
||||||
|
expect(res.body.app.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/api/metadata/status exige sesión', () => {
|
it('/api/metadata/status exige sesión', () => {
|
||||||
|
|||||||
@@ -69,7 +69,8 @@
|
|||||||
},
|
},
|
||||||
"defaultConfiguration": "development",
|
"defaultConfiguration": "development",
|
||||||
"options": {
|
"options": {
|
||||||
"proxyConfig": "src/proxy.conf.json"
|
"proxyConfig": "src/proxy.conf.json",
|
||||||
|
"port": 4215
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ describe('App', () => {
|
|||||||
expect(router.url).toBe('/planificaciones');
|
expect(router.url).toBe('/planificaciones');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('la barra superior muestra la versión de la aplicación', async () => {
|
||||||
|
TestBed.inject(MetadataService).fijarVerificacionAlIngresar(false);
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const navegacion = router.navigateByUrl('/planificaciones');
|
||||||
|
await tick();
|
||||||
|
http.expectOne('/api/auth/me').flush({
|
||||||
|
userName: 'manager', companyDb: 'TEST', version: '1000',
|
||||||
|
app: { version: '1.4.2', publicado: '2026-10-15T19:00:00.000Z', commit: 'abc1234' },
|
||||||
|
});
|
||||||
|
await navegacion;
|
||||||
|
await tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const version = (fixture.nativeElement as HTMLElement).querySelector('.version');
|
||||||
|
expect(version?.textContent?.trim()).toBe('v1.4.2');
|
||||||
|
expect(version?.getAttribute('title')).toContain('commit abc1234');
|
||||||
|
});
|
||||||
|
|
||||||
it('desactivada pero con un estado ya conocido incompleto, igual lleva a Configuración', async () => {
|
it('desactivada pero con un estado ya conocido incompleto, igual lleva a Configuración', async () => {
|
||||||
const metadata = TestBed.inject(MetadataService);
|
const metadata = TestBed.inject(MetadataService);
|
||||||
metadata.fijarVerificacionAlIngresar(false);
|
metadata.fijarVerificacionAlIngresar(false);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { generarXlsx, letraColumna, serieFecha } from './excel';
|
||||||
|
import { leerZip } from './leer-zip.testing';
|
||||||
|
|
||||||
|
describe('excel', () => {
|
||||||
|
it('letraColumna', () => {
|
||||||
|
expect(letraColumna(0)).toBe('A');
|
||||||
|
expect(letraColumna(25)).toBe('Z');
|
||||||
|
expect(letraColumna(26)).toBe('AA');
|
||||||
|
expect(letraColumna(27)).toBe('AB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serieFecha usa el número de serie de Excel', () => {
|
||||||
|
expect(serieFecha('1900-03-01')).toBe(61);
|
||||||
|
expect(serieFecha('2026-01-31')).toBe(46053);
|
||||||
|
expect(serieFecha('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('genera un libro con encabezado, números, fechas y texto escapado', () => {
|
||||||
|
const archivos = leerZip(
|
||||||
|
generarXlsx({
|
||||||
|
nombre: 'Líneas',
|
||||||
|
columnas: [{ titulo: 'Código' }, { titulo: 'Cantidad', tipo: 'numero' }, { titulo: 'Fecha', tipo: 'fecha' }],
|
||||||
|
filas: [
|
||||||
|
['A<1>&"', 5, '2026-01-31'],
|
||||||
|
['B2', null, null],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...archivos.keys()]).toEqual([
|
||||||
|
'[Content_Types].xml',
|
||||||
|
'_rels/.rels',
|
||||||
|
'xl/workbook.xml',
|
||||||
|
'xl/_rels/workbook.xml.rels',
|
||||||
|
'xl/styles.xml',
|
||||||
|
'xl/worksheets/sheet1.xml',
|
||||||
|
]);
|
||||||
|
expect(archivos.get('xl/workbook.xml')).toContain('<sheet name="Líneas"');
|
||||||
|
|
||||||
|
const hoja = archivos.get('xl/worksheets/sheet1.xml')!;
|
||||||
|
expect(hoja).toContain('<c r="A1" s="1" t="inlineStr"><is><t>Código</t></is></c>');
|
||||||
|
expect(hoja).toContain('<t xml:space="preserve">A<1>&"</t>');
|
||||||
|
expect(hoja).toContain('<c r="B2"><v>5</v></c>');
|
||||||
|
expect(hoja).toContain('<c r="C2" s="2"><v>46053</v></c>');
|
||||||
|
expect(hoja).toContain('<row r="3"><c r="A3" t="inlineStr"><is><t xml:space="preserve">B2</t></is></c></row>');
|
||||||
|
expect(hoja).toContain('<autoFilter ref="A1:C3"/>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Genera un libro de Excel (.xlsx) de una hoja, sin dependencias: el XML de SpreadsheetML va
|
||||||
|
* en un ZIP sin compresión (método "stored"), que Excel abre sin problemas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TipoColumna = 'texto' | 'numero' | 'fecha';
|
||||||
|
|
||||||
|
export interface ColumnaExcel {
|
||||||
|
titulo: string;
|
||||||
|
tipo?: TipoColumna;
|
||||||
|
/** Ancho en caracteres. */
|
||||||
|
ancho?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valor de una celda. Las fechas van como texto `yyyy-MM-dd`; `null` o `''` dejan la celda vacía. */
|
||||||
|
export type ValorExcel = string | number | null | undefined;
|
||||||
|
|
||||||
|
export interface HojaExcel {
|
||||||
|
nombre: string;
|
||||||
|
columnas: ColumnaExcel[];
|
||||||
|
filas: ValorExcel[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
|
||||||
|
// Estilos de celda (cellXfs en styles.xml): 0 normal, 1 encabezado en negrita, 2 fecha.
|
||||||
|
const ESTILO_ENCABEZADO = 1;
|
||||||
|
const ESTILO_FECHA = 2;
|
||||||
|
|
||||||
|
const codificador = new TextEncoder();
|
||||||
|
|
||||||
|
/** Escapa texto para XML y quita los caracteres de control que XML no admite. */
|
||||||
|
function xml(texto: string): string {
|
||||||
|
return texto
|
||||||
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 → A, 25 → Z, 26 → AA. */
|
||||||
|
export function letraColumna(indice: number): string {
|
||||||
|
let letra = '';
|
||||||
|
for (let n = indice + 1; n > 0; n = Math.floor((n - 1) / 26)) {
|
||||||
|
letra = String.fromCharCode(65 + ((n - 1) % 26)) + letra;
|
||||||
|
}
|
||||||
|
return letra;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `yyyy-MM-dd` → número de serie de Excel (días desde 1899-12-30). `null` si no es una fecha. */
|
||||||
|
export function serieFecha(fecha: string): number | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(fecha);
|
||||||
|
if (!m) return null;
|
||||||
|
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86_400_000 + 25_569;
|
||||||
|
}
|
||||||
|
|
||||||
|
function celda(ref: string, valor: ValorExcel, tipo: TipoColumna): string {
|
||||||
|
if (valor == null || valor === '') return '';
|
||||||
|
if (tipo === 'fecha' && typeof valor === 'string') {
|
||||||
|
const serie = serieFecha(valor);
|
||||||
|
if (serie != null) return `<c r="${ref}" s="${ESTILO_FECHA}"><v>${serie}</v></c>`;
|
||||||
|
}
|
||||||
|
if (typeof valor === 'number' && Number.isFinite(valor)) return `<c r="${ref}"><v>${valor}</v></c>`;
|
||||||
|
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(String(valor))}</t></is></c>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hojaXml(hoja: HojaExcel): string {
|
||||||
|
const { columnas, filas } = hoja;
|
||||||
|
const ultima = letraColumna(Math.max(columnas.length - 1, 0));
|
||||||
|
|
||||||
|
const encabezado = columnas
|
||||||
|
.map((c, i) => `<c r="${letraColumna(i)}1" s="${ESTILO_ENCABEZADO}" t="inlineStr"><is><t>${xml(c.titulo)}</t></is></c>`)
|
||||||
|
.join('');
|
||||||
|
const cuerpo = filas
|
||||||
|
.map((fila, f) => {
|
||||||
|
const n = f + 2;
|
||||||
|
const celdas = columnas.map((c, i) => celda(`${letraColumna(i)}${n}`, fila[i], c.tipo ?? 'texto')).join('');
|
||||||
|
return `<row r="${n}">${celdas}</row>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const anchos = columnas
|
||||||
|
.map((c, i) => `<col min="${i + 1}" max="${i + 1}" width="${c.ancho ?? Math.max(10, c.titulo.length + 2)}" customWidth="1"/>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||||
|
'<sheetViews><sheetView workbookViewId="0">' +
|
||||||
|
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>' +
|
||||||
|
'</sheetView></sheetViews>' +
|
||||||
|
`<cols>${anchos}</cols>` +
|
||||||
|
`<sheetData><row r="1">${encabezado}</row>${cuerpo}</sheetData>` +
|
||||||
|
`<autoFilter ref="A1:${ultima}${filas.length + 1}"/>` +
|
||||||
|
'</worksheet>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function archivosLibro(hoja: HojaExcel): [string, string][] {
|
||||||
|
// Excel no admite en el nombre de la hoja : \ / ? * [ ] y lo limita a 31 caracteres.
|
||||||
|
const nombre = xml(hoja.nombre.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31) || 'Hoja1');
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'[Content_Types].xml',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||||
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
||||||
|
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
||||||
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
||||||
|
'</Types>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'_rels/.rels',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||||
|
'</Relationships>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'xl/workbook.xml',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
||||||
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
||||||
|
`<sheets><sheet name="${nombre}" sheetId="1" r:id="rId1"/></sheets>` +
|
||||||
|
'<definedNames>' +
|
||||||
|
`<definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">'${nombre.replace(/'/g, "''")}'!$A$1:$${letraColumna(Math.max(hoja.columnas.length - 1, 0))}$${hoja.filas.length + 1}</definedName>` +
|
||||||
|
'</definedNames>' +
|
||||||
|
'</workbook>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'xl/_rels/workbook.xml.rels',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
||||||
|
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
|
||||||
|
'</Relationships>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'xl/styles.xml',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||||
|
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||||
|
'<numFmts count="1"><numFmt numFmtId="164" formatCode="dd-mm-yyyy"/></numFmts>' +
|
||||||
|
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
|
||||||
|
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
|
||||||
|
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
|
||||||
|
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
||||||
|
'<cellXfs count="3">' +
|
||||||
|
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
|
||||||
|
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
|
||||||
|
'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
|
||||||
|
'</cellXfs>' +
|
||||||
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
|
||||||
|
'</styleSheet>',
|
||||||
|
],
|
||||||
|
['xl/worksheets/sheet1.xml', hojaXml(hoja)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ZIP sin compresión ---
|
||||||
|
|
||||||
|
const TABLA_CRC = (() => {
|
||||||
|
const tabla = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
tabla[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return tabla;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(datos: Uint8Array): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const b of datos) crc = TABLA_CRC[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zip(archivos: [string, string][]): Uint8Array<ArrayBuffer> {
|
||||||
|
const ahora = new Date();
|
||||||
|
const hora = (ahora.getHours() << 11) | (ahora.getMinutes() << 5) | (ahora.getSeconds() >> 1);
|
||||||
|
const fecha = ((ahora.getFullYear() - 1980) << 9) | ((ahora.getMonth() + 1) << 5) | ahora.getDate();
|
||||||
|
|
||||||
|
const entradas = archivos.map(([nombre, contenido]) => {
|
||||||
|
const datos = codificador.encode(contenido);
|
||||||
|
return { nombre: codificador.encode(nombre), datos, crc: crc32(datos), offset: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const tamLocales = entradas.reduce((t, e) => t + 30 + e.nombre.length + e.datos.length, 0);
|
||||||
|
const tamCentral = entradas.reduce((t, e) => t + 46 + e.nombre.length, 0);
|
||||||
|
const salida = new Uint8Array(tamLocales + tamCentral + 22);
|
||||||
|
const vista = new DataView(salida.buffer);
|
||||||
|
let p = 0;
|
||||||
|
|
||||||
|
const u16 = (v: number) => {
|
||||||
|
vista.setUint16(p, v, true);
|
||||||
|
p += 2;
|
||||||
|
};
|
||||||
|
const u32 = (v: number) => {
|
||||||
|
vista.setUint32(p, v, true);
|
||||||
|
p += 4;
|
||||||
|
};
|
||||||
|
const bytes = (b: Uint8Array) => {
|
||||||
|
salida.set(b, p);
|
||||||
|
p += b.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const e of entradas) {
|
||||||
|
e.offset = p;
|
||||||
|
u32(0x04034b50);
|
||||||
|
u16(20); // versión necesaria
|
||||||
|
u16(0x0800); // nombres en UTF-8
|
||||||
|
u16(0); // sin compresión
|
||||||
|
u16(hora);
|
||||||
|
u16(fecha);
|
||||||
|
u32(e.crc);
|
||||||
|
u32(e.datos.length);
|
||||||
|
u32(e.datos.length);
|
||||||
|
u16(e.nombre.length);
|
||||||
|
u16(0);
|
||||||
|
bytes(e.nombre);
|
||||||
|
bytes(e.datos);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inicioCentral = p;
|
||||||
|
for (const e of entradas) {
|
||||||
|
u32(0x02014b50);
|
||||||
|
u16(20); // versión que lo creó
|
||||||
|
u16(20);
|
||||||
|
u16(0x0800);
|
||||||
|
u16(0);
|
||||||
|
u16(hora);
|
||||||
|
u16(fecha);
|
||||||
|
u32(e.crc);
|
||||||
|
u32(e.datos.length);
|
||||||
|
u32(e.datos.length);
|
||||||
|
u16(e.nombre.length);
|
||||||
|
u16(0); // extra
|
||||||
|
u16(0); // comentario
|
||||||
|
u16(0); // disco
|
||||||
|
u16(0); // atributos internos
|
||||||
|
u32(0); // atributos externos
|
||||||
|
u32(e.offset);
|
||||||
|
bytes(e.nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tamDirectorio = p - inicioCentral;
|
||||||
|
u32(0x06054b50);
|
||||||
|
u16(0);
|
||||||
|
u16(0);
|
||||||
|
u16(entradas.length);
|
||||||
|
u16(entradas.length);
|
||||||
|
u32(tamDirectorio);
|
||||||
|
u32(inicioCentral);
|
||||||
|
u16(0);
|
||||||
|
|
||||||
|
return salida;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bytes del archivo .xlsx con una hoja. */
|
||||||
|
export function generarXlsx(hoja: HojaExcel): Uint8Array<ArrayBuffer> {
|
||||||
|
return zip(archivosLibro(hoja));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genera el .xlsx y lo descarga en el navegador. */
|
||||||
|
export function descargarXlsx(nombreArchivo: string, hoja: HojaExcel): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([generarXlsx(hoja)], { type: MIME_XLSX }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = nombreArchivo.endsWith('.xlsx') ? nombreArchivo : `${nombreArchivo}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
// Se libera después: algunos navegadores leen la URL de forma asíncrona tras el clic.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const dos = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
|
||||||
|
export function hoy(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hora local actual en formato HH:mm (el de `<input type="time">`). */
|
||||||
|
export function horaActual(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${dos(d.getHours())}:${dos(d.getMinutes())}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** Solo para tests: lee un ZIP sin compresión (como los .xlsx de `excel.ts`) y devuelve sus archivos como texto. */
|
||||||
|
export function leerZip(bytes: Uint8Array): Map<string, string> {
|
||||||
|
const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const decodificador = new TextDecoder();
|
||||||
|
const archivos = new Map<string, string>();
|
||||||
|
let p = 0;
|
||||||
|
while (vista.getUint32(p, true) === 0x04034b50) {
|
||||||
|
if (vista.getUint16(p + 8, true) !== 0) throw new Error('El ZIP tiene archivos comprimidos.');
|
||||||
|
const tam = vista.getUint32(p + 18, true);
|
||||||
|
const largoNombre = vista.getUint16(p + 26, true);
|
||||||
|
const nombre = decodificador.decode(bytes.subarray(p + 30, p + 30 + largoNombre));
|
||||||
|
const inicio = p + 30 + largoNombre;
|
||||||
|
archivos.set(nombre, decodificador.decode(bytes.subarray(inicio, inicio + tam)));
|
||||||
|
p = inicio + tam;
|
||||||
|
}
|
||||||
|
const fin = bytes.length - 22;
|
||||||
|
if (vista.getUint32(fin, true) !== 0x06054b50) throw new Error('Falta el final del directorio del ZIP.');
|
||||||
|
if (vista.getUint16(fin + 10, true) !== archivos.size) throw new Error('El directorio no coincide con los archivos.');
|
||||||
|
// El directorio central empieza donde terminan los archivos y llega justo hasta el registro final.
|
||||||
|
if (vista.getUint32(fin + 16, true) !== p) throw new Error('El inicio del directorio central no es correcto.');
|
||||||
|
if (vista.getUint32(fin + 12, true) !== fin - p) throw new Error('El tamaño del directorio central no es correcto.');
|
||||||
|
if (vista.getUint32(p, true) !== 0x02014b50) throw new Error('El directorio central no empieza donde se indica.');
|
||||||
|
return archivos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solo para tests: las celdas de texto y número de una hoja, fila por fila. */
|
||||||
|
export function filasDeHoja(hojaXml: string): string[][] {
|
||||||
|
return [...hojaXml.matchAll(/<row r="\d+">(.*?)<\/row>/g)].map(([, fila]) =>
|
||||||
|
[...fila.matchAll(/<c r="[A-Z]+\d+"[^>]*>(?:<is><t[^>]*>(.*?)<\/t><\/is>|<v>(.*?)<\/v>)<\/c>/g)].map(([, t, v]) => t ?? v),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { detalleVersion } from './version';
|
||||||
|
|
||||||
|
describe('detalleVersion', () => {
|
||||||
|
it('en desarrollo lo dice', () => {
|
||||||
|
expect(detalleVersion({ version: '1.0.0', publicado: null, commit: null })).toBe('desarrollo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un release muestra la fecha local y el commit', () => {
|
||||||
|
const publicado = new Date(2026, 9, 15, 16, 5).toISOString();
|
||||||
|
expect(detalleVersion({ version: '1.0.0', publicado, commit: 'abc1234' })).toBe('publicada 15-10-2026 16:05 · commit abc1234');
|
||||||
|
expect(detalleVersion({ version: '1.0.0', publicado, commit: null })).toBe('publicada 15-10-2026 16:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { VersionApp } from '@dp/shared';
|
||||||
|
|
||||||
|
const dos = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/** "publicada 15-10-2026 16:50 · commit abc1234", o "desarrollo" si no es un release. Hora local del navegador. */
|
||||||
|
export function detalleVersion(v: VersionApp): string {
|
||||||
|
if (!v.publicado) return 'desarrollo';
|
||||||
|
const d = new Date(v.publicado);
|
||||||
|
const fecha = Number.isNaN(d.getTime())
|
||||||
|
? v.publicado
|
||||||
|
: `${dos(d.getDate())}-${dos(d.getMonth() + 1)}-${d.getFullYear()} ${dos(d.getHours())}:${dos(d.getMinutes())}`;
|
||||||
|
return `publicada ${fecha}${v.commit ? ` · commit ${v.commit}` : ''}`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
|
|||||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
import { AuthService } from '../core/auth.service';
|
import { AuthService } from '../core/auth.service';
|
||||||
import { MetadataService } from '../core/metadata.service';
|
import { MetadataService } from '../core/metadata.service';
|
||||||
|
import { detalleVersion } from '../core/version';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell',
|
selector: 'app-shell',
|
||||||
@@ -9,6 +10,9 @@ import { MetadataService } from '../core/metadata.service';
|
|||||||
template: `
|
template: `
|
||||||
<header class="barra">
|
<header class="barra">
|
||||||
<strong class="marca">Devolución a Proveedores</strong>
|
<strong class="marca">Devolución a Proveedores</strong>
|
||||||
|
@if (auth.usuario()?.app; as v) {
|
||||||
|
<span class="version muted" [title]="'Versión ' + v.version + ' · ' + detalleVersion(v)">v{{ v.version }}</span>
|
||||||
|
}
|
||||||
<nav>
|
<nav>
|
||||||
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
|
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
|
||||||
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
|
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
|
||||||
@@ -38,6 +42,11 @@ import { MetadataService } from '../core/metadata.service';
|
|||||||
.marca {
|
.marca {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
.version {
|
||||||
|
margin-left: -16px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -71,6 +80,7 @@ import { MetadataService } from '../core/metadata.service';
|
|||||||
})
|
})
|
||||||
export class Shell {
|
export class Shell {
|
||||||
protected readonly auth = inject(AuthService);
|
protected readonly auth = inject(AuthService);
|
||||||
|
protected readonly detalleVersion = detalleVersion;
|
||||||
private readonly metadata = inject(MetadataService);
|
private readonly metadata = inject(MetadataService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ header p {
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.opcion {
|
.opcion {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -38,5 +38,9 @@
|
|||||||
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
|
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
|
||||||
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
|
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (info()?.app; as v) {
|
||||||
|
<p class="version muted">Versión {{ v.version }} · {{ detalleVersion(v) }}</p>
|
||||||
|
}
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
@@ -5,6 +5,7 @@ import type { InfoConexion } from '@dp/shared';
|
|||||||
import { AuthService } from '../../core/auth.service';
|
import { AuthService } from '../../core/auth.service';
|
||||||
import { mensajeDeError } from '../../core/http-error';
|
import { mensajeDeError } from '../../core/http-error';
|
||||||
import { MetadataService } from '../../core/metadata.service';
|
import { MetadataService } from '../../core/metadata.service';
|
||||||
|
import { detalleVersion } from '../../core/version';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -20,6 +21,7 @@ export class LoginPage implements OnInit {
|
|||||||
protected readonly info = signal<InfoConexion | null>(null);
|
protected readonly info = signal<InfoConexion | null>(null);
|
||||||
protected readonly error = signal<string | null>(null);
|
protected readonly error = signal<string | null>(null);
|
||||||
protected readonly enviando = signal(false);
|
protected readonly enviando = signal(false);
|
||||||
|
protected readonly detalleVersion = detalleVersion;
|
||||||
|
|
||||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||||
userName: ['', Validators.required],
|
userName: ['', Validators.required],
|
||||||
|
|||||||
@@ -49,9 +49,18 @@ h3 {
|
|||||||
|
|
||||||
.titulo-grilla {
|
.titulo-grilla {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exportar {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
|
|||||||
@@ -122,7 +122,10 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="titulo-grilla">
|
<div class="titulo-grilla">
|
||||||
<h2>Líneas</h2>
|
<h2>Líneas</h2>
|
||||||
<span class="muted">
|
<button class="btn exportar" type="button" (click)="exportarExcel()" [disabled]="lineas().length === 0">
|
||||||
|
Exportar a Excel
|
||||||
|
</button>
|
||||||
|
<span class="muted resumen">
|
||||||
{{ aplicables().length }} marcada(s) pendiente(s) · {{ conDestino() }} con Devolución
|
{{ aplicables().length }} marcada(s) pendiente(s) · {{ conDestino() }} con Devolución
|
||||||
@if (conError() > 0) {
|
@if (conError() > 0) {
|
||||||
· <span class="texto-error">{{ conError() }} con error</span>
|
· <span class="texto-error">{{ conError() }} con error</span>
|
||||||
@@ -133,7 +136,7 @@
|
|||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Stock</th>
|
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Consignación</th>
|
||||||
<th>Entrada</th><th>Fecha entrada</th><th class="num">Abierta</th><th class="num">Asignada</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>
|
<th>Proveedor</th><th>Devolución</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||||
|
import { filasDeHoja, leerZip } from '../../../core/leer-zip.testing';
|
||||||
import { PlanificacionDetallePage } from './detalle';
|
import { PlanificacionDetallePage } from './detalle';
|
||||||
|
|
||||||
const linea = (extra: Record<string, unknown>) => ({
|
const linea = (extra: Record<string, unknown>) => ({
|
||||||
@@ -100,6 +101,50 @@ describe('PlanificacionDetallePage', () => {
|
|||||||
expect(el.textContent).toContain('Devolución Nº 33');
|
expect(el.textContent).toContain('Devolución Nº 33');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('valores por defecto de la cabecera', () => {
|
||||||
|
const sinDatos = (status: 'O' | 'C') => {
|
||||||
|
const p = plan(status, [linea({ lineId: 1 })]);
|
||||||
|
return { ...p, cabecera: { ...p.cabecera, docDate: '', tipoDespacho: '', indicadorTraslado: '' } };
|
||||||
|
};
|
||||||
|
const valor = (el: HTMLElement, id: string) => el.querySelector<HTMLInputElement | HTMLSelectElement>(`#${id}`)!.value;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 15, 9, 7));
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('abierta: fechas de hoy, hora actual, despacho 1 y traslado 7 (Guía de devolución)', async () => {
|
||||||
|
const el: HTMLElement = (await crear(sinDatos('O'))).nativeElement;
|
||||||
|
|
||||||
|
expect(valor(el, 'docDate')).toBe('2026-10-15');
|
||||||
|
expect(valor(el, 'fechaSalida')).toBe('2026-10-15');
|
||||||
|
expect(valor(el, 'fechaLlegada')).toBe('2026-10-15');
|
||||||
|
expect(valor(el, 'horaSalida')).toBe('09:07');
|
||||||
|
expect(valor(el, 'tipoDespacho')).toBe('1');
|
||||||
|
expect(valor(el, 'indicadorTraslado')).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abierta: no pisa lo que ya está guardado', async () => {
|
||||||
|
const p = plan('O', [linea({ lineId: 1 })]);
|
||||||
|
const guardada = { ...p, cabecera: { ...p.cabecera, fechaSalida: '2026-10-20', horaSalida: '18:30', fechaLlegada: '2026-10-21' } };
|
||||||
|
const el: HTMLElement = (await crear(guardada)).nativeElement;
|
||||||
|
|
||||||
|
expect(valor(el, 'docDate')).toBe('2026-10-12');
|
||||||
|
expect(valor(el, 'fechaSalida')).toBe('2026-10-20');
|
||||||
|
expect(valor(el, 'horaSalida')).toBe('18:30');
|
||||||
|
expect(valor(el, 'fechaLlegada')).toBe('2026-10-21');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cerrada: muestra lo guardado, sin valores por defecto', async () => {
|
||||||
|
const el: HTMLElement = (await crear(sinDatos('C'))).nativeElement;
|
||||||
|
|
||||||
|
expect(valor(el, 'fechaSalida')).toBe('');
|
||||||
|
expect(valor(el, 'horaSalida')).toBe('');
|
||||||
|
expect(valor(el, 'indicadorTraslado')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('una planificación cerrada no permite cambios', async () => {
|
it('una planificación cerrada no permite cambios', async () => {
|
||||||
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
|
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
|
||||||
const el: HTMLElement = fixture.nativeElement;
|
const el: HTMLElement = fixture.nativeElement;
|
||||||
@@ -128,6 +173,38 @@ describe('PlanificacionDetallePage', () => {
|
|||||||
expect(botonRehacer(fixture)).toBeFalsy();
|
expect(botonRehacer(fixture)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Exportar a Excel: cada Hijo sale con los datos de su Padre; un Padre sin Hijos sale solo', async () => {
|
||||||
|
const blobs: Blob[] = [];
|
||||||
|
const nombres: string[] = [];
|
||||||
|
URL.createObjectURL = vi.fn((b: Blob) => (blobs.push(b), 'blob:prueba'));
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||||
|
nombres.push(this.download);
|
||||||
|
});
|
||||||
|
const padreF = (itemCode: string, extra: Record<string, unknown>) =>
|
||||||
|
linea({ tipoFila: 'F', itemCode, baseType: null, baseEntry: null, baseLine: null, baseRef: null, baseDocDate: null, openQty: null, allocatedQty: null, applied: false, cardCode: '', cardName: '', ...extra });
|
||||||
|
const fixture = await crear(
|
||||||
|
plan('O', [
|
||||||
|
padreF('A1', { lineId: 1, quantity: 7, onHand: 9, status: 'OK' }),
|
||||||
|
linea({ lineId: 2, itemCode: 'A1', targetType: 21, targetEntry: 33 }),
|
||||||
|
linea({ lineId: 3, itemCode: 'A1', baseRef: 102, applied: false, errCode: '-10', errMsg: 'Falla' }),
|
||||||
|
padreF('A2', { lineId: 4, itemDescription: 'Otro', quantity: 3, onHand: 0, status: 'SIN_STOCK' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const el: HTMLElement = fixture.nativeElement;
|
||||||
|
|
||||||
|
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Exportar a Excel'))!.click();
|
||||||
|
|
||||||
|
expect(nombres).toEqual(['planificacion-5-lineas.xlsx']);
|
||||||
|
const hoja = leerZip(new Uint8Array(await blobs[0].arrayBuffer())).get('xl/worksheets/sheet1.xml')!;
|
||||||
|
const filas = filasDeHoja(hoja);
|
||||||
|
expect(filas).toHaveLength(4);
|
||||||
|
expect(filas[0][0]).toBe('Artículo');
|
||||||
|
expect(filas[1]).toEqual(['A1', 'Libro', '7', '9', 'OK', 'Sí', '101', '46053', '10', '4', 'P1', 'Proveedor', '33']);
|
||||||
|
expect(filas[2]).toEqual(['A1', 'Libro', '7', '9', 'OK', 'No', '102', '46053', '10', '4', 'P1', 'Proveedor', '-10: Falla']);
|
||||||
|
expect(filas[3]).toEqual(['A2', 'Otro', '3', '0', 'Sin stock']);
|
||||||
|
});
|
||||||
|
|
||||||
describe('descarga de PDF (paso 4)', () => {
|
describe('descarga de PDF (paso 4)', () => {
|
||||||
const conDevolucion = () => plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]);
|
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 botonPdf = (el: HTMLElement) => [...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Descargar PDF'));
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ import type {
|
|||||||
ResultadoPdf,
|
ResultadoPdf,
|
||||||
} from '@dp/shared';
|
} from '@dp/shared';
|
||||||
import { consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from '@dp/shared';
|
import { consolidarDocumentos, extraerLineasAplicables, puedeCambiarSeleccion } from '@dp/shared';
|
||||||
|
import { descargarXlsx, type ValorExcel } from '../../../core/excel';
|
||||||
|
import { horaActual, hoy } from '../../../core/fechas';
|
||||||
import { mensajeDeError } from '../../../core/http-error';
|
import { mensajeDeError } from '../../../core/http-error';
|
||||||
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||||
import { Stepper } from '../../../layout/stepper';
|
import { Stepper } from '../../../layout/stepper';
|
||||||
|
|
||||||
|
/** Valores por defecto de la cabecera de las Devoluciones. */
|
||||||
|
const TIPO_DESPACHO_POR_DEFECTO = '1';
|
||||||
|
/** 7 = Guía de devolución. */
|
||||||
|
const INDICADOR_TRASLADO_POR_DEFECTO = '7';
|
||||||
|
|
||||||
interface DescargaPdf {
|
interface DescargaPdf {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
folios: number[];
|
folios: number[];
|
||||||
@@ -112,26 +119,39 @@ export class PlanificacionDetallePage implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reemplaza el estado en pantalla con lo que devolvió la API. */
|
/**
|
||||||
|
* Reemplaza el estado en pantalla con lo que devolvió la API. En una planificación abierta, los campos
|
||||||
|
* vacíos toman su valor por defecto (fechas de hoy, hora actual, despacho 1, traslado 7); se guardan en
|
||||||
|
* la planificación con "Guardar cabecera" o al crear las Devoluciones.
|
||||||
|
*/
|
||||||
private mostrar(plan: Planificacion): void {
|
private mostrar(plan: Planificacion): void {
|
||||||
this.plan.set(plan);
|
this.plan.set(plan);
|
||||||
const c = plan.cabecera;
|
const c = plan.cabecera;
|
||||||
|
const abierta = c.status === 'O';
|
||||||
|
const porDefecto = (valor: string | null | undefined, defecto: () => string) => valor || (abierta ? defecto() : '');
|
||||||
this.form.reset({
|
this.form.reset({
|
||||||
docDate: c.docDate,
|
docDate: porDefecto(c.docDate, hoy),
|
||||||
series: c.series,
|
series: c.series,
|
||||||
tipoDespacho: c.tipoDespacho,
|
tipoDespacho: porDefecto(c.tipoDespacho, () => this.opcionPorDefecto('tiposDespacho', TIPO_DESPACHO_POR_DEFECTO)),
|
||||||
indicadorTraslado: c.indicadorTraslado,
|
indicadorTraslado: porDefecto(c.indicadorTraslado, () =>
|
||||||
|
this.opcionPorDefecto('indicadoresTraslado', INDICADOR_TRASLADO_POR_DEFECTO),
|
||||||
|
),
|
||||||
printingLines: c.printingLines || 11,
|
printingLines: c.printingLines || 11,
|
||||||
rutTransporte: c.rutTransporte ?? '',
|
rutTransporte: c.rutTransporte ?? '',
|
||||||
patente: c.patente ?? '',
|
patente: c.patente ?? '',
|
||||||
rutChofer: c.rutChofer ?? '',
|
rutChofer: c.rutChofer ?? '',
|
||||||
nombreChofer: c.nombreChofer ?? '',
|
nombreChofer: c.nombreChofer ?? '',
|
||||||
fechaSalida: c.fechaSalida ?? '',
|
fechaSalida: porDefecto(c.fechaSalida, hoy),
|
||||||
horaSalida: c.horaSalida ?? '',
|
horaSalida: porDefecto(c.horaSalida, horaActual),
|
||||||
fechaLlegada: c.fechaLlegada ?? '',
|
fechaLlegada: porDefecto(c.fechaLlegada, hoy),
|
||||||
});
|
});
|
||||||
if (plan.cabecera.status === 'C') this.form.disable();
|
if (abierta) this.form.enable();
|
||||||
else this.form.enable();
|
else this.form.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** El valor por defecto, solo si existe en el catálogo de SAP (si no, el select quedaría con un valor que no ofrece). */
|
||||||
|
private opcionPorDefecto(catalogo: 'tiposDespacho' | 'indicadoresTraslado', valor: string): string {
|
||||||
|
return this.catalogos()?.[catalogo].some((o) => o.value === valor) ? valor : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected puedeMarcar(l: LineaPlanificacion): boolean {
|
protected puedeMarcar(l: LineaPlanificacion): boolean {
|
||||||
@@ -202,6 +222,60 @@ export class PlanificacionDetallePage implements OnInit, OnDestroy {
|
|||||||
this.pdfDescargas.set([]);
|
this.pdfDescargas.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descarga las líneas en Excel: una fila por cada Hijo (S), con los datos de su Padre (F).
|
||||||
|
* Un Padre sin Hijos (sin stock o sin Entradas abiertas) sale solo, con las columnas de la Entrada vacías.
|
||||||
|
*/
|
||||||
|
protected exportarExcel(): void {
|
||||||
|
const c = this.cabecera();
|
||||||
|
if (!c) return;
|
||||||
|
const filas: ValorExcel[][] = [];
|
||||||
|
const lineas = this.lineas();
|
||||||
|
lineas.forEach((l, i) => {
|
||||||
|
if (l.tipoFila !== 'F') return;
|
||||||
|
const padre: ValorExcel[] = [l.itemCode, l.itemDescription, l.quantity, l.onHand, l.status === 'SIN_STOCK' ? 'Sin stock' : 'OK'];
|
||||||
|
// Los Hijos van a continuación de su Padre, hasta el siguiente Padre.
|
||||||
|
const fin = lineas.findIndex((x, j) => j > i && x.tipoFila === 'F');
|
||||||
|
const hijos = lineas.slice(i + 1, fin === -1 ? undefined : fin);
|
||||||
|
if (hijos.length === 0) filas.push(padre);
|
||||||
|
for (const h of hijos) {
|
||||||
|
filas.push([
|
||||||
|
...padre,
|
||||||
|
h.applied ? 'Sí' : 'No',
|
||||||
|
h.baseRef,
|
||||||
|
h.baseDocDate,
|
||||||
|
h.openQty,
|
||||||
|
h.allocatedQty,
|
||||||
|
h.cardCode,
|
||||||
|
h.cardName,
|
||||||
|
h.targetEntry,
|
||||||
|
h.errMsg ? `${h.errCode}: ${h.errMsg}` : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
descargarXlsx(`planificacion-${c.docNum ?? c.docEntry}-lineas.xlsx`, {
|
||||||
|
nombre: `Planificación ${c.docNum ?? c.docEntry}`,
|
||||||
|
columnas: [
|
||||||
|
{ titulo: 'Artículo', ancho: 18 },
|
||||||
|
{ titulo: 'Descripción', ancho: 45 },
|
||||||
|
{ titulo: 'Cantidad', tipo: 'numero' },
|
||||||
|
{ titulo: 'Consignación', tipo: 'numero' },
|
||||||
|
{ titulo: 'Estado', ancho: 11 },
|
||||||
|
{ titulo: 'Devolver', ancho: 10 },
|
||||||
|
{ titulo: 'Entrada', tipo: 'numero' },
|
||||||
|
{ titulo: 'Fecha entrada', tipo: 'fecha', ancho: 14 },
|
||||||
|
{ titulo: 'Abierta', tipo: 'numero' },
|
||||||
|
{ titulo: 'Asignada', tipo: 'numero' },
|
||||||
|
{ titulo: 'Proveedor', ancho: 14 },
|
||||||
|
{ titulo: 'Nombre proveedor', ancho: 40 },
|
||||||
|
{ titulo: 'Devolución', tipo: 'numero', ancho: 12 },
|
||||||
|
{ titulo: 'Error', ancho: 50 },
|
||||||
|
],
|
||||||
|
filas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected async cerrar(): Promise<void> {
|
protected async cerrar(): Promise<void> {
|
||||||
const pendientes = this.aplicables().length;
|
const pendientes = this.aplicables().length;
|
||||||
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
|
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ textarea {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titulo-grilla {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.ayuda {
|
.ayuda {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<section class="encabezado">
|
<section class="encabezado">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ reemplaza() ? 'Rehacer planificación Nº ' + reemplazaNumero() : 'Nueva planificación' }}</h1>
|
<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>
|
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida contra lo disponible en Consignación del almacén (cantidad abierta de las Entradas de mercancías) antes de procesar.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn" routerLink="/planificaciones">Volver</a>
|
<a class="btn" routerLink="/planificaciones">Volver</a>
|
||||||
</section>
|
</section>
|
||||||
@@ -84,11 +84,14 @@
|
|||||||
|
|
||||||
@if (r.lineas.length > 0) {
|
@if (r.lineas.length > 0) {
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Artículos validados</h2>
|
<div class="titulo-grilla">
|
||||||
|
<h2>Artículos validados</h2>
|
||||||
|
<button class="btn" type="button" (click)="exportarExcel()">Exportar a Excel</button>
|
||||||
|
</div>
|
||||||
<table class="grid">
|
<table class="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Stock</th><th>Estado</th>
|
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Consignación</th><th>Estado</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -109,7 +112,7 @@
|
|||||||
</table>
|
</table>
|
||||||
@if (sinStock() > 0) {
|
@if (sinStock() > 0) {
|
||||||
<p class="muted nota">
|
<p class="muted nota">
|
||||||
{{ sinStock() }} artículo(s) sin stock suficiente quedarán fuera al procesar.
|
{{ sinStock() }} artículo(s) sin consignación suficiente quedarán fuera al procesar.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { filasDeHoja, leerZip } from '../../../core/leer-zip.testing';
|
||||||
import { NuevaPlanificacionPage } from './nueva';
|
import { NuevaPlanificacionPage } from './nueva';
|
||||||
|
|
||||||
const CATALOGOS = {
|
const CATALOGOS = {
|
||||||
@@ -74,6 +75,32 @@ describe('NuevaPlanificacionPage', () => {
|
|||||||
expect(procesar?.disabled).toBe(false);
|
expect(procesar?.disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Exportar a Excel descarga los artículos validados tal como se ven en la tabla', async () => {
|
||||||
|
const blobs: Blob[] = [];
|
||||||
|
const nombres: string[] = [];
|
||||||
|
URL.createObjectURL = vi.fn((b: Blob) => (blobs.push(b), 'blob:prueba'));
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||||
|
nombres.push(this.download);
|
||||||
|
});
|
||||||
|
const fixture = await crear();
|
||||||
|
const el = await escribir(fixture, 'A1\t5\nA2\t5');
|
||||||
|
|
||||||
|
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Validar')!.click();
|
||||||
|
http.expectOne('/api/planificaciones/pegar').flush({ lineas: [padre('A1', 'OK'), padre('A2', 'SIN_STOCK')], errores: [] });
|
||||||
|
await tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
[...el.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('Exportar a Excel'))!.click();
|
||||||
|
|
||||||
|
expect(nombres[0]).toMatch(/^articulos-validados-01-\d{4}-\d{2}-\d{2}\.xlsx$/);
|
||||||
|
const hoja = leerZip(new Uint8Array(await blobs[0].arrayBuffer())).get('xl/worksheets/sheet1.xml')!;
|
||||||
|
expect(filasDeHoja(hoja)).toEqual([
|
||||||
|
['Código', 'Descripción', 'A devolver', 'Consignación', 'Estado'],
|
||||||
|
['A1', 'Libro', '5', '10', 'OK'],
|
||||||
|
['A2', 'Libro', '5', '10', 'Sin stock'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('el botón Pegar lee el portapapeles y valida', async () => {
|
it('el botón Pegar lee el portapapeles y valida', async () => {
|
||||||
const leer = vi.fn().mockResolvedValue('A1\t5');
|
const leer = vi.fn().mockResolvedValue('A1\t5');
|
||||||
Object.defineProperty(navigator, 'clipboard', { value: { readText: leer }, configurable: true });
|
Object.defineProperty(navigator, 'clipboard', { value: { readText: leer }, configurable: true });
|
||||||
|
|||||||
@@ -2,17 +2,12 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import type { Almacen, Planificacion, ResultadoPegadoApi, ResumenPlanificacion } from '@dp/shared';
|
import type { Almacen, Planificacion, ResultadoPegadoApi, ResumenPlanificacion } from '@dp/shared';
|
||||||
|
import { descargarXlsx } from '../../../core/excel';
|
||||||
|
import { hoy } from '../../../core/fechas';
|
||||||
import { mensajeDeError } from '../../../core/http-error';
|
import { mensajeDeError } from '../../../core/http-error';
|
||||||
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
import { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||||
import { Stepper } from '../../../layout/stepper';
|
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({
|
@Component({
|
||||||
selector: 'app-nueva-planificacion',
|
selector: 'app-nueva-planificacion',
|
||||||
imports: [FormsModule, RouterLink, Stepper],
|
imports: [FormsModule, RouterLink, Stepper],
|
||||||
@@ -149,6 +144,23 @@ export class NuevaPlanificacionPage implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Descarga la tabla "Artículos validados" tal como se ve en pantalla. */
|
||||||
|
protected exportarExcel(): void {
|
||||||
|
const lineas = this.resultado()?.lineas ?? [];
|
||||||
|
if (lineas.length === 0) return;
|
||||||
|
descargarXlsx(`articulos-validados-${this.whsCode()}-${hoy()}.xlsx`, {
|
||||||
|
nombre: 'Artículos validados',
|
||||||
|
columnas: [
|
||||||
|
{ titulo: 'Código', ancho: 18 },
|
||||||
|
{ titulo: 'Descripción', ancho: 50 },
|
||||||
|
{ titulo: 'A devolver', tipo: 'numero', ancho: 12 },
|
||||||
|
{ titulo: 'Consignación', tipo: 'numero', ancho: 12 },
|
||||||
|
{ titulo: 'Estado', ancho: 12 },
|
||||||
|
],
|
||||||
|
filas: lineas.map((l) => [l.itemCode, l.itemDescription, l.quantity, l.onHand, l.status === 'OK' ? 'OK' : 'Sin stock']),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async procesar(): Promise<void> {
|
async procesar(): Promise<void> {
|
||||||
if (!this.puedeProcesar()) return;
|
if (!this.puedeProcesar()) return;
|
||||||
this.procesando.set(true);
|
this.procesando.set(true);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"/api": {
|
"/api": {
|
||||||
"target": "http://localhost:3000",
|
"target": "http://localhost:3015",
|
||||||
"secure": false
|
"secure": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +133,33 @@ New-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTPS' -Direction Inbou
|
|||||||
New-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTP (redireccion)' -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
|
New-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTP (redireccion)' -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Desde el propio servidor (`localhost`) la aplicación funciona sin reglas; los demás equipos solo llegan si
|
||||||
|
el puerto está abierto.
|
||||||
|
|
||||||
|
Ver las reglas que hay y su puerto:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-NetFirewallRule -DisplayName 'Devolucion Proveedores*' |
|
||||||
|
Select-Object DisplayName, Enabled, @{ n = 'Puerto'; e = { ($_ | Get-NetFirewallPortFilter).LocalPort } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Si más adelante cambia `PORT`**, cambie el puerto de la regla existente en vez de crear otra, o borre la
|
||||||
|
anterior para no dejar abierto un puerto que ya no se usa (por ejemplo, para pasar al 8443):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTPS' -LocalPort 8443
|
||||||
|
# o bien
|
||||||
|
Remove-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTPS'
|
||||||
|
```
|
||||||
|
|
||||||
|
Comprobar desde **otro equipo** que el puerto responde:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-NetConnection servidor -Port 443 # TcpTestSucceeded : True
|
||||||
|
```
|
||||||
|
|
||||||
|
Si da `False` con el servicio en `Running`, el bloqueo está en el firewall (de Windows o de la red).
|
||||||
|
|
||||||
## 7. Instalar el servicio
|
## 7. Instalar el servicio
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -198,7 +225,18 @@ cd C:\apps\devolucion-proveedores\servicio
|
|||||||
|
|
||||||
Para cambiar una variable de entorno del servicio con `nssm set ... AppEnvironmentExtra`, indique **todas** las
|
Para cambiar una variable de entorno del servicio con `nssm set ... AppEnvironmentExtra`, indique **todas** las
|
||||||
variables juntas (reemplaza la lista completa): `NODE_ENV=production NO_COLOR=1 [NODE_OPTIONS=--use-system-ca]`.
|
variables juntas (reemplaza la lista completa): `NODE_ENV=production NO_COLOR=1 [NODE_OPTIONS=--use-system-ca]`.
|
||||||
Los valores de la aplicación (puerto, SAP, HTTPS) no van aquí: se cambian en el `.env` y se reinicia el servicio.
|
Los valores de la aplicación (puerto, SAP, HTTPS) no van aquí: se cambian en el `.env` y se reinicia el servicio:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Stop-Service DevolucionProveedores
|
||||||
|
notepad C:\apps\devolucion-proveedores\app\apps\api\.env
|
||||||
|
Start-Service DevolucionProveedores
|
||||||
|
```
|
||||||
|
|
||||||
|
- Al cambiar `SL_COMPANY_DB`, revise que `DB_TYPE` (`HANA`/`MSSQL`) corresponda a esa base y, tras entrar,
|
||||||
|
pase por **Configuración → Metadatos**. El login muestra la sociedad conectada.
|
||||||
|
- Al cambiar `PORT`, ajuste el firewall (paso 6). La web y la API usan el mismo puerto.
|
||||||
|
- La versión instalada se ve en el login, en la barra superior, en `VERSION.txt` y en el log al arrancar.
|
||||||
|
|
||||||
## 11. Actualizar a una versión nueva
|
## 11. Actualizar a una versión nueva
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ describe('asignarFifo', () => {
|
|||||||
expect(lineas[1]).toMatchObject({ itemCode: 'a001', applied: true, baseType: 20, baseRef: 1001 });
|
expect(lineas[1]).toMatchObject({ itemCode: 'a001', applied: true, baseType: 20, baseRef: 1001 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omite artículos sin stock suficiente', () => {
|
it('omite artículos sin consignación suficiente', () => {
|
||||||
const { lineas, advertencias } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 6, onHand: 5 }], new Map());
|
const { lineas, advertencias } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 6, onHand: 5 }], new Map());
|
||||||
expect(lineas).toEqual([]);
|
expect(lineas).toEqual([]);
|
||||||
expect(advertencias[0]).toContain('sin stock');
|
expect(advertencias[0]).toContain('sin consignación');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advierte cuando las Entradas no alcanzan', () => {
|
it('advierte cuando las Entradas no alcanzan', () => {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function asignarFifo(articulos: ArticuloADevolver[], entradasPorItem: Map
|
|||||||
const padre = crearLineaPadre(articulo);
|
const padre = crearLineaPadre(articulo);
|
||||||
|
|
||||||
if (padre.status === 'SIN_STOCK') {
|
if (padre.status === 'SIN_STOCK') {
|
||||||
advertencias.push(`${articulo.itemCode}: sin stock suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
|
advertencias.push(`${articulo.itemCode}: sin consignación suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,25 @@ export interface LoginRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Versión de esta aplicación (la del `package.json` raíz) y datos de su publicación. */
|
||||||
|
export interface VersionApp {
|
||||||
|
version: string;
|
||||||
|
/** Fecha ISO de `npm run publicar`; `null` en desarrollo (no es un release). */
|
||||||
|
publicado: string | null;
|
||||||
|
/** Commit de git del release (con `-modificado` si había cambios sin commit), si se pudo leer. */
|
||||||
|
commit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsuarioSesion {
|
export interface UsuarioSesion {
|
||||||
userName: string;
|
userName: string;
|
||||||
companyDb: string;
|
companyDb: string;
|
||||||
/** Versión del Service Layer devuelta en el Login. */
|
/** Versión del Service Layer devuelta en el Login. */
|
||||||
version: string;
|
version: string;
|
||||||
|
app: VersionApp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InfoConexion {
|
export interface InfoConexion {
|
||||||
companyDb: string;
|
companyDb: string;
|
||||||
dbType: 'HANA' | 'MSSQL';
|
dbType: 'HANA' | 'MSSQL';
|
||||||
|
app: VersionApp;
|
||||||
}
|
}
|
||||||
Generated
+2
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "devolucion-proveedores-web",
|
"name": "devolucion-proveedores-web",
|
||||||
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "devolucion-proveedores-web",
|
"name": "devolucion-proveedores-web",
|
||||||
|
"version": "2.0.1",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"libs/*",
|
"libs/*",
|
||||||
"apps/*"
|
"apps/*"
|
||||||
|
|||||||
+6
-2
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "devolucion-proveedores-web",
|
"name": "devolucion-proveedores-web",
|
||||||
|
"version": "2.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"libs/*",
|
"libs/*",
|
||||||
@@ -9,8 +10,11 @@
|
|||||||
"build:shared": "npm run build -w @dp/shared",
|
"build:shared": "npm run build -w @dp/shared",
|
||||||
"build": "npm run build:shared && npm run build -w api && npm run build -w web",
|
"build": "npm run build:shared && npm run build -w api && npm run build -w web",
|
||||||
"dev:api": "npm run build:shared && npm run start:dev -w api",
|
"dev:api": "npm run build:shared && npm run start:dev -w api",
|
||||||
"dev:web": "npm run build:shared && npm run start -w web",
|
"dev:web": "npm run build:shared && npm run start -w web -- --poll 1500",
|
||||||
"test": "npm run build:shared && npm test -w @dp/shared && npm test -w api && npm test -w web -- --watch=false",
|
"test": "npm run build:shared && npm test -w @dp/shared && npm test -w api && npm test -w web -- --watch=false",
|
||||||
"publicar": "node scripts/publicar.mjs"
|
"publicar": "node scripts/publicar.mjs",
|
||||||
|
"version:parche": "npm version patch --no-git-tag-version --no-workspaces-update",
|
||||||
|
"version:menor": "npm version minor --no-git-tag-version --no-workspaces-update",
|
||||||
|
"version:mayor": "npm version major --no-git-tag-version --no-workspaces-update"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-2
@@ -36,6 +36,15 @@ function copiar(origen, destinoRelativo) {
|
|||||||
cpSync(desde, join(app, destinoRelativo ?? origen), { recursive: true });
|
cpSync(desde, join(app, destinoRelativo ?? origen), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Commit corto de git (con "-modificado" si hay cambios sin commit), o null si no hay git o commits. */
|
||||||
|
function commitActual() {
|
||||||
|
const git = (...args) => spawnSync('git', args, { cwd: raiz, encoding: 'utf8' });
|
||||||
|
const head = git('rev-parse', '--short', 'HEAD');
|
||||||
|
if (head.status !== 0) return null;
|
||||||
|
const cambios = git('status', '--porcelain').stdout.trim().length > 0;
|
||||||
|
return `${head.stdout.trim()}${cambios ? '-modificado' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
function tamano(carpeta) {
|
function tamano(carpeta) {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const e of readdirSync(carpeta, { withFileTypes: true, recursive: true })) {
|
for (const e of readdirSync(carpeta, { withFileTypes: true, recursive: true })) {
|
||||||
@@ -94,11 +103,18 @@ for (const archivo of readdirSync(join(raiz, 'deploy', 'windows'))) {
|
|||||||
for (const carpeta of ['logs', 'certificados']) mkdirSync(join(destino, carpeta), { recursive: true });
|
for (const carpeta of ['logs', 'certificados']) mkdirSync(join(destino, carpeta), { recursive: true });
|
||||||
writeFileSync(join(destino, 'certificados', 'LEEME.txt'), 'Ponga aquí el certificado HTTPS (.pfx, o .key y .crt) y apunte el .env a estos archivos.\n');
|
writeFileSync(join(destino, 'certificados', 'LEEME.txt'), 'Ponga aquí el certificado HTTPS (.pfx, o .key y .crt) y apunte el .env a estos archivos.\n');
|
||||||
|
|
||||||
const version = JSON.parse(readFileSync(join(raiz, 'package.json'), 'utf8')).name;
|
// Versión: el número sale del package.json raíz (npm run version:parche|menor|mayor). La API lee
|
||||||
|
// app/version.json al arrancar y la web la muestra en el login y en la barra superior.
|
||||||
|
const { name, version } = JSON.parse(readFileSync(join(raiz, 'package.json'), 'utf8'));
|
||||||
|
const publicado = new Date().toISOString();
|
||||||
|
const commit = commitActual();
|
||||||
|
writeFileSync(join(app, 'version.json'), `${JSON.stringify({ version, publicado, commit }, null, 2)}\n`);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(destino, 'VERSION.txt'),
|
join(destino, 'VERSION.txt'),
|
||||||
`${version}\nPublicado: ${new Date().toISOString()}\nNode de la publicación: ${process.version}\nSistema: ${process.platform}\n`,
|
`${name} ${version}\nPublicado: ${publicado}\nCommit: ${commit ?? '(sin git)'}\nNode de la publicación: ${process.version}\nSistema: ${process.platform}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`\nListo: ${destino} (${tamano(destino)})`);
|
console.log(`\nListo: ${destino} (${tamano(destino)})`);
|
||||||
|
console.log(`Versión ${version} · commit ${commit ?? '(sin git)'}`);
|
||||||
|
if (commit?.endsWith('-modificado')) console.log('Atención: había cambios sin commit; el release no corresponde exactamente a ese commit.');
|
||||||
console.log('Siguiente paso: copie esa carpeta al servidor y siga DEPLOY.md.');
|
console.log('Siguiente paso: copie esa carpeta al servidor y siga DEPLOY.md.');
|
||||||
Reference in new issue
Block a user