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:
commit
287b187c14
126 files changed
+20033
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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`. |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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[],
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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&b</Usuario>');
|
||||
expect(xml).toContain('<PWD>x<y>"z'</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 <1234> 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');
|
||||
});
|
||||
});
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
const desescapar = (v: string): string =>
|
||||
v.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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,
|
||||
};
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.e2e-spec.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
test: {
|
||||
globals: true,
|
||||
root: './',
|
||||
include: ['**/*.spec.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user