Versión 1.1.0: exportar a Excel, valores por defecto de la cabecera y versión visible

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
georgeandClaude Sonnet 5.5 committed 2026-10-15 16:03:00 -05:00
1 parent 699dd983d0
commit b6e34276ce
35 files changed
+915 -50

No files matched your search

+46 -14
View File
@@ -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 `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`.
@@ -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 `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); 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 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 - **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
@@ -110,7 +123,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,
@@ -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). `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
@@ -213,7 +234,7 @@ 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):**
- 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).
@@ -227,7 +248,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.
@@ -240,6 +262,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}`).
@@ -261,11 +286,17 @@ 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`.
- **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 - **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.
@@ -273,9 +304,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
+3 -1
View File
@@ -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,
}; };
} }
+3
View File
@@ -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) {
@@ -42,7 +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 }];
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 }]) }; const catalogos = { series: vi.fn(async () => [{ series: 15, name: 'Primario', nextNumber: 1, esPorDefecto: true }]) };
@@ -48,7 +48,7 @@ interface FilaEntrada {
DocDate: string; DocDate: string;
DocEntry: number; DocEntry: number;
LineNum: number; LineNum: number;
OpenQty: number | string; OpenInvQty: number | string;
} }
interface DocumentoCreado { interface DocumentoCreado {
@@ -242,7 +242,7 @@ export class PlanificacionesService {
docDate: fechaIso(String(f.DocDate)), docDate: fechaIso(String(f.DocDate)),
cardCode: f.CardCode, cardCode: f.CardCode,
cardName: f.CardName, cardName: f.CardName,
openQty: Math.floor(Number(f.OpenQty)), openQty: Math.floor(Number(f.OpenInvQty)),
})), })),
); );
}, },
@@ -28,19 +28,23 @@ export const CONSULTA_STOCK: ConsultaGuardada = {
'WHERE T1."WhsCode" = :whs AND T0."ItemCode" = :item', '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 = { export const CONSULTA_ENTRADAS_ABIERTAS: ConsultaGuardada = {
codigo: 'DP_EntradasAbiertas', codigo: 'DP_EntradasAbiertas',
version: 2, version: 3,
mssql: mssql:
'SELECT T0.[DocNum], T0.[CardCode], T0.[CardName], T0.[DocDate], ' + '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] ' + 'FROM OPDN T0 INNER JOIN PDN1 T1 ON T1.[DocEntry] = T0.[DocEntry] ' +
"WHERE T0.[U_DOT_TipoRecepcion] = 'C' AND T1.[LineStatus] = 'O' " + "WHERE T0.[U_DOT_TipoRecepcion] = 'C' AND T1.[LineStatus] = 'O' " +
'AND T1.[ItemCode] = :item AND T1.[WhsCode] = :whs ' + 'AND T1.[ItemCode] = :item AND T1.[WhsCode] = :whs ' +
'ORDER BY T0.[DocDate], T1.[DocEntry], T1.[LineNum]', 'ORDER BY T0.[DocDate], T1.[DocEntry], T1.[LineNum]',
hana: hana:
'SELECT T0."DocNum", T0."CardCode", T0."CardName", T0."DocDate", ' + '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" ' + 'FROM OPDN T0 INNER JOIN PDN1 T1 ON T1."DocEntry" = T0."DocEntry" ' +
"WHERE T0.\"U_DOT_TipoRecepcion\" = 'C' AND T1.\"LineStatus\" = 'O' " + "WHERE T0.\"U_DOT_TipoRecepcion\" = 'C' AND T1.\"LineStatus\" = 'O' " +
'AND T1."ItemCode" = :item AND T1."WhsCode" = :whs ' + 'AND T1."ItemCode" = :item AND T1."WhsCode" = :whs ' +
+33
View File
@@ -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+/);
});
});
+41
View File
@@ -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();
+7 -1
View File
@@ -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', () => {
+2 -1
View File
@@ -69,7 +69,8 @@
}, },
"defaultConfiguration": "development", "defaultConfiguration": "development",
"options": { "options": {
"proxyConfig": "src/proxy.conf.json" "proxyConfig": "src/proxy.conf.json",
"port": 4215
} }
}, },
"test": { "test": {
+18
View File
@@ -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);
+48
View File
@@ -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&lt;1&gt;&amp;&quot;</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"/>');
});
});
+274
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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);
}
+13
View File
@@ -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())}`;
}
+31
View File
@@ -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),
);
}
+13
View File
@@ -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');
});
});
+13
View File
@@ -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}` : ''}`;
}
+10
View File
@@ -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);
+6
View File
@@ -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;
+4
View File
@@ -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>
+2
View File
@@ -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>
@@ -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: 'Stock', 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;
} }
@@ -84,7 +84,10 @@
@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>
@@ -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', 'Stock', '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: 'Stock', 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 -1
View File
@@ -1,6 +1,6 @@
{ {
"/api": { "/api": {
"target": "http://localhost:3000", "target": "http://localhost:3015",
"secure": false "secure": false
} }
} }
+44 -2
View File
@@ -52,7 +52,11 @@ ejecutar en Windows.
## 3. Copiar al servidor ## 3. Copiar al servidor
Copie toda la carpeta `devolucion-proveedores` a, por ejemplo, `C:\apps\devolucion-proveedores`: Copie **solo** la carpeta `release\devolucion-proveedores` (no el resto del proyecto) a, por ejemplo,
`C:\apps\devolucion-proveedores`. No contiene enlaces simbólicos, así que se puede copiar por red, USB o
comprimida en un `.zip` (más rápido: son miles de archivos pequeños; en el servidor, `Expand-Archive`).
`nssm.exe` **no** viene en la carpeta: se descarga aparte y se indica con `-Nssm`.
``` ```
C:\apps\devolucion-proveedores\ C:\apps\devolucion-proveedores\
@@ -129,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
@@ -194,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
+11
View File
@@ -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;
} }
+2
View File
@@ -1,10 +1,12 @@
{ {
"name": "devolucion-proveedores-web", "name": "devolucion-proveedores-web",
"version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "devolucion-proveedores-web", "name": "devolucion-proveedores-web",
"version": "1.1.0",
"workspaces": [ "workspaces": [
"libs/*", "libs/*",
"apps/*" "apps/*"
+6 -2
View File
@@ -1,5 +1,6 @@
{ {
"name": "devolucion-proveedores-web", "name": "devolucion-proveedores-web",
"version": "1.1.0",
"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"
} }
} }
+41 -2
View File
@@ -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 })) {
@@ -64,6 +73,29 @@ copiar('apps/web/dist/web/browser');
ejecutar(npm, ['ci', '--omit=dev', '--workspace=@dp/shared', '--workspace=api', '--no-audit', '--no-fund'], app); ejecutar(npm, ['ci', '--omit=dev', '--workspace=@dp/shared', '--workspace=api', '--no-audit', '--no-fund'], app);
// npm deja enlaces simbólicos (el de cada workspace y los atajos de .bin) que no sobreviven al copiar la
// carpeta de un Mac/Linux a Windows. La API importa @dp/shared al ejecutarse, así que ese enlace se
// reemplaza por una copia real; los demás no se usan y se quitan.
const nodeModules = join(app, 'node_modules');
rmSync(join(nodeModules, 'api'), { recursive: true, force: true });
const compartido = join(nodeModules, '@dp', 'shared');
rmSync(compartido, { recursive: true, force: true }); // quita solo el enlace, no su destino
mkdirSync(compartido, { recursive: true });
cpSync(join(app, 'libs', 'shared', 'package.json'), join(compartido, 'package.json'));
cpSync(join(app, 'libs', 'shared', 'dist'), join(compartido, 'dist'), { recursive: true });
rmSync(join(app, 'libs'), { recursive: true, force: true });
for (const e of readdirSync(nodeModules, { withFileTypes: true, recursive: true })) {
if (e.name === '.bin') rmSync(join(e.parentPath, e.name), { recursive: true, force: true });
}
const enlaces = readdirSync(app, { withFileTypes: true, recursive: true })
.filter((e) => e.isSymbolicLink())
.map((e) => join(e.parentPath, e.name));
if (enlaces.length > 0) {
console.error(`\nQuedaron enlaces simbólicos en el paquete (no sobreviven en Windows):\n ${enlaces.join('\n ')}`);
process.exit(1);
}
// Archivos de despliegue para Windows junto a la carpeta app/ // Archivos de despliegue para Windows junto a la carpeta app/
for (const archivo of readdirSync(join(raiz, 'deploy', 'windows'))) { for (const archivo of readdirSync(join(raiz, 'deploy', 'windows'))) {
cpSync(join(raiz, 'deploy', 'windows', archivo), join(destino, archivo)); cpSync(join(raiz, 'deploy', 'windows', archivo), join(destino, archivo));
@@ -71,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.');