Aplicación web de Devolución a Proveedores para SAP Business One

Reemplaza al addon .NET Framework (SolucionLibros). Genera Devoluciones de mercancías
enlazadas a sus Entradas por compra a partir de un listado pegado desde Excel.

- libs/shared (@dp/shared): modelos y lógica de dominio pura (FIFO, consolidación por
  proveedor, parseo del portapapeles), portada del addon.
- apps/api (NestJS): cliente del Service Layer con cola por sesión, login con la sesión de
  SAP, metadatos (UDT/UDF/UDO) y botón Reconstruir, catálogos, planificaciones (pegar,
  procesar, reprocesar, marcar líneas, crear Devoluciones, cerrar), listado paginado con
  filtros y descarga de PDF de la Guía de despacho desde BES. Sirve también la web
  compilada, con HTTPS opcional.
- apps/web (Angular): login, Configuración → Metadatos y Planificaciones (listado,
  nueva, detalle).
- deploy/windows y scripts/publicar.mjs: despliegue como servicio de Windows con WinSW.

Verificado contra el Service Layer de SQL Server. Sin probar aún: SAP HANA y la
instalación del servicio en Windows (ver CLAUDE.md, sección "Sin verificar").

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
georgeandClaude Sonnet 5.5 committed 2026-10-13 08:09:07 -05:00
commit 287b187c14
126 files changed
+20033

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# Puerto de la API NestJS
PORT=3000
# Origen del frontend Angular (CORS). En desarrollo ng serve usa proxy, no hace falta.
WEB_ORIGIN=http://localhost:4200
# Service Layer de SAP Business One (con /b1s/v1 al final)
SL_URL=https://servidor-sap:50000/b1s/v1
# Base de datos de la sociedad (CompanyDB del Login)
SL_COMPANY_DB=SBODEMOCL
# false si el Service Layer usa un certificado autofirmado
SL_REJECT_UNAUTHORIZED=false
# Motor de la base de datos: HANA (producción) o MSSQL (pruebas)
DB_TYPE=HANA
# true cuando la API se publica por HTTPS (cookie de sesión con Secure)
COOKIE_SECURE=false
# ---------------------------------------------------------------------------------------------
# Despliegue (producción en Windows). En desarrollo se dejan vacías.
# ---------------------------------------------------------------------------------------------
# La API sirve también la web compilada. Si se omite, usa apps/web/dist/web/browser cuando existe.
# En la carpeta publicada (release) ya queda en su sitio y no hace falta configurarla.
# WEB_DIST_DIR=
# HTTPS directo en la API. Use UNA de estas dos formas (las rutas son absolutas).
# Con un .pfx:
# HTTPS_PFX_PATH=C:\apps\devolucion-proveedores\certificados\sitio.pfx
# HTTPS_PFX_PASSPHRASE=
# O con clave privada y certificado en PEM:
# HTTPS_KEY_PATH=C:\apps\devolucion-proveedores\certificados\sitio.key
# HTTPS_CERT_PATH=C:\apps\devolucion-proveedores\certificados\sitio.crt
# Con HTTPS: puerto HTTP que redirige al HTTPS (normalmente 80). Vacío = no se abre.
# HTTP_REDIRECT_PORT=80
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "error"
},
"env": {
"node": true
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+138
View File
@@ -0,0 +1,138 @@
# API — Devolución a Proveedores
Backend en **NestJS 12** (ESM) de la aplicación Devolución a Proveedores para SAP Business One.
No tiene base de datos propia: habla con el **Service Layer** de SAP B1 en nombre del usuario que
inició sesión, y guarda cada ejecución en el UDO `LIB_ODEV`.
La documentación general del proyecto (decisiones, flujo, estado) está en
[`../../CLAUDE.md`](../../CLAUDE.md). Este archivo cubre solo cómo ejecutar y mantener la API.
## Requisitos
- Node 22 LTS o superior.
- Acceso de red al Service Layer de SAP B1 (por defecto `https://servidor:50000/b1s/v1`).
- Red con Fortinet (inspección SSL): exportar `NODE_OPTIONS=--use-system-ca` (o `NODE_EXTRA_CA_CERTS`)
antes de usar `npm`.
## Puesta en marcha
Desde la raíz del monorepo (`devolucion-proveedores-web/`):
```bash
npm install
cp apps/api/.env.example apps/api/.env # y completar los valores
npm run dev:api # compila @dp/shared y levanta la API en :3000
```
### Variables de `.env`
| Variable | Descripción |
|---|---|
| `PORT` | Puerto de la API (3000). |
| `WEB_ORIGIN` | Origen del frontend para CORS. En desarrollo `ng serve` usa proxy y no hace falta. |
| `SL_URL` | URL del Service Layer, **con `/b1s/v1` al final**. |
| `SL_COMPANY_DB` | Base de la sociedad (CompanyDB del Login). |
| `SL_REJECT_UNAUTHORIZED` | `false` si el Service Layer usa un certificado autofirmado. |
| `DB_TYPE` | `HANA` (producción) o `MSSQL` (pruebas). Elige la variante de cada consulta SQL. |
| `COOKIE_SECURE` | `true` solo cuando la API se publica por HTTPS. |
| `HTTPS_PFX_PATH` / `HTTPS_PFX_PASSPHRASE` | Solo producción: certificado `.pfx` y su clave. La API escucha por HTTPS. |
| `HTTPS_KEY_PATH` / `HTTPS_CERT_PATH` | Alternativa al `.pfx`: clave privada y certificado en PEM (no mezclar con el `.pfx`). |
| `HTTP_REDIRECT_PORT` | Con HTTPS: puerto HTTP que redirige (301) al HTTPS, normalmente 80. |
| `WEB_DIST_DIR` | Carpeta con la web compilada. Por defecto `apps/web/dist/web/browser` si existe. |
El **usuario y la contraseña de SAP no se configuran aquí**: se escriben en la pantalla de login y se
usan para el `Login` del Service Layer. La API reinicia sin conservar sesiones.
## Scripts
Se ejecutan con `npm run <script> -w api` desde la raíz, o directamente desde `apps/api`.
| Script | Qué hace |
|---|---|
| `start:dev` | Modo watch (recompila y reinicia; **se pierden las sesiones** en cada reinicio). |
| `build` / `start:prod` | Compila a `dist/` y ejecuta `node dist/main`. |
| `test` | Tests unitarios (Vitest). |
| `test:e2e` | Tests e2e (`test/`). |
| `test:cov` | Cobertura. |
| `lint` | `oxlint --type-aware src/ test/`. |
## Endpoints (prefijo `/api`)
Todos exigen sesión (cookie `dp_sid`) salvo `/health`, `/auth/info`, `/auth/login` y `/auth/logout`.
| Área | Rutas |
|---|---|
| Salud y sesión | `GET /health` · `GET /auth/info` · `POST /auth/login` · `POST /auth/logout` · `GET /auth/me` |
| Metadatos | `GET /metadata/status` · `POST /metadata/rebuild` |
| Catálogos | `GET /catalogos` · `GET /catalogos/almacenes` |
| Planificaciones | `GET /planificaciones` (paginado, con filtros) · `GET /planificaciones/abierta` · `GET /planificaciones/:id` |
| | `POST /planificaciones/pegar` · `POST /planificaciones` · `PATCH /planificaciones/:id/cabecera` · `PATCH /planificaciones/:id/lineas/:lineId` |
| | `POST /planificaciones/:id/devoluciones` · `POST /planificaciones/:id/reprocesar` · `POST /planificaciones/:id/cerrar` |
| PDF | `POST /planificaciones/:id/pdf` |
El detalle de cada ruta (parámetros y reglas) está en la tabla de [`CLAUDE.md`](../../CLAUDE.md#api-prefijo-api).
Ejemplo del listado: `GET /api/planificaciones?status=C&whsCode=01&desde=2026-10-01&pagina=2&tamano=20`.
## Producción
En producción la API **sirve también la web compilada** (si existe `apps/web/dist/web/browser`): todo queda
en el mismo origen y en un solo puerto. Las rutas de Angular (sin extensión y fuera de `/api`) devuelven
`index.html`; los archivos con hash se cachean un año; se agregan cabeceras de seguridad (y HSTS con HTTPS).
```bash
npm run publicar # desde la raíz: arma release/devolucion-proveedores/
```
Instalación en Windows (servicio con WinSW, HTTPS, firewall, actualizaciones): [`../../deploy/windows/DEPLOY.md`](../../deploy/windows/DEPLOY.md).
**Logs:** la API no escribe archivos; usa el `Logger` de Nest por consola. Como servicio, WinSW captura
esa salida y la rota. Con `NO_COLOR=1` el log sale sin códigos de color.
## Estructura
```
src/
├── config/ lectura y validación del .env
├── service-layer/ ServiceLayerClient (axios) y SessionStore (en memoria)
├── auth/ login/logout/me, SessionGuard global y @Public()
├── sql-queries/ consultas guardadas (SQLQueries) con variantes HANA y MSSQL
├── catalogos/ almacenes, series y valores válidos de los UDF
├── planificaciones/ FIFO, UDO LIB_ODEV, Devoluciones, reprocesar y listado
├── pdf/ SOAP de BES (Consulta_TXTDTE) y unión de PDF
├── metadata/ definiciones JSON de tablas/campos/UDO y su reconstrucción
├── health/
├── http-exception.logger.ts registra en el log todo error 4xx/5xx
├── web-estatico.ts sirve la web compilada, fallback a index.html y cabeceras de seguridad
└── https.ts lee el certificado y redirige HTTP → HTTPS
```
La lógica de dominio pura (FIFO, consolidación por proveedor, parseo del texto de Excel) vive en
`libs/shared` (`@dp/shared`), que se consume **compilada** (`dist/`): tras cambiarla,
`npm run build:shared`.
## Cosas que conviene saber
- **Una sesión del Service Layer no admite peticiones simultáneas**: las procesa en serie y cada una
espera unos 7 s. `ServiceLayerClient.request` las encola por sesión; no uses otro cliente HTTP para
hablar con SAP. Una petición que tarde más de 2 s queda avisada en el log.
- **Consultas SQL**: se guardan en SAP como `SQLQueries` (`DP_Stock`, `DP_EntradasAbiertas`) y la API las
crea o actualiza sola. SAP reescribe el SQL al guardarlo, por lo que **al modificar una consulta hay
que subir su `version`** en `sql-queries/sql-queries.definitions.ts`. El analizador SQL no admite
funciones como `FORMAT`; los parámetros van entre comillas simples.
- **Credenciales del web service DTE** (`@DTE_CONFC`): se leen en cada descarga de PDF, no se registran
en el log y no se devuelven al navegador.
- **Metadatos**: la fuente de verdad son los JSON de `src/metadata/definitions/`. *Reconstruir* crea lo
que falta; borrar y recrear un campo solo ocurre con autorización explícita.
- Los tipos que aparecen en firmas con decoradores se importan con `import type` (por
`isolatedModules` + `emitDecoratorMetadata`); los DTO, que son clases, se importan como valor.
- **HANA**: las variantes HANA de las consultas están escritas pero no se han ejecutado todavía.
## Diagnóstico rápido
| Síntoma | Causa probable |
|---|---|
| `No se pudo conectar con el Service Layer (EHOSTUNREACH)` | Proceso de la API con un estado de red viejo (cambio de red o VPN). Reiniciarla. |
| 401 `Debe iniciar sesión` tras tocar el código | La API se reinició y perdió las sesiones. Volver a iniciar sesión. |
| Respuestas de 20-40 s | Peticiones en paralelo sobre una misma sesión (ver arriba). Mirar los avisos `Petición lenta` del log. |
| `Invalid SQL syntax ... Cannot support this function` | El analizador SQL del Service Layer rechazó una función en una consulta guardada. |
| `SELF_SIGNED_CERT_IN_CHAIN` al instalar | Falta `NODE_OPTIONS=--use-system-ca` o `NODE_EXTRA_CA_CERTS`. |
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"type": "module",
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "oxlint --type-aware src/ test/",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@dp/shared": "*",
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.0.1",
"@nestjs/platform-express": "^12.0.1",
"axios": "^1.20.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"pdf-lib": "^1.17.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"@vitest/coverage-v8": "^5.0.3",
"oxlint": "^1.58.0",
"oxlint-tsgolint": "^7.0.2001",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"typescript": "^6.0.2",
"vitest": "^5.0.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CatalogosModule } from './catalogos/catalogos.module.js';
import { AuthModule } from './auth/auth.module.js';
import configuration from './config/configuration.js';
import { HealthController } from './health/health.controller.js';
import { MetadataModule } from './metadata/metadata.module.js';
import { PdfModule } from './pdf/pdf.module.js';
import { PlanificacionesModule } from './planificaciones/planificaciones.module.js';
import { ServiceLayerModule } from './service-layer/service-layer.module.js';
import { SqlQueriesModule } from './sql-queries/sql-queries.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
ServiceLayerModule,
SqlQueriesModule,
AuthModule,
MetadataModule,
CatalogosModule,
PlanificacionesModule,
PdfModule,
],
controllers: [HealthController],
})
export class AppModule {}
+64
View File
@@ -0,0 +1,64 @@
import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { InfoConexion, UsuarioSesion } from '@dp/shared';
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 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 };
}
@Controller('auth')
export class AuthController {
constructor(
private readonly serviceLayer: ServiceLayerClient,
private readonly sessions: SessionStore,
private readonly config: ConfigService<AppConfig, true>,
) {}
@Public()
@Get('info')
info(): InfoConexion {
return {
companyDb: this.config.get('serviceLayer', { infer: true }).companyDb,
dbType: this.config.get('dbType', { infer: true }),
};
}
@Public()
@Post('login')
@HttpCode(200)
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response): Promise<UsuarioSesion> {
const sesion = await this.serviceLayer.login(dto.userName, dto.password);
res.cookie(SESSION_COOKIE, sesion.id, {
httpOnly: true,
sameSite: 'strict',
secure: this.config.get('cookieSecure', { infer: true }),
path: '/',
});
return aUsuario(sesion);
}
@Public()
@Post('logout')
@HttpCode(204)
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
const sesion = this.sessions.obtener(req.cookies?.[SESSION_COOKIE] as string | undefined);
if (sesion) {
await this.serviceLayer.logout(sesion);
}
res.clearCookie(SESSION_COOKIE, { path: '/' });
}
@Get('me')
me(@CurrentSession() sesion: SapSession): UsuarioSesion {
return aUsuario(sesion);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller.js';
import { SessionGuard } from './session.guard.js';
@Module({
controllers: [AuthController],
providers: [{ provide: APP_GUARD, useClass: SessionGuard }],
})
export class AuthModule {}
+12
View File
@@ -0,0 +1,12 @@
import { LoginRequest } from '@dp/shared';
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto implements LoginRequest {
@IsString()
@IsNotEmpty()
userName: string;
@IsString()
@IsNotEmpty()
password: string;
}
+42
View File
@@ -0,0 +1,42 @@
import { CanActivate, ExecutionContext, Injectable, SetMetadata, UnauthorizedException, createParamDecorator } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { SapSession, SessionStore } from '../service-layer/session.store.js';
export const SESSION_COOKIE = 'dp_sid';
const IS_PUBLIC = 'isPublic';
/** Marca un endpoint como accesible sin sesión de SAP. */
export const Public = () => SetMetadata(IS_PUBLIC, true);
type RequestConSesion = Request & { sapSession?: SapSession };
/** Inyecta la sesión del Service Layer del usuario que hace la petición. */
export const CurrentSession = createParamDecorator((_: unknown, ctx: ExecutionContext): SapSession => {
return ctx.switchToHttp().getRequest<RequestConSesion>().sapSession!;
});
/** Guard global: exige la cookie de sesión salvo en endpoints marcados con @Public(). */
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionStore,
) {}
canActivate(context: ExecutionContext): boolean {
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [context.getHandler(), context.getClass()])) {
return true;
}
const request = context.switchToHttp().getRequest<RequestConSesion>();
const sesion = this.sessions.obtener(request.cookies?.[SESSION_COOKIE] as string | undefined);
if (!sesion) {
throw new UnauthorizedException('Debe iniciar sesión.');
}
request.sapSession = sesion;
return true;
}
}
@@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import type { Almacen, CatalogosDevolucion } from '@dp/shared';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CatalogosService } from './catalogos.service.js';
@Controller('catalogos')
export class CatalogosController {
constructor(private readonly catalogos: CatalogosService) {}
/** Solo los almacenes: más liviano que el catálogo completo (para filtros). */
@Get('almacenes')
almacenes(@CurrentSession() sesion: SapSession): Promise<Almacen[]> {
return this.catalogos.almacenes(sesion);
}
/** Almacenes, series de Devoluciones y valores válidos de despacho/traslado. */
@Get()
todos(@CurrentSession() sesion: SapSession): Promise<CatalogosDevolucion> {
return this.catalogos.todos(sesion);
}
}
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { CatalogosController } from './catalogos.controller.js';
import { CatalogosService } from './catalogos.service.js';
@Module({ controllers: [CatalogosController], providers: [CatalogosService], exports: [CatalogosService] })
export class CatalogosModule {}
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import type { Almacen, CatalogosDevolucion, OpcionUdf, SerieDocumento } from '@dp/shared';
import { OBJ_TYPE_DEVOLUCION } from '@dp/shared';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
interface SlAlmacen {
WarehouseCode: string;
WarehouseName: string;
}
interface SlSerie {
Series: number;
Name: string;
NextNumber: number | null;
Locked: string;
}
interface SlUdf {
ValidValuesMD?: { Value: string; Description: string }[];
}
const DOCUMENTO = { DocumentTypeParams: { Document: String(OBJ_TYPE_DEVOLUCION) } };
@Injectable()
export class CatalogosService {
constructor(private readonly sl: ServiceLayerClient) {}
async todos(sesion: SapSession): Promise<CatalogosDevolucion> {
const [almacenes, series, tiposDespacho, indicadoresTraslado] = await Promise.all([
this.almacenes(sesion),
this.series(sesion),
this.valoresUdf(sesion, 'BFE_TipoDespacho'),
this.valoresUdf(sesion, 'BFE_IndTraslado'),
]);
return { almacenes, series, tiposDespacho, indicadoresTraslado };
}
async almacenes(sesion: SapSession): Promise<Almacen[]> {
const filas = await this.sl.getAll<SlAlmacen>(
sesion,
`Warehouses?$select=WarehouseCode,WarehouseName&$filter=${encodeURIComponent("Inactive eq 'tNO'")}&$orderby=WarehouseCode`,
);
return filas.map((a) => ({ code: a.WarehouseCode, name: a.WarehouseName }));
}
/** Solo las series de Devoluciones (ObjType 21) que no están bloqueadas. */
async series(sesion: SapSession): Promise<SerieDocumento[]> {
const [lista, porDefecto] = await Promise.all([
this.sl.post<{ value: SlSerie[] }>(sesion, 'SeriesService_GetDocumentSeries', DOCUMENTO),
this.sl.post<{ Series: number }>(sesion, 'SeriesService_GetDefaultSeries', DOCUMENTO).catch(() => undefined),
]);
return lista.value
.filter((s) => s.Locked !== 'tYES')
.map((s) => ({ series: s.Series, name: s.Name, nextNumber: s.NextNumber, esPorDefecto: s.Series === porDefecto?.Series }));
}
/** Valores válidos de un UDF de la Devolución (ORPD). */
async valoresUdf(sesion: SapSession, campo: string): Promise<OpcionUdf[]> {
const filtro = encodeURIComponent(`TableName eq 'ORPD' and Name eq '${campo}'`);
const [udf] = await this.sl.getAll<SlUdf>(sesion, `UserFieldsMD?$filter=${filtro}`);
return (udf?.ValidValuesMD ?? []).map((v) => ({ value: v.Value, description: v.Description }));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
/** Rutas de los archivos de certificado (se leen al arrancar). Con `pfx` o con `key` + `cert`. */
export interface HttpsConfig {
pfxPath?: string;
passphrase?: string;
keyPath?: string;
certPath?: string;
}
export interface AppConfig {
port: number;
webOrigin: string;
serviceLayer: {
url: string;
companyDb: string;
rejectUnauthorized: boolean;
};
dbType: 'HANA' | 'MSSQL';
cookieSecure: boolean;
/** Carpeta con el build de Angular que la API sirve; null si no existe (desarrollo con `ng serve`). */
webDistDir: string | null;
/** null = la API escucha en HTTP. */
https: HttpsConfig | null;
/** Si hay HTTPS: puerto HTTP que redirige al HTTPS (por ejemplo 80). null = no se abre. */
httpRedirectPort: number | null;
}
const vacio = (v: string | undefined): string | undefined => (v && v.trim() !== '' ? v.trim() : undefined);
function leerHttps(): HttpsConfig | null {
const pfxPath = vacio(process.env.HTTPS_PFX_PATH);
const keyPath = vacio(process.env.HTTPS_KEY_PATH);
const certPath = vacio(process.env.HTTPS_CERT_PATH);
if (!pfxPath && !keyPath && !certPath) return null;
if (pfxPath && (keyPath || certPath)) {
throw new Error('Configure el certificado con HTTPS_PFX_PATH o con HTTPS_KEY_PATH + HTTPS_CERT_PATH, no con ambos.');
}
if (!pfxPath && !(keyPath && certPath)) {
throw new Error('Falta HTTPS_KEY_PATH o HTTPS_CERT_PATH: ambos son necesarios.');
}
return { pfxPath, passphrase: vacio(process.env.HTTPS_PFX_PASSPHRASE), keyPath, certPath };
}
/** WEB_DIST_DIR, o el build de Angular junto a la API (apps/web/dist/web/browser) si existe. */
function carpetaWeb(): string | null {
const configurada = vacio(process.env.WEB_DIST_DIR);
const ruta = configurada ? resolve(configurada) : resolve(import.meta.dirname, '../../../web/dist/web/browser');
if (existsSync(resolve(ruta, 'index.html'))) return ruta;
if (configurada) throw new Error(`WEB_DIST_DIR no contiene un index.html: ${ruta}`);
return null;
}
export default (): AppConfig => {
const dbType = (process.env.DB_TYPE ?? 'HANA').toUpperCase();
if (dbType !== 'HANA' && dbType !== 'MSSQL') {
throw new Error(`DB_TYPE inválido: ${dbType}. Valores permitidos: HANA, MSSQL.`);
}
const url = process.env.SL_URL;
const companyDb = process.env.SL_COMPANY_DB;
if (!url || !companyDb) {
throw new Error('Faltan SL_URL y/o SL_COMPANY_DB en la configuración (.env).');
}
return {
port: Number(process.env.PORT ?? 3000),
webOrigin: process.env.WEB_ORIGIN ?? 'http://localhost:4200',
serviceLayer: {
url: url.replace(/\/+$/, ''),
companyDb,
rejectUnauthorized: process.env.SL_REJECT_UNAUTHORIZED !== 'false',
},
dbType,
cookieSecure: process.env.COOKIE_SECURE === 'true',
webDistDir: carpetaWeb(),
https: leerHttps(),
httpRedirectPort: Number(vacio(process.env.HTTP_REDIRECT_PORT)) || null,
};
};
+11
View File
@@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/session.guard.js';
@Controller('health')
export class HealthController {
@Public()
@Get()
health(): { status: 'ok' } {
return { status: 'ok' };
}
}
+17
View File
@@ -0,0 +1,17 @@
import { ArgumentsHost, Catch, HttpException, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import type { Request } from 'express';
/** Deja en el log toda respuesta de error (4xx/5xx) y las excepciones no controladas. */
@Catch()
export class HttpExceptionLogger extends BaseExceptionFilter {
private readonly logger = new Logger('Http');
override catch(exception: unknown, host: ArgumentsHost): void {
const req = host.switchToHttp().getRequest<Request>();
const detalle = exception instanceof HttpException ? JSON.stringify(exception.getResponse()) : (exception as Error)?.stack;
const status = exception instanceof HttpException ? exception.getStatus() : 500;
this.logger.error(`${req.method} ${req.originalUrl} -> ${status} ${detalle}`);
super.catch(exception, host);
}
}
+120
View File
@@ -0,0 +1,120 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { get } from 'node:http';
import configuration from './config/configuration.js';
import { cargarHttps, crearRedireccionHttp, destinoHttps } from './https.js';
const ENV_BASE = { SL_URL: 'https://sl:50000/b1s/v1', SL_COMPANY_DB: 'X', DB_TYPE: 'MSSQL' };
const CLAVES = ['HTTPS_PFX_PATH', 'HTTPS_PFX_PASSPHRASE', 'HTTPS_KEY_PATH', 'HTTPS_CERT_PATH', 'WEB_DIST_DIR', 'HTTP_REDIRECT_PORT'];
describe('configuración de despliegue', () => {
const respaldo = { ...process.env };
beforeEach(() => {
for (const k of CLAVES) delete process.env[k];
Object.assign(process.env, ENV_BASE);
});
afterEach(() => {
process.env = { ...respaldo };
});
it('sin variables HTTPS la API escucha en HTTP', () => {
expect(configuration().https).toBeNull();
expect(configuration().httpRedirectPort).toBeNull();
});
it('acepta un .pfx con su contraseña', () => {
process.env.HTTPS_PFX_PATH = 'C:\\certs\\sitio.pfx';
process.env.HTTPS_PFX_PASSPHRASE = 'clave';
expect(configuration().https).toMatchObject({ pfxPath: 'C:\\certs\\sitio.pfx', passphrase: 'clave' });
});
it('acepta key + cert', () => {
process.env.HTTPS_KEY_PATH = '/k.pem';
process.env.HTTPS_CERT_PATH = '/c.pem';
expect(configuration().https).toMatchObject({ keyPath: '/k.pem', certPath: '/c.pem' });
});
it('rechaza mezclar .pfx con key/cert', () => {
process.env.HTTPS_PFX_PATH = '/a.pfx';
process.env.HTTPS_KEY_PATH = '/k.pem';
expect(() => configuration()).toThrow(/no con ambos/);
});
it('rechaza key sin cert', () => {
process.env.HTTPS_KEY_PATH = '/k.pem';
expect(() => configuration()).toThrow(/ambos son necesarios/);
});
it('ignora las variables vacías (como las deja un .env con "VAR=")', () => {
process.env.HTTPS_PFX_PATH = ' ';
process.env.HTTP_REDIRECT_PORT = '';
expect(configuration().https).toBeNull();
expect(configuration().httpRedirectPort).toBeNull();
});
it('lee el puerto de la redirección HTTP', () => {
process.env.HTTP_REDIRECT_PORT = '80';
expect(configuration().httpRedirectPort).toBe(80);
});
it('WEB_DIST_DIR que no tiene index.html es un error claro', () => {
process.env.WEB_DIST_DIR = join(tmpdir(), 'no-existe-dp');
expect(() => configuration()).toThrow(/WEB_DIST_DIR no contiene un index.html/);
});
it('WEB_DIST_DIR válido se usa tal cual', () => {
const dir = mkdtempSync(join(tmpdir(), 'dp-dist-'));
writeFileSync(join(dir, 'index.html'), 'x');
process.env.WEB_DIST_DIR = dir;
expect(configuration().webDistDir).toBe(dir);
rmSync(dir, { recursive: true, force: true });
});
});
describe('cargarHttps', () => {
it('lee los archivos del certificado', () => {
const dir = mkdtempSync(join(tmpdir(), 'dp-cert-'));
writeFileSync(join(dir, 'k.pem'), 'KEY');
writeFileSync(join(dir, 'c.pem'), 'CERT');
const r = cargarHttps({ keyPath: join(dir, 'k.pem'), certPath: join(dir, 'c.pem') }) as { key: Buffer; cert: Buffer };
expect(r.key.toString()).toBe('KEY');
expect(r.cert.toString()).toBe('CERT');
rmSync(dir, { recursive: true, force: true });
});
it('si falta un archivo dice cuál variable lo pide y la ruta', () => {
expect(() => cargarHttps({ pfxPath: '/no/existe.pfx' })).toThrow(/HTTPS_PFX_PATH \(\/no\/existe\.pfx\)/);
});
});
describe('redirección HTTP → HTTPS', () => {
it.each([
['sitio.local', '/planificaciones?x=1', 443, 'https://sitio.local/planificaciones?x=1'],
['sitio.local:80', '/', 8443, 'https://sitio.local:8443/'],
['10.0.0.5:8080', '/login', 443, 'https://10.0.0.5/login'],
[undefined, '/', 443, 'https://localhost/'],
])('%s %s → %s', (host, url, puerto, esperado) => {
expect(destinoHttps(host, url, puerto)).toBe(esperado);
});
it('el servidor responde 301 con el Location correcto', async () => {
const servidor = crearRedireccionHttp(8443);
await new Promise<void>((r) => servidor.listen(0, '127.0.0.1', r));
const puerto = (servidor.address() as AddressInfo).port;
const cabecera = await new Promise<{ status?: number; location?: string }>((resolver) => {
get({ host: '127.0.0.1', port: puerto, path: '/login', headers: { host: `miservidor:${puerto}` } }, (res) => {
resolver({ status: res.statusCode, location: res.headers.location });
res.resume();
});
});
await new Promise((r) => servidor.close(r));
expect(cabecera).toEqual({ status: 301, location: 'https://miservidor:8443/login' });
});
});
+32
View File
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import type { Server } from 'node:http';
import type { HttpsConfig } from './config/configuration.js';
/** Lee el certificado. Falla al arrancar, con un mensaje claro, si una ruta no existe. */
export function cargarHttps(cfg: HttpsConfig): { pfx: Buffer; passphrase?: string } | { key: Buffer; cert: Buffer } {
const leer = (ruta: string, nombre: string): Buffer => {
try {
return readFileSync(ruta);
} catch (error) {
throw new Error(`No se pudo leer ${nombre} (${ruta}): ${(error as Error).message}`);
}
};
if (cfg.pfxPath) return { pfx: leer(cfg.pfxPath, 'HTTPS_PFX_PATH'), passphrase: cfg.passphrase };
return { key: leer(cfg.keyPath!, 'HTTPS_KEY_PATH'), cert: leer(cfg.certPath!, 'HTTPS_CERT_PATH') };
}
/** Destino de la redirección: mismo host, esquema https y el puerto de la API (se omite el 443). */
export function destinoHttps(hostHeader: string | undefined, url: string, puertoHttps: number): string {
const host = (hostHeader ?? 'localhost').replace(/:\d+$/, '').replace(/^\[(.*)\]$/, '[$1]');
return `https://${host}${puertoHttps === 443 ? '' : `:${puertoHttps}`}${url}`;
}
/** Servidor HTTP que solo redirige (301) a la misma dirección por HTTPS. */
export function crearRedireccionHttp(puertoHttps: number): Server {
return createServer((req, res) => {
res.writeHead(301, { Location: destinoHttps(req.headers.host, req.url ?? '/', puertoHttps) });
res.end();
});
}
+40
View File
@@ -0,0 +1,40 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost, NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
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 { cargarHttps, crearRedireccionHttp } from './https.js';
import { cabecerasSeguridad, servirWeb } from './web-estatico.js';
async function bootstrap() {
// El .env ya está cargado en process.env: ConfigModule.forRoot corre al importar AppModule.
const cfg = configuration();
const logger = new Logger('Arranque');
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
httpsOptions: cfg.https ? cargarHttps(cfg.https) : undefined,
});
app.setGlobalPrefix('api');
app.use(cabecerasSeguridad(cfg.https !== null));
app.use(cookieParser());
app.useGlobalFilters(new HttpExceptionLogger(app.get(HttpAdapterHost).httpAdapter));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
app.enableCors({ origin: cfg.webOrigin, credentials: true });
if (cfg.webDistDir) servirWeb(app, cfg.webDistDir);
await app.listen(cfg.port);
const esquema = cfg.https ? 'https' : 'http';
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) {
logger.warn('COOKIE_SECURE=true sin HTTPS: el navegador solo guardará la cookie de sesión si hay un proxy HTTPS delante.');
}
if (cfg.https && cfg.httpRedirectPort) {
crearRedireccionHttp(cfg.port).listen(cfg.httpRedirectPort, () => logger.log(`Redirigiendo http:${cfg.httpRedirectPort} → ${esquema}:${cfg.port}`));
}
}
await bootstrap();
@@ -0,0 +1,25 @@
[
{
"tipo": "campo",
"tabla": "OPDN",
"campo": "DOT_TipoRecepcion",
"descripcion": "Tipo de recepción de la Entrada de mercancías (el FIFO solo toma las de tipo 'C')"
},
{
"tipo": "campo",
"tabla": "OINV",
"campo": "BFE_TipoDespacho",
"descripcion": "Valores válidos del Tipo de despacho"
},
{
"tipo": "campo",
"tabla": "OINV",
"campo": "BFE_IndTraslado",
"descripcion": "Valores válidos del Indicador de traslado"
},
{
"tipo": "tabla",
"tabla": "@DTE_CONFC",
"descripcion": "Configuración del web service DTE (RUT, usuario, contraseña y URL) para descargar PDF"
}
]
@@ -0,0 +1,325 @@
[
{
"TableName": "@LIB_ODEV",
"Name": "WhsCode",
"Description": "LIB: Almacen",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "DocDate",
"Description": "LIB: Fecha contabilizacion",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "Series",
"Description": "LIB: Serie electrónica devolucion",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "ObjType",
"Description": "LIB: Objeto destino",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "PrintingLines",
"Description": "LIB: Cantidad maxima lineas",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "TipoDespacho",
"Description": "LIB: Tipo despacho",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "IndicadorTraslado",
"Description": "LIB: Indicador traslado",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "RutTransporte",
"Description": "LIB: RUT Transportista",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "RutChofer",
"Description": "LIB: RUT Chofer",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "NombreChofer",
"Description": "LIB: Nombre Chofer",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 100,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "FechaSalida",
"Description": "LIB: Fecha salida",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "HoraSalida",
"Description": "LIB: Hora salida",
"Type": "db_Date",
"SubType": "st_Time",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "FechaLlegada",
"Description": "LIB: Fecha llegada",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_ODEV",
"Name": "Patente",
"Description": "LIB: Patente",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "LineType",
"Description": "LIB: Tipo fila",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 1,
"Mandatory": "tNO",
"DefaultValue": "F",
"ValidValuesMD": [
{
"Value": "F",
"Description": "Padre"
},
{
"Value": "S",
"Description": "Hijo"
}
]
},
{
"TableName": "@LIB_DEV1",
"Name": "ItemCode",
"Description": "LIB: Codigo articulo",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 50,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "ItemDescription",
"Description": "LIB: Descripcion articulo",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 254,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "Quantity",
"Description": "LIB: Cantidad a devolver",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "OnHand",
"Description": "LIB: Cantidad disponible",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "Status",
"Description": "LIB: Resultado de la comparacion",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseType",
"Description": "LIB: Tipo base",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseEntry",
"Description": "LIB: Documento base",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseLine",
"Description": "LIB: Linea base",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseRef",
"Description": "LIB: Numero documento",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "BaseDocDate",
"Description": "LIB: Fecha Entrada",
"Type": "db_Date",
"SubType": "st_None",
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "OpenQty",
"Description": "LIB: Cantidad abierta entrada",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "AllocatedQty",
"Description": "LIB: Cantidad asignada",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "Applied",
"Description": "LIB: Aplica devolucion",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 1,
"Mandatory": "tNO",
"DefaultValue": "Y",
"ValidValuesMD": [
{
"Value": "Y",
"Description": "Si"
},
{
"Value": "N",
"Description": "No"
}
]
},
{
"TableName": "@LIB_DEV1",
"Name": "TargetType",
"Description": "LIB: Tipo documento destino",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "TargetEntry",
"Description": "LIB: Documento destino",
"Type": "db_Numeric",
"SubType": "st_None",
"EditSize": 11,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "CardCode",
"Description": "LIB: Proveedor",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 20,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "CardName",
"Description": "LIB: Nombre",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 200,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "ErrCode",
"Description": "LIB: Codigo de error",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 10,
"Mandatory": "tNO"
},
{
"TableName": "@LIB_DEV1",
"Name": "ErrMsg",
"Description": "LIB: Mensaje de error",
"Type": "db_Alpha",
"SubType": "st_None",
"EditSize": 254,
"Mandatory": "tNO"
}
]
@@ -0,0 +1,27 @@
[
{
"Code": "LIB_ODEV",
"Name": "Devolucion proveedores",
"ObjectType": "boud_Document",
"TableName": "LIB_ODEV",
"CanFind": "tYES",
"CanClose": "tYES",
"CanCancel": "tYES",
"CanDelete": "tNO",
"CanLog": "tYES",
"LogTableName": "ALIB_ODEV",
"CanCreateDefaultForm": "tNO",
"ManageSeries": "tNO",
"UserObjectMD_ChildTables": [
{
"TableName": "LIB_DEV1",
"ObjectName": "LIB_DEV1"
}
],
"UserObjectMD_FindColumns": [
{ "ColumnAlias": "DocEntry", "ColumnDescription": "Nº documento" },
{ "ColumnAlias": "U_WhsCode", "ColumnDescription": "Almacén" },
{ "ColumnAlias": "U_DocDate", "ColumnDescription": "Fecha" }
]
}
]
@@ -0,0 +1,14 @@
[
{
"TableName": "LIB_ODEV",
"TableDescription": "LIB: Devolucion proveedores",
"TableType": "bott_Document",
"Archivable": "tNO"
},
{
"TableName": "LIB_DEV1",
"TableDescription": "LIB: Detalle devolucion",
"TableType": "bott_DocumentLines",
"Archivable": "tNO"
}
]
@@ -0,0 +1,104 @@
import { CampoReal, MetadatosReales, ObjetoReal, analizarMetadatos, claveDependencia } from './metadata.analyzer.js';
import { DEFINICIONES } from './metadata.definitions.js';
function baseCompleta(): MetadatosReales {
const campos: CampoReal[] = DEFINICIONES.campos.map((c, i) => ({
TableName: c.TableName,
FieldID: i,
Name: c.Name,
Type: c.Type,
SubType: c.SubType,
EditSize: c.EditSize ?? 8,
}));
const objetos: ObjetoReal[] = DEFINICIONES.objetos.map((o) => ({ ...o }));
return {
tablas: DEFINICIONES.tablas.map((t) => ({ TableName: t.TableName, TableType: t.TableType })),
campos,
objetos,
dependenciasPresentes: new Set(DEFINICIONES.dependencias.map((d) => claveDependencia(d.tipo, d.tabla, d.campo))),
};
}
describe('definiciones', () => {
it('incluyen las 2 tablas, 34 campos y el UDO LIB_ODEV', () => {
expect(DEFINICIONES.tablas.map((t) => t.TableName)).toEqual(['LIB_ODEV', 'LIB_DEV1']);
expect(DEFINICIONES.campos).toHaveLength(34);
expect(DEFINICIONES.objetos[0].Code).toBe('LIB_ODEV');
expect(DEFINICIONES.campos.find((c) => c.Name === 'Series')?.Type).toBe('db_Numeric');
expect(DEFINICIONES.campos.some((c) => c.TableName === '@LIB_ODEV' && c.Name === 'Status')).toBe(false);
});
});
describe('analizarMetadatos', () => {
it('base al día: completo y sin acciones', () => {
const { estado, acciones } = analizarMetadatos(DEFINICIONES, baseCompleta());
expect(estado.completo).toBe(true);
expect(acciones).toEqual([]);
});
it('base vacía: crea tablas, luego campos y al final el objeto', () => {
const { estado, acciones } = analizarMetadatos(DEFINICIONES, {
tablas: [],
campos: [],
objetos: [],
dependenciasPresentes: new Set(),
});
expect(estado.completo).toBe(false);
const tipos = acciones.map((a) => a.tipo);
expect(tipos.slice(0, 2)).toEqual(['crear-tabla', 'crear-tabla']);
expect(tipos.filter((t) => t === 'crear-campo')).toHaveLength(34);
expect(tipos.at(-1)).toBe('crear-objeto');
expect(estado.elementos.filter((e) => e.tipo === 'dependencia').every((e) => e.estado === 'FALTA')).toBe(true);
});
it('campo alfanumérico que pasó a numérico: eliminar y volver a crear (destructivo)', () => {
const real = baseCompleta();
const qty = real.campos.find((c) => c.TableName === '@LIB_DEV1' && c.Name === 'Quantity')!;
qty.Type = 'db_Alpha';
const { estado, acciones } = analizarMetadatos(DEFINICIONES, real);
expect(estado.completo).toBe(false);
expect(acciones.map((a) => [a.tipo, a.nombre, a.destructivo])).toEqual([
['eliminar-campo', '@LIB_DEV1.U_Quantity', true],
['crear-campo', '@LIB_DEV1.U_Quantity', false],
]);
});
it('campo alfanumérico más chico: se amplía sin borrar', () => {
const real = baseCompleta();
real.campos.find((c) => c.Name === 'TipoDespacho')!.EditSize = 5;
const { acciones } = analizarMetadatos(DEFINICIONES, real);
expect(acciones).toEqual([expect.objectContaining({ tipo: 'ampliar-campo', editSize: 10, destructivo: false })]);
});
it('campo sobrante en tabla propia (U_Status de cabecera): se informa y se puede eliminar', () => {
const real = baseCompleta();
real.campos.push({ TableName: '@LIB_ODEV', FieldID: 99, Name: 'Status', Type: 'db_Alpha', SubType: 'st_None', EditSize: 1 });
real.campos.push({ TableName: 'OPDN', FieldID: 5, Name: 'Otro', Type: 'db_Alpha', SubType: 'st_None', EditSize: 1 });
const { estado, acciones } = analizarMetadatos(DEFINICIONES, real);
expect(estado.completo).toBe(true);
expect(estado.elementos.filter((e) => e.estado === 'SOBRANTE').map((e) => e.nombre)).toEqual(['@LIB_ODEV.U_Status']);
expect(acciones).toEqual([expect.objectContaining({ tipo: 'eliminar-campo', fieldId: 99, destructivo: true })]);
});
it('UDO sin CanClose: se actualiza la propiedad', () => {
const real = baseCompleta();
real.objetos[0].CanClose = 'tNO';
const { acciones } = analizarMetadatos(DEFINICIONES, real);
expect(acciones).toEqual([expect.objectContaining({ tipo: 'actualizar-objeto', cambios: { CanClose: 'tYES' } })]);
});
it('UDO sin la tabla hija: se informa para corregir a mano', () => {
const real = baseCompleta();
real.objetos[0].UserObjectMD_ChildTables = [];
const { estado, acciones } = analizarMetadatos(DEFINICIONES, real);
expect(acciones).toEqual([]);
expect(estado.elementos.find((e) => e.tipo === 'objeto')).toMatchObject({ estado: 'DIFERENTE', destructivo: true });
});
});
+196
View File
@@ -0,0 +1,196 @@
import type { DefinicionCampo, DefinicionObjeto, DefinicionTabla, EstadoMetadato, EstadoMetadatos } from '@dp/shared';
import { DefinicionesMetadatos } from './metadata.definitions.js';
// Compara las definiciones contra lo que hay en la base SAP y arma el plan de acciones
// para dejarla al día. Es lógica pura: la lectura y la ejecución están en MetadataService.
export interface TablaReal {
TableName: string;
TableType: string;
}
export interface CampoReal {
TableName: string;
FieldID: number;
Name: string;
Type: string;
SubType: string;
EditSize: number;
}
export interface ObjetoReal extends Partial<Omit<DefinicionObjeto, 'UserObjectMD_ChildTables'>> {
Code: string;
UserObjectMD_ChildTables?: { TableName: string }[];
}
export interface MetadatosReales {
tablas: TablaReal[];
campos: CampoReal[];
objetos: ObjetoReal[];
/** Claves "campo:OPDN.DOT_TipoRecepcion" / "tabla:@DTE_CONFC" de las dependencias encontradas. */
dependenciasPresentes: Set<string>;
}
/** El Service Layer informa los campos de hora (db_Date/st_Time) como db_Numeric/st_Time. */
function tipoReal(c: { Type: string; SubType: string }): string {
const tipo = c.SubType === 'st_Time' && c.Type === 'db_Numeric' ? 'db_Date' : c.Type;
return `${tipo}/${c.SubType}`;
}
export type Accion =
| { tipo: 'crear-tabla'; nombre: string; destructivo: false; tabla: DefinicionTabla }
| { tipo: 'crear-campo'; nombre: string; destructivo: false; campo: DefinicionCampo }
| { tipo: 'ampliar-campo'; nombre: string; destructivo: false; tableName: string; fieldId: number; editSize: number }
| { tipo: 'eliminar-campo'; nombre: string; destructivo: true; tableName: string; fieldId: number }
| { tipo: 'crear-objeto'; nombre: string; destructivo: false; objeto: DefinicionObjeto }
| { tipo: 'actualizar-objeto'; nombre: string; destructivo: false; code: string; cambios: Partial<DefinicionObjeto> };
export interface Analisis {
estado: EstadoMetadatos;
acciones: Accion[];
}
const PROPIEDADES_OBJETO = ['CanFind', 'CanClose', 'CanCancel', 'CanDelete', 'CanLog', 'ManageSeries'] as const;
export function claveDependencia(tipo: 'tabla' | 'campo', tabla: string, campo?: string): string {
return tipo === 'tabla' ? `tabla:${tabla}` : `campo:${tabla}.${campo}`;
}
export function analizarMetadatos(def: DefinicionesMetadatos, real: MetadatosReales): Analisis {
const elementos: EstadoMetadato[] = [];
const acciones: Accion[] = [];
// Tablas
for (const tabla of def.tablas) {
const existente = real.tablas.find((t) => t.TableName === tabla.TableName);
const base = { tipo: 'tabla' as const, nombre: tabla.TableName, descripcion: tabla.TableDescription };
if (!existente) {
elementos.push({ ...base, estado: 'FALTA' });
acciones.push({ tipo: 'crear-tabla', nombre: tabla.TableName, destructivo: false, tabla });
} else if (existente.TableType !== tabla.TableType) {
elementos.push({
...base,
estado: 'DIFERENTE',
detalle: `Tipo ${existente.TableType}, se esperaba ${tabla.TableType}. Debe corregirse manualmente en SAP.`,
destructivo: true,
});
} else {
elementos.push({ ...base, estado: 'OK' });
}
}
// Campos
const tablasPropias = new Set(def.tablas.map((t) => `@${t.TableName}`));
for (const campo of def.campos) {
const nombre = `${campo.TableName}.U_${campo.Name}`;
const base = { tipo: 'campo' as const, nombre, descripcion: campo.Description };
const existente = real.campos.find((c) => c.TableName === campo.TableName && c.Name === campo.Name);
if (!existente) {
elementos.push({ ...base, estado: 'FALTA' });
acciones.push({ tipo: 'crear-campo', nombre, destructivo: false, campo });
continue;
}
if (tipoReal(existente) !== tipoReal(campo)) {
elementos.push({
...base,
estado: 'DIFERENTE',
detalle: `Tipo ${existente.Type}/${existente.SubType}, se esperaba ${campo.Type}/${campo.SubType}. Se borra y se vuelve a crear (se pierden sus datos).`,
destructivo: true,
});
acciones.push({ tipo: 'eliminar-campo', nombre, destructivo: true, tableName: campo.TableName, fieldId: existente.FieldID });
acciones.push({ tipo: 'crear-campo', nombre, destructivo: false, campo });
continue;
}
if (campo.Type === 'db_Alpha' && campo.EditSize && existente.EditSize < campo.EditSize) {
elementos.push({ ...base, estado: 'DIFERENTE', detalle: `Tamaño ${existente.EditSize}, se esperaba ${campo.EditSize}.` });
acciones.push({
tipo: 'ampliar-campo',
nombre,
destructivo: false,
tableName: campo.TableName,
fieldId: existente.FieldID,
editSize: campo.EditSize,
});
continue;
}
elementos.push({ ...base, estado: 'OK' });
}
for (const sobrante of real.campos) {
const definido = def.campos.some((c) => c.TableName === sobrante.TableName && c.Name === sobrante.Name);
if (!definido && tablasPropias.has(sobrante.TableName)) {
const nombre = `${sobrante.TableName}.U_${sobrante.Name}`;
elementos.push({
tipo: 'campo',
nombre,
descripcion: 'Campo que ya no se utiliza',
estado: 'SOBRANTE',
detalle: 'Se elimina de la base (se pierden sus datos).',
destructivo: true,
});
acciones.push({ tipo: 'eliminar-campo', nombre, destructivo: true, tableName: sobrante.TableName, fieldId: sobrante.FieldID });
}
}
// Objetos de usuario
for (const objeto of def.objetos) {
const base = { tipo: 'objeto' as const, nombre: objeto.Code, descripcion: objeto.Name };
const existente = real.objetos.find((o) => o.Code === objeto.Code);
if (!existente) {
elementos.push({ ...base, estado: 'FALTA' });
acciones.push({ tipo: 'crear-objeto', nombre: objeto.Code, destructivo: false, objeto });
continue;
}
const hijasReales = new Set((existente.UserObjectMD_ChildTables ?? []).map((h) => h.TableName));
const faltanHijas = objeto.UserObjectMD_ChildTables.filter((h) => !hijasReales.has(h.TableName)).map((h) => h.TableName);
if (existente.TableName !== objeto.TableName || existente.ObjectType !== objeto.ObjectType || faltanHijas.length > 0) {
elementos.push({
...base,
estado: 'DIFERENTE',
detalle: `Tabla ${existente.TableName} (${existente.ObjectType})${faltanHijas.length ? `, faltan tablas hijas ${faltanHijas.join(', ')}` : ''}. Debe corregirse manualmente en SAP.`,
destructivo: true,
});
continue;
}
const cambios: Partial<DefinicionObjeto> = {};
for (const prop of PROPIEDADES_OBJETO) {
if (existente[prop] !== objeto[prop]) cambios[prop] = objeto[prop];
}
if (cambios.CanLog === 'tYES') cambios.LogTableName = objeto.LogTableName;
if (cambios.CanFind === 'tYES') cambios.UserObjectMD_FindColumns = objeto.UserObjectMD_FindColumns;
const diferencias = Object.keys(cambios).filter((k) => k !== 'LogTableName' && k !== 'UserObjectMD_FindColumns');
if (diferencias.length > 0) {
elementos.push({ ...base, estado: 'DIFERENTE', detalle: `Propiedades distintas: ${diferencias.join(', ')}.` });
acciones.push({ tipo: 'actualizar-objeto', nombre: objeto.Code, destructivo: false, code: objeto.Code, cambios });
} else {
elementos.push({ ...base, estado: 'OK' });
}
}
// Dependencias externas (solo se verifican, no se crean)
for (const dep of def.dependencias) {
const nombre = dep.tipo === 'tabla' ? dep.tabla : `${dep.tabla}.U_${dep.campo}`;
const presente = real.dependenciasPresentes.has(claveDependencia(dep.tipo, dep.tabla, dep.campo));
elementos.push({
tipo: 'dependencia',
nombre,
descripcion: dep.descripcion,
estado: presente ? 'OK' : 'FALTA',
detalle: presente ? undefined : 'Lo crea otro addon o la localización; esta aplicación no lo genera.',
});
}
const completo = elementos.every((e) => e.estado === 'OK' || e.estado === 'SOBRANTE');
return { estado: { completo, elementos }, acciones };
}
@@ -0,0 +1,29 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoReconstruccion } from '@dp/shared';
import { IsBoolean } from 'class-validator';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { MetadataService } from './metadata.service.js';
class ReconstruirDto implements OpcionesReconstruccion {
@IsBoolean()
permitirDestructivos: boolean;
}
@Controller('metadata')
export class MetadataController {
constructor(private readonly metadata: MetadataService) {}
/** Compara tablas, campos y objeto de usuario esperados contra la base SAP. */
@Get('status')
estado(@CurrentSession() sesion: SapSession): Promise<EstadoMetadatos> {
return this.metadata.estado(sesion);
}
/** Crea o corrige lo que falta. Lo que borra datos solo se hace con permitirDestructivos. */
@Post('rebuild')
@HttpCode(200)
reconstruir(@CurrentSession() sesion: SapSession, @Body() dto: ReconstruirDto): Promise<ResultadoReconstruccion> {
return this.metadata.reconstruir(sesion, dto);
}
}
@@ -0,0 +1,25 @@
import type { DefinicionCampo, DefinicionObjeto, DefinicionTabla, DependenciaExterna } from '@dp/shared';
import dependencias from './definitions/external-dependencies.json' with { type: 'json' };
import campos from './definitions/user-fields.json' with { type: 'json' };
import objetos from './definitions/user-objects.json' with { type: 'json' };
import tablas from './definitions/user-tables.json' with { type: 'json' };
/**
* Metadatos que el proceso necesita en la base SAP. Los JSON de ./definitions son la
* fuente de verdad (reemplazan a UserTables.xml / UserFields.xml del addon .NET) y usan
* el mismo formato que los endpoints UserTablesMD / UserFieldsMD / UserObjectsMD del
* Service Layer.
*/
export interface DefinicionesMetadatos {
tablas: DefinicionTabla[];
campos: DefinicionCampo[];
objetos: DefinicionObjeto[];
dependencias: DependenciaExterna[];
}
export const DEFINICIONES: DefinicionesMetadatos = {
tablas: tablas as DefinicionTabla[],
campos: campos as DefinicionCampo[],
objetos: objetos as DefinicionObjeto[],
dependencias: dependencias as DependenciaExterna[],
};
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MetadataController } from './metadata.controller.js';
import { MetadataService } from './metadata.service.js';
@Module({
controllers: [MetadataController],
providers: [MetadataService],
})
export class MetadataModule {}
+124
View File
@@ -0,0 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import type { EstadoMetadatos, OpcionesReconstruccion, ResultadoPaso, ResultadoReconstruccion } from '@dp/shared';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import { SapSession } from '../service-layer/session.store.js';
import { Accion, CampoReal, MetadatosReales, ObjetoReal, TablaReal, analizarMetadatos, claveDependencia } from './metadata.analyzer.js';
import { DEFINICIONES } from './metadata.definitions.js';
/** Arma "Entidad?$filter=...&$select=..." con los valores codificados para la URL. */
function odata(entidad: string, query: Record<string, string>): string {
const params = Object.entries(query).map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
return `${entidad}?${params.join('&')}`;
}
const DESCRIPCION_ACCION: Record<Accion['tipo'], string> = {
'crear-tabla': 'Crear tabla',
'crear-campo': 'Crear campo',
'ampliar-campo': 'Ampliar campo',
'eliminar-campo': 'Eliminar campo',
'crear-objeto': 'Crear objeto',
'actualizar-objeto': 'Actualizar objeto',
};
@Injectable()
export class MetadataService {
private readonly logger = new Logger(MetadataService.name);
constructor(private readonly sl: ServiceLayerClient) {}
async estado(sesion: SapSession): Promise<EstadoMetadatos> {
return analizarMetadatos(DEFINICIONES, await this.leerReales(sesion)).estado;
}
async reconstruir(sesion: SapSession, opciones: OpcionesReconstruccion): Promise<ResultadoReconstruccion> {
const { acciones } = analizarMetadatos(DEFINICIONES, await this.leerReales(sesion));
const pasos: ResultadoPaso[] = [];
// Campos cuyo borrado no se hizo: tampoco se intenta volver a crearlos.
const sinEliminar = new Set<string>();
for (const accion of acciones) {
const paso: ResultadoPaso = { accion: DESCRIPCION_ACCION[accion.tipo], nombre: accion.nombre, ok: false };
if (accion.destructivo && !opciones.permitirDestructivos) {
paso.mensaje = 'Omitido: requiere autorizar cambios que borran datos.';
sinEliminar.add(accion.nombre);
} else if (accion.tipo === 'crear-campo' && sinEliminar.has(accion.nombre)) {
paso.mensaje = 'Omitido: el campo existente no se eliminó.';
} else {
try {
await this.ejecutar(sesion, accion);
paso.ok = true;
} catch (error) {
paso.mensaje = (error as Error).message;
if (accion.tipo === 'eliminar-campo') sinEliminar.add(accion.nombre);
this.logger.warn(`${paso.accion} ${paso.nombre}: ${paso.mensaje}`);
}
}
pasos.push(paso);
}
return { pasos, estado: await this.estado(sesion) };
}
private async ejecutar(sesion: SapSession, accion: Accion): Promise<void> {
switch (accion.tipo) {
case 'crear-tabla':
await this.sl.post(sesion, 'UserTablesMD', accion.tabla);
return;
case 'crear-campo':
await this.sl.post(sesion, 'UserFieldsMD', accion.campo);
return;
case 'ampliar-campo':
await this.sl.patch(sesion, `UserFieldsMD(TableName='${accion.tableName}',FieldID=${accion.fieldId})`, {
EditSize: accion.editSize,
});
return;
case 'eliminar-campo':
await this.sl.delete(sesion, `UserFieldsMD(TableName='${accion.tableName}',FieldID=${accion.fieldId})`);
return;
case 'crear-objeto':
await this.sl.post(sesion, 'UserObjectsMD', accion.objeto);
return;
case 'actualizar-objeto':
await this.sl.patch(sesion, `UserObjectsMD('${accion.code}')`, accion.cambios);
return;
}
}
private async leerReales(sesion: SapSession): Promise<MetadatosReales> {
const filtroTablas = DEFINICIONES.tablas.map((t) => `TableName eq '${t.TableName}'`).join(' or ');
const filtroCampos = DEFINICIONES.tablas.map((t) => `TableName eq '@${t.TableName}'`).join(' or ');
const filtroObjetos = DEFINICIONES.objetos.map((o) => `Code eq '${o.Code}'`).join(' or ');
const [tablas, campos, objetos, dependenciasPresentes] = await Promise.all([
this.sl.getAll<TablaReal>(sesion, odata('UserTablesMD', { $filter: filtroTablas, $select: 'TableName,TableType' })),
this.sl.getAll<CampoReal>(
sesion,
odata('UserFieldsMD', { $filter: filtroCampos, $select: 'TableName,FieldID,Name,Type,SubType,EditSize' }),
),
this.sl.getAll<ObjetoReal>(sesion, odata('UserObjectsMD', { $filter: filtroObjetos })),
this.leerDependencias(sesion),
]);
return { tablas, campos, objetos, dependenciasPresentes };
}
private async leerDependencias(sesion: SapSession): Promise<Set<string>> {
const presentes = new Set<string>();
await Promise.all(
DEFINICIONES.dependencias.map(async (dep) => {
const consulta =
dep.tipo === 'tabla'
? odata('UserTablesMD', { $filter: `TableName eq '${dep.tabla.replace(/^@/, '')}'`, $select: 'TableName' })
: odata('UserFieldsMD', { $filter: `TableName eq '${dep.tabla}' and Name eq '${dep.campo}'`, $select: 'FieldID' });
const encontrados = await this.sl.getAll<unknown>(sesion, consulta);
if (encontrados.length > 0) presentes.add(claveDependencia(dep.tipo, dep.tabla, dep.campo));
}),
);
return presentes;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { ACCION_CONSULTA_TXTDTE, construirSobre, interpretarRespuesta, normalizarUrl } from './bes-soap.js';
describe('construirSobre', () => {
it('lleva los 5 parámetros en el espacio de nombres de BES', () => {
const xml = construirSobre({ rutEmpresa: '78137716-3', tipoDocto: '52', folioDocto: '1234', usuario: 'U', pwd: 'P' });
expect(xml).toContain('<Consulta_TXTDTE xmlns="http://tempuri.org/">');
expect(xml).toContain('<RutEmpresa>78137716-3</RutEmpresa>');
expect(xml).toContain('<TipoDocto>52</TipoDocto>');
expect(xml).toContain('<FolioDocto>1234</FolioDocto>');
expect(xml).toContain('<Usuario>U</Usuario>');
expect(xml).toContain('<PWD>P</PWD>');
expect(ACCION_CONSULTA_TXTDTE).toBe('"http://tempuri.org/Consulta_TXTDTE"');
});
it('escapa los caracteres especiales (una contraseña con < & " no rompe el XML)', () => {
const xml = construirSobre({ rutEmpresa: '1', tipoDocto: '52', folioDocto: '1', usuario: 'a&b', pwd: `x<y>"z'` });
expect(xml).toContain('<Usuario>a&amp;b</Usuario>');
expect(xml).toContain('<PWD>x&lt;y&gt;&quot;z&apos;</PWD>');
});
});
describe('normalizarUrl', () => {
it.each([
['https://bes/wsconsulta/avanzado.asmx?WSDL', 'https://bes/wsconsulta/avanzado.asmx'],
['https://bes/wsconsulta/avanzado.asmx?wsdl', 'https://bes/wsconsulta/avanzado.asmx'],
['https://bes/wsconsulta/avanzado.asmx', 'https://bes/wsconsulta/avanzado.asmx'],
[' https://bes/x.asmx?WSDL ', 'https://bes/x.asmx'],
])('%s', (entrada, esperado) => {
expect(normalizarUrl(entrada)).toBe(esperado);
});
});
describe('interpretarRespuesta', () => {
const sobre = (cuerpo: string) =>
`<?xml version="1.0"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body>` +
`<Consulta_TXTDTEResponse xmlns="http://tempuri.org/"><Consulta_TXTDTEResult>${cuerpo}</Consulta_TXTDTEResult></Consulta_TXTDTEResponse>` +
`</soap:Body></soap:Envelope>`;
it('extrae el PDF en base64 y no lo confunde con PDFCedible', () => {
const pdf = Buffer.from('%PDF-1.4 contenido');
const cedible = Buffer.from('otro');
const r = interpretarRespuesta(
sobre(`<PDF>${pdf.toString('base64')}</PDF><PDFCedible>${cedible.toString('base64')}</PDFCedible><XML /><Estatus>0</Estatus><MsgEstatus>OK</MsgEstatus>`),
);
expect(r.pdf?.toString()).toBe('%PDF-1.4 contenido');
expect(r.estatus).toBe(0);
expect(r.mensaje).toBe('OK');
});
it('tolera saltos de línea dentro del base64', () => {
const b64 = Buffer.from('%PDF-1.7 hola mundo').toString('base64');
const r = interpretarRespuesta(sobre(`<PDF>${b64.slice(0, 8)}\n${b64.slice(8)}</PDF>`));
expect(r.pdf?.toString()).toBe('%PDF-1.7 hola mundo');
});
it('sin PDF devuelve el mensaje de estado del servicio', () => {
const r = interpretarRespuesta(sobre('<PDF /><Estatus>5</Estatus><MsgEstatus>Documento &lt;1234&gt; no existe</MsgEstatus>'));
expect(r.pdf).toBeUndefined();
expect(r.estatus).toBe(5);
expect(r.mensaje).toBe('Documento <1234> no existe');
});
it('una falla SOAP se informa', () => {
const r = interpretarRespuesta(
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><soap:Fault><faultcode>soap:Server</faultcode><faultstring>Usuario inválido</faultstring></soap:Fault></soap:Body></soap:Envelope>',
);
expect(r.pdf).toBeUndefined();
expect(r.mensaje).toBe('Falla del servicio: Usuario inválido');
});
});
+73
View File
@@ -0,0 +1,73 @@
// Cliente SOAP mínimo de Consulta_TXTDTE (avanzado.asmx de BES). Portado de
// WebServices/ConsultaDTE.cs y AvanzadoWS.cs (.NET), que usaba un proxy generado con wsdl.exe.
// Solo se arma el sobre y se lee la respuesta; el envío HTTP está en BesClient.
export const NS_BES = 'http://tempuri.org/';
export const ACCION_CONSULTA_TXTDTE = `"${NS_BES}Consulta_TXTDTE"`;
export interface ParametrosConsulta {
rutEmpresa: string;
tipoDocto: string;
folioDocto: string;
usuario: string;
pwd: string;
}
export interface RespuestaBes {
pdf?: Buffer;
estatus?: number;
mensaje?: string;
}
const escapar = (v: string): string =>
v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
const desescapar = (v: string): string =>
v.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
export function construirSobre(p: ParametrosConsulta): string {
return (
'<?xml version="1.0" encoding="utf-8"?>' +
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' +
'<soap:Body>' +
`<Consulta_TXTDTE xmlns="${NS_BES}">` +
`<RutEmpresa>${escapar(p.rutEmpresa)}</RutEmpresa>` +
`<TipoDocto>${escapar(p.tipoDocto)}</TipoDocto>` +
`<FolioDocto>${escapar(p.folioDocto)}</FolioDocto>` +
`<Usuario>${escapar(p.usuario)}</Usuario>` +
`<PWD>${escapar(p.pwd)}</PWD>` +
'</Consulta_TXTDTE>' +
'</soap:Body>' +
'</soap:Envelope>'
);
}
/**
* U_ConsultaTXTDTEWSDL guarda la dirección de descripción del servicio (termina en ?WSDL); el
* endpoint a invocar es la misma dirección sin ese sufijo.
*/
export function normalizarUrl(url: string): string {
const pos = url.search(/\?wsdl/i);
return (pos >= 0 ? url.slice(0, pos) : url).trim();
}
/** Contenido de la primera etiqueta con ese nombre local (ignora prefijo de espacio de nombres). */
function contenido(xml: string, etiqueta: string): string | undefined {
const re = new RegExp(`<(?:[\\w.-]+:)?${etiqueta}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${etiqueta}>`);
return re.exec(xml)?.[1];
}
export function interpretarRespuesta(xml: string): RespuestaBes {
const falla = contenido(xml, 'faultstring');
if (falla !== undefined) return { mensaje: `Falla del servicio: ${desescapar(falla.trim())}` };
const base64 = contenido(xml, 'PDF')?.replace(/\s+/g, '');
const estatus = contenido(xml, 'Estatus');
const mensaje = contenido(xml, 'MsgEstatus')?.trim();
return {
pdf: base64 ? Buffer.from(base64, 'base64') : undefined,
estatus: estatus === undefined ? undefined : Number(estatus),
mensaje: mensaje ? desescapar(mensaje) : undefined,
};
}
+55
View File
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { ACCION_CONSULTA_TXTDTE, construirSobre, interpretarRespuesta, normalizarUrl } from './bes-soap.js';
/** Fila única de @DTE_CONFC: credenciales y dirección del web service. Nunca se registra ni se devuelve al navegador. */
export interface ConfiguracionDte {
rutEmpresa: string;
usuario: string;
password: string;
wsdlUrl: string;
}
export interface ResultadoConsultaBes {
pdf?: Buffer;
error?: string;
}
const TIMEOUT_MS = 60_000;
@Injectable()
export class BesClient {
/** Llama a Consulta_TXTDTE. Se considera éxito que el servicio devuelva contenido en PDF. */
async consultarPdf(cfg: ConfiguracionDte, tipoDocto: string, folio: number): Promise<ResultadoConsultaBes> {
const url = normalizarUrl(cfg.wsdlUrl);
if (!/^https?:\/\//i.test(url)) return { error: 'La URL del web service en @DTE_CONFC no es válida.' };
const sobre = construirSobre({
rutEmpresa: cfg.rutEmpresa,
tipoDocto,
folioDocto: String(folio),
usuario: cfg.usuario,
pwd: cfg.password,
});
let cuerpo: string;
try {
const res = await axios.post<string>(url, sobre, {
headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: ACCION_CONSULTA_TXTDTE },
timeout: TIMEOUT_MS,
responseType: 'text',
// Las fallas SOAP llegan con HTTP 500 pero traen un cuerpo que se interpreta abajo.
validateStatus: () => true,
});
cuerpo = res.data;
} catch (error) {
// Solo el mensaje: el objeto de error de axios incluye la petición completa (con la contraseña).
return { error: `Error al consultar el servicio: ${(error as Error).message}` };
}
const respuesta = interpretarRespuesta(cuerpo);
if (respuesta.pdf && respuesta.pdf.length > 0) return { pdf: respuesta.pdf };
return { error: respuesta.mensaje ?? 'El servicio no devolvió un PDF para el documento consultado.' };
}
}
+154
View File
@@ -0,0 +1,154 @@
import { PDFDocument } from 'pdf-lib';
import type { SapSession } from '../service-layer/session.store.js';
import { PdfDteService } from './pdf-dte.service.js';
const sesion = { id: 's', userName: 'manager' } as SapSession;
const unaPagina = async (): Promise<Buffer> => {
const doc = await PDFDocument.create();
doc.addPage();
return Buffer.from(await doc.save());
};
const paginas = async (base64: string): Promise<number> => (await PDFDocument.load(Buffer.from(base64, 'base64'))).getPageCount();
const linea = (targetEntry: number | null) => ({ tipoFila: 'S', targetEntry }) as never;
function crear(opciones: {
lineas?: unknown[];
config?: object | null;
folios?: Record<number, { DocNum: number; FolioNumber: number | null }>;
respuestas?: Record<number, { pdf?: Buffer; error?: string }>;
}) {
const planificaciones = {
obtener: vi.fn(async () => ({ cabecera: { docNum: 7 }, lineas: opciones.lineas ?? [linea(33), linea(33), linea(34)] })),
};
const sl = {
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
if (path.startsWith('U_DTE_CONFC')) {
return { value: opciones.config === null ? [] : [opciones.config ?? { U_RutEmpresa: '1-9', U_UsuarioDTE: 'u', U_PasswordDTE: 'p', U_ConsultaTXTDTEWSDL: 'https://bes/x.asmx?WSDL' }] };
}
const docEntry = Number(/PurchaseReturns\((\d+)\)/.exec(path)![1]);
return { DocEntry: docEntry, ...(opciones.folios?.[docEntry] ?? { DocNum: docEntry, FolioNumber: null }) };
}),
};
const bes = {
consultarPdf: vi.fn(async (_c: unknown, _t: string, folio: number) => opciones.respuestas?.[folio] ?? { error: `sin respuesta para ${folio}` }),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { servicio: new PdfDteService(sl as any, planificaciones as any, bes as any), sl, bes };
}
describe('PdfDteService.generar', () => {
it('pide cada Devolución una sola vez, con el tipo 52 y su folio, y devuelve un archivo por cada una', async () => {
const pdf = await unaPagina();
const { servicio, bes } = crear({
folios: { 33: { DocNum: 33, FolioNumber: 5001 }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5001: { pdf }, 5002: { pdf } },
});
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).toHaveBeenCalledTimes(2);
expect(bes.consultarPdf.mock.calls.map((c) => [c[1], c[2]])).toEqual([['52', 5001], ['52', 5002]]);
expect(r.unificado).toBe(false);
expect(r.archivos.map((a) => [a.nombre, a.folios])).toEqual([['DTE_5001.pdf', [5001]], ['DTE_5002.pdf', [5002]]]);
expect(r.devoluciones).toEqual([{ docEntry: 33, folio: 5001 }, { docEntry: 34, folio: 5002 }]);
});
it('con unir junta las páginas de todos en un solo PDF', async () => {
const pdf = await unaPagina();
const { servicio } = crear({
folios: { 33: { DocNum: 33, FolioNumber: 5001 }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5001: { pdf }, 5002: { pdf } },
});
const r = await servicio.generar(sesion, 7, true);
expect(r.unificado).toBe(true);
expect(r.archivos).toHaveLength(1);
expect(r.archivos[0]).toMatchObject({ nombre: 'DTE_Unificado_7.pdf', folios: [5001, 5002] });
expect(await paginas(r.archivos[0].base64)).toBe(2);
});
it('con un solo PDF no hay nada que unir', async () => {
const { servicio } = crear({
lineas: [linea(33)],
folios: { 33: { DocNum: 33, FolioNumber: 5001 } },
respuestas: { 5001: { pdf: await unaPagina() } },
});
const r = await servicio.generar(sesion, 7, true);
expect(r.unificado).toBe(false);
expect(r.archivos.map((a) => a.nombre)).toEqual(['DTE_5001.pdf']);
});
it('si una Devolución falla, las demás igual se descargan y el error queda en la suya', async () => {
const { servicio } = crear({
folios: { 33: { DocNum: 33, FolioNumber: 5001 }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5001: { error: 'Documento no emitido' }, 5002: { pdf: await unaPagina() } },
});
const r = await servicio.generar(sesion, 7, true);
expect(r.devoluciones).toEqual([{ docEntry: 33, folio: 5001, error: 'Documento no emitido' }, { docEntry: 34, folio: 5002 }]);
expect(r.archivos.map((a) => a.nombre)).toEqual(['DTE_5002.pdf']);
});
it('sin FolioNum no consulta a BES y lo informa en esa Devolución', async () => {
const { servicio, bes } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 12, FolioNumber: null } } });
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).not.toHaveBeenCalled();
expect(r.archivos).toEqual([]);
expect(r.devoluciones).toEqual([{ docEntry: 33, error: 'La Devolución Nº 12 todavía no tiene folio (FolioNum): no se puede consultar su PDF.' }]);
});
it('un FolioNum en 0 tampoco cuenta como folio', async () => {
const { servicio, bes } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 12, FolioNumber: 0 } } });
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).not.toHaveBeenCalled();
expect(r.devoluciones[0].error).toContain('todavía no tiene folio');
});
it('una Devolución sin folio no impide descargar las que sí lo tienen', async () => {
const { servicio, bes } = crear({
folios: { 33: { DocNum: 33, FolioNumber: null }, 34: { DocNum: 34, FolioNumber: 5002 } },
respuestas: { 5002: { pdf: await unaPagina() } },
});
const r = await servicio.generar(sesion, 7, false);
expect(bes.consultarPdf).toHaveBeenCalledTimes(1);
expect(bes.consultarPdf.mock.calls[0][2]).toBe(5002);
expect(r.archivos.map((a) => a.nombre)).toEqual(['DTE_5002.pdf']);
expect(r.devoluciones[0].error).toContain('todavía no tiene folio');
});
it('rechaza lo que el servicio devuelve si no es un PDF', async () => {
const { servicio } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 33, FolioNumber: 5001 } }, respuestas: { 5001: { pdf: Buffer.from('<html>error</html>') } } });
const r = await servicio.generar(sesion, 7, false);
expect(r.archivos).toEqual([]);
expect(r.devoluciones[0].error).toContain('no es un PDF');
});
it('sin Devoluciones creadas avisa y no consulta nada', async () => {
const { servicio, bes } = crear({ lineas: [linea(null)] });
await expect(servicio.generar(sesion, 7, true)).rejects.toThrow(/No hay Devoluciones creadas/);
expect(bes.consultarPdf).not.toHaveBeenCalled();
});
it('sin configuración en @DTE_CONFC avisa y no consulta nada', async () => {
const { servicio, bes } = crear({ config: null });
await expect(servicio.generar(sesion, 7, true)).rejects.toThrow(/@DTE_CONFC/);
expect(bes.consultarPdf).not.toHaveBeenCalled();
});
it('la contraseña de @DTE_CONFC no aparece en el resultado', async () => {
const { servicio } = crear({ lineas: [linea(33)], folios: { 33: { DocNum: 33, FolioNumber: 5001 } }, respuestas: { 5001: { pdf: await unaPagina() } } });
const r = await servicio.generar(sesion, 7, false);
expect(JSON.stringify(r)).not.toContain('"p"');
expect(JSON.stringify(r)).not.toMatch(/password|pwd|U_Password/i);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { ArchivoPdf, ResultadoPdf, ResultadoPdfDevolucion } from '@dp/shared';
import { PDFDocument } from 'pdf-lib';
import { PlanificacionesService } from '../planificaciones/planificaciones.service.js';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import { BesClient } from './bes.client.js';
import type { ConfiguracionDte } from './bes.client.js';
/** Guía de despacho electrónica (DTE tipo 52). */
export const TIPO_GUIA_DESPACHO = '52';
interface FilaConfiguracion {
U_RutEmpresa: string | null;
U_UsuarioDTE: string | null;
U_PasswordDTE: string | null;
U_ConsultaTXTDTEWSDL: string | null;
}
interface DevolucionSl {
DocEntry: number;
DocNum: number;
FolioNumber: number | null;
}
const esPdf = (b: Buffer): boolean => b.subarray(0, 5).toString('latin1') === '%PDF-';
@Injectable()
export class PdfDteService {
private readonly logger = new Logger(PdfDteService.name);
constructor(
private readonly sl: ServiceLayerClient,
private readonly planificaciones: PlanificacionesService,
private readonly bes: BesClient,
) {}
/**
* Descarga el PDF de la Guía de despacho de cada Devolución creada por la planificación y,
* si `unir` y hay más de uno, los junta en un solo PDF. Se pide de a uno (como el addon) y un
* fallo en uno no impide los demás: cada Devolución informa su propio resultado.
*/
async generar(sesion: SapSession, docEntry: number, unir: boolean): Promise<ResultadoPdf> {
const plan = await this.planificaciones.obtener(sesion, docEntry);
const devoluciones = [...new Set(plan.lineas.map((l) => l.targetEntry).filter((e): e is number => e != null))];
if (devoluciones.length === 0) throw new BadRequestException('No hay Devoluciones creadas para descargar su PDF.');
const cfg = await this.configuracion(sesion);
if (!cfg) {
throw new BadRequestException('No se encontró configuración en la tabla @DTE_CONFC (RUT, usuario, contraseña y URL del web service).');
}
const resultados: ResultadoPdfDevolucion[] = [];
const descargados: { folio: number; pdf: Buffer }[] = [];
for (const dev of devoluciones) {
const resultado: ResultadoPdfDevolucion = { docEntry: dev };
resultados.push(resultado);
try {
const { docNum, folio } = await this.folioDe(sesion, dev);
if (!folio) {
// Sin folio no hay DTE emitido: consultar con otro número podría traer el PDF de otro documento.
resultado.error = `La Devolución Nº ${docNum} todavía no tiene folio (FolioNum): no se puede consultar su PDF.`;
continue;
}
resultado.folio = folio;
const respuesta = await this.bes.consultarPdf(cfg, TIPO_GUIA_DESPACHO, folio);
if (!respuesta.pdf) resultado.error = respuesta.error;
else if (!esPdf(respuesta.pdf)) resultado.error = 'El servicio devolvió un archivo que no es un PDF.';
else descargados.push({ folio: resultado.folio, pdf: respuesta.pdf });
} catch (error) {
resultado.error = (error as Error).message;
}
}
const fallidas = resultados.filter((r) => r.error).length;
this.logger.log(`PDF de la planificación ${docEntry} (${sesion.userName}): ${descargados.length} de ${devoluciones.length} descargados${fallidas ? `, ${fallidas} con error` : ''}.`);
const unificar = unir && descargados.length > 1;
const archivos: ArchivoPdf[] = unificar
? [await this.unificar(descargados, `DTE_Unificado_${plan.cabecera.docNum ?? docEntry}.pdf`)]
: descargados.map((d) => ({ nombre: `DTE_${d.folio}.pdf`, base64: d.pdf.toString('base64'), folios: [d.folio] }));
return { devoluciones: resultados, archivos, unificado: unificar };
}
/** Lee la fila única de @DTE_CONFC; null si la tabla está vacía. */
private async configuracion(sesion: SapSession): Promise<ConfiguracionDte | null> {
const columnas = 'U_RutEmpresa,U_UsuarioDTE,U_PasswordDTE,U_ConsultaTXTDTEWSDL';
const { value } = await this.sl.get<{ value: FilaConfiguracion[] }>(sesion, `U_DTE_CONFC?$select=${columnas}&$top=1`);
const fila = value[0];
if (!fila) return null;
return {
rutEmpresa: fila.U_RutEmpresa ?? '',
usuario: fila.U_UsuarioDTE ?? '',
password: fila.U_PasswordDTE ?? '',
wsdlUrl: fila.U_ConsultaTXTDTEWSDL ?? '',
};
}
/** FolioNum de la Devolución (indefinido si todavía no lo tiene) y su Nº de documento, para el mensaje. */
private async folioDe(sesion: SapSession, docEntry: number): Promise<{ docNum: number; folio?: number }> {
const dev = await this.sl.get<DevolucionSl>(sesion, `PurchaseReturns(${docEntry})?$select=DocEntry,DocNum,FolioNumber`);
return { docNum: dev.DocNum, folio: dev.FolioNumber || undefined };
}
private async unificar(archivos: { folio: number; pdf: Buffer }[], nombre: string): Promise<ArchivoPdf> {
const salida = await PDFDocument.create();
for (const { pdf } of archivos) {
const origen = await PDFDocument.load(pdf);
for (const pagina of await salida.copyPages(origen, origen.getPageIndices())) salida.addPage(pagina);
}
return {
nombre,
base64: Buffer.from(await salida.save()).toString('base64'),
folios: archivos.map((a) => a.folio),
};
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Body, Controller, HttpCode, Param, ParseIntPipe, Post } from '@nestjs/common';
import type { ResultadoPdf } from '@dp/shared';
import { IsBoolean, IsOptional } from 'class-validator';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { PdfDteService } from './pdf-dte.service.js';
class PdfDto {
/** Unir todos los PDF en uno solo (por defecto sí). */
@IsOptional() @IsBoolean() unir?: boolean;
}
@Controller('planificaciones')
export class PdfController {
constructor(private readonly pdf: PdfDteService) {}
/** Paso 4: descarga el PDF de la Guía de despacho de cada Devolución de la planificación. */
@Post(':docEntry/pdf')
@HttpCode(200)
generar(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Body() dto: PdfDto,
): Promise<ResultadoPdf> {
return this.pdf.generar(sesion, docEntry, dto.unir ?? true);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PlanificacionesModule } from '../planificaciones/planificaciones.module.js';
import { BesClient } from './bes.client.js';
import { PdfController } from './pdf.controller.js';
import { PdfDteService } from './pdf-dte.service.js';
@Module({ imports: [PlanificacionesModule], controllers: [PdfController], providers: [PdfDteService, BesClient] })
export class PdfModule {}
@@ -0,0 +1,49 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
import { TAMANO_PAGINA_MAXIMO } from '@dp/shared';
const FECHA_ISO = /^\d{4}-\d{2}-\d{2}$/;
/** Fecha/hora opcionales: la cadena vacía borra el valor guardado. */
const FECHA_OPCIONAL = /^(\d{4}-\d{2}-\d{2})?$/;
const HORA_OPCIONAL = /^(([01]\d|2[0-3]):[0-5]\d)?$/;
/** Campos de cabecera que el usuario puede cambiar mientras la planificación está abierta. */
export class CabeceraDto {
@IsOptional() @Matches(FECHA_ISO, { message: 'docDate debe tener formato yyyy-MM-dd' }) docDate?: string;
@IsOptional() @IsInt() @Min(0) series?: number;
@IsOptional() @IsInt() @Min(1) printingLines?: number;
@IsOptional() @IsString() @MaxLength(10) tipoDespacho?: string;
@IsOptional() @IsString() @MaxLength(10) indicadorTraslado?: string;
@IsOptional() @IsString() @MaxLength(20) rutTransporte?: string;
@IsOptional() @IsString() @MaxLength(20) rutChofer?: string;
@IsOptional() @IsString() @MaxLength(100) nombreChofer?: string;
@IsOptional() @Matches(FECHA_OPCIONAL, { message: 'fechaSalida debe tener formato yyyy-MM-dd' }) fechaSalida?: string;
@IsOptional() @Matches(HORA_OPCIONAL, { message: 'horaSalida debe tener formato HH:mm' }) horaSalida?: string;
@IsOptional() @Matches(FECHA_OPCIONAL, { message: 'fechaLlegada debe tener formato yyyy-MM-dd' }) fechaLlegada?: string;
@IsOptional() @IsString() @MaxLength(20) patente?: string;
}
export class PegarDto {
@IsString() @MaxLength(10) whsCode!: string;
@IsString() texto!: string;
}
export class ProcesarDto extends CabeceraDto {
@IsString() @MaxLength(10) whsCode!: string;
@Matches(FECHA_ISO, { message: 'docDate debe tener formato yyyy-MM-dd' }) declare docDate: string;
@IsString() texto!: string;
}
export class SeleccionDto {
@IsBoolean() applied!: boolean;
}
/** Parámetros de GET /planificaciones. Llegan como texto en la URL: `@Type` los convierte a número. */
export class ListarDto {
@IsOptional() @IsIn(['O', 'C'], { message: 'status debe ser O (abierta) o C (cerrada)' }) status?: 'O' | 'C';
@IsOptional() @IsString() @MaxLength(10) whsCode?: string;
@IsOptional() @Matches(FECHA_ISO, { message: 'desde debe tener formato yyyy-MM-dd' }) desde?: string;
@IsOptional() @Matches(FECHA_ISO, { message: 'hasta debe tener formato yyyy-MM-dd' }) hasta?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) pagina?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(TAMANO_PAGINA_MAXIMO) tamano?: number;
}
@@ -0,0 +1,96 @@
import { Body, Controller, Get, HttpCode, Param, ParseIntPipe, Patch, Post, Query, ValidationPipe } from '@nestjs/common';
import type {
PaginaPlanificaciones,
Planificacion,
RespuestaAbierta,
ResultadoCrearDevoluciones,
ResultadoPegadoApi,
ResultadoProcesar,
} from '@dp/shared';
import { CurrentSession } from '../auth/session.guard.js';
import type { SapSession } from '../service-layer/session.store.js';
import { CabeceraDto, ListarDto, PegarDto, ProcesarDto, SeleccionDto } from './dto/planificaciones.dto.js';
import { PlanificacionesService } from './planificaciones.service.js';
@Controller('planificaciones')
export class PlanificacionesController {
constructor(private readonly planificaciones: PlanificacionesService) {}
/** Una página de planificaciones, con filtros opcionales por estado, almacén y fechas. */
@Get()
listar(
@CurrentSession() sesion: SapSession,
@Query(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) filtros: ListarDto,
): Promise<PaginaPlanificaciones> {
return this.planificaciones.listar(sesion, filtros);
}
/** La planificación abierta (`{ abierta: null }` si no hay ninguna). Siempre responde un JSON. */
@Get('abierta')
async abierta(@CurrentSession() sesion: SapSession): Promise<RespuestaAbierta> {
return { abierta: await this.planificaciones.abierta(sesion) };
}
/** Paso 1: valida el texto pegado desde Excel contra el stock del almacén. No guarda. */
@Post('pegar')
@HttpCode(200)
pegar(@CurrentSession() sesion: SapSession, @Body() dto: PegarDto): Promise<ResultadoPegadoApi> {
return this.planificaciones.validarPegado(sesion, dto.whsCode, dto.texto);
}
/** Paso 2: asigna FIFO y guarda la planificación. */
@Post()
procesar(@CurrentSession() sesion: SapSession, @Body() dto: ProcesarDto): Promise<ResultadoProcesar> {
return this.planificaciones.procesar(sesion, dto);
}
/** Rehace el FIFO de una planificación abierta sin Devoluciones: crea una nueva y cierra la anterior. */
@Post(':docEntry/reprocesar')
reprocesar(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Body() dto: ProcesarDto,
): Promise<ResultadoProcesar> {
return this.planificaciones.reprocesar(sesion, docEntry, dto);
}
@Get(':docEntry')
obtener(@CurrentSession() sesion: SapSession, @Param('docEntry', ParseIntPipe) docEntry: number): Promise<Planificacion> {
return this.planificaciones.obtener(sesion, docEntry);
}
@Patch(':docEntry/cabecera')
actualizarCabecera(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Body() dto: CabeceraDto,
): Promise<Planificacion> {
return this.planificaciones.actualizarCabecera(sesion, docEntry, dto);
}
@Patch(':docEntry/lineas/:lineId')
cambiarSeleccion(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
@Param('lineId', ParseIntPipe) lineId: number,
@Body() dto: SeleccionDto,
): Promise<Planificacion> {
return this.planificaciones.cambiarSeleccion(sesion, docEntry, lineId, dto.applied);
}
/** Paso 3: crea las Devoluciones de las líneas marcadas que aún no tienen documento. */
@Post(':docEntry/devoluciones')
@HttpCode(200)
crearDevoluciones(
@CurrentSession() sesion: SapSession,
@Param('docEntry', ParseIntPipe) docEntry: number,
): Promise<ResultadoCrearDevoluciones> {
return this.planificaciones.crearDevoluciones(sesion, docEntry);
}
@Post(':docEntry/cerrar')
@HttpCode(200)
cerrar(@CurrentSession() sesion: SapSession, @Param('docEntry', ParseIntPipe) docEntry: number): Promise<Planificacion> {
return this.planificaciones.cerrar(sesion, docEntry);
}
}
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CatalogosModule } from '../catalogos/catalogos.module.js';
import { PlanificacionesController } from './planificaciones.controller.js';
import { PlanificacionesService } from './planificaciones.service.js';
@Module({ imports: [CatalogosModule], controllers: [PlanificacionesController], providers: [PlanificacionesService], exports: [PlanificacionesService] })
export class PlanificacionesModule {}
@@ -0,0 +1,168 @@
import { PlanificacionesService, construirFiltro } from './planificaciones.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js';
import type { SapSession } from '../service-layer/session.store.js';
const sesion = { id: 's', userName: 'manager' } as SapSession;
const udo = (docEntry: number, status: 'O' | 'C', lineas: Record<string, unknown>[] = []) => ({
DocEntry: docEntry,
DocNum: docEntry,
Status: status,
U_WhsCode: '01',
U_DocDate: '2026-10-12T00:00:00Z',
U_Series: 15,
U_PrintingLines: 11,
U_TipoDespacho: '1',
U_IndicadorTraslado: '7',
U_Patente: 'AB1234',
LIB_DEV1Collection: lineas,
});
const filaHijo = (extra: Record<string, unknown> = {}) => ({ LineId: 2, U_LineType: 'S', U_ItemCode: 'A1', U_Applied: 'Y', ...extra });
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 } = {}) {
const llamadas: string[] = [];
const sl = {
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
if (path.startsWith('LIB_ODEV(')) return anterior;
throw new Error(`GET inesperado ${path}`);
}),
post: vi.fn(async (_s: unknown, path: string, body: unknown) => {
llamadas.push(path);
if (path === 'LIB_ODEV') return { ...udo(4, 'O'), ...(body as object) , DocEntry: 4, DocNum: 4, Status: 'O' };
if (path.endsWith('/Close')) {
if (opciones.fallaCierre) throw new Error('no se pudo cerrar');
return {};
}
throw new Error(`POST inesperado ${path}`);
}),
};
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 }];
}),
};
const catalogos = { series: vi.fn(async () => [{ series: 15, name: 'Primario', nextNumber: 1, esPorDefecto: true }]) };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const servicio = new PlanificacionesService(sl as any, sql as any, catalogos as any);
return { servicio, sl, llamadas };
}
describe('PlanificacionesService.reprocesar', () => {
it('crea la nueva planificación heredando la cabecera y luego cierra la anterior', async () => {
const { servicio, sl, llamadas } = crear(udo(3, 'O', [filaHijo()]));
const r = await servicio.reprocesar(sesion, 3, dto());
expect(llamadas).toEqual(['LIB_ODEV', 'LIB_ODEV(3)/Close']);
const cuerpo = sl.post.mock.calls[0][2] as Record<string, unknown>;
expect(cuerpo).toMatchObject({ U_WhsCode: '01', U_DocDate: '2026-10-13', U_Series: 15, U_TipoDespacho: '1', U_IndicadorTraslado: '7', U_Patente: 'AB1234' });
expect(cuerpo['LIB_DEV1Collection']).toHaveLength(2); // Padre + 1 Hijo
expect(r.planificacion.cabecera.docEntry).toBe(4);
expect(r.reemplazada).toEqual({ docNum: 3, errorCierre: undefined });
});
it('no deja reprocesar si ya hay una Devolución creada', async () => {
const { servicio, llamadas } = crear(udo(3, 'O', [filaHijo({ U_TargetEntry: 33, U_TargetType: '21' })]));
await expect(servicio.reprocesar(sesion, 3, dto())).rejects.toThrow(/ya tiene Devoluciones creadas/);
expect(llamadas).toEqual([]);
});
it('no deja reprocesar una planificación cerrada', async () => {
const { servicio, llamadas } = crear(udo(3, 'C'));
await expect(servicio.reprocesar(sesion, 3, dto())).rejects.toThrow(/está cerrada/);
expect(llamadas).toEqual([]);
});
it('si el listado nuevo no valida, no crea nada ni cierra la anterior', async () => {
const { servicio, llamadas } = crear(udo(3, 'O', [filaHijo()]), { stock: [] });
await expect(servicio.reprocesar(sesion, 3, dto('X9\t1'))).rejects.toThrow(/no existe/);
expect(llamadas).toEqual([]);
});
it('si la nueva se creó pero no se pudo cerrar la anterior, lo informa', async () => {
const { servicio } = crear(udo(3, 'O', [filaHijo()]), { fallaCierre: true });
const r = await servicio.reprocesar(sesion, 3, dto());
expect(r.planificacion.cabecera.docEntry).toBe(4);
expect(r.reemplazada?.errorCierre).toBe('no se pudo cerrar');
});
});
describe('PlanificacionesService.procesar', () => {
it('con una planificación abierta no crea otra', async () => {
const { servicio, sl } = crear(udo(3, 'O'));
sl.get.mockImplementation(async (_s: unknown, path: string) => {
if (path.includes('$filter')) return { value: [{ DocEntry: 3, DocNum: 3, Status: 'O', U_WhsCode: '01', U_DocDate: '2026-10-12T00:00:00Z' }] };
return udo(3, 'O');
});
await expect(servicio.procesar(sesion, dto())).rejects.toThrow(/Ya hay una planificación abierta/);
expect(sl.post).not.toHaveBeenCalled();
});
});
describe('construirFiltro', () => {
it('sin filtros queda vacío', () => {
expect(construirFiltro({})).toBe('');
});
it('combina estado, almacén y rango de fechas con "and"', () => {
expect(construirFiltro({ status: 'O', whsCode: '01', desde: '2026-10-01', hasta: '2026-10-31' })).toBe(
"Status eq 'O' and U_WhsCode eq '01' and U_DocDate ge '2026-10-01' and U_DocDate le '2026-10-31'",
);
});
it('escapa las comillas simples del texto', () => {
expect(construirFiltro({ whsCode: "a'b" })).toBe("U_WhsCode eq 'a''b'");
});
});
describe('PlanificacionesService.listar', () => {
const fila = (n: number) => ({ DocEntry: n, DocNum: n, Status: 'O', U_WhsCode: '01', U_DocDate: '2026-10-12T00:00:00Z' });
function crearConLista(respuesta: object) {
const sl = { request: vi.fn(async () => respuesta) };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { servicio: new PlanificacionesService(sl as any, {} as any, {} as any), sl };
}
it('pide a SAP solo la página pedida, con filtros y total', async () => {
const { servicio, sl } = crearConLista({ value: [fila(41), fila(40)], 'odata.count': '45' });
const r = await servicio.listar(sesion, { status: 'C', pagina: 3, tamano: 20, desde: '2026-10-01' });
const [, metodo, ruta, , cabeceras] = sl.request.mock.calls[0] as unknown as [unknown, string, string, unknown, Record<string, string>];
expect(metodo).toBe('GET');
expect(ruta).toContain('$top=20');
expect(ruta).toContain('$skip=40');
expect(ruta).toContain('$inlinecount=allpages');
expect(decodeURIComponent(ruta)).toContain("$filter=Status eq 'C' and U_DocDate ge '2026-10-01'");
expect(decodeURIComponent(ruta)).toContain('$orderby=DocEntry desc');
expect(cabeceras).toEqual({ Prefer: 'odata.maxpagesize=20' });
expect(r).toMatchObject({ total: 45, pagina: 3, tamano: 20, totalPaginas: 3 });
expect(r.items.map((i) => i.docEntry)).toEqual([41, 40]);
});
it('por defecto: página 1 de 20, sin $filter', async () => {
const { servicio, sl } = crearConLista({ value: [], 'odata.count': 0 });
const r = await servicio.listar(sesion);
const ruta = (sl.request.mock.calls[0] as unknown as [unknown, string, string])[2];
expect(ruta).toContain('$top=20');
expect(ruta).toContain('$skip=0');
expect(ruta).not.toContain('$filter');
expect(r).toMatchObject({ total: 0, pagina: 1, totalPaginas: 1, items: [] });
});
it('limita el tamaño de página al máximo', async () => {
const { servicio, sl } = crearConLista({ value: [], 'odata.count': 0 });
await servicio.listar(sesion, { tamano: 5000 });
expect((sl.request.mock.calls[0] as unknown as [unknown, string, string])[2]).toContain('$top=100');
});
});
@@ -0,0 +1,416 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import type {
ArticuloADevolver,
CabeceraEditable,
CabeceraPlanificacion,
FiltrosPlanificaciones,
PaginaPlanificaciones,
EntradaAbierta,
Planificacion,
ResultadoCrearDevoluciones,
ResultadoDevolucion,
ResultadoPegadoApi,
ResultadoProcesar,
ResumenPlanificacion,
} from '@dp/shared';
import {
MAX_LINEAS_POR_DOCUMENTO_DEFECTO,
TAMANO_PAGINA_DEFECTO,
TAMANO_PAGINA_MAXIMO,
OBJ_TYPE_DEVOLUCION,
OBJ_TYPE_ENTRADA_MERCANCIAS,
asignarFifo,
consolidarDocumentos,
crearLineaPadre,
extraerLineasAplicables,
parsearPortapapeles,
puedeCambiarSeleccion,
} from '@dp/shared';
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 { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js';
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
import type { UdoSl } from './udo.mapper.js';
interface FilaStock {
ItemCode: string;
ItemName: string | null;
OnHand: number | string;
}
interface FilaEntrada {
DocNum: number;
CardCode: string;
CardName: string;
DocDate: string;
DocEntry: number;
LineNum: number;
OpenQty: number | string;
}
interface DocumentoCreado {
DocEntry: number;
DocNum: number;
}
interface ResumenUdoSl {
DocEntry: number;
DocNum: number;
Status: string;
U_WhsCode: string;
U_DocDate: string;
}
const CONCURRENCIA_CONSULTAS = 8;
const COLUMNAS_RESUMEN = 'DocEntry,DocNum,Status,U_WhsCode,U_DocDate';
/** "2026-10-12T00:00:00Z" o "20261012" → "2026-10-12". */
const fechaIso = (valor: string): string =>
valor.includes('-') ? valor.slice(0, 10) : `${valor.slice(0, 4)}-${valor.slice(4, 6)}-${valor.slice(6, 8)}`;
/** Ejecuta `fn` sobre cada elemento con a lo sumo `limite` llamadas simultáneas, conservando el orden. */
async function enLotes<T, R>(items: T[], limite: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const resultados = Array.from<R>({ length: items.length });
let siguiente = 0;
const trabajadores = Array.from({ length: Math.min(limite, items.length) }, async () => {
while (siguiente < items.length) {
const i = siguiente++;
resultados[i] = await fn(items[i]);
}
});
await Promise.all(trabajadores);
return resultados;
}
@Injectable()
export class PlanificacionesService {
private readonly logger = new Logger(PlanificacionesService.name);
constructor(
private readonly sl: ServiceLayerClient,
private readonly sql: SqlQueriesService,
private readonly catalogos: CatalogosService,
) {}
/** Paso 1: parsea lo pegado desde Excel y completa descripción y stock del almacén. No guarda nada. */
async validarPegado(sesion: SapSession, whsCode: string, texto: string): Promise<ResultadoPegadoApi> {
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],
}));
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}.`);
continue;
}
const articulo: ArticuloADevolver = {
itemCode: stock.ItemCode,
itemName: stock.ItemName ?? '',
quantity: fila.quantity,
onHand: Math.floor(Number(stock.OnHand)),
};
lineas.push(crearLineaPadre(articulo));
}
return { lineas, errores };
}
/**
* Una página de planificaciones, de la más nueva a la más vieja. Los filtros se aplican en SAP
* ($filter) y la paginación con $top/$skip; el total sale de $inlinecount (OData v3 del SL v1).
*/
async listar(sesion: SapSession, filtros: FiltrosPlanificaciones = {}): Promise<PaginaPlanificaciones> {
const tamano = Math.min(Math.max(filtros.tamano ?? TAMANO_PAGINA_DEFECTO, 1), TAMANO_PAGINA_MAXIMO);
const pagina = Math.max(filtros.pagina ?? 1, 1);
const filtro = construirFiltro(filtros);
const consulta = [
`$select=${COLUMNAS_RESUMEN}`,
`$orderby=${encodeURIComponent('DocEntry desc')}`,
`$top=${tamano}`,
`$skip=${(pagina - 1) * tamano}`,
'$inlinecount=allpages',
...(filtro ? [`$filter=${encodeURIComponent(filtro)}`] : []),
].join('&');
const respuesta = await this.sl.request<{ value: ResumenUdoSl[]; 'odata.count'?: string | number }>(
sesion,
'GET',
`LIB_ODEV?${consulta}`,
undefined,
{ Prefer: `odata.maxpagesize=${tamano}` },
);
const total = Number(respuesta['odata.count'] ?? respuesta.value.length);
return { items: respuesta.value.map(aResumen), total, pagina, tamano, totalPaginas: Math.max(Math.ceil(total / tamano), 1) };
}
/** La planificación abierta (solo puede haber una) o null. */
async abierta(sesion: SapSession): Promise<ResumenPlanificacion | null> {
const filtro = encodeURIComponent("Status eq 'O'");
const { value } = await this.sl.get<{ value: ResumenUdoSl[] }>(sesion, `LIB_ODEV?$select=${COLUMNAS_RESUMEN}&$filter=${filtro}&$top=1`);
return value[0] ? aResumen(value[0]) : null;
}
async obtener(sesion: SapSession, docEntry: number): Promise<Planificacion> {
return planificacionDesdeUdo(await this.sl.get<UdoSl>(sesion, `LIB_ODEV(${docEntry})`));
}
/** Paso 2: valida, asigna FIFO contra Entradas abiertas del almacén y guarda la planificación. */
async procesar(sesion: SapSession, dto: ProcesarDto): Promise<ResultadoProcesar> {
const existente = await this.abierta(sesion);
if (existente) {
throw new ConflictException(`Ya hay una planificación abierta (Nº ${existente.docNum}). Ciérrela antes de crear otra.`);
}
const { payload, lineas, advertencias } = await this.construir(sesion, dto);
const creada = await this.sl.post<UdoSl>(sesion, 'LIB_ODEV', payload);
this.logger.log(`Planificación ${creada.DocEntry} creada por ${sesion.userName}: ${lineas} líneas.`);
return { planificacion: planificacionDesdeUdo(creada), advertencias };
}
/**
* Rehace el FIFO de una planificación abierta que aún no tiene Devoluciones. Las filas de un UDO
* no se pueden quitar por Service Layer, así que se crea una planificación nueva (heredando los
* datos de cabecera) y, solo si salió bien, se cierra la anterior.
*/
async reprocesar(sesion: SapSession, docEntry: number, dto: ProcesarDto): Promise<ResultadoProcesar> {
const anterior = await this.exigirAbierta(sesion, docEntry);
if (anterior.lineas.some((l) => l.targetEntry != null)) {
throw new ConflictException(
`La planificación ${anterior.cabecera.docNum} ya tiene Devoluciones creadas y no se puede reprocesar. Ciérrela y cree una nueva con lo pendiente.`,
);
}
const { payload, lineas, advertencias } = await this.construir(sesion, dto, anterior.cabecera);
const creada = await this.sl.post<UdoSl>(sesion, 'LIB_ODEV', payload);
this.logger.log(
`Planificación ${creada.DocEntry} creada por ${sesion.userName} reemplazando a ${anterior.cabecera.docNum}: ${lineas} líneas.`,
);
let errorCierre: string | undefined;
try {
await this.sl.post(sesion, `LIB_ODEV(${docEntry})/Close`, {});
} catch (error) {
errorCierre = (error as Error).message;
this.logger.error(`No se pudo cerrar la planificación ${anterior.cabecera.docNum} tras reemplazarla: ${errorCierre}`);
}
return { planificacion: planificacionDesdeUdo(creada), advertencias, reemplazada: { docNum: anterior.cabecera.docNum ?? docEntry, errorCierre } };
}
/**
* Valida lo pegado, asigna el FIFO y arma el cuerpo del POST a LIB_ODEV. `base` aporta los datos de
* cabecera que el usuario ya había completado en la planificación que se reemplaza.
*/
private async construir(
sesion: SapSession,
dto: ProcesarDto,
base?: CabeceraPlanificacion,
): Promise<{ payload: Record<string, unknown>; lineas: number; advertencias: string[] }> {
const pegado = await this.validarPegado(sesion, dto.whsCode, dto.texto);
if (pegado.errores.length > 0) throw new BadRequestException({ message: pegado.errores.join(' '), errores: pegado.errores });
if (pegado.lineas.length === 0) throw new BadRequestException('No hay artículos para procesar.');
const articulos: ArticuloADevolver[] = pegado.lineas.map((l) => ({
itemCode: l.itemCode,
itemName: l.itemDescription,
quantity: l.quantity ?? 0,
onHand: l.onHand ?? 0,
}));
const entradasPorItem = new Map<string, EntradaAbierta[]>();
await enLotes(
articulos.filter((a) => a.quantity <= a.onHand),
CONCURRENCIA_CONSULTAS,
async (a) => {
const filas = await this.sql.consultar<FilaEntrada>(sesion, CONSULTA_ENTRADAS_ABIERTAS, { whs: dto.whsCode, item: a.itemCode });
entradasPorItem.set(
a.itemCode.toUpperCase(),
filas.map((f) => ({
docEntry: f.DocEntry,
lineNum: f.LineNum,
docNum: f.DocNum,
docDate: fechaIso(String(f.DocDate)),
cardCode: f.CardCode,
cardName: f.CardName,
openQty: Math.floor(Number(f.OpenQty)),
})),
);
},
);
const { lineas, advertencias } = asignarFifo(articulos, entradasPorItem);
if (lineas.length === 0) throw new BadRequestException({ message: 'Ningún artículo tiene stock suficiente.', advertencias });
const { texto: _texto, ...cabecera } = dto;
const heredada = base && {
series: base.series,
printingLines: base.printingLines,
tipoDespacho: base.tipoDespacho,
indicadorTraslado: base.indicadorTraslado,
rutTransporte: base.rutTransporte,
rutChofer: base.rutChofer,
nombreChofer: base.nombreChofer,
fechaSalida: base.fechaSalida,
horaSalida: base.horaSalida,
fechaLlegada: base.fechaLlegada,
patente: base.patente,
};
const combinada = { ...heredada, ...cabecera };
const series = combinada.series || ((await this.catalogos.series(sesion)).find((s) => s.esPorDefecto)?.series ?? 0);
return {
payload: {
...cabeceraAUdo(
{ ...combinada, series, printingLines: combinada.printingLines || MAX_LINEAS_POR_DOCUMENTO_DEFECTO },
OBJ_TYPE_DEVOLUCION,
),
LIB_DEV1Collection: lineas.map(lineaAUdo),
},
lineas: lineas.length,
advertencias,
};
}
async actualizarCabecera(sesion: SapSession, docEntry: number, cambios: CabeceraEditable): Promise<Planificacion> {
await this.exigirAbierta(sesion, docEntry);
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, cabeceraAUdo(cambios));
return this.obtener(sesion, docEntry);
}
/** Marca o desmarca una fila Hijo. Una fila con Devolución ya creada no se puede cambiar. */
async cambiarSeleccion(sesion: SapSession, docEntry: number, lineId: number, applied: boolean): Promise<Planificacion> {
const plan = await this.exigirAbierta(sesion, docEntry);
const linea = plan.lineas.find((l) => l.lineId === lineId);
if (!linea) throw new BadRequestException(`La línea ${lineId} no existe en la planificación.`);
if (!puedeCambiarSeleccion(linea)) {
throw new ConflictException('Solo las filas Hijo sin Devolución creada pueden marcarse o desmarcarse.');
}
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, { LIB_DEV1Collection: [{ LineId: lineId, U_Applied: applied ? 'Y' : 'N' }] });
return this.obtener(sesion, docEntry);
}
/**
* Paso 3: crea una Devolución por proveedor y lote de U_PrintingLines líneas. Cada documento se
* crea por separado: los que salen bien graban TargetEntry en sus líneas, los que fallan
* graban ErrCode/ErrMsg y quedan listos para reintentar.
*/
async crearDevoluciones(sesion: SapSession, docEntry: number): Promise<ResultadoCrearDevoluciones> {
const plan = await this.exigirAbierta(sesion, docEntry);
const cab = plan.cabecera;
const faltantes = [
!cab.docDate && 'fecha',
!cab.series && 'serie',
!cab.tipoDespacho && 'tipo de despacho',
!cab.indicadorTraslado && 'indicador de traslado',
].filter(Boolean);
if (faltantes.length > 0) throw new BadRequestException(`Falta completar: ${faltantes.join(', ')}.`);
const aplicables = extraerLineasAplicables(plan.lineas);
if (aplicables.length === 0) throw new BadRequestException('No hay líneas marcadas pendientes de devolver.');
const documentos = consolidarDocumentos(aplicables, cab.printingLines);
const devoluciones: ResultadoDevolucion[] = [];
const cambiosLineas: Record<string, unknown>[] = [];
for (const doc of documentos) {
const base = { numero: doc.numero, cardCode: doc.cardCode, cardName: doc.cardName, lineas: doc.lineas.length };
try {
const creado = await this.sl.post<DocumentoCreado>(sesion, 'PurchaseReturns', {
CardCode: doc.cardCode,
Series: cab.series,
DocDate: cab.docDate,
TaxDate: cab.docDate,
DocDueDate: cab.docDate,
U_BFE_TipoDespacho: cab.tipoDespacho,
U_BFE_IndTraslado: cab.indicadorTraslado,
...(cab.rutTransporte && { U_BFE_RutTrasporte: cab.rutTransporte }),
...(cab.patente && { U_BFE_Patente: cab.patente }),
...(cab.rutChofer && { U_BFE_RutChofer: cab.rutChofer }),
...(cab.nombreChofer && { U_BFE_NombreChofer: cab.nombreChofer }),
DocumentLines: doc.lineas.map((l) => ({
Quantity: l.allocatedQty,
BaseType: OBJ_TYPE_ENTRADA_MERCANCIAS,
BaseEntry: l.baseEntry,
BaseLine: l.baseLine,
})),
});
devoluciones.push({ ...base, docEntry: creado.DocEntry, docNum: creado.DocNum });
for (const l of doc.lineas) {
cambiosLineas.push({
LineId: l.lineId,
U_TargetType: String(OBJ_TYPE_DEVOLUCION),
U_TargetEntry: creado.DocEntry,
U_ErrCode: '',
U_ErrMsg: '',
});
}
} catch (error) {
const mensaje = (error as Error).message;
const codigo = error instanceof ServiceLayerException ? String(error.sapCode ?? 'ERR') : 'ERR';
this.logger.warn(`Devolución ${doc.numero} (${doc.cardCode}) falló: ${mensaje}`);
devoluciones.push({ ...base, error: mensaje });
for (const l of doc.lineas) {
cambiosLineas.push({ LineId: l.lineId, U_ErrCode: codigo.slice(0, 10), U_ErrMsg: mensaje.slice(0, 254) });
}
}
}
let errorGuardado: string | undefined;
try {
await this.sl.patch(sesion, `LIB_ODEV(${docEntry})`, { LIB_DEV1Collection: cambiosLineas });
} catch (error) {
errorGuardado = (error as Error).message;
this.logger.error(`Devoluciones creadas pero no se pudo guardar el detalle de la planificación ${docEntry}: ${errorGuardado}`);
}
const planificacion = await this.obtener(sesion, docEntry).catch(() => plan);
return { devoluciones, errorGuardado, planificacion };
}
async cerrar(sesion: SapSession, docEntry: number): Promise<Planificacion> {
await this.exigirAbierta(sesion, docEntry);
await this.sl.post(sesion, `LIB_ODEV(${docEntry})/Close`, {});
return this.obtener(sesion, docEntry);
}
private async exigirAbierta(sesion: SapSession, docEntry: number): Promise<Planificacion> {
const plan = await this.obtener(sesion, docEntry);
if (plan.cabecera.status === 'C') throw new ConflictException(`La planificación ${plan.cabecera.docNum} está cerrada.`);
return plan;
}
}
/** Arma el $filter de OData a partir de los filtros; los textos van entre comillas simples escapadas. */
export function construirFiltro(f: FiltrosPlanificaciones): string {
const texto = (v: string) => `'${v.replace(/'/g, "''")}'`;
return [
f.status && `Status eq ${texto(f.status)}`,
f.whsCode && `U_WhsCode eq ${texto(f.whsCode)}`,
f.desde && `U_DocDate ge ${texto(f.desde)}`,
f.hasta && `U_DocDate le ${texto(f.hasta)}`,
]
.filter(Boolean)
.join(' and ');
}
function aResumen(u: ResumenUdoSl): ResumenPlanificacion {
return {
docEntry: u.DocEntry,
docNum: u.DocNum,
status: u.Status === 'C' ? 'C' : 'O',
whsCode: u.U_WhsCode ?? '',
docDate: typeof u.U_DocDate === 'string' ? u.U_DocDate.slice(0, 10) : '',
};
}
@@ -0,0 +1,89 @@
import type { LineaPlanificacion } from '@dp/shared';
import { cabeceraAUdo, cabeceraDesdeUdo, horaDesdeSl, lineaAUdo, lineaDesdeUdo, planificacionDesdeUdo } from './udo.mapper.js';
const hijo: LineaPlanificacion = {
tipoFila: 'S',
itemCode: 'A1',
itemDescription: 'Libro',
quantity: null,
onHand: null,
status: null,
baseType: 20,
baseEntry: 15,
baseLine: 2,
baseRef: 101,
baseDocDate: '2026-01-31',
openQty: 10,
allocatedQty: 4,
applied: true,
cardCode: 'P001',
cardName: 'Proveedor',
targetType: null,
targetEntry: null,
errCode: '',
errMsg: '',
};
describe('horaDesdeSl', () => {
it.each([
['14:30:00', '14:30'],
['9:05:00', '09:05'],
[1430, '14:30'],
['905', '09:05'],
[null, ''],
['', ''],
])('%s → %s', (entrada, esperado) => {
expect(horaDesdeSl(entrada)).toBe(esperado);
});
});
describe('cabecera', () => {
it('convierte fechas ISO, hora y estado', () => {
const cab = cabeceraDesdeUdo({
DocEntry: 3,
DocNum: 3,
Status: 'O',
U_WhsCode: '01',
U_DocDate: '2026-10-12T00:00:00Z',
U_Series: 15,
U_PrintingLines: 11,
U_HoraSalida: '08:05:00',
U_FechaSalida: null,
});
expect(cab).toMatchObject({ docEntry: 3, status: 'O', whsCode: '01', docDate: '2026-10-12', series: 15, horaSalida: '08:05', fechaSalida: '' });
});
it('al escribir solo manda lo que viene y la hora con segundos', () => {
expect(cabeceraAUdo({ patente: 'AB1234', horaSalida: '08:05', fechaSalida: '' })).toEqual({
U_Patente: 'AB1234',
U_HoraSalida: '08:05:00',
U_FechaSalida: null,
});
});
});
describe('líneas', () => {
it('ida y vuelta de una fila Hijo', () => {
const udo = lineaAUdo(hijo);
expect(udo).toMatchObject({ U_LineType: 'S', U_BaseType: '20', U_BaseEntry: 15, U_AllocatedQty: 4, U_Applied: 'Y' });
expect(udo).not.toHaveProperty('U_Quantity');
expect(lineaDesdeUdo(udo)).toEqual(hijo);
});
it('un TargetEntry en 0 (campo sin llenar) se lee como null', () => {
const l = lineaDesdeUdo({ LineId: 1, U_LineType: 'S', U_TargetEntry: 0, U_TargetType: '' });
expect(l.targetEntry).toBeNull();
expect(l.targetType).toBeNull();
});
it('una fila Padre conserva cantidad, stock y estado', () => {
const padre = lineaDesdeUdo({ LineId: 1, U_LineType: 'F', U_ItemCode: 'A1', U_Quantity: 5, U_OnHand: 8, U_Status: 'OK', U_BaseEntry: 0 });
expect(padre).toMatchObject({ tipoFila: 'F', quantity: 5, onHand: 8, status: 'OK', baseEntry: null });
});
it('planificacionDesdeUdo arma cabecera y líneas', () => {
const p = planificacionDesdeUdo({ DocEntry: 1, DocNum: 1, Status: 'C', LIB_DEV1Collection: [{ LineId: 1, U_LineType: 'F' }] });
expect(p.cabecera.status).toBe('C');
expect(p.lineas).toHaveLength(1);
});
});
+160
View File
@@ -0,0 +1,160 @@
import type { CabeceraPlanificacion, LineaPlanificacion, Planificacion } from '@dp/shared';
// Traducción entre el modelo de dominio (@dp/shared) y el UDO LIB_ODEV tal como lo expone el
// Service Layer (cabecera con prefijo U_ y líneas en LIB_DEV1Collection).
export interface UdoLineaSl {
LineId?: number;
[campo: string]: unknown;
}
export interface UdoSl {
DocEntry: number;
DocNum: number;
Status: string;
LIB_DEV1Collection?: UdoLineaSl[];
[campo: string]: unknown;
}
type Escalar = string | number | boolean | null | undefined;
const texto = (v: unknown): string => (v == null ? '' : String(v as Escalar));
const entero = (v: unknown): number | null => {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/** Referencias a documentos: el UDO devuelve 0 cuando el campo numérico no se llenó. */
const referencia = (v: unknown): number | null => {
const n = entero(v);
return n === 0 ? null : n;
};
/** "2026-10-12T00:00:00Z" → "2026-10-12". */
const fechaDesdeSl = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
/** El Service Layer entrega la hora como "14:30:00" o como número (1430): se normaliza a "HH:mm". */
export function horaDesdeSl(v: unknown): string {
if (v == null || v === '') return '';
const s = String(v as Escalar);
const hhmm = /^(\d{1,2}):(\d{2})/.exec(s);
if (hhmm) return `${hhmm[1].padStart(2, '0')}:${hhmm[2]}`;
if (/^\d{3,4}$/.test(s)) return `${s.padStart(4, '0').slice(0, 2)}:${s.padStart(4, '0').slice(2)}`;
return '';
}
/** "14:30" → "14:30:00" (formato que acepta el Service Layer para db_Date/st_Time). */
const horaASl = (v: string): string => (/^\d{2}:\d{2}$/.test(v) ? `${v}:00` : v);
export function cabeceraDesdeUdo(u: UdoSl): CabeceraPlanificacion {
return {
docEntry: u.DocEntry,
docNum: u.DocNum,
status: u.Status === 'C' ? 'C' : 'O',
whsCode: texto(u.U_WhsCode),
docDate: fechaDesdeSl(u.U_DocDate),
series: entero(u.U_Series) ?? 0,
printingLines: entero(u.U_PrintingLines) ?? 0,
tipoDespacho: texto(u.U_TipoDespacho),
indicadorTraslado: texto(u.U_IndicadorTraslado),
rutTransporte: texto(u.U_RutTransporte),
rutChofer: texto(u.U_RutChofer),
nombreChofer: texto(u.U_NombreChofer),
fechaSalida: fechaDesdeSl(u.U_FechaSalida),
horaSalida: horaDesdeSl(u.U_HoraSalida),
fechaLlegada: fechaDesdeSl(u.U_FechaLlegada),
patente: texto(u.U_Patente),
};
}
export function lineaDesdeUdo(l: UdoLineaSl): LineaPlanificacion {
const esPadre = l.U_LineType !== 'S';
const estado = texto(l.U_Status);
return {
lineId: l.LineId,
tipoFila: esPadre ? 'F' : 'S',
itemCode: texto(l.U_ItemCode),
itemDescription: texto(l.U_ItemDescription),
quantity: esPadre ? entero(l.U_Quantity) : null,
onHand: esPadre ? entero(l.U_OnHand) : null,
status: esPadre && (estado === 'OK' || estado === 'SIN_STOCK') ? estado : null,
baseType: esPadre ? null : referencia(l.U_BaseType),
baseEntry: esPadre ? null : referencia(l.U_BaseEntry),
baseLine: esPadre ? null : entero(l.U_BaseLine),
baseRef: esPadre ? null : referencia(l.U_BaseRef),
baseDocDate: esPadre ? null : fechaDesdeSl(l.U_BaseDocDate) || null,
openQty: esPadre ? null : entero(l.U_OpenQty),
allocatedQty: esPadre ? null : entero(l.U_AllocatedQty),
applied: l.U_Applied === 'Y',
cardCode: texto(l.U_CardCode),
cardName: texto(l.U_CardName),
targetType: referencia(l.U_TargetType),
targetEntry: referencia(l.U_TargetEntry),
errCode: texto(l.U_ErrCode),
errMsg: texto(l.U_ErrMsg),
};
}
export function planificacionDesdeUdo(u: UdoSl): Planificacion {
return {
cabecera: cabeceraDesdeUdo(u),
lineas: (u.LIB_DEV1Collection ?? []).map(lineaDesdeUdo),
};
}
/** Quita los valores indefinidos para no mandar campos vacíos al Service Layer. */
function sinIndefinidos(o: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
}
/** Cabecera → propiedades U_ del UDO. Solo incluye los campos presentes (sirve para PATCH). */
export function cabeceraAUdo(c: Partial<CabeceraPlanificacion>, objType?: number): Record<string, unknown> {
const fecha = (v: string | undefined) => (v === undefined ? undefined : v === '' ? null : v);
return sinIndefinidos({
U_WhsCode: c.whsCode,
U_DocDate: fecha(c.docDate),
U_Series: c.series,
U_ObjType: objType === undefined ? undefined : String(objType),
U_PrintingLines: c.printingLines,
U_TipoDespacho: c.tipoDespacho,
U_IndicadorTraslado: c.indicadorTraslado,
U_RutTransporte: c.rutTransporte,
U_RutChofer: c.rutChofer,
U_NombreChofer: c.nombreChofer,
U_FechaSalida: fecha(c.fechaSalida),
U_HoraSalida: c.horaSalida === undefined ? undefined : c.horaSalida === '' ? null : horaASl(c.horaSalida),
U_FechaLlegada: fecha(c.fechaLlegada),
U_Patente: c.patente,
});
}
/** Línea → propiedades U_ de LIB_DEV1. Con lineId sirve para actualizar esa fila por PATCH. */
export function lineaAUdo(l: LineaPlanificacion): Record<string, unknown> {
const esPadre = l.tipoFila === 'F';
return sinIndefinidos({
LineId: l.lineId,
U_LineType: l.tipoFila,
U_ItemCode: l.itemCode,
U_ItemDescription: l.itemDescription,
U_Quantity: esPadre ? (l.quantity ?? undefined) : undefined,
U_OnHand: esPadre ? (l.onHand ?? undefined) : undefined,
U_Status: esPadre ? (l.status ?? undefined) : undefined,
U_BaseType: l.baseType == null ? undefined : String(l.baseType),
U_BaseEntry: l.baseEntry ?? undefined,
U_BaseLine: l.baseLine ?? undefined,
U_BaseRef: l.baseRef ?? undefined,
U_BaseDocDate: l.baseDocDate ?? undefined,
U_OpenQty: l.openQty ?? undefined,
U_AllocatedQty: l.allocatedQty ?? undefined,
U_Applied: l.applied ? 'Y' : 'N',
U_CardCode: l.cardCode,
U_CardName: l.cardName,
U_TargetType: l.targetType == null ? undefined : String(l.targetType),
U_TargetEntry: l.targetEntry ?? undefined,
U_ErrCode: l.errCode,
U_ErrMsg: l.errMsg,
});
}
@@ -0,0 +1,70 @@
import type { ConfigService } from '@nestjs/config';
import type { AppConfig } from '../config/configuration.js';
import { ServiceLayerClient } from './service-layer.client.js';
import { SessionStore } from './session.store.js';
import type { SapSession } from './session.store.js';
// Reemplaza a axios: cuenta cuántas peticiones están en vuelo a la vez.
const estado = vi.hoisted(() => ({ enVuelo: 0, maximo: 0, orden: [] as string[], fallar: new Set<string>() }));
vi.mock('axios', async (importOriginal) => {
const original = await importOriginal<typeof import('axios')>();
return {
...original,
default: {
create: () => ({
request: async ({ url }: { url: string }) => {
estado.enVuelo++;
estado.maximo = Math.max(estado.maximo, estado.enVuelo);
estado.orden.push(url);
await new Promise((r) => setTimeout(r, 5));
estado.enVuelo--;
if (estado.fallar.has(url)) throw new Error('falló');
return { data: { url } };
},
}),
},
};
});
const config = { get: () => ({ url: 'https://sl/b1s/v1', companyDb: 'X', rejectUnauthorized: false }) } as unknown as ConfigService<AppConfig, true>;
const sesion = (id: string): SapSession => ({
id, b1Session: 'b', userName: 'u', companyDb: 'X', version: '1', timeoutMinutes: 30, expiresAt: Date.now() + 60_000,
});
describe('ServiceLayerClient: cola por sesión', () => {
let client: ServiceLayerClient;
let store: SessionStore;
beforeEach(() => {
Object.assign(estado, { enVuelo: 0, maximo: 0, orden: [], fallar: new Set<string>() });
store = new SessionStore();
client = new ServiceLayerClient(config, store);
});
afterEach(() => store.onModuleDestroy());
it('las peticiones simultáneas de una sesión se envían de a una y en orden', async () => {
const s = sesion('a');
const resultados = await Promise.all(['uno', 'dos', 'tres', 'cuatro'].map((p) => client.get<{ url: string }>(s, p)));
expect(estado.maximo).toBe(1);
expect(estado.orden).toEqual(['uno', 'dos', 'tres', 'cuatro']);
expect(resultados.map((r) => r.url)).toEqual(['uno', 'dos', 'tres', 'cuatro']);
});
it('sesiones distintas sí pueden ir en paralelo', async () => {
await Promise.all([client.get(sesion('a'), 'x'), client.get(sesion('b'), 'y')]);
expect(estado.maximo).toBe(2);
});
it('una petición que falla no bloquea a las siguientes', async () => {
estado.fallar.add('malo');
const s = sesion('a');
const [malo, bueno] = await Promise.allSettled([client.get(s, 'malo'), client.get<{ url: string }>(s, 'bueno')]);
expect(malo.status).toBe('rejected');
expect(bueno).toMatchObject({ status: 'fulfilled', value: { url: 'bueno' } });
});
});
@@ -0,0 +1,182 @@
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance, Method } from 'axios';
import { Agent } from 'node:https';
import { AppConfig } from '../config/configuration.js';
import { SapSession, SessionStore } from './session.store.js';
/** Una petición que tarda más que esto se avisa en el log. */
const UMBRAL_LENTA_MS = 2000;
interface SlErrorBody {
error?: { code?: number | string; message?: string | { value?: string } };
}
interface SlLoginResponse {
SessionId: string;
Version: string;
SessionTimeout: number;
}
interface SlCollection<T> {
value: T[];
'odata.nextLink'?: string;
'@odata.nextLink'?: string;
}
/** Error devuelto por el Service Layer, con el código SAP para mostrarlo al usuario. */
export class ServiceLayerException extends HttpException {
constructor(status: number, message: string, readonly sapCode?: number | string) {
super({ statusCode: status, message, sapCode }, status);
}
}
@Injectable()
export class ServiceLayerClient {
private readonly logger = new Logger(ServiceLayerClient.name);
/** Última petición en curso de cada sesión (id de sesión → promesa), para enviarlas en serie. */
private readonly colas = new Map<string, Promise<unknown>>();
private readonly http: AxiosInstance;
private readonly companyDb: string;
constructor(
config: ConfigService<AppConfig, true>,
private readonly sessions: SessionStore,
) {
const sl = config.get('serviceLayer', { infer: true });
this.companyDb = sl.companyDb;
this.http = axios.create({
baseURL: `${sl.url}/`,
timeout: 120_000,
httpsAgent: new Agent({ rejectUnauthorized: sl.rejectUnauthorized }),
headers: { 'Content-Type': 'application/json' },
});
}
async login(userName: string, password: string): Promise<SapSession> {
try {
const res = await this.http.post<SlLoginResponse>('Login', {
CompanyDB: this.companyDb,
UserName: userName,
Password: password,
});
const cookies = res.headers['set-cookie'] ?? [];
const leer = (nombre: string) =>
cookies.map((c) => c.split(';')[0]).find((c) => c.startsWith(`${nombre}=`))?.slice(nombre.length + 1);
return this.sessions.crear({
b1Session: leer('B1SESSION') ?? res.data.SessionId,
routeId: leer('ROUTEID'),
userName,
companyDb: this.companyDb,
version: res.data.Version,
timeoutMinutes: res.data.SessionTimeout || 30,
});
} catch (error) {
throw this.traducirError(error, 'Login');
}
}
async logout(sesion: SapSession): Promise<void> {
try {
await this.http.post('Logout', undefined, { headers: this.cabeceras(sesion) });
} catch (error) {
this.logger.warn(`Logout de ${sesion.userName} falló: ${(error as Error).message}`);
} finally {
this.sessions.eliminar(sesion.id);
}
}
/**
* Las peticiones de una misma sesión se envían de a una. El Service Layer no atiende bien las
* simultáneas: las procesa en serie y cada una espera ~7 s tras la anterior, así que seis
* consultas en paralelo tardaban ~40 s en lugar de ~0,3 s.
*/
request<T>(sesion: SapSession, method: Method, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> {
const anterior = this.colas.get(sesion.id) ?? Promise.resolve();
const actual = anterior.then(
() => this.enviar<T>(sesion, method, path, data, headers),
() => this.enviar<T>(sesion, method, path, data, headers),
);
this.colas.set(sesion.id, actual);
const limpiar = () => {
if (this.colas.get(sesion.id) === actual) this.colas.delete(sesion.id);
};
actual.then(limpiar, limpiar);
return actual;
}
private async enviar<T>(sesion: SapSession, method: Method, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> {
const inicio = performance.now();
try {
const res = await this.http.request<T>({ method, url: path, data, headers: { ...this.cabeceras(sesion), ...headers } });
this.sessions.renovar(sesion);
const ms = Math.round(performance.now() - inicio);
if (ms > UMBRAL_LENTA_MS) this.logger.warn(`Petición lenta al Service Layer: ${method.toUpperCase()} ${path.slice(0, 120)} (${ms} ms)`);
return res.data;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 401) {
this.sessions.eliminar(sesion.id);
throw new UnauthorizedException('La sesión de SAP Business One expiró. Ingrese nuevamente.');
}
throw this.traducirError(error, `${method.toUpperCase()} ${path}`);
}
}
get<T>(sesion: SapSession, path: string): Promise<T> {
return this.request<T>(sesion, 'GET', path);
}
post<T>(sesion: SapSession, path: string, data: unknown): Promise<T> {
return this.request<T>(sesion, 'POST', path, data);
}
patch(sesion: SapSession, path: string, data: unknown): Promise<void> {
return this.request<void>(sesion, 'PATCH', path, data);
}
delete(sesion: SapSession, path: string): Promise<void> {
return this.request<void>(sesion, 'DELETE', path);
}
/** Lee una colección completa siguiendo los nextLink de paginación. */
async getAll<T>(sesion: SapSession, path: string): Promise<T[]> {
const resultado: T[] = [];
let siguiente: string | undefined = path;
while (siguiente) {
const pagina: SlCollection<T> = await this.request<SlCollection<T>>(sesion, 'GET', siguiente, undefined, {
Prefer: 'odata.maxpagesize=500',
});
resultado.push(...pagina.value);
siguiente = (pagina['@odata.nextLink'] ?? pagina['odata.nextLink'])?.replace(/^.*\/b1s\/v\d+\//, '');
}
return resultado;
}
private cabeceras(sesion: SapSession): Record<string, string> {
const cookie = [`B1SESSION=${sesion.b1Session}`, sesion.routeId ? `ROUTEID=${sesion.routeId}` : undefined]
.filter(Boolean)
.join('; ');
return { Cookie: cookie };
}
private traducirError(error: unknown, operacion: string): HttpException {
if (error instanceof AxiosError) {
if (error.response) {
const body = error.response.data as SlErrorBody | undefined;
const mensaje = typeof body?.error?.message === 'string' ? body.error.message : body?.error?.message?.value;
const status = error.response.status >= 500 ? HttpStatus.BAD_GATEWAY : error.response.status;
this.logger.error(`${operacion}: HTTP ${error.response.status} ${mensaje ?? JSON.stringify(body)}`);
return new ServiceLayerException(status, mensaje ?? `Error del Service Layer en ${operacion}.`, body?.error?.code);
}
this.logger.error(`${operacion}: ${error.code} ${error.message}`);
return new ServiceLayerException(HttpStatus.BAD_GATEWAY, `No se pudo conectar con el Service Layer (${error.code ?? error.message}).`);
}
return error instanceof HttpException ? error : new HttpException((error as Error).message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { ServiceLayerClient } from './service-layer.client.js';
import { SessionStore } from './session.store.js';
@Global()
@Module({
providers: [SessionStore, ServiceLayerClient],
exports: [SessionStore, ServiceLayerClient],
})
export class ServiceLayerModule {}
@@ -0,0 +1,62 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { randomBytes } from 'node:crypto';
/** Sesión del Service Layer de un usuario; el navegador solo conoce el id. */
export interface SapSession {
id: string;
b1Session: string;
routeId?: string;
userName: string;
companyDb: string;
version: string;
/** Timeout de inactividad devuelto por el Login, en minutos. */
timeoutMinutes: number;
expiresAt: number;
}
/**
* Guarda en memoria las sesiones del Service Layer. Suficiente para una sola instancia de
* la API; con varias instancias habría que llevarlo a Redis o similar.
*/
@Injectable()
export class SessionStore implements OnModuleDestroy {
private readonly sesiones = new Map<string, SapSession>();
private readonly limpieza = setInterval(() => this.purgarVencidas(), 60_000);
crear(datos: Omit<SapSession, 'id' | 'expiresAt'>): SapSession {
const sesion: SapSession = { ...datos, id: randomBytes(32).toString('hex'), expiresAt: 0 };
this.renovar(sesion);
this.sesiones.set(sesion.id, sesion);
return sesion;
}
obtener(id: string | undefined): SapSession | undefined {
if (!id) return undefined;
const sesion = this.sesiones.get(id);
if (!sesion) return undefined;
if (sesion.expiresAt < Date.now()) {
this.sesiones.delete(id);
return undefined;
}
return sesion;
}
renovar(sesion: SapSession): void {
sesion.expiresAt = Date.now() + sesion.timeoutMinutes * 60_000;
}
eliminar(id: string): void {
this.sesiones.delete(id);
}
onModuleDestroy(): void {
clearInterval(this.limpieza);
}
private purgarVencidas(): void {
const ahora = Date.now();
for (const [id, sesion] of this.sesiones) {
if (sesion.expiresAt < ahora) this.sesiones.delete(id);
}
}
}
@@ -0,0 +1,48 @@
/**
* Consultas guardadas (SQLQueries del Service Layer) que usa la API. Cada una tiene su
* variante MSSQL (pruebas) y HANA (producción). Los parámetros se escriben ":nombre".
*
* El analizador SQL del Service Layer no admite funciones como FORMAT: las fechas se devuelven
* tal cual y se normalizan en el código.
*
* SAP reescribe el SqlText al guardarlo (por ejemplo OITM → [OITM]), así que no se puede
* comparar el texto para saber si cambió: al modificar el SQL hay que subir `version`.
*/
export interface ConsultaGuardada {
codigo: string;
version: number;
mssql: string;
hana: string;
}
export const CONSULTA_STOCK: ConsultaGuardada = {
codigo: 'DP_Stock',
version: 1,
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',
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',
};
export const CONSULTA_ENTRADAS_ABIERTAS: ConsultaGuardada = {
codigo: 'DP_EntradasAbiertas',
version: 2,
mssql:
'SELECT T0.[DocNum], T0.[CardCode], T0.[CardName], T0.[DocDate], ' +
'T1.[DocEntry], T1.[LineNum], T1.[OpenQty] ' +
'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" ' +
'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"',
};
@@ -0,0 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { SqlQueriesService } from './sql-queries.service.js';
@Global()
@Module({ providers: [SqlQueriesService], exports: [SqlQueriesService] })
export class SqlQueriesModule {}
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppConfig } from '../config/configuration.js';
import { ServiceLayerClient } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.js';
import type { ConsultaGuardada } from './sql-queries.definitions.js';
interface SqlQueryExistente {
SqlName: string;
}
/** Texto de un parámetro para la URL del Service Layer: entre comillas simples y escapado. */
export function parametroSl(valor: string): string {
return encodeURIComponent(`'${valor.replace(/'/g, "''")}'`);
}
@Injectable()
export class SqlQueriesService {
private readonly dbType: AppConfig['dbType'];
/** Consultas ya verificadas en esta ejecución de la API ("codigo:version"). */
private readonly verificadas = new Set<string>();
constructor(
private readonly sl: ServiceLayerClient,
config: ConfigService<AppConfig, true>,
) {
this.dbType = config.get('dbType', { infer: true });
}
/** Ejecuta la consulta (la crea o actualiza en SAP si hace falta) y devuelve todas las filas. */
async consultar<T>(sesion: SapSession, consulta: ConsultaGuardada, parametros: Record<string, string>): Promise<T[]> {
await this.asegurar(sesion, consulta);
const query = Object.entries(parametros)
.map(([nombre, valor]) => `${nombre}=${parametroSl(valor)}`)
.join('&');
return this.sl.getAll<T>(sesion, `SQLQueries('${consulta.codigo}')/List${query ? `?${query}` : ''}`);
}
private async asegurar(sesion: SapSession, consulta: ConsultaGuardada): Promise<void> {
const clave = `${consulta.codigo}:${consulta.version}`;
if (this.verificadas.has(clave)) return;
const nombre = `Devolución proveedores ${consulta.codigo} v${consulta.version}`;
const sqlText = this.dbType === 'HANA' ? consulta.hana : consulta.mssql;
const ruta = `SQLQueries('${consulta.codigo}')`;
const filtro = encodeURIComponent(`SqlCode eq '${consulta.codigo}'`);
const [existente] = await this.sl.getAll<SqlQueryExistente>(sesion, `SQLQueries?$filter=${filtro}&$select=SqlName`);
if (!existente) {
await this.sl.post(sesion, 'SQLQueries', { SqlCode: consulta.codigo, SqlName: nombre, SqlText: sqlText });
} else if (existente.SqlName !== nombre) {
await this.sl.patch(sesion, ruta, { SqlName: nombre, SqlText: sqlText });
}
this.verificadas.add(clave);
}
}
+98
View File
@@ -0,0 +1,98 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Controller, Get } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { cabecerasSeguridad, servirWeb } from './web-estatico.js';
@Controller('health')
class SaludFalsa {
@Get()
salud() {
return { status: 'ok' };
}
}
describe('servirWeb', () => {
let app: NestExpressApplication;
let carpeta: string;
beforeAll(async () => {
carpeta = mkdtempSync(join(tmpdir(), 'dp-web-'));
writeFileSync(join(carpeta, 'index.html'), '<html>INDEX</html>');
writeFileSync(join(carpeta, 'main-ABCD1234.js'), 'console.log(1)');
writeFileSync(join(carpeta, 'favicon.ico'), 'ico');
mkdirSync(join(carpeta, 'assets'));
const modulo = await Test.createTestingModule({ controllers: [SaludFalsa] }).compile();
app = modulo.createNestApplication<NestExpressApplication>();
app.setGlobalPrefix('api');
app.use(cabecerasSeguridad(false));
servirWeb(app, carpeta);
await app.init();
});
afterAll(async () => {
await app.close();
rmSync(carpeta, { recursive: true, force: true });
});
it('la raíz devuelve index.html sin caché', async () => {
const r = await request(app.getHttpServer()).get('/').expect(200);
expect(r.text).toContain('INDEX');
expect(r.headers['cache-control']).toBe('no-cache');
});
it('una ruta de Angular sin extensión devuelve index.html (para que el router la resuelva)', async () => {
const r = await request(app.getHttpServer()).get('/planificaciones/5').expect(200);
expect(r.text).toContain('INDEX');
});
it('los archivos con hash se cachean un año', async () => {
const r = await request(app.getHttpServer()).get('/main-ABCD1234.js').expect(200);
expect(r.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('un archivo sin hash no se cachea', async () => {
const r = await request(app.getHttpServer()).get('/favicon.ico').expect(200);
expect(r.headers['cache-control']).toBe('no-cache');
});
it('un archivo que no existe responde 404, no index.html', async () => {
const r = await request(app.getHttpServer()).get('/no-existe.js').expect(404);
expect(r.text).not.toContain('INDEX');
});
it('la API sigue respondiendo y una ruta /api inexistente es 404, no index.html', async () => {
await request(app.getHttpServer()).get('/api/health').expect(200).expect({ status: 'ok' });
const r = await request(app.getHttpServer()).get('/api/no-existe').expect(404);
expect(r.text).not.toContain('INDEX');
});
it('un POST a una ruta de la web no devuelve index.html', async () => {
const r = await request(app.getHttpServer()).post('/planificaciones').expect(404);
expect(r.text).not.toContain('INDEX');
});
it('agrega las cabeceras de seguridad y no manda HSTS por HTTP', async () => {
const r = await request(app.getHttpServer()).get('/').expect(200);
expect(r.headers['x-content-type-options']).toBe('nosniff');
expect(r.headers['x-frame-options']).toBe('DENY');
expect(r.headers['strict-transport-security']).toBeUndefined();
});
});
describe('cabecerasSeguridad con HTTPS', () => {
it('agrega Strict-Transport-Security', () => {
const cabeceras: Record<string, string> = {};
const res = { setHeader: (k: string, v: string) => (cabeceras[k] = v) };
const siguiente = vi.fn();
cabecerasSeguridad(true)({} as never, res as never, siguiente);
expect(cabeceras['Strict-Transport-Security']).toBe('max-age=31536000');
expect(siguiente).toHaveBeenCalled();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { extname, join } from 'node:path';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { NextFunction, Request, Response } from 'express';
/** Nombres con hash de Angular (main-HFVHQMEH.js, styles-RS7YTGMB.css): no cambian, se pueden cachear un año. */
const CON_HASH = /-[A-Z0-9]{8}\.(js|css)$/;
/**
* Cabeceras de seguridad básicas. HSTS solo si la API atiende por HTTPS: con HTTP obligaría al
* navegador a usar un HTTPS que no existe.
*/
export function cabecerasSeguridad(https: boolean) {
return (_req: Request, res: Response, next: NextFunction): void => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'same-origin');
if (https) res.setHeader('Strict-Transport-Security', 'max-age=31536000');
next();
};
}
/**
* La API sirve también la web compilada, así que todo queda en el mismo origen (sin CORS y con la
* cookie de sesión SameSite=strict). Las rutas de Angular (sin extensión y fuera de /api) devuelven
* index.html; un archivo que no existe responde 404 en lugar de index.html.
*/
export function servirWeb(app: NestExpressApplication, carpeta: string): void {
app.useStaticAssets(carpeta, {
index: false,
setHeaders: (res, ruta) => {
res.setHeader('Cache-Control', CON_HASH.test(ruta) ? 'public, max-age=31536000, immutable' : 'no-cache');
},
});
app.use((req: Request, res: Response, next: NextFunction) => {
const esRutaDeLaApp = (req.method === 'GET' || req.method === 'HEAD') && !req.path.startsWith('/api') && extname(req.path) === '';
if (!esRutaDeLaApp) return next();
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(join(carpeta, 'index.html'), (error) => error && next(error));
});
}
+43
View File
@@ -0,0 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { AppModule } from './../src/app.module.js';
describe('API (e2e)', () => {
let app: INestApplication<App>;
beforeAll(() => {
process.env.SL_URL = 'https://localhost:50000/b1s/v1';
process.env.SL_COMPANY_DB = 'TEST';
process.env.DB_TYPE = 'MSSQL';
});
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.use(cookieParser());
await app.init();
});
it('/api/health es público', () => {
return request(app.getHttpServer()).get('/api/health').expect(200).expect({ status: 'ok' });
});
it('/api/auth/info devuelve la sociedad configurada', () => {
return request(app.getHttpServer()).get('/api/auth/info').expect(200).expect({ companyDb: 'TEST', dbType: 'MSSQL' });
});
it('/api/metadata/status exige sesión', () => {
return request(app.getHttpServer()).get('/api/metadata/status').expect(401);
});
afterEach(async () => {
await app.close();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { AppModule } from './../src/app.module.js';
import { ServiceLayerClient } from './../src/service-layer/service-layer.client.js';
import { SessionStore } from './../src/service-layer/session.store.js';
// El Service Layer se reemplaza por uno falso que acepta cualquier login (y registra la sesión en el
// almacén real): lo que se comprueba es la cookie de sesión que la API entrega al navegador, no SAP.
describe('Cookie de sesión (e2e)', () => {
let app: INestApplication<App>;
async function crear(cookieSecure: boolean) {
process.env.SL_URL = 'https://localhost:50000/b1s/v1';
process.env.SL_COMPANY_DB = 'TEST';
process.env.DB_TYPE = 'MSSQL';
process.env.COOKIE_SECURE = String(cookieSecure);
const modulo = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(ServiceLayerClient)
.useFactory({
inject: [SessionStore],
factory: (sesiones: SessionStore) => ({
login: async () => sesiones.crear({ b1Session: 'b', userName: 'u', companyDb: 'X', version: '1', timeoutMinutes: 30 }),
logout: async () => undefined,
}),
})
.compile();
app = modulo.createNestApplication();
app.setGlobalPrefix('api');
app.use(cookieParser());
await app.init();
}
const loguear = async () => {
const r = await request(app.getHttpServer()).post('/api/auth/login').send({ userName: 'u', password: 'p' }).expect(200);
return (r.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('dp_sid=')) ?? '';
};
afterEach(async () => {
await app.close();
delete process.env.COOKIE_SECURE;
});
it('con COOKIE_SECURE=true la cookie es HttpOnly, Secure, SameSite=Strict y de toda la aplicación', async () => {
await crear(true);
const cookie = await loguear();
expect(cookie).toMatch(/^dp_sid=[0-9a-f]{64};/);
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('Secure');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).toContain('Path=/');
});
it('con COOKIE_SECURE=false (desarrollo por HTTP) no lleva Secure, pero sigue HttpOnly y Strict', async () => {
await crear(false);
const cookie = await loguear();
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Strict');
expect(cookie).not.toContain('Secure');
});
it('con esa cookie el navegador queda autenticado; sin ella, no', async () => {
await crear(true);
const cookie = await loguear();
await request(app.getHttpServer()).get('/api/auth/me').expect(401);
await request(app.getHttpServer()).get('/api/auth/me').set('Cookie', cookie.split(';')[0]).expect(200);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"types": [
"vitest/globals",
"node"
],
"resolveJsonModule": true
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
globals: true,
root: './',
include: ['**/*.e2e-spec.ts'],
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
globals: true,
root: './',
include: ['**/*.spec.ts'],
},
});