From b6e34276ceba2945af601b8c540fda9a53bd7f70 Mon Sep 17 00:00:00 2001 From: george Date: Thu, 15 Oct 2026 16:03:00 -0500 Subject: [PATCH] =?UTF-8?q?Versi=C3=B3n=201.1.0:=20exportar=20a=20Excel,?= =?UTF-8?q?=20valores=20por=20defecto=20de=20la=20cabecera=20y=20versi?= =?UTF-8?q?=C3=B3n=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 60 +++- apps/api/src/auth/auth.controller.ts | 4 +- apps/api/src/main.ts | 3 + .../planificaciones.service.spec.ts | 2 +- .../planificaciones.service.ts | 4 +- .../sql-queries/sql-queries.definitions.ts | 10 +- apps/api/src/version.spec.ts | 33 +++ apps/api/src/version.ts | 41 +++ apps/api/test/app.e2e-spec.ts | 8 +- apps/web/angular.json | 3 +- apps/web/src/app/app.spec.ts | 18 ++ apps/web/src/app/core/excel.spec.ts | 48 +++ apps/web/src/app/core/excel.ts | 274 ++++++++++++++++++ apps/web/src/app/core/fechas.ts | 13 + apps/web/src/app/core/leer-zip.testing.ts | 31 ++ apps/web/src/app/core/version.spec.ts | 13 + apps/web/src/app/core/version.ts | 13 + apps/web/src/app/layout/shell.ts | 10 + apps/web/src/app/pages/login/login.css | 6 + apps/web/src/app/pages/login/login.html | 4 + apps/web/src/app/pages/login/login.ts | 2 + .../pages/planificaciones/detalle/detalle.css | 13 +- .../planificaciones/detalle/detalle.html | 5 +- .../planificaciones/detalle/detalle.spec.ts | 77 +++++ .../pages/planificaciones/detalle/detalle.ts | 92 +++++- .../app/pages/planificaciones/nueva/nueva.css | 8 + .../pages/planificaciones/nueva/nueva.html | 5 +- .../pages/planificaciones/nueva/nueva.spec.ts | 27 ++ .../app/pages/planificaciones/nueva/nueva.ts | 26 +- apps/web/src/proxy.conf.json | 2 +- deploy/windows/DEPLOY.md | 46 ++- libs/shared/src/models/auth.ts | 11 + package-lock.json | 2 + package.json | 8 +- scripts/publicar.mjs | 43 ++- 35 files changed, 915 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/version.spec.ts create mode 100644 apps/api/src/version.ts create mode 100644 apps/web/src/app/core/excel.spec.ts create mode 100644 apps/web/src/app/core/excel.ts create mode 100644 apps/web/src/app/core/fechas.ts create mode 100644 apps/web/src/app/core/leer-zip.testing.ts create mode 100644 apps/web/src/app/core/version.spec.ts create mode 100644 apps/web/src/app/core/version.ts diff --git a/CLAUDE.md b/CLAUDE.md index 717e260..135167a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,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 "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. +- **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`, `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`. @@ -69,7 +76,13 @@ Pasos de la UI (decididos): `HTTPS_KEY_PATH`+`HTTPS_CERT_PATH`) y redirección opcional de HTTP (`HTTP_REDIRECT_PORT`). El servicio lo administra **NSSM** (decisión del usuario; se evaluó WinSW y no se lo consideró lo bastante estable); 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). Guía completa: `deploy/windows/DEPLOY.md`. + 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`. +- **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"). - **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 limpiar los viejos). Con `NO_COLOR=1` el log sale sin códigos de @@ -110,7 +123,7 @@ devolucion-proveedores-web/ npm workspaces │ ├── web-estatico.ts sirve el build de Angular, fallback a index.html, cabeceras de seguridad │ └── https.ts lectura del certificado y redirección HTTP → HTTPS ├── scripts/publicar.mjs arma la carpeta de despliegue (release/, ignorada por git) -├── deploy/windows/ 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) └── src/app/ ├── core/ auth.service, metadata.service, planificaciones.service, @@ -189,9 +202,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). 4. Desarrollo, en dos terminales: ```sh - npm run dev:api # compila @dp/shared y levanta NestJS en :3000 - npm run dev:web # compila @dp/shared y levanta Angular en :4200 (proxy /api → :3000) + 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 :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: ```sh npm test # shared + api + web @@ -213,7 +234,7 @@ Reglas: - Para ver la UI sin la extensión de Chrome se puede abrir Chrome sin cabeza con `--remote-debugging-port` y manejarlo por DevTools Protocol (`WebSocket` de Node 22). -## Estado al 2026-10-12 +## Estado al 2026-10-13 **Hecho y verificado contra el Service Layer real (SBOLibros, MSSQL):** - Login, Metadatos y **Reconstruir** (creación de tablas, campos y UDO desde cero). @@ -227,7 +248,8 @@ Reglas: paginado y filtrado** (total, saltos, estado/almacén/fechas) OK. **PDF** contra BES (certificación) con 4 Devoluciones reales: individuales y unificado (3 páginas) OK. - `apps/web`: login, guards, Metadatos y Planificaciones completas (listado con filtros y paginación, - Nueva, Detalle con crear devoluciones, Rehacer FIFO, PDF y cerrar). 38 tests. Verificado en Chrome + 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 descarga de PDF del Detalle. @@ -240,6 +262,9 @@ Reglas: - `PATCH` de `LIB_DEV1Collection` actualiza las filas por `LineId` sin tocar las demás; **no se pueden quitar filas** de un UDO. - Los campos numéricos sin llenar del UDO vuelven como `0`: el mapeo los trata como `null`. +- **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`). - Si no hay resultado, el Service Layer responde cuerpo vacío: por eso `/planificaciones/abierta` devuelve siempre un JSON (`{abierta: null}`). @@ -261,11 +286,17 @@ Reglas: - **PDF en la base de pruebas**: las Devoluciones de `SBOLibros` no tienen `FolioNum`, así que ahora su descarga informa el error de folio. El PDF contra BES se probó antes del cambio, usando el `DocNum`. Para repetirlo hace falta una Devolución con folio (la que emite el addon de facturación electrónica). -- **Despliegue en Windows**: la carpeta publicada se probó en un Mac (API con HTTPS autofirmado, web - servida por la API, rutas de Angular, 404, cabeceras, redirección 301, log sin colores). **No se ha - ejecutado en Windows**: los `.ps1` solo pasaron la comprobación de sintaxis y la lista de comandos de - NSSM que generan (`-SoloMostrar`); falta instalar el servicio de verdad. Las banderas de la cookie de sesión están comprobadas en un - test e2e; solo falta verlo en un navegador real sobre HTTPS (iniciar sesión en el sitio desplegado). +- **Despliegue en Windows**: el usuario instaló el servicio `DevolucionProveedores` (NSSM) en el servidor + de pruebas, en `C:\tmp\devolucion-proveedores` (`PORT=3015`, `SBOTSTLIBROS`, HANA), con un release + publicado desde el Mac. `npm run publicar` funciona también en Windows (2026-10-13). Falta verificar + la cookie de sesión en un navegador real sobre HTTPS. +- **`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`. +- **Versión del release** (2026-10-15): release **1.1.0** generado en el servidor Windows de pruebas (Excel, + valores por defecto de la cabecera, versión visible). Lleva "(sin git)": **git no está instalado en ese + servidor**, aunque la carpeta es un repositorio con commits y remoto `origin` + (`http://100.68.242.27:30000/george/devolucion_proveedores.git`). Los cambios del 2026-10-15 **no están en + ningún commit**: hacerlo (y `push`) cuando haya git, antes de seguir trabajando desde el Mac. - **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. @@ -273,9 +304,10 @@ Reglas: 1. **Validar en HANA** (ver "Sin verificar"): `DB_TYPE=HANA`, Reconstruir, repetir la prueba de extremo a extremo. 2. Probar los casos sin verificar (fallo parcial y reprocesar contra SAP). -3. **Probar el despliegue en un servidor Windows** (de pruebas) siguiendo `deploy/windows/DEPLOY.md`. El - botón de pegar desde el portapapeles solo funciona en `localhost` o HTTPS. -4. Archivar `../SolucionLibros/` cuando HANA esté validado (ninguna carpeta está bajo `git`). +3. **Actualizar el servicio de pruebas** con el release que trae `OpenInvQty` (DEPLOY.md, sección 11) y + rehacer el FIFO de la planificación 7. El botón de pegar desde el portapapeles solo funciona en + `localhost` o HTTPS. +4. Archivar `../SolucionLibros/` cuando HANA esté validado (`../SolucionLibros/` no está bajo `git`). ## Preguntas abiertas para el usuario diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index bbc8553..125b40c 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -5,12 +5,13 @@ import type { Request, Response } from 'express'; import { AppConfig } from '../config/configuration.js'; import { ServiceLayerClient } from '../service-layer/service-layer.client.js'; import { SessionStore } from '../service-layer/session.store.js'; +import { VERSION_APP } from '../version.js'; import type { SapSession } from '../service-layer/session.store.js'; import { LoginDto } from './dto/login.dto.js'; import { CurrentSession, Public, SESSION_COOKIE } from './session.guard.js'; function aUsuario(sesion: SapSession): UsuarioSesion { - return { userName: sesion.userName, companyDb: sesion.companyDb, version: sesion.version }; + return { userName: sesion.userName, companyDb: sesion.companyDb, version: sesion.version, app: VERSION_APP }; } @Controller('auth') @@ -27,6 +28,7 @@ export class AuthController { return { companyDb: this.config.get('serviceLayer', { infer: true }).companyDb, dbType: this.config.get('dbType', { infer: true }), + app: VERSION_APP, }; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 2fc2cab..c567ffc 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser'; import { AppModule } from './app.module.js'; import configuration from './config/configuration.js'; import { HttpExceptionLogger } from './http-exception.logger.js'; +import { VERSION_APP } from './version.js'; import { cargarHttps, crearRedireccionHttp } from './https.js'; import { cabecerasSeguridad, servirWeb } from './web-estatico.js'; @@ -28,6 +29,8 @@ async function bootstrap() { await app.listen(cfg.port); 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(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) { diff --git a/apps/api/src/planificaciones/planificaciones.service.spec.ts b/apps/api/src/planificaciones/planificaciones.service.spec.ts index fbf4e5e..76d0246 100644 --- a/apps/api/src/planificaciones/planificaciones.service.spec.ts +++ b/apps/api/src/planificaciones/planificaciones.service.spec.ts @@ -42,7 +42,7 @@ function crear(anterior: ReturnType, opciones: { stock?: unknown[]; const sql = { consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => { if (consulta.codigo === 'DP_Stock') return opciones.stock ?? [{ ItemCode: 'A1', ItemName: 'Libro', OnHand: 100 }]; - return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenQty: 10 }]; + return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenInvQty: 10 }]; }), }; const catalogos = { series: vi.fn(async () => [{ series: 15, name: 'Primario', nextNumber: 1, esPorDefecto: true }]) }; diff --git a/apps/api/src/planificaciones/planificaciones.service.ts b/apps/api/src/planificaciones/planificaciones.service.ts index b30fec6..bc0eec1 100644 --- a/apps/api/src/planificaciones/planificaciones.service.ts +++ b/apps/api/src/planificaciones/planificaciones.service.ts @@ -48,7 +48,7 @@ interface FilaEntrada { DocDate: string; DocEntry: number; LineNum: number; - OpenQty: number | string; + OpenInvQty: number | string; } interface DocumentoCreado { @@ -242,7 +242,7 @@ export class PlanificacionesService { docDate: fechaIso(String(f.DocDate)), cardCode: f.CardCode, cardName: f.CardName, - openQty: Math.floor(Number(f.OpenQty)), + openQty: Math.floor(Number(f.OpenInvQty)), })), ); }, diff --git a/apps/api/src/sql-queries/sql-queries.definitions.ts b/apps/api/src/sql-queries/sql-queries.definitions.ts index 8d84679..8d65d44 100644 --- a/apps/api/src/sql-queries/sql-queries.definitions.ts +++ b/apps/api/src/sql-queries/sql-queries.definitions.ts @@ -28,19 +28,23 @@ export const CONSULTA_STOCK: ConsultaGuardada = { 'WHERE T1."WhsCode" = :whs AND 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 = { codigo: 'DP_EntradasAbiertas', - version: 2, + version: 3, mssql: 'SELECT T0.[DocNum], T0.[CardCode], T0.[CardName], T0.[DocDate], ' + - 'T1.[DocEntry], T1.[LineNum], T1.[OpenQty] ' + + 'T1.[DocEntry], T1.[LineNum], T1.[OpenInvQty] ' + 'FROM OPDN T0 INNER JOIN PDN1 T1 ON T1.[DocEntry] = T0.[DocEntry] ' + "WHERE T0.[U_DOT_TipoRecepcion] = 'C' AND T1.[LineStatus] = 'O' " + 'AND T1.[ItemCode] = :item AND T1.[WhsCode] = :whs ' + 'ORDER BY T0.[DocDate], T1.[DocEntry], T1.[LineNum]', hana: 'SELECT T0."DocNum", T0."CardCode", T0."CardName", T0."DocDate", ' + - 'T1."DocEntry", T1."LineNum", T1."OpenQty" ' + + 'T1."DocEntry", T1."LineNum", T1."OpenInvQty" ' + 'FROM OPDN T0 INNER JOIN PDN1 T1 ON T1."DocEntry" = T0."DocEntry" ' + "WHERE T0.\"U_DOT_TipoRecepcion\" = 'C' AND T1.\"LineStatus\" = 'O' " + 'AND T1."ItemCode" = :item AND T1."WhsCode" = :whs ' + diff --git a/apps/api/src/version.spec.ts b/apps/api/src/version.spec.ts new file mode 100644 index 0000000..f395192 --- /dev/null +++ b/apps/api/src/version.spec.ts @@ -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+/); + }); +}); diff --git a/apps/api/src/version.ts b/apps/api/src/version.ts new file mode 100644 index 0000000..2f8b1b6 --- /dev/null +++ b/apps/api/src/version.ts @@ -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 | null { + try { + return JSON.parse(readFileSync(ruta, 'utf8')) as Record; + } 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(); diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index 4bdda0d..7ec8a3b 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -30,7 +30,13 @@ describe('API (e2e)', () => { }); 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', () => { diff --git a/apps/web/angular.json b/apps/web/angular.json index 2d4fafa..a37f149 100644 --- a/apps/web/angular.json +++ b/apps/web/angular.json @@ -69,7 +69,8 @@ }, "defaultConfiguration": "development", "options": { - "proxyConfig": "src/proxy.conf.json" + "proxyConfig": "src/proxy.conf.json", + "port": 4215 } }, "test": { diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 000342b..675f8da 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -54,6 +54,24 @@ describe('App', () => { 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 () => { const metadata = TestBed.inject(MetadataService); metadata.fijarVerificacionAlIngresar(false); diff --git a/apps/web/src/app/core/excel.spec.ts b/apps/web/src/app/core/excel.spec.ts new file mode 100644 index 0000000..e0c59b8 --- /dev/null +++ b/apps/web/src/app/core/excel.spec.ts @@ -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('Código'); + expect(hoja).toContain('A<1>&"'); + expect(hoja).toContain('5'); + expect(hoja).toContain('46053'); + expect(hoja).toContain('B2'); + expect(hoja).toContain(''); + }); +}); diff --git a/apps/web/src/app/core/excel.ts b/apps/web/src/app/core/excel.ts new file mode 100644 index 0000000..622523e --- /dev/null +++ b/apps/web/src/app/core/excel.ts @@ -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, '"'); +} + +/** 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 `${serie}`; + } + if (typeof valor === 'number' && Number.isFinite(valor)) return `${valor}`; + return `${xml(String(valor))}`; +} + +function hojaXml(hoja: HojaExcel): string { + const { columnas, filas } = hoja; + const ultima = letraColumna(Math.max(columnas.length - 1, 0)); + + const encabezado = columnas + .map((c, i) => `${xml(c.titulo)}`) + .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 `${celdas}`; + }) + .join(''); + const anchos = columnas + .map((c, i) => ``) + .join(''); + + return ( + '' + + '' + + '' + + '' + + '' + + `${anchos}` + + `${encabezado}${cuerpo}` + + `` + + '' + ); +} + +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', + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + ], + [ + '_rels/.rels', + '' + + '' + + '' + + '', + ], + [ + 'xl/workbook.xml', + '' + + '' + + `` + + '' + + `` + + '' + + '', + ], + [ + 'xl/_rels/workbook.xml.rels', + '' + + '' + + '' + + '' + + '', + ], + [ + 'xl/styles.xml', + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + ], + ['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 { + 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 { + 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); +} diff --git a/apps/web/src/app/core/fechas.ts b/apps/web/src/app/core/fechas.ts new file mode 100644 index 0000000..e567ff6 --- /dev/null +++ b/apps/web/src/app/core/fechas.ts @@ -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 ``). */ +export function horaActual(): string { + const d = new Date(); + return `${dos(d.getHours())}:${dos(d.getMinutes())}`; +} diff --git a/apps/web/src/app/core/leer-zip.testing.ts b/apps/web/src/app/core/leer-zip.testing.ts new file mode 100644 index 0000000..0015404 --- /dev/null +++ b/apps/web/src/app/core/leer-zip.testing.ts @@ -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 { + const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decodificador = new TextDecoder(); + const archivos = new Map(); + 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>/g)].map(([, fila]) => + [...fila.matchAll(/]*>(?:]*>(.*?)<\/t><\/is>|(.*?)<\/v>)<\/c>/g)].map(([, t, v]) => t ?? v), + ); +} diff --git a/apps/web/src/app/core/version.spec.ts b/apps/web/src/app/core/version.spec.ts new file mode 100644 index 0000000..5f803d2 --- /dev/null +++ b/apps/web/src/app/core/version.spec.ts @@ -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'); + }); +}); diff --git a/apps/web/src/app/core/version.ts b/apps/web/src/app/core/version.ts new file mode 100644 index 0000000..717d683 --- /dev/null +++ b/apps/web/src/app/core/version.ts @@ -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}` : ''}`; +} diff --git a/apps/web/src/app/layout/shell.ts b/apps/web/src/app/layout/shell.ts index 8eff8cf..92d515f 100644 --- a/apps/web/src/app/layout/shell.ts +++ b/apps/web/src/app/layout/shell.ts @@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core'; import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { AuthService } from '../core/auth.service'; import { MetadataService } from '../core/metadata.service'; +import { detalleVersion } from '../core/version'; @Component({ selector: 'app-shell', @@ -9,6 +10,9 @@ import { MetadataService } from '../core/metadata.service'; template: `
Devolución a Proveedores + @if (auth.usuario()?.app; as v) { + v{{ v.version }} + }