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:
1 parent
699dd983d0
commit
b6e34276ce
35 files changed
+914
-49
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -42,7 +42,7 @@ function crear(anterior: ReturnType<typeof udo>, 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 }]) };
|
||||
|
||||
@@ -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)),
|
||||
})),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { leerVersion } from './version.js';
|
||||
|
||||
describe('leerVersion', () => {
|
||||
let raiz: string;
|
||||
let dist: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Igual que el release: app/package.json (raíz) y la API en app/apps/api/dist, con su propio package.json.
|
||||
raiz = mkdtempSync(join(tmpdir(), 'dp-version-'));
|
||||
dist = join(raiz, 'apps', 'api', 'dist');
|
||||
mkdirSync(dist, { recursive: true });
|
||||
writeFileSync(join(raiz, 'package.json'), JSON.stringify({ name: 'devolucion-proveedores-web', version: '1.2.3' }));
|
||||
writeFileSync(join(raiz, 'apps', 'api', 'package.json'), JSON.stringify({ name: 'api', version: '0.0.1' }));
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(raiz, { recursive: true, force: true }));
|
||||
|
||||
it('en desarrollo: el número del package.json raíz, sin fecha de publicación', () => {
|
||||
expect(leerVersion(dist)).toEqual({ version: '1.2.3', publicado: null, commit: null });
|
||||
});
|
||||
|
||||
it('en un release: agrega la fecha y el commit de version.json', () => {
|
||||
writeFileSync(join(raiz, 'version.json'), JSON.stringify({ version: '1.2.3', publicado: '2026-10-15T20:00:00.000Z', commit: 'abc1234' }));
|
||||
expect(leerVersion(dist)).toEqual({ version: '1.2.3', publicado: '2026-10-15T20:00:00.000Z', commit: 'abc1234' });
|
||||
});
|
||||
|
||||
it('el repositorio real tiene un número de versión', () => {
|
||||
expect(leerVersion().version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { VersionApp } from '@dp/shared';
|
||||
|
||||
/** Nombre del `package.json` raíz del proyecto; está igual en el repositorio y en el release (`app/`). */
|
||||
const NOMBRE_RAIZ = 'devolucion-proveedores-web';
|
||||
|
||||
/** Lo que `npm run publicar` deja en `app/version.json`, junto al `package.json` raíz. */
|
||||
const ARCHIVO_PUBLICACION = 'version.json';
|
||||
|
||||
function leerJson(ruta: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(ruta, 'utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca hacia arriba desde `desde` la carpeta raíz del proyecto y arma la versión: el número sale del
|
||||
* `package.json` raíz; la fecha y el commit, del `version.json` del release (en desarrollo no existe).
|
||||
*/
|
||||
export function leerVersion(desde = dirname(fileURLToPath(import.meta.url))): VersionApp {
|
||||
for (let carpeta = desde; ; carpeta = dirname(carpeta)) {
|
||||
const paquete = leerJson(join(carpeta, 'package.json'));
|
||||
if (paquete?.name === NOMBRE_RAIZ) {
|
||||
const publicacion = existsSync(join(carpeta, ARCHIVO_PUBLICACION)) ? leerJson(join(carpeta, ARCHIVO_PUBLICACION)) : null;
|
||||
const texto = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
||||
return {
|
||||
version: texto(paquete.version) ?? '0.0.0',
|
||||
publicado: texto(publicacion?.publicado),
|
||||
commit: texto(publicacion?.commit),
|
||||
};
|
||||
}
|
||||
if (dirname(carpeta) === carpeta) return { version: '0.0.0', publicado: null, commit: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Se lee una vez al arrancar: cambiar de versión implica reiniciar el servicio. */
|
||||
export const VERSION_APP: VersionApp = leerVersion();
|
||||
@@ -30,7 +30,13 @@ describe('API (e2e)', () => {
|
||||
});
|
||||
|
||||
it('/api/auth/info devuelve la sociedad configurada', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -69,7 +69,8 @@
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "src/proxy.conf.json"
|
||||
"proxyConfig": "src/proxy.conf.json",
|
||||
"port": 4215
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { generarXlsx, letraColumna, serieFecha } from './excel';
|
||||
import { leerZip } from './leer-zip.testing';
|
||||
|
||||
describe('excel', () => {
|
||||
it('letraColumna', () => {
|
||||
expect(letraColumna(0)).toBe('A');
|
||||
expect(letraColumna(25)).toBe('Z');
|
||||
expect(letraColumna(26)).toBe('AA');
|
||||
expect(letraColumna(27)).toBe('AB');
|
||||
});
|
||||
|
||||
it('serieFecha usa el número de serie de Excel', () => {
|
||||
expect(serieFecha('1900-03-01')).toBe(61);
|
||||
expect(serieFecha('2026-01-31')).toBe(46053);
|
||||
expect(serieFecha('')).toBeNull();
|
||||
});
|
||||
|
||||
it('genera un libro con encabezado, números, fechas y texto escapado', () => {
|
||||
const archivos = leerZip(
|
||||
generarXlsx({
|
||||
nombre: 'Líneas',
|
||||
columnas: [{ titulo: 'Código' }, { titulo: 'Cantidad', tipo: 'numero' }, { titulo: 'Fecha', tipo: 'fecha' }],
|
||||
filas: [
|
||||
['A<1>&"', 5, '2026-01-31'],
|
||||
['B2', null, null],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect([...archivos.keys()]).toEqual([
|
||||
'[Content_Types].xml',
|
||||
'_rels/.rels',
|
||||
'xl/workbook.xml',
|
||||
'xl/_rels/workbook.xml.rels',
|
||||
'xl/styles.xml',
|
||||
'xl/worksheets/sheet1.xml',
|
||||
]);
|
||||
expect(archivos.get('xl/workbook.xml')).toContain('<sheet name="Líneas"');
|
||||
|
||||
const hoja = archivos.get('xl/worksheets/sheet1.xml')!;
|
||||
expect(hoja).toContain('<c r="A1" s="1" t="inlineStr"><is><t>Código</t></is></c>');
|
||||
expect(hoja).toContain('<t xml:space="preserve">A<1>&"</t>');
|
||||
expect(hoja).toContain('<c r="B2"><v>5</v></c>');
|
||||
expect(hoja).toContain('<c r="C2" s="2"><v>46053</v></c>');
|
||||
expect(hoja).toContain('<row r="3"><c r="A3" t="inlineStr"><is><t xml:space="preserve">B2</t></is></c></row>');
|
||||
expect(hoja).toContain('<autoFilter ref="A1:C3"/>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Genera un libro de Excel (.xlsx) de una hoja, sin dependencias: el XML de SpreadsheetML va
|
||||
* en un ZIP sin compresión (método "stored"), que Excel abre sin problemas.
|
||||
*/
|
||||
|
||||
export type TipoColumna = 'texto' | 'numero' | 'fecha';
|
||||
|
||||
export interface ColumnaExcel {
|
||||
titulo: string;
|
||||
tipo?: TipoColumna;
|
||||
/** Ancho en caracteres. */
|
||||
ancho?: number;
|
||||
}
|
||||
|
||||
/** Valor de una celda. Las fechas van como texto `yyyy-MM-dd`; `null` o `''` dejan la celda vacía. */
|
||||
export type ValorExcel = string | number | null | undefined;
|
||||
|
||||
export interface HojaExcel {
|
||||
nombre: string;
|
||||
columnas: ColumnaExcel[];
|
||||
filas: ValorExcel[][];
|
||||
}
|
||||
|
||||
const MIME_XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
// Estilos de celda (cellXfs en styles.xml): 0 normal, 1 encabezado en negrita, 2 fecha.
|
||||
const ESTILO_ENCABEZADO = 1;
|
||||
const ESTILO_FECHA = 2;
|
||||
|
||||
const codificador = new TextEncoder();
|
||||
|
||||
/** Escapa texto para XML y quita los caracteres de control que XML no admite. */
|
||||
function xml(texto: string): string {
|
||||
return texto
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 0 → A, 25 → Z, 26 → AA. */
|
||||
export function letraColumna(indice: number): string {
|
||||
let letra = '';
|
||||
for (let n = indice + 1; n > 0; n = Math.floor((n - 1) / 26)) {
|
||||
letra = String.fromCharCode(65 + ((n - 1) % 26)) + letra;
|
||||
}
|
||||
return letra;
|
||||
}
|
||||
|
||||
/** `yyyy-MM-dd` → número de serie de Excel (días desde 1899-12-30). `null` si no es una fecha. */
|
||||
export function serieFecha(fecha: string): number | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(fecha);
|
||||
if (!m) return null;
|
||||
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86_400_000 + 25_569;
|
||||
}
|
||||
|
||||
function celda(ref: string, valor: ValorExcel, tipo: TipoColumna): string {
|
||||
if (valor == null || valor === '') return '';
|
||||
if (tipo === 'fecha' && typeof valor === 'string') {
|
||||
const serie = serieFecha(valor);
|
||||
if (serie != null) return `<c r="${ref}" s="${ESTILO_FECHA}"><v>${serie}</v></c>`;
|
||||
}
|
||||
if (typeof valor === 'number' && Number.isFinite(valor)) return `<c r="${ref}"><v>${valor}</v></c>`;
|
||||
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(String(valor))}</t></is></c>`;
|
||||
}
|
||||
|
||||
function hojaXml(hoja: HojaExcel): string {
|
||||
const { columnas, filas } = hoja;
|
||||
const ultima = letraColumna(Math.max(columnas.length - 1, 0));
|
||||
|
||||
const encabezado = columnas
|
||||
.map((c, i) => `<c r="${letraColumna(i)}1" s="${ESTILO_ENCABEZADO}" t="inlineStr"><is><t>${xml(c.titulo)}</t></is></c>`)
|
||||
.join('');
|
||||
const cuerpo = filas
|
||||
.map((fila, f) => {
|
||||
const n = f + 2;
|
||||
const celdas = columnas.map((c, i) => celda(`${letraColumna(i)}${n}`, fila[i], c.tipo ?? 'texto')).join('');
|
||||
return `<row r="${n}">${celdas}</row>`;
|
||||
})
|
||||
.join('');
|
||||
const anchos = columnas
|
||||
.map((c, i) => `<col min="${i + 1}" max="${i + 1}" width="${c.ancho ?? Math.max(10, c.titulo.length + 2)}" customWidth="1"/>`)
|
||||
.join('');
|
||||
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||
'<sheetViews><sheetView workbookViewId="0">' +
|
||||
'<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>' +
|
||||
'</sheetView></sheetViews>' +
|
||||
`<cols>${anchos}</cols>` +
|
||||
`<sheetData><row r="1">${encabezado}</row>${cuerpo}</sheetData>` +
|
||||
`<autoFilter ref="A1:${ultima}${filas.length + 1}"/>` +
|
||||
'</worksheet>'
|
||||
);
|
||||
}
|
||||
|
||||
function archivosLibro(hoja: HojaExcel): [string, string][] {
|
||||
// Excel no admite en el nombre de la hoja : \ / ? * [ ] y lo limita a 31 caracteres.
|
||||
const nombre = xml(hoja.nombre.replace(/[:\\/?*[\]]/g, ' ').slice(0, 31) || 'Hoja1');
|
||||
return [
|
||||
[
|
||||
'[Content_Types].xml',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
||||
'</Types>',
|
||||
],
|
||||
[
|
||||
'_rels/.rels',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||
'</Relationships>',
|
||||
],
|
||||
[
|
||||
'xl/workbook.xml',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
||||
`<sheets><sheet name="${nombre}" sheetId="1" r:id="rId1"/></sheets>` +
|
||||
'<definedNames>' +
|
||||
`<definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">'${nombre.replace(/'/g, "''")}'!$A$1:$${letraColumna(Math.max(hoja.columnas.length - 1, 0))}$${hoja.filas.length + 1}</definedName>` +
|
||||
'</definedNames>' +
|
||||
'</workbook>',
|
||||
],
|
||||
[
|
||||
'xl/_rels/workbook.xml.rels',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
|
||||
'</Relationships>',
|
||||
],
|
||||
[
|
||||
'xl/styles.xml',
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
||||
'<numFmts count="1"><numFmt numFmtId="164" formatCode="dd-mm-yyyy"/></numFmts>' +
|
||||
'<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
||||
'<cellXfs count="3">' +
|
||||
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
|
||||
'<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
|
||||
'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>' +
|
||||
'</cellXfs>' +
|
||||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
|
||||
'</styleSheet>',
|
||||
],
|
||||
['xl/worksheets/sheet1.xml', hojaXml(hoja)],
|
||||
];
|
||||
}
|
||||
|
||||
// --- ZIP sin compresión ---
|
||||
|
||||
const TABLA_CRC = (() => {
|
||||
const tabla = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
tabla[n] = c >>> 0;
|
||||
}
|
||||
return tabla;
|
||||
})();
|
||||
|
||||
function crc32(datos: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const b of datos) crc = TABLA_CRC[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function zip(archivos: [string, string][]): Uint8Array<ArrayBuffer> {
|
||||
const ahora = new Date();
|
||||
const hora = (ahora.getHours() << 11) | (ahora.getMinutes() << 5) | (ahora.getSeconds() >> 1);
|
||||
const fecha = ((ahora.getFullYear() - 1980) << 9) | ((ahora.getMonth() + 1) << 5) | ahora.getDate();
|
||||
|
||||
const entradas = archivos.map(([nombre, contenido]) => {
|
||||
const datos = codificador.encode(contenido);
|
||||
return { nombre: codificador.encode(nombre), datos, crc: crc32(datos), offset: 0 };
|
||||
});
|
||||
|
||||
const tamLocales = entradas.reduce((t, e) => t + 30 + e.nombre.length + e.datos.length, 0);
|
||||
const tamCentral = entradas.reduce((t, e) => t + 46 + e.nombre.length, 0);
|
||||
const salida = new Uint8Array(tamLocales + tamCentral + 22);
|
||||
const vista = new DataView(salida.buffer);
|
||||
let p = 0;
|
||||
|
||||
const u16 = (v: number) => {
|
||||
vista.setUint16(p, v, true);
|
||||
p += 2;
|
||||
};
|
||||
const u32 = (v: number) => {
|
||||
vista.setUint32(p, v, true);
|
||||
p += 4;
|
||||
};
|
||||
const bytes = (b: Uint8Array) => {
|
||||
salida.set(b, p);
|
||||
p += b.length;
|
||||
};
|
||||
|
||||
for (const e of entradas) {
|
||||
e.offset = p;
|
||||
u32(0x04034b50);
|
||||
u16(20); // versión necesaria
|
||||
u16(0x0800); // nombres en UTF-8
|
||||
u16(0); // sin compresión
|
||||
u16(hora);
|
||||
u16(fecha);
|
||||
u32(e.crc);
|
||||
u32(e.datos.length);
|
||||
u32(e.datos.length);
|
||||
u16(e.nombre.length);
|
||||
u16(0);
|
||||
bytes(e.nombre);
|
||||
bytes(e.datos);
|
||||
}
|
||||
|
||||
const inicioCentral = p;
|
||||
for (const e of entradas) {
|
||||
u32(0x02014b50);
|
||||
u16(20); // versión que lo creó
|
||||
u16(20);
|
||||
u16(0x0800);
|
||||
u16(0);
|
||||
u16(hora);
|
||||
u16(fecha);
|
||||
u32(e.crc);
|
||||
u32(e.datos.length);
|
||||
u32(e.datos.length);
|
||||
u16(e.nombre.length);
|
||||
u16(0); // extra
|
||||
u16(0); // comentario
|
||||
u16(0); // disco
|
||||
u16(0); // atributos internos
|
||||
u32(0); // atributos externos
|
||||
u32(e.offset);
|
||||
bytes(e.nombre);
|
||||
}
|
||||
|
||||
const tamDirectorio = p - inicioCentral;
|
||||
u32(0x06054b50);
|
||||
u16(0);
|
||||
u16(0);
|
||||
u16(entradas.length);
|
||||
u16(entradas.length);
|
||||
u32(tamDirectorio);
|
||||
u32(inicioCentral);
|
||||
u16(0);
|
||||
|
||||
return salida;
|
||||
}
|
||||
|
||||
/** Bytes del archivo .xlsx con una hoja. */
|
||||
export function generarXlsx(hoja: HojaExcel): Uint8Array<ArrayBuffer> {
|
||||
return zip(archivosLibro(hoja));
|
||||
}
|
||||
|
||||
/** Genera el .xlsx y lo descarga en el navegador. */
|
||||
export function descargarXlsx(nombreArchivo: string, hoja: HojaExcel): void {
|
||||
const url = URL.createObjectURL(new Blob([generarXlsx(hoja)], { type: MIME_XLSX }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = nombreArchivo.endsWith('.xlsx') ? nombreArchivo : `${nombreArchivo}.xlsx`;
|
||||
a.click();
|
||||
// Se libera después: algunos navegadores leen la URL de forma asíncrona tras el clic.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
|
||||
export function hoy(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Hora local actual en formato HH:mm (el de `<input type="time">`). */
|
||||
export function horaActual(): string {
|
||||
const d = new Date();
|
||||
return `${dos(d.getHours())}:${dos(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Solo para tests: lee un ZIP sin compresión (como los .xlsx de `excel.ts`) y devuelve sus archivos como texto. */
|
||||
export function leerZip(bytes: Uint8Array): Map<string, string> {
|
||||
const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const decodificador = new TextDecoder();
|
||||
const archivos = new Map<string, string>();
|
||||
let p = 0;
|
||||
while (vista.getUint32(p, true) === 0x04034b50) {
|
||||
if (vista.getUint16(p + 8, true) !== 0) throw new Error('El ZIP tiene archivos comprimidos.');
|
||||
const tam = vista.getUint32(p + 18, true);
|
||||
const largoNombre = vista.getUint16(p + 26, true);
|
||||
const nombre = decodificador.decode(bytes.subarray(p + 30, p + 30 + largoNombre));
|
||||
const inicio = p + 30 + largoNombre;
|
||||
archivos.set(nombre, decodificador.decode(bytes.subarray(inicio, inicio + tam)));
|
||||
p = inicio + tam;
|
||||
}
|
||||
const fin = bytes.length - 22;
|
||||
if (vista.getUint32(fin, true) !== 0x06054b50) throw new Error('Falta el final del directorio del ZIP.');
|
||||
if (vista.getUint16(fin + 10, true) !== archivos.size) throw new Error('El directorio no coincide con los archivos.');
|
||||
// El directorio central empieza donde terminan los archivos y llega justo hasta el registro final.
|
||||
if (vista.getUint32(fin + 16, true) !== p) throw new Error('El inicio del directorio central no es correcto.');
|
||||
if (vista.getUint32(fin + 12, true) !== fin - p) throw new Error('El tamaño del directorio central no es correcto.');
|
||||
if (vista.getUint32(p, true) !== 0x02014b50) throw new Error('El directorio central no empieza donde se indica.');
|
||||
return archivos;
|
||||
}
|
||||
|
||||
/** Solo para tests: las celdas de texto y número de una hoja, fila por fila. */
|
||||
export function filasDeHoja(hojaXml: string): string[][] {
|
||||
return [...hojaXml.matchAll(/<row r="\d+">(.*?)<\/row>/g)].map(([, fila]) =>
|
||||
[...fila.matchAll(/<c r="[A-Z]+\d+"[^>]*>(?:<is><t[^>]*>(.*?)<\/t><\/is>|<v>(.*?)<\/v>)<\/c>/g)].map(([, t, v]) => t ?? v),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { detalleVersion } from './version';
|
||||
|
||||
describe('detalleVersion', () => {
|
||||
it('en desarrollo lo dice', () => {
|
||||
expect(detalleVersion({ version: '1.0.0', publicado: null, commit: null })).toBe('desarrollo');
|
||||
});
|
||||
|
||||
it('un release muestra la fecha local y el commit', () => {
|
||||
const publicado = new Date(2026, 9, 15, 16, 5).toISOString();
|
||||
expect(detalleVersion({ version: '1.0.0', publicado, commit: 'abc1234' })).toBe('publicada 15-10-2026 16:05 · commit abc1234');
|
||||
expect(detalleVersion({ version: '1.0.0', publicado, commit: null })).toBe('publicada 15-10-2026 16:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { VersionApp } from '@dp/shared';
|
||||
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** "publicada 15-10-2026 16:50 · commit abc1234", o "desarrollo" si no es un release. Hora local del navegador. */
|
||||
export function detalleVersion(v: VersionApp): string {
|
||||
if (!v.publicado) return 'desarrollo';
|
||||
const d = new Date(v.publicado);
|
||||
const fecha = Number.isNaN(d.getTime())
|
||||
? v.publicado
|
||||
: `${dos(d.getDate())}-${dos(d.getMonth() + 1)}-${d.getFullYear()} ${dos(d.getHours())}:${dos(d.getMinutes())}`;
|
||||
return `publicada ${fecha}${v.commit ? ` · commit ${v.commit}` : ''}`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { 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: `
|
||||
<header class="barra">
|
||||
<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>
|
||||
<a routerLink="/planificaciones" routerLinkActive="activo">Planificaciones</a>
|
||||
<a routerLink="/configuracion/metadatos" routerLinkActive="activo">Configuración</a>
|
||||
@@ -38,6 +42,11 @@ import { MetadataService } from '../core/metadata.service';
|
||||
.marca {
|
||||
font-size: 15px;
|
||||
}
|
||||
.version {
|
||||
margin-left: -16px;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -71,6 +80,7 @@ import { MetadataService } from '../core/metadata.service';
|
||||
})
|
||||
export class Shell {
|
||||
protected readonly auth = inject(AuthService);
|
||||
protected readonly detalleVersion = detalleVersion;
|
||||
private readonly metadata = inject(MetadataService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ header p {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.opcion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
<button class="btn primary" type="submit" [disabled]="form.invalid || enviando()">
|
||||
{{ enviando() ? 'Ingresando…' : 'Ingresar' }}
|
||||
</button>
|
||||
|
||||
@if (info()?.app; as v) {
|
||||
<p class="version muted">Versión {{ v.version }} · {{ detalleVersion(v) }}</p>
|
||||
}
|
||||
</form>
|
||||
</main>
|
||||
@@ -5,6 +5,7 @@ import type { InfoConexion } from '@dp/shared';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { mensajeDeError } from '../../core/http-error';
|
||||
import { MetadataService } from '../../core/metadata.service';
|
||||
import { detalleVersion } from '../../core/version';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -20,6 +21,7 @@ export class LoginPage implements OnInit {
|
||||
protected readonly info = signal<InfoConexion | null>(null);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly enviando = signal(false);
|
||||
protected readonly detalleVersion = detalleVersion;
|
||||
|
||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||
userName: ['', Validators.required],
|
||||
|
||||
@@ -49,9 +49,18 @@ h3 {
|
||||
|
||||
.titulo-grilla {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.exportar {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll {
|
||||
|
||||
@@ -122,7 +122,10 @@
|
||||
<section class="card">
|
||||
<div class="titulo-grilla">
|
||||
<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
|
||||
@if (conError() > 0) {
|
||||
· <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 { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { filasDeHoja, leerZip } from '../../../core/leer-zip.testing';
|
||||
import { PlanificacionDetallePage } from './detalle';
|
||||
|
||||
const linea = (extra: Record<string, unknown>) => ({
|
||||
@@ -100,6 +101,50 @@ describe('PlanificacionDetallePage', () => {
|
||||
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 () => {
|
||||
const fixture = await crear(plan('C', [linea({ lineId: 1, targetType: 21, targetEntry: 33 })]));
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
@@ -128,6 +173,38 @@ describe('PlanificacionDetallePage', () => {
|
||||
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)', () => {
|
||||
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'));
|
||||
|
||||
@@ -9,10 +9,17 @@ import type {
|
||||
ResultadoPdf,
|
||||
} 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 { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||
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 {
|
||||
nombre: string;
|
||||
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 {
|
||||
this.plan.set(plan);
|
||||
const c = plan.cabecera;
|
||||
const abierta = c.status === 'O';
|
||||
const porDefecto = (valor: string | null | undefined, defecto: () => string) => valor || (abierta ? defecto() : '');
|
||||
this.form.reset({
|
||||
docDate: c.docDate,
|
||||
docDate: porDefecto(c.docDate, hoy),
|
||||
series: c.series,
|
||||
tipoDespacho: c.tipoDespacho,
|
||||
indicadorTraslado: c.indicadorTraslado,
|
||||
tipoDespacho: porDefecto(c.tipoDespacho, () => this.opcionPorDefecto('tiposDespacho', TIPO_DESPACHO_POR_DEFECTO)),
|
||||
indicadorTraslado: porDefecto(c.indicadorTraslado, () =>
|
||||
this.opcionPorDefecto('indicadoresTraslado', INDICADOR_TRASLADO_POR_DEFECTO),
|
||||
),
|
||||
printingLines: c.printingLines || 11,
|
||||
rutTransporte: c.rutTransporte ?? '',
|
||||
patente: c.patente ?? '',
|
||||
rutChofer: c.rutChofer ?? '',
|
||||
nombreChofer: c.nombreChofer ?? '',
|
||||
fechaSalida: c.fechaSalida ?? '',
|
||||
horaSalida: c.horaSalida ?? '',
|
||||
fechaLlegada: c.fechaLlegada ?? '',
|
||||
fechaSalida: porDefecto(c.fechaSalida, hoy),
|
||||
horaSalida: porDefecto(c.horaSalida, horaActual),
|
||||
fechaLlegada: porDefecto(c.fechaLlegada, hoy),
|
||||
});
|
||||
if (plan.cabecera.status === 'C') this.form.disable();
|
||||
else this.form.enable();
|
||||
if (abierta) 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 {
|
||||
@@ -202,6 +222,60 @@ export class PlanificacionDetallePage implements OnInit, OnDestroy {
|
||||
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> {
|
||||
const pendientes = this.aplicables().length;
|
||||
const aviso = pendientes > 0 ? ` Quedan ${pendientes} línea(s) marcada(s) sin Devolución.` : '';
|
||||
|
||||
@@ -59,6 +59,14 @@ textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.titulo-grilla {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ayuda {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
|
||||
@if (r.lineas.length > 0) {
|
||||
<section class="card">
|
||||
<div class="titulo-grilla">
|
||||
<h2>Artículos validados</h2>
|
||||
<button class="btn" type="button" (click)="exportarExcel()">Exportar a Excel</button>
|
||||
</div>
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { filasDeHoja, leerZip } from '../../../core/leer-zip.testing';
|
||||
import { NuevaPlanificacionPage } from './nueva';
|
||||
|
||||
const CATALOGOS = {
|
||||
@@ -74,6 +75,32 @@ describe('NuevaPlanificacionPage', () => {
|
||||
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 () => {
|
||||
const leer = vi.fn().mockResolvedValue('A1\t5');
|
||||
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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
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 { PlanificacionesService } from '../../../core/planificaciones.service';
|
||||
import { Stepper } from '../../../layout/stepper';
|
||||
|
||||
/** Fecha de hoy en formato yyyy-MM-dd según la hora local (no UTC). */
|
||||
function hoy(): string {
|
||||
const d = new Date();
|
||||
const dos = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-nueva-planificacion',
|
||||
imports: [FormsModule, RouterLink, Stepper],
|
||||
@@ -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> {
|
||||
if (!this.puedeProcesar()) return;
|
||||
this.procesando.set(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"target": "http://localhost:3015",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,11 @@ ejecutar en Windows.
|
||||
|
||||
## 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\
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
|
||||
@@ -3,14 +3,25 @@ export interface LoginRequest {
|
||||
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 {
|
||||
userName: string;
|
||||
companyDb: string;
|
||||
/** Versión del Service Layer devuelta en el Login. */
|
||||
version: string;
|
||||
app: VersionApp;
|
||||
}
|
||||
|
||||
export interface InfoConexion {
|
||||
companyDb: string;
|
||||
dbType: 'HANA' | 'MSSQL';
|
||||
app: VersionApp;
|
||||
}
|
||||
Generated
+2
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "devolucion-proveedores-web",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "devolucion-proveedores-web",
|
||||
"version": "1.1.0",
|
||||
"workspaces": [
|
||||
"libs/*",
|
||||
"apps/*"
|
||||
|
||||
+6
-2
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "devolucion-proveedores-web",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"libs/*",
|
||||
@@ -9,8 +10,11 @@
|
||||
"build:shared": "npm run build -w @dp/shared",
|
||||
"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: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",
|
||||
"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
@@ -36,6 +36,15 @@ function copiar(origen, destinoRelativo) {
|
||||
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) {
|
||||
let total = 0;
|
||||
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);
|
||||
|
||||
// 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/
|
||||
for (const archivo of readdirSync(join(raiz, 'deploy', 'windows'))) {
|
||||
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 });
|
||||
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(
|
||||
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(`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.');
|
||||
Reference in new issue
Block a user