Compare commits

...
10 Commits
Author SHA1 Message Date
dot.gcalleandClaude Opus 5.5 e343a18da1 CLAUDE.md: nuevo remoto en Gitea y tags SemVer por release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:30:49 -03:00
dot.gcalleandClaude Opus 5.5 29dcc8ccef README: cómo generar un release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:29:40 -03:00
dot.gcalleandClaude Opus 5.5 7efdcce113 Agregar README.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:29:12 -03:00
georgeandClaude Sonnet 5.5 b6c553fed3 CLAUDE.md: validación contra Consignación verificada en SAP
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:12:17 -05:00
georgeandClaude Sonnet 5.5 691aa454ca Versión 2.0.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:56:36 -05:00
georgeandClaude Sonnet 5.5 67a80e18db DP_Stock v3: sin COALESCE ni subconsultas (el analizador del Service Layer no los admite)
Suma OpenInvQty con JOIN + GROUP BY. Sin consignación no hay fila, y la nueva consulta
DP_Articulo distingue artículo existente (0) de inexistente.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:50:58 -05:00
georgeandClaude Sonnet 5.5 cd748bb4ef Documentar la validación contra Consignación en CLAUDE.md
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:41:46 -05:00
georgeandClaude Sonnet 5.5 2182147adb Versión 2.0.0: validar contra Consignación (suma de OpenInvQty) en lugar del stock
DP_Stock (v2) suma PDN1.OpenInvQty de las Entradas de Consignación abiertas del almacén,
lo mismo que usa el FIFO. La columna se muestra como Consignación.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:40:19 -05:00
george 3f6395bfb0 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	CLAUDE.md
2026-10-15 16:03:06 -05:00
georgeandClaude Sonnet 5.5 b6e34276ce 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>
2026-10-15 16:03:00 -05:00
38 changed files with 1057 additions and 69 deletions

No files matched your search

+54 -14
View File
@@ -14,7 +14,7 @@ cantidades pegado desde Excel. Cada ejecución se guarda en el UDO `LIB_ODEV`
Pasos de la UI (decididos):
1. **Pegar**: ItemCode + cantidad desde Excel (con el botón *Pegar desde el portapapeles* o
`Ctrl/Cmd+V` en el cuadro). Se valida el stock (`OITW.OnHand`) del almacén elegido. No guarda.
`Ctrl/Cmd+V` en el cuadro). Se valida contra la **Consignación** del almacén elegido (ver Decisiones). No guarda.
2. **Procesar**: reemplaza a "Selección" del addon. Asignación FIFO contra líneas abiertas
de Entradas de mercancías **del almacén elegido**, ordenadas por `DocDate`. Genera filas
Padre (`F`, artículo) y Hijo (`S`, una por Entrada consumida). El usuario marca o desmarca
@@ -48,6 +48,13 @@ Pasos de la UI (decididos):
para cerrarla a mano).
- **Crear devoluciones**: resultado **parcial por documento**. Cada Devolución se crea aparte; las
buenas graban `U_TargetType`/`U_TargetEntry`, las fallidas `U_ErrCode`/`U_ErrMsg` y se reintentan.
- **El "stock" que se muestra y valida es la Consignación**, no `OITW.OnHand`: `DP_Stock` (v3) suma
`PDN1.OpenInvQty` de las líneas abiertas de Entradas con `U_DOT_TipoRecepcion = 'C'` del almacén, o sea lo
mismo que consume el FIFO y lo único que se puede devolver. Sin consignación no hay fila: entonces `DP_Articulo`
distingue artículo existente (0, queda "Sin stock") de inexistente (error). **El analizador SQL del Service Layer
no admite `COALESCE` ni subconsultas** (error `Cannot support this function or expression`): solo JOIN + GROUP BY. La columna se sigue llamando `OnHand` en la consulta, el modelo y
el UDO (`U_OnHand`); en pantalla y en Excel el encabezado es **Consignación**. Las planificaciones guardadas
antes de la 2.0.0 conservan el valor viejo hasta rehacer el FIFO.
- **Consultas SQL** por `SQLQueries` del Service Layer (no hay conexión directa a la BD). La API
crea/actualiza `DP_Stock` y `DP_EntradasAbiertas` sola (variante según `DB_TYPE`). El analizador
SQL del Service Layer **no admite `FORMAT`** y SAP reescribe el `SqlText`: al cambiar el SQL hay
@@ -63,6 +70,13 @@ Pasos de la UI (decididos):
`FolioNumber` de la Devolución: **si todavía no tiene folio no se llama a BES** y esa Devolución informa
"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`.
@@ -73,6 +87,16 @@ Pasos de la UI (decididos):
nginx no es necesario. NSSM 2.24 estable puede no arrancar en Windows recientes: usar la 2.24-101. `npm run publicar` arma `release/devolucion-proveedores/` (API + web + solo dependencias de
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").
- **Repositorio**: Gitea, remoto `origin` = `https://unasgeorge.vaquita-hadar.ts.net/george/devolucion_proveedores.git`
(rama `main`; reemplaza al anterior `http://100.68.242.27:30000/...`). Cada release lleva un **tag SemVer
anotado `vX.Y.Z`** igual al `version` del `package.json` (`git tag -a vX.Y.Z` + `git push origin main --tags`),
hecho **antes** de `npm run publicar` para que `version.json` lleve el commit correcto. El primero es
`v2.0.1`. El procedimiento para el usuario está en `README.md` ("Generar un release").
- **Logs**: la aplicación **no escribe logs a archivo**; usa el `Logger` de Nest por consola. En
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
@@ -113,7 +137,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,
@@ -192,9 +216,17 @@ pierde al reiniciar la API** (hay que volver a iniciar sesión).
`HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`, `HTTP_REDIRECT_PORT` y `WEB_DIST_DIR` (opcional).
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
@@ -216,9 +248,11 @@ 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):**
- **Validación contra Consignación** (2026-10-19, 2.0.1): `DP_Stock` v3 (`SUM` + `GROUP BY`) y `DP_Articulo` funcionan
contra el Service Layer real (confirmado por el usuario; la variante probada fue HANA, la de la v2 rechazada).
- Login, Metadatos y **Reconstruir** (creación de tablas, campos y UDO desde cero).
- `libs/shared`: modelos y dominio portado del .NET. 14 tests.
- `apps/api`: Service Layer, autenticación, metadatos, catálogos, planificaciones y PDF. 81 tests
@@ -230,7 +264,8 @@ Reglas:
paginado y filtrado** (total, saltos, estado/almacén/fechas) OK. **PDF** contra BES (certificación)
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.
@@ -243,6 +278,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}`).
@@ -262,11 +300,12 @@ 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`.
- **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.
@@ -274,9 +313,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
+100
View File
@@ -0,0 +1,100 @@
# Devolución a Proveedores
Aplicación web (Angular + NestJS) para SAP Business One que genera **Devoluciones de mercancías**
(ORPD) enlazadas a sus **Entradas de mercancías por compra** (OPDN), a partir de un listado de
artículos y cantidades pegado desde Excel. Reemplaza al addon .NET anterior.
## Cómo funciona
1. **Pegar**: ItemCode + cantidad desde Excel. Se valida contra la **Consignación** del almacén elegido.
2. **Procesar**: asignación FIFO contra las líneas abiertas de Entradas de mercancías del almacén,
ordenadas por fecha. El usuario marca qué líneas devolver.
3. **Crear devoluciones**: agrupa las líneas marcadas por proveedor y crea una Devolución por lote
(11 líneas por defecto).
4. **Descargar PDF**: pide al web service de BES el PDF de la Guía de despacho (DTE 52) de cada
Devolución, con opción de unirlos en uno.
Cada ejecución (planificación) se guarda en el UDO `LIB_ODEV`. Solo puede haber una planificación
abierta a la vez; se cierra a mano.
## Requisitos
- Node.js 22 LTS o superior.
- SAP Business One con **Service Layer** (`/b1s/v1`), base **HANA** (producción) o **MSSQL** (pruebas).
- El usuario de SAP inicia sesión en la web; sus credenciales no se guardan en la configuración.
## Estructura
```
libs/shared/ @dp/shared tipos y lógica de dominio (parseo de Excel, FIFO)
apps/api/ NestJS API REST (/api) sobre el Service Layer; en producción sirve también la web
apps/web/ Angular interfaz
scripts/ publicar.mjs arma la carpeta de despliegue en release/
deploy/windows/ instalación como servicio de Windows (NSSM)
```
## Puesta en marcha (desarrollo)
```sh
npm install
cp apps/api/.env.example apps/api/.env # completar SL_URL, SL_COMPANY_DB y DB_TYPE
npm run dev:api # API NestJS (puerto PORT del .env)
npm run dev:web # Angular en http://localhost:4215 (proxy /api → API)
```
Variables principales del `.env`: `PORT`, `SL_URL`, `SL_COMPANY_DB`, `SL_REJECT_UNAUTHORIZED`,
`DB_TYPE` (`HANA`/`MSSQL`) y `COOKIE_SECURE`. Para producción con HTTPS: `HTTPS_PFX_PATH` y
`HTTPS_PFX_PASSPHRASE` (o `HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`) y `HTTP_REDIRECT_PORT`.
Ver `apps/api/.env.example`.
En el primer ingreso, **Configuración → Metadatos → Reconstruir** crea las tablas, los campos y
el UDO necesarios en SAP.
## Pruebas y compilación
```sh
npm test # shared + api + web
npm run test:e2e -w api
npm run lint -w api
npm run build
```
## Generar un release
La versión es el `version` del `package.json` raíz y sigue SemVer (`MAYOR.MENOR.PARCHE`). Cada
release sube la versión y lleva un tag `vX.Y.Z` en git.
1. **Subir la versión** (elegir uno; no crea commit ni tag):
```sh
npm run version:parche # 2.0.1 → 2.0.2 correcciones
npm run version:menor # 2.0.1 → 2.1.0 funciones nuevas compatibles
npm run version:mayor # 2.0.1 → 3.0.0 cambios incompatibles
```
2. **Commit y tag**:
```sh
git add -A
git commit -m "Versión X.Y.Z"
git tag -a vX.Y.Z -m "Versión X.Y.Z"
git push origin main --tags
```
3. **Armar la carpeta de despliegue** (después del commit, para que el release lleve el commit
correcto):
```sh
npm run publicar # compila shared + api + web y arma la carpeta
npm run publicar -- --sin-build # reutiliza los builds ya hechos (más rápido)
```
El resultado queda en `release/devolucion-proveedores/` (ignorada por git): la API compilada,
la web compilada, solo las dependencias de producción y los scripts de instalación. Incluye
`app/version.json` con el número, la fecha y el commit (si hay cambios sin commit, el commit
aparece con `-modificado`). La versión se muestra en el login y en la barra superior.
4. **Instalar o actualizar en el servidor**: copiar esa carpeta al servidor Windows. Se instala
como un único servicio (NSSM) con HTTPS directo en Node. Instalación nueva: secciones 1 a 8 de
[`deploy/windows/DEPLOY.md`](deploy/windows/DEPLOY.md); actualizar una instalación existente
sin tocar el `.env`: sección 11.
+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) {
@@ -22,7 +22,7 @@ const filaHijo = (extra: Record<string, unknown> = {}) => ({ LineId: 2, U_LineTy
const dto = (texto = 'A1\t5'): ProcesarDto => ({ whsCode: '01', docDate: '2026-10-13', texto }) as ProcesarDto;
function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[]; fallaCierre?: boolean } = {}) {
function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[]; articulo?: unknown[]; fallaCierre?: boolean } = {}) {
const llamadas: string[] = [];
const sl = {
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
@@ -42,6 +42,7 @@ function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[];
const sql = {
consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => {
if (consulta.codigo === 'DP_Stock') return opciones.stock ?? [{ ItemCode: 'A1', ItemName: 'Libro', OnHand: 100 }];
if (consulta.codigo === 'DP_Articulo') return opciones.articulo ?? [];
return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenInvQty: 10 }];
}),
};
@@ -166,3 +167,18 @@ describe('PlanificacionesService.listar', () => {
expect((sl.request.mock.calls[0] as unknown as [unknown, string, string])[2]).toContain('$top=100');
});
});
describe('PlanificacionesService.validarPegado', () => {
it('un artículo que existe pero no tiene consignación abierta queda con 0 (Sin stock)', async () => {
const { servicio } = crear(udo(3, 'O'), { stock: [], articulo: [{ ItemCode: 'A1', ItemName: 'Libro' }] });
const r = await servicio.validarPegado(sesion, 'B1', 'A1\t5');
expect(r.errores).toEqual([]);
expect(r.lineas[0]).toMatchObject({ itemCode: 'A1', onHand: 0, status: 'SIN_STOCK' });
});
it('un artículo inexistente es un error', async () => {
const { servicio } = crear(udo(3, 'O'), { stock: [], articulo: [] });
const r = await servicio.validarPegado(sesion, 'B1', 'X9\t5');
expect(r.errores[0]).toMatch(/no existe/);
});
});
@@ -29,7 +29,7 @@ import {
import { CatalogosService } from '../catalogos/catalogos.service.js';
import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js';
import { CONSULTA_ARTICULO, CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js';
import { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js';
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
@@ -38,7 +38,8 @@ import type { UdoSl } from './udo.mapper.js';
interface FilaStock {
ItemCode: string;
ItemName: string | null;
OnHand: number | string;
/** Ausente cuando la fila viene de DP_Articulo (sin consignación). */
OnHand?: number | string;
}
interface FilaEntrada {
@@ -100,21 +101,24 @@ export class PlanificacionesService {
const { filas, errores } = parsearPortapapeles(texto);
const lineas = [];
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => ({
fila,
stock: (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, { whs: whsCode, item: fila.itemCode }))[0],
}));
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => {
const params = { whs: whsCode, item: fila.itemCode };
const stock = (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, params))[0];
// Sin consignación abierta no hay fila: el artículo puede existir con 0 o no existir.
const articulo = stock ?? (await this.sql.consultar<FilaStock>(sesion, CONSULTA_ARTICULO, { item: fila.itemCode }))[0];
return { fila, articulo };
});
for (const { fila, stock } of consultas) {
if (!stock) {
errores.push(`${fila.itemCode}: el artículo no existe o no tiene stock registrado en el almacén ${whsCode}.`);
for (const { fila, articulo: datos } of consultas) {
if (!datos) {
errores.push(`${fila.itemCode}: el artículo no existe.`);
continue;
}
const articulo: ArticuloADevolver = {
itemCode: stock.ItemCode,
itemName: stock.ItemName ?? '',
itemCode: datos.ItemCode,
itemName: datos.ItemName ?? '',
quantity: fila.quantity,
onHand: Math.floor(Number(stock.OnHand)),
onHand: Math.floor(Number(datos.OnHand ?? 0)),
};
lineas.push(crearLineaPadre(articulo));
}
@@ -15,19 +15,45 @@ export interface ConsultaGuardada {
hana: string;
}
/**
* "Stock" = lo que realmente se puede devolver: la suma de PDN1.OpenInvQty de las líneas abiertas de
* Entradas de mercancías de Consignación (U_DOT_TipoRecepcion = 'C') del almacén, las mismas que usa el
* FIFO. No es OITW.OnHand: el stock físico incluye mercancía que no es devolvible por esta vía.
* Sin filas si el artículo no tiene consignación abierta (el servicio lo distingue con DP_Articulo).
* El analizador del Service Layer no admite COALESCE ni subconsultas: por eso solo JOIN + GROUP BY.
* La columna se llama OnHand porque así se guarda en el UDO (U_OnHand).
*/
export const CONSULTA_STOCK: ConsultaGuardada = {
codigo: 'DP_Stock',
version: 1,
version: 3,
mssql:
'SELECT T0.[ItemCode], T0.[ItemName], T1.[OnHand] ' +
'FROM OITM T0 INNER JOIN OITW T1 ON T1.[ItemCode] = T0.[ItemCode] ' +
'WHERE T1.[WhsCode] = :whs AND T0.[ItemCode] = :item',
'SELECT T2.[ItemCode], T0.[ItemName], SUM(T2.[OpenInvQty]) AS [OnHand] ' +
'FROM OPDN T1 INNER JOIN PDN1 T2 ON T2.[DocEntry] = T1.[DocEntry] ' +
'INNER JOIN OITM T0 ON T0.[ItemCode] = T2.[ItemCode] ' +
"WHERE T1.[U_DOT_TipoRecepcion] = 'C' AND T2.[LineStatus] = 'O' " +
'AND T2.[ItemCode] = :item AND T2.[WhsCode] = :whs ' +
'GROUP BY T2.[ItemCode], T0.[ItemName]',
hana:
'SELECT T0."ItemCode", T0."ItemName", T1."OnHand" ' +
'FROM OITM T0 INNER JOIN OITW T1 ON T1."ItemCode" = T0."ItemCode" ' +
'WHERE T1."WhsCode" = :whs AND T0."ItemCode" = :item',
'SELECT T2."ItemCode", T0."ItemName", SUM(T2."OpenInvQty") AS "OnHand" ' +
'FROM OPDN T1 INNER JOIN PDN1 T2 ON T2."DocEntry" = T1."DocEntry" ' +
'INNER JOIN OITM T0 ON T0."ItemCode" = T2."ItemCode" ' +
"WHERE T1.\"U_DOT_TipoRecepcion\" = 'C' AND T2.\"LineStatus\" = 'O' " +
'AND T2."ItemCode" = :item AND T2."WhsCode" = :whs ' +
'GROUP BY T2."ItemCode", T0."ItemName"',
};
/** Solo para saber si el artículo existe (y su nombre) cuando DP_Stock no devuelve filas. */
export const CONSULTA_ARTICULO: ConsultaGuardada = {
codigo: 'DP_Articulo',
version: 1,
mssql: 'SELECT T0.[ItemCode], T0.[ItemName] FROM OITM T0 WHERE T0.[ItemCode] = :item',
hana: 'SELECT T0."ItemCode", T0."ItemName" FROM OITM T0 WHERE T0."ItemCode" = :item',
};
/**
* OpenInvQty, no OpenQty: OpenQty solo baja con las Devoluciones, mientras que OpenInvQty también
* descuenta lo facturado, y SAP rechaza (-10) devolver contra la Entrada más que eso.
*/
export const CONSULTA_ENTRADAS_ABIERTAS: ConsultaGuardada = {
codigo: 'DP_EntradasAbiertas',
version: 3,
+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>
@@ -133,7 +136,7 @@
<table class="grid">
<thead>
<tr>
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Stock</th>
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Consignación</th>
<th>Entrada</th><th>Fecha entrada</th><th class="num">Abierta</th><th class="num">Asignada</th>
<th>Proveedor</th><th>Devolución</th>
</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, 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: 'Consignación', tipo: 'numero' },
{ titulo: 'Estado', ancho: 11 },
{ titulo: 'Devolver', ancho: 10 },
{ titulo: 'Entrada', tipo: 'numero' },
{ titulo: 'Fecha entrada', tipo: 'fecha', ancho: 14 },
{ titulo: 'Abierta', tipo: 'numero' },
{ titulo: 'Asignada', tipo: 'numero' },
{ titulo: 'Proveedor', ancho: 14 },
{ titulo: 'Nombre proveedor', ancho: 40 },
{ titulo: 'Devolución', tipo: 'numero', ancho: 12 },
{ titulo: 'Error', ancho: 50 },
],
filas,
});
}
protected async cerrar(): Promise<void> {
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;
}
@@ -1,7 +1,7 @@
<section class="encabezado">
<div>
<h1>{{ reemplaza() ? 'Rehacer planificación Nº ' + reemplazaNumero() : 'Nueva planificación' }}</h1>
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida el stock del almacén antes de procesar.</p>
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida contra lo disponible en Consignación del almacén (cantidad abierta de las Entradas de mercancías) antes de procesar.</p>
</div>
<a class="btn" routerLink="/planificaciones">Volver</a>
</section>
@@ -84,11 +84,14 @@
@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>
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Stock</th><th>Estado</th>
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Consignación</th><th>Estado</th>
</tr>
</thead>
<tbody>
@@ -109,7 +112,7 @@
</table>
@if (sinStock() > 0) {
<p class="muted nota">
{{ sinStock() }} artículo(s) sin stock suficiente quedarán fuera al procesar.
{{ sinStock() }} artículo(s) sin consignación suficiente quedarán fuera al procesar.
</p>
}
</section>
@@ -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', 'Consignación', 'Estado'],
['A1', 'Libro', '5', '10', 'OK'],
['A2', 'Libro', '5', '10', 'Sin stock'],
]);
});
it('el botón Pegar lee el portapapeles y valida', async () => {
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: 'Consignación', tipo: 'numero', ancho: 12 },
{ titulo: 'Estado', ancho: 12 },
],
filas: lineas.map((l) => [l.itemCode, l.itemDescription, l.quantity, l.onHand, l.status === 'OK' ? 'OK' : 'Sin stock']),
});
}
async procesar(): Promise<void> {
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
}
}
+39 -1
View File
@@ -133,6 +133,33 @@ New-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTPS' -Direction Inbou
New-NetFirewallRule -DisplayName 'Devolucion Proveedores HTTP (redireccion)' -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
```
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
@@ -198,7 +225,18 @@ cd C:\apps\devolucion-proveedores\servicio
Para cambiar una variable de entorno del servicio con `nssm set ... AppEnvironmentExtra`, indique **todas** las
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
+2 -2
View File
@@ -20,10 +20,10 @@ describe('asignarFifo', () => {
expect(lineas[1]).toMatchObject({ itemCode: 'a001', applied: true, baseType: 20, baseRef: 1001 });
});
it('omite artículos sin stock suficiente', () => {
it('omite artículos sin consignación suficiente', () => {
const { lineas, advertencias } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 6, onHand: 5 }], new Map());
expect(lineas).toEqual([]);
expect(advertencias[0]).toContain('sin stock');
expect(advertencias[0]).toContain('sin consignación');
});
it('advierte cuando las Entradas no alcanzan', () => {
+1 -1
View File
@@ -76,7 +76,7 @@ export function asignarFifo(articulos: ArticuloADevolver[], entradasPorItem: Map
const padre = crearLineaPadre(articulo);
if (padre.status === 'SIN_STOCK') {
advertencias.push(`${articulo.itemCode}: sin stock suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
advertencias.push(`${articulo.itemCode}: sin consignación suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
continue;
}
+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": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devolucion-proveedores-web",
"version": "2.0.1",
"workspaces": [
"libs/*",
"apps/*"
+6 -2
View File
@@ -1,5 +1,6 @@
{
"name": "devolucion-proveedores-web",
"version": "2.0.1",
"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"
}
}
+18 -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 })) {
@@ -94,11 +103,18 @@ for (const archivo of readdirSync(join(raiz, 'deploy', 'windows'))) {
for (const carpeta of ['logs', 'certificados']) mkdirSync(join(destino, carpeta), { recursive: true });
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.');