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
+914 -49

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
"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
+3 -1
View File
@@ -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,
};
}
+3
View File
@@ -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 ' +
+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', () => {
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', () => {
+2 -1
View File
@@ -69,7 +69,8 @@
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "src/proxy.conf.json"
"proxyConfig": "src/proxy.conf.json",
"port": 4215
}
},
"test": {
+18
View File
@@ -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);
+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 { 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);
+6
View File
@@ -32,6 +32,12 @@ header p {
height: 40px;
}
.version {
margin: 0;
font-size: 12px;
text-align: center;
}
.opcion {
display: flex;
align-items: flex-start;
+4
View File
@@ -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>
+2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"/api": {
"target": "http://localhost:3000",
"target": "http://localhost:3015",
"secure": false
}
}
+44 -2
View File
@@ -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
+11
View File
@@ -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;
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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.');