Initial commit: sap-intercompany-api
Backend NestJS + Prisma, dueno de la configuracion del worker (databases, scenarios, scenario_destinations, value_mappings) y lectura de webhook_events / replication_attempts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
2e92523641
25 files changed
+4707
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
DATABASE_URL="postgresql://intercompany:intercompany@localhost:5432/sap_intercompany?schema=public"
|
||||||
|
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Habilita GET /scenarios/:code/source: ruta absoluta a la raíz del proyecto
|
||||||
|
# sap-intercompany-worker (asume que ambos proyectos corren en la misma
|
||||||
|
# máquina). De ahí se arma <WORKER_ROOT_PATH>/src/scenarios/<slug>.ts.
|
||||||
|
WORKER_ROOT_PATH="../sap-intercompany-worker"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# sap-intercompany-api
|
||||||
|
|
||||||
|
Backend HTTP (NestJS) dueño de toda la configuración de
|
||||||
|
[`sap-intercompany-worker`](../sap-intercompany-worker) — bases de datos,
|
||||||
|
escenarios, destinos, mapeos de valor — y del log de replicación
|
||||||
|
(`webhook_events` + `replication_attempts`), sobre la misma Postgres.
|
||||||
|
Reemplaza a las rutas `/replication/*` que tenía `sap-webhook-api`.
|
||||||
|
|
||||||
|
`sap-intercompany-worker` no expone HTTP a propósito (sigue siendo un
|
||||||
|
script plano por cron); esta API es la única superficie HTTP para
|
||||||
|
administrar su configuración, pensada para ser consumida por
|
||||||
|
`sap-intercompany-viewer` (frontend, aún no implementado).
|
||||||
|
|
||||||
|
## Qué NO hace
|
||||||
|
|
||||||
|
- **No corre migraciones.** `prisma/schema.prisma` es una copia de la del
|
||||||
|
worker (mismo `DATABASE_URL`); acá solo se corre `prisma generate` para
|
||||||
|
tener un cliente tipado. El worker sigue siendo el dueño del schema.
|
||||||
|
- **No genera la lógica de `filter`/`transform` de un escenario.**
|
||||||
|
`POST /scenarios` crea la fila en Postgres y, si no existe, escribe el
|
||||||
|
scaffold de 3 secciones en
|
||||||
|
`sap-intercompany-worker/src/scenarios/<slug>.ts` (mismo scaffold que
|
||||||
|
genera `scenario:add` del CLI del worker) — el cuerpo real de `filter`/
|
||||||
|
`transform` siempre se edita a mano, en el editor del usuario.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET/POST /databases`, `GET /databases/:code`, `PATCH /databases/:code`
|
||||||
|
(incluye desactivar con `{"active": false}`). Las respuestas nunca
|
||||||
|
incluyen `password`.
|
||||||
|
- `GET /scenarios`, `GET /scenarios/:code`, `POST /scenarios`,
|
||||||
|
`PATCH /scenarios/:code`, `POST /scenarios/:code/rename`.
|
||||||
|
- `GET /scenarios/:code/source` — código fuente crudo del `.ts` (solo
|
||||||
|
lectura; requiere `WORKER_ROOT_PATH`).
|
||||||
|
- `GET/POST /scenarios/:code/destinations`,
|
||||||
|
`DELETE /scenarios/:code/destinations/:databaseCode`.
|
||||||
|
- `GET/POST /scenarios/:code/mappings` (`GET` acepta `?fieldName=`).
|
||||||
|
- `GET /events` (filtros `from`, `to`, `businessObject`, `scenarioCode`,
|
||||||
|
`status`), `GET /events/:id` (incluye `replication_attempts`).
|
||||||
|
- `POST /events/:id/reprocess` — pone el evento en `status='pending'` de
|
||||||
|
nuevo; el worker lo va a reprocesar contra **todos** los escenarios y
|
||||||
|
destinos aplicables en su próximo ciclo, no solo el que falló.
|
||||||
|
|
||||||
|
`POST /databases` y `POST /scenarios` fallan con `409` si el `code` ya
|
||||||
|
existe (a diferencia del CLI del worker, que hace upsert) — para
|
||||||
|
actualizar un existente se usa `PATCH`.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # completar DATABASE_URL (la misma del worker) y WORKER_ROOT_PATH
|
||||||
|
npx prisma generate # cliente tipado propio; NUNCA correr `prisma migrate` acá
|
||||||
|
npm start # http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- NestJS (mismo framework/estilo que `sap-webhook-api`), corrido con `tsx`
|
||||||
|
en vez de `nest build` — mismo enfoque ESM + TypeScript nativo que ya usa
|
||||||
|
`sap-intercompany-worker`, para poder importar directo el cliente Prisma
|
||||||
|
generado (`prisma-client`, salida `.ts`) sin fricción de compilación.
|
||||||
|
- Prisma 7 + `@prisma/adapter-pg`, contra la Postgres compartida con el
|
||||||
|
worker.
|
||||||
|
|
||||||
|
Nota de implementación: la inyección de dependencias de Nest usa
|
||||||
|
`@Inject(Token)` explícito en cada constructor (en vez de depender solo del
|
||||||
|
tipo declarado) porque bajo `tsx`/esbuild la emisión de
|
||||||
|
`design:paramtypes` (`emitDecoratorMetadata`) no es confiable — sin esto,
|
||||||
|
Nest instancia los controllers/servicios sin fallar pero con las
|
||||||
|
dependencias en `undefined`.
|
||||||
Generated
+3712
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "sap-intercompany-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend HTTP (NestJS) dueño de la configuración de sap-intercompany-worker (bases, escenarios, destinos, mapeos) y del log de replicación — consumido por sap-intercompany-viewer.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx src/main.ts",
|
||||||
|
"start:dev": "tsx watch src/main.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prisma:generate": "prisma generate"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
|
"@prisma/client": "^7.9.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"prisma": "^7.9.1",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Copia de sap-intercompany-worker/prisma/schema.prisma — mismo DATABASE_URL,
|
||||||
|
// misma Postgres. Esta API solo corre `prisma generate` para tener un
|
||||||
|
// cliente tipado propio; las migraciones (`prisma migrate`) corren SIEMPRE
|
||||||
|
// desde sap-intercompany-worker, que es el dueño del schema. Si el schema
|
||||||
|
// del worker cambia, copiar los cambios acá y volver a generar.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EventStatus {
|
||||||
|
pending
|
||||||
|
processing
|
||||||
|
done
|
||||||
|
partial
|
||||||
|
failed
|
||||||
|
filtered // ningún escenario activo para el objeto aceptó el mensaje: no se reintenta
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScenarioDestinationMode {
|
||||||
|
fixed // datos maestros: la lista de destinos está fija en ScenarioDestination
|
||||||
|
payload // transacciones: el destino se resuelve leyendo routingField del payload (no implementado aún, ver docs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandeja de entrada: un registro por evento recibido del webhook de SAP.
|
||||||
|
// sap-webhook-api inserta acá (además del .json local) apenas recibe la notificación.
|
||||||
|
model WebhookEvent {
|
||||||
|
id String @id // el "id" del CloudEvent que manda SAP
|
||||||
|
businessObject String @map("business_object") // 'BusinessPartners', 'Orders', etc.
|
||||||
|
operation String // 'Created' | 'Updated'
|
||||||
|
subject String // CardCode / DocEntry, según el objeto
|
||||||
|
payload Json
|
||||||
|
receivedAt DateTime @default(now()) @map("received_at")
|
||||||
|
status EventStatus @default(pending)
|
||||||
|
|
||||||
|
attempts ReplicationAttempt[]
|
||||||
|
|
||||||
|
@@map("webhook_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registro único de toda base de datos SAP alcanzable por Service Layer.
|
||||||
|
// Ya no existe distinción de HQ vs. subsidiaria: cualquier fila puede ser
|
||||||
|
// origen o destino, según lo que declare cada escenario.
|
||||||
|
model Database {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique // mnemónico corto y estable, ej. 'HQ', 'SUB-NORTE'; asignado una sola vez al crear, NO autoincremental
|
||||||
|
name String
|
||||||
|
baseUrl String @map("base_url")
|
||||||
|
companyDb String @map("company_db")
|
||||||
|
username String
|
||||||
|
password String
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
originOfScenarios Scenario[] @relation("ScenarioOrigin")
|
||||||
|
destinations ScenarioDestination[]
|
||||||
|
valueMappings ScenarioValueMapping[]
|
||||||
|
|
||||||
|
@@map("databases")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScenarioKeyStrategy {
|
||||||
|
portable // el keyField vale lo mismo en origen y destino (ej. CardCode, ItemCode): GET directo por clave
|
||||||
|
udfLink // SAP asigna la clave por base (ej. SalesEmployeeCode): se busca por un UDF en el destino que guarda la clave de origen
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScenarioKeyType {
|
||||||
|
string // la clave va entre comillas en la URL OData: BusinessPartners('C23900')
|
||||||
|
number // la clave va sin comillas: SalesPersons(5) — mandar comillas en un campo numérico da HTTP 400
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un escenario es una forma completa e independiente de procesar un business
|
||||||
|
// object: su propio filtro, base origen, base(s) destino y transformación de
|
||||||
|
// payload. Puede haber varios escenarios activos para el mismo business
|
||||||
|
// object a la vez (ej. "clientes" y "proveedores" sobre BusinessPartners), y
|
||||||
|
// un mismo evento puede matchear y ser procesado por más de uno.
|
||||||
|
//
|
||||||
|
// El filtro y la transformación del payload NO viven en esta tabla: cada
|
||||||
|
// escenario tiene un archivo de código en src/scenarios/<code>.ts con
|
||||||
|
// SIEMPRE las mismas dos partes — filter(record) -> boolean y
|
||||||
|
// transform(record, ctx) -> record — porque ambos necesitan lógica
|
||||||
|
// arbitraria (condiciones compuestas, elegir campos) que una tabla no puede
|
||||||
|
// expresar. Lo que SÍ vive en tabla son los mapeos de valor puntuales que
|
||||||
|
// transform consulta vía ctx.mapValue(...) dentro de su lógica — ver
|
||||||
|
// ScenarioValueMapping más abajo y docs/ESCENARIOS.md — pensado para que a
|
||||||
|
// futuro una UI permita cargarlos sin editar código, aunque el archivo siga
|
||||||
|
// siendo quien decide cómo usarlos.
|
||||||
|
model Scenario {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique // 'BP_CLIENTES', 'BP_PROVEEDORES' — también el nombre del archivo en src/scenarios/
|
||||||
|
name String
|
||||||
|
businessObject String @map("business_object") // objeto que dispara el escenario y que se lee del origen (con qué se matchea webhook_events y con qué se hace el GET a la base origen)
|
||||||
|
destinationBusinessObject String? @map("destination_business_object") // objeto a usar en el GET/POST/PATCH del DESTINO, si es distinto al de origen (ej. origen PurchaseOrders -> destino Orders). null = mismo que businessObject
|
||||||
|
originDatabaseCode String @map("origin_database_code")
|
||||||
|
originDatabase Database @relation("ScenarioOrigin", fields: [originDatabaseCode], references: [code])
|
||||||
|
destinationMode ScenarioDestinationMode @map("destination_mode")
|
||||||
|
keyField String @map("key_field") // clave del registro para GET/POST/PATCH, ej. 'CardCode', 'SalesEmployeeCode'
|
||||||
|
keyType ScenarioKeyType @default(string) @map("key_type") // tipo de dato del keyField en el Service Layer (define si va entre comillas en la URL)
|
||||||
|
keyStrategy ScenarioKeyStrategy @default(portable) @map("key_strategy")
|
||||||
|
matchField String? @map("match_field") // solo si keyStrategy = udfLink: UDF en el destino que guarda el valor de keyField de origen, ej. 'U_HQ_SlpCode'
|
||||||
|
routingField String? @map("routing_field") // solo si destinationMode = payload
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
destinations ScenarioDestination[]
|
||||||
|
valueMappings ScenarioValueMapping[]
|
||||||
|
attempts ReplicationAttempt[]
|
||||||
|
|
||||||
|
@@map("scenarios")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destinos fijos de un escenario en modo 'fixed' (datos maestros). En modo
|
||||||
|
// 'payload' (transacciones) no se usa: el destino se resuelve en runtime.
|
||||||
|
model ScenarioDestination {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
scenarioId Int @map("scenario_id")
|
||||||
|
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||||
|
databaseCode String @map("database_code")
|
||||||
|
database Database @relation(fields: [databaseCode], references: [code])
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([scenarioId, databaseCode])
|
||||||
|
@@map("scenario_destinations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabla de equivalencias que el archivo de transformación del escenario
|
||||||
|
// consulta vía ctx.mapValue(fieldName, sourceValue) — el código decide QUÉ
|
||||||
|
// campo mapear y QUÉ hacer si falta (fallar, dejar sin traducir, etc.); acá
|
||||||
|
// solo vive el dato. Escopada por escenario, no global por objeto+campo,
|
||||||
|
// porque el mismo campo puede tener dominios de valores distintos según el
|
||||||
|
// escenario (ej. GroupCode de clientes vs. de proveedores). Pensada para
|
||||||
|
// que a futuro una UI la administre sin tocar código.
|
||||||
|
model ScenarioValueMapping {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
scenarioId Int @map("scenario_id")
|
||||||
|
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||||
|
fieldName String @map("field_name")
|
||||||
|
databaseCode String @map("database_code")
|
||||||
|
database Database @relation(fields: [databaseCode], references: [code])
|
||||||
|
sourceValue String @map("source_value")
|
||||||
|
targetValue String @map("target_value")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([scenarioId, fieldName, databaseCode, sourceValue])
|
||||||
|
@@map("scenario_value_mappings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un registro por cada intento de réplica de un evento, dentro de UN
|
||||||
|
// escenario puntual, hacia UNA base destino. origin_database y
|
||||||
|
// destination_database son texto libre (sin FK) a propósito: es un log de
|
||||||
|
// auditoría inmutable que no debe verse afectado si la base o el escenario
|
||||||
|
// se borran o renombran después.
|
||||||
|
model ReplicationAttempt {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
event WebhookEvent @relation(fields: [eventId], references: [id])
|
||||||
|
eventId String @map("event_id")
|
||||||
|
scenario Scenario @relation(fields: [scenarioId], references: [id])
|
||||||
|
scenarioId Int @map("scenario_id")
|
||||||
|
originDatabase String @map("origin_database")
|
||||||
|
destinationDatabase String @map("destination_database") // 'N/A' si falló antes de resolver destino (ej. error leyendo el origen)
|
||||||
|
verbUsed String @map("verb_used") // 'POST' | 'PATCH' | 'SKIPPED' | 'N/A'
|
||||||
|
httpStatus Int? @map("http_status")
|
||||||
|
errorMessage String? @map("error_message")
|
||||||
|
payloadSent Json? @map("payload_sent") // payload ya transformado enviado al destino; se guarda en éxito Y en falla
|
||||||
|
resultKey String? @map("result_key") // clave propia del destino cuando difiere de la de origen (keyStrategy=udfLink) o DocEntry (transacciones); null si no aplica
|
||||||
|
attemptedAt DateTime @default(now()) @map("attempted_at")
|
||||||
|
|
||||||
|
@@index([eventId])
|
||||||
|
@@index([scenarioId])
|
||||||
|
@@index([attemptedAt])
|
||||||
|
@@map("replication_attempts")
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
@Get()
|
||||||
|
health() {
|
||||||
|
return { status: 'ok', service: 'sap-intercompany-api' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppController } from './app.controller.ts';
|
||||||
|
import { DashboardModule } from './dashboard/dashboard.module.ts';
|
||||||
|
import { DatabasesModule } from './databases/databases.module.ts';
|
||||||
|
import { EventsModule } from './events/events.module.ts';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module.ts';
|
||||||
|
import { ScenariosModule } from './scenarios/scenarios.module.ts';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, DatabasesModule, ScenariosModule, EventsModule, DashboardModule],
|
||||||
|
controllers: [AppController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import { DashboardService } from './dashboard.service.ts';
|
||||||
|
|
||||||
|
@Controller('dashboard')
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(@Inject(DashboardService) private readonly dashboard: DashboardService) {}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
getStats() {
|
||||||
|
return this.dashboard.getStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DashboardController } from './dashboard.controller.ts';
|
||||||
|
import { DashboardService } from './dashboard.service.ts';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function startOfToday(): Date {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
const today = startOfToday();
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - SEVEN_DAYS_MS);
|
||||||
|
|
||||||
|
const [activeScenarios, pendingEvents, doneToday, failedToday, doneLast7Days, failedLast7Days, totalDatabases] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.scenario.count({ where: { active: true } }),
|
||||||
|
this.prisma.webhookEvent.count({ where: { status: 'pending' } }),
|
||||||
|
this.prisma.webhookEvent.count({ where: { status: 'done', receivedAt: { gte: today } } }),
|
||||||
|
this.prisma.webhookEvent.count({ where: { status: 'failed', receivedAt: { gte: today } } }),
|
||||||
|
this.prisma.webhookEvent.count({ where: { status: 'done', receivedAt: { gte: sevenDaysAgo } } }),
|
||||||
|
this.prisma.webhookEvent.count({ where: { status: 'failed', receivedAt: { gte: sevenDaysAgo } } }),
|
||||||
|
this.prisma.database.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { activeScenarios, pendingEvents, doneToday, failedToday, doneLast7Days, failedLast7Days, totalDatabases };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { CreateDatabaseInput, DatabasesService, UpdateDatabaseInput } from './databases.service.ts';
|
||||||
|
|
||||||
|
@Controller('databases')
|
||||||
|
export class DatabasesController {
|
||||||
|
constructor(@Inject(DatabasesService) private readonly databases: DatabasesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.databases.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code')
|
||||||
|
get(@Param('code') code: string) {
|
||||||
|
return this.databases.get(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: Partial<CreateDatabaseInput>) {
|
||||||
|
const { code, name, baseUrl, companyDb, username, password } = body;
|
||||||
|
if (!code || !name || !baseUrl || !companyDb || !username || !password) {
|
||||||
|
throw new BadRequestException('code, name, baseUrl, companyDb, username y password son obligatorios');
|
||||||
|
}
|
||||||
|
return this.databases.create({ code, name, baseUrl, companyDb, username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code')
|
||||||
|
update(@Param('code') code: string, @Body() body: UpdateDatabaseInput) {
|
||||||
|
return this.databases.update(code, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabasesController } from './databases.controller.ts';
|
||||||
|
import { DatabasesService } from './databases.service.ts';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DatabasesController],
|
||||||
|
providers: [DatabasesService],
|
||||||
|
})
|
||||||
|
export class DatabasesModule {}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||||
|
|
||||||
|
// Nunca se devuelve `password` en las respuestas — este endpoint lo recibe
|
||||||
|
// en el body para guardarlo, pero no hay motivo para que cualquier consumidor
|
||||||
|
// del listado (el visor, o quien sea) pueda leerlo de vuelta.
|
||||||
|
const PUBLIC_SELECT = {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
baseUrl: true,
|
||||||
|
companyDb: true,
|
||||||
|
username: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateDatabaseInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
companyDb: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDatabaseInput {
|
||||||
|
name?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
companyDb?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatabasesService {
|
||||||
|
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.prisma.database.findMany({ orderBy: { code: 'asc' }, select: PUBLIC_SELECT });
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(code: string) {
|
||||||
|
const db = await this.prisma.database.findUnique({ where: { code }, select: PUBLIC_SELECT });
|
||||||
|
if (!db) throw new NotFoundException(`No existe la base de datos '${code}'`);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateDatabaseInput) {
|
||||||
|
const existing = await this.prisma.database.findUnique({ where: { code: input.code } });
|
||||||
|
if (existing) throw new ConflictException(`Ya existe una base de datos con code '${input.code}'`);
|
||||||
|
return this.prisma.database.create({ data: input, select: PUBLIC_SELECT });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(code: string, input: UpdateDatabaseInput) {
|
||||||
|
await this.get(code);
|
||||||
|
return this.prisma.database.update({ where: { code }, data: input, select: PUBLIC_SELECT });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Controller, Get, Inject, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { EventsService } from './events.service.ts';
|
||||||
|
|
||||||
|
@Controller('events')
|
||||||
|
export class EventsController {
|
||||||
|
constructor(@Inject(EventsService) private readonly events: EventsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('businessObject') businessObject?: string,
|
||||||
|
@Query('scenarioCode') scenarioCode?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.events.list({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
businessObject,
|
||||||
|
scenarioCode,
|
||||||
|
status,
|
||||||
|
page: page ? Number(page) : undefined,
|
||||||
|
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
get(@Param('id') id: string) {
|
||||||
|
return this.events.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reprocess')
|
||||||
|
reprocess(@Param('id') id: string) {
|
||||||
|
return this.events.reprocess(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EventsController } from './events.controller.ts';
|
||||||
|
import { EventsService } from './events.service.ts';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [EventsController],
|
||||||
|
providers: [EventsService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||||
|
|
||||||
|
const VALID_STATUSES = ['pending', 'processing', 'done', 'partial', 'failed', 'filtered'];
|
||||||
|
|
||||||
|
export interface ListEventsFilters {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
businessObject?: string;
|
||||||
|
scenarioCode?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventsService {
|
||||||
|
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(filters: ListEventsFilters) {
|
||||||
|
const statuses = filters.status ? filters.status.split(',').filter(Boolean) : [];
|
||||||
|
const invalidStatus = statuses.find((s) => !VALID_STATUSES.includes(s));
|
||||||
|
if (invalidStatus) {
|
||||||
|
throw new BadRequestException(`status inválido: '${invalidStatus}' (valores válidos: ${VALID_STATUSES.join(', ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = filters.page && filters.page > 0 ? filters.page : 1;
|
||||||
|
const pageSize = filters.pageSize && filters.pageSize > 0 ? Math.min(filters.pageSize, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(filters.businessObject ? { businessObject: filters.businessObject } : {}),
|
||||||
|
...(statuses.length ? { status: { in: statuses as any } } : {}),
|
||||||
|
...(filters.scenarioCode ? { attempts: { some: { scenario: { code: filters.scenarioCode } } } } : {}),
|
||||||
|
...(filters.from || filters.to
|
||||||
|
? {
|
||||||
|
receivedAt: {
|
||||||
|
...(filters.from ? { gte: new Date(filters.from) } : {}),
|
||||||
|
...(filters.to ? { lte: new Date(filters.to) } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.webhookEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { receivedAt: 'desc' },
|
||||||
|
include: { _count: { select: { attempts: true } } },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.webhookEvent.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string) {
|
||||||
|
const event = await this.prisma.webhookEvent.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
attempts: {
|
||||||
|
orderBy: { attemptedAt: 'asc' },
|
||||||
|
include: { scenario: { select: { code: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!event) throw new NotFoundException(`No existe el evento '${id}'`);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reprocess(id: string) {
|
||||||
|
await this.get(id);
|
||||||
|
return this.prisma.webhookEvent.update({ where: { id }, data: { status: 'pending' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module.ts';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableCors(); // consumido por sap-intercompany-viewer, otro origen
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 3001;
|
||||||
|
await app.listen(port);
|
||||||
|
logger.log(`sap-intercompany-api escuchando en el puerto ${port}`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service.ts';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '../generated/prisma/client.ts';
|
||||||
|
|
||||||
|
// Mismo cliente/adapter que sap-intercompany-worker (Prisma 7 + @prisma/adapter-pg),
|
||||||
|
// sobre la misma Postgres. Esta API nunca corre `prisma migrate` — ver nota en prisma/schema.prisma.
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
this.logger.log('Conexión a Postgres OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Helpers de filesystem para el código fuente (filter/transform) de cada
|
||||||
|
// escenario, que vive en sap-intercompany-worker/src/scenarios/<slug>.ts —
|
||||||
|
// esta API nunca genera esa lógica, solo el scaffold de 3 secciones (import,
|
||||||
|
// filter, transform) la primera vez que se crea un escenario, y lo puede
|
||||||
|
// leer en modo solo lectura (GET /scenarios/:code/source). Editar el cuerpo
|
||||||
|
// de filter/transform queda siempre a mano, en el editor del usuario.
|
||||||
|
//
|
||||||
|
// IMPORTANTE: el scaffold de abajo es una copia intencional de
|
||||||
|
// `scaffoldTransformFile` en sap-intercompany-worker/scripts/manage-config.ts
|
||||||
|
// (no se importa desde ahí para no acoplar esta API al resto del código del
|
||||||
|
// worker, que trae su propio cliente Prisma generado). Si ese scaffold
|
||||||
|
// cambia, actualizar los dos lugares.
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export function scenarioFileSlug(scenarioCode: string): string {
|
||||||
|
return scenarioCode.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scenariosDir(): string {
|
||||||
|
const workerRootPath = process.env.WORKER_ROOT_PATH;
|
||||||
|
if (!workerRootPath) {
|
||||||
|
throw new Error('WORKER_ROOT_PATH no está configurada (ver .env.example)');
|
||||||
|
}
|
||||||
|
return resolve(process.cwd(), workerRootPath, 'src', 'scenarios');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scenarioFilePath(code: string): string {
|
||||||
|
return resolve(scenariosDir(), `${scenarioFileSlug(code)}.ts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve la ruta creada, o null si el archivo ya existía (no lo pisa).
|
||||||
|
export function scaffoldScenarioFile(code: string): string | null {
|
||||||
|
const path = scenarioFilePath(code);
|
||||||
|
if (existsSync(path)) return null;
|
||||||
|
mkdirSync(scenariosDir(), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
`// Escenario ${code} — completar según corresponda (ver docs/ESCENARIOS.md).
|
||||||
|
import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts';
|
||||||
|
|
||||||
|
// --- Filtro: decide si este escenario aplica al registro ---
|
||||||
|
// Puede ser async: ctx.get(path) permite consultar la base ORIGEN (ej. si
|
||||||
|
// el filtro depende de un dato que no viene en el payload del evento).
|
||||||
|
export function filter(record: Record<string, unknown>, ctx: ScenarioFilterContext): boolean | Promise<boolean> {
|
||||||
|
return true; // reemplazar con la condición real, o dejar así para "sin filtro"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Transformación: arma el payload para un destino puntual ---
|
||||||
|
// Se llama una vez por destino: ctx.destinationCode permite transformar
|
||||||
|
// distinto según a cuál le toque. Puede lanzar una excepción para marcar
|
||||||
|
// el intento como SKIPPED (ej. un valor sin mapeo cargado).
|
||||||
|
export function transform(record: Record<string, unknown>, ctx: ScenarioTransformContext): Record<string, unknown> {
|
||||||
|
// --- Mapeo: ctx.mapValue(fieldName, sourceValue) consulta scenario_value_mappings
|
||||||
|
// (cargados desde /scenarios/:code/mappings) para ESTE escenario y destino ---
|
||||||
|
|
||||||
|
return { ...record };
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameScenarioFile(oldCode: string, newCode: string): { renamed: boolean; from: string; to: string } {
|
||||||
|
const from = scenarioFilePath(oldCode);
|
||||||
|
const to = scenarioFilePath(newCode);
|
||||||
|
if (existsSync(to)) throw new Error(`ya existe un archivo en ${to}`);
|
||||||
|
if (!existsSync(from)) return { renamed: false, from, to };
|
||||||
|
renameSync(from, to);
|
||||||
|
return { renamed: true, from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScenarioSource(code: string): { path: string; content: string } | null {
|
||||||
|
const path = scenarioFilePath(code);
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
return { path, content: readFileSync(path, 'utf-8') };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { CreateScenarioInput, ScenariosService, UpdateScenarioInput } from './scenarios.service.ts';
|
||||||
|
|
||||||
|
@Controller('scenarios')
|
||||||
|
export class ScenariosController {
|
||||||
|
constructor(@Inject(ScenariosService) private readonly scenarios: ScenariosService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.scenarios.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code')
|
||||||
|
get(@Param('code') code: string) {
|
||||||
|
return this.scenarios.get(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code/source')
|
||||||
|
getSource(@Param('code') code: string) {
|
||||||
|
return this.scenarios.getSource(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: Partial<CreateScenarioInput>) {
|
||||||
|
const { code, name, businessObject, originDatabaseCode, keyField } = body;
|
||||||
|
if (!code || !name || !businessObject || !originDatabaseCode || !keyField) {
|
||||||
|
throw new BadRequestException('code, name, businessObject, originDatabaseCode y keyField son obligatorios');
|
||||||
|
}
|
||||||
|
return this.scenarios.create({ ...body, code, name, businessObject, originDatabaseCode, keyField });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code')
|
||||||
|
update(@Param('code') code: string, @Body() body: UpdateScenarioInput) {
|
||||||
|
return this.scenarios.update(code, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':code/rename')
|
||||||
|
rename(@Param('code') code: string, @Body() body: { newCode?: string }) {
|
||||||
|
if (!body.newCode) throw new BadRequestException('newCode es obligatorio');
|
||||||
|
return this.scenarios.rename(code, body.newCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code/destinations')
|
||||||
|
listDestinations(@Param('code') code: string) {
|
||||||
|
return this.scenarios.listDestinations(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':code/destinations')
|
||||||
|
addDestination(@Param('code') code: string, @Body() body: { databaseCode?: string }) {
|
||||||
|
if (!body.databaseCode) throw new BadRequestException('databaseCode es obligatorio');
|
||||||
|
return this.scenarios.addDestination(code, body.databaseCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':code/destinations/:databaseCode')
|
||||||
|
removeDestination(@Param('code') code: string, @Param('databaseCode') databaseCode: string) {
|
||||||
|
return this.scenarios.removeDestination(code, databaseCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code/mappings')
|
||||||
|
listMappings(@Param('code') code: string, @Query('fieldName') fieldName?: string) {
|
||||||
|
return this.scenarios.listMappings(code, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':code/mappings')
|
||||||
|
addMapping(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body() body: { fieldName?: string; databaseCode?: string; sourceValue?: string; targetValue?: string },
|
||||||
|
) {
|
||||||
|
const { fieldName, databaseCode, sourceValue, targetValue } = body;
|
||||||
|
if (!fieldName || !databaseCode || !sourceValue || !targetValue) {
|
||||||
|
throw new BadRequestException('fieldName, databaseCode, sourceValue y targetValue son obligatorios');
|
||||||
|
}
|
||||||
|
return this.scenarios.addMapping(code, { fieldName, databaseCode, sourceValue, targetValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScenariosController } from './scenarios.controller.ts';
|
||||||
|
import { ScenariosService } from './scenarios.service.ts';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ScenariosController],
|
||||||
|
providers: [ScenariosService],
|
||||||
|
})
|
||||||
|
export class ScenariosModule {}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.ts';
|
||||||
|
import { readScenarioSource, renameScenarioFile, scaffoldScenarioFile, scenarioFileSlug } from './scenario-file.ts';
|
||||||
|
|
||||||
|
type KeyStrategy = 'portable' | 'udfLink';
|
||||||
|
type KeyType = 'string' | 'number';
|
||||||
|
type DestinationMode = 'fixed' | 'payload';
|
||||||
|
|
||||||
|
export interface CreateScenarioInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
businessObject: string;
|
||||||
|
destinationBusinessObject?: string | null;
|
||||||
|
originDatabaseCode: string;
|
||||||
|
keyField: string;
|
||||||
|
keyType?: KeyType;
|
||||||
|
keyStrategy?: KeyStrategy;
|
||||||
|
matchField?: string | null;
|
||||||
|
destinationMode?: DestinationMode;
|
||||||
|
routingField?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateScenarioInput = Partial<Omit<CreateScenarioInput, 'code'>> & { active?: boolean };
|
||||||
|
|
||||||
|
// Misma validación de combinaciones que `scenario:add` en
|
||||||
|
// sap-intercompany-worker/scripts/manage-config.ts — mantener sincronizadas.
|
||||||
|
function validateCombination(input: {
|
||||||
|
keyStrategy: KeyStrategy;
|
||||||
|
matchField?: string | null;
|
||||||
|
destinationMode: DestinationMode;
|
||||||
|
routingField?: string | null;
|
||||||
|
}) {
|
||||||
|
if (input.keyStrategy === 'udfLink' && !input.matchField) {
|
||||||
|
throw new BadRequestException("keyStrategy 'udfLink' necesita matchField");
|
||||||
|
}
|
||||||
|
if (input.destinationMode === 'payload' && !input.routingField) {
|
||||||
|
throw new BadRequestException("destinationMode 'payload' necesita routingField (UDF con el code de la base destino)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ScenariosService {
|
||||||
|
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.prisma.scenario.findMany({ orderBy: { code: 'asc' }, include: { destinations: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(code: string) {
|
||||||
|
const scenario = await this.prisma.scenario.findUnique({ where: { code }, include: { destinations: true } });
|
||||||
|
if (!scenario) throw new NotFoundException(`No existe el escenario '${code}'`);
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateScenarioInput) {
|
||||||
|
const existing = await this.prisma.scenario.findUnique({ where: { code: input.code } });
|
||||||
|
if (existing) throw new ConflictException(`Ya existe un escenario con code '${input.code}'`);
|
||||||
|
|
||||||
|
const keyStrategy = input.keyStrategy ?? 'portable';
|
||||||
|
const keyType = input.keyType ?? 'string';
|
||||||
|
const destinationMode = input.destinationMode ?? 'fixed';
|
||||||
|
validateCombination({ keyStrategy, matchField: input.matchField, destinationMode, routingField: input.routingField });
|
||||||
|
|
||||||
|
const scenario = await this.prisma.scenario.create({
|
||||||
|
data: {
|
||||||
|
code: input.code,
|
||||||
|
name: input.name,
|
||||||
|
businessObject: input.businessObject,
|
||||||
|
destinationBusinessObject: input.destinationBusinessObject ?? null,
|
||||||
|
originDatabaseCode: input.originDatabaseCode,
|
||||||
|
destinationMode,
|
||||||
|
routingField: input.routingField ?? null,
|
||||||
|
keyField: input.keyField,
|
||||||
|
keyType,
|
||||||
|
keyStrategy,
|
||||||
|
matchField: input.matchField ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scaffoldedFilePath = scaffoldScenarioFile(scenario.code);
|
||||||
|
return { scenario, scaffoldedFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(code: string, input: UpdateScenarioInput) {
|
||||||
|
const current = await this.get(code);
|
||||||
|
const keyStrategy = input.keyStrategy ?? (current.keyStrategy as KeyStrategy);
|
||||||
|
const destinationMode = input.destinationMode ?? (current.destinationMode as DestinationMode);
|
||||||
|
const matchField = input.matchField !== undefined ? input.matchField : current.matchField;
|
||||||
|
const routingField = input.routingField !== undefined ? input.routingField : current.routingField;
|
||||||
|
validateCombination({ keyStrategy, matchField, destinationMode, routingField });
|
||||||
|
|
||||||
|
return this.prisma.scenario.update({ where: { code }, data: { ...input } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(oldCode: string, newCode: string) {
|
||||||
|
const scenario = await this.get(oldCode);
|
||||||
|
const existingWithNewCode = await this.prisma.scenario.findUnique({ where: { code: newCode } });
|
||||||
|
if (existingWithNewCode) throw new ConflictException(`Ya existe un escenario con code '${newCode}'`);
|
||||||
|
|
||||||
|
const fileResult = renameScenarioFile(oldCode, newCode);
|
||||||
|
const updated = await this.prisma.scenario.update({ where: { id: scenario.id }, data: { code: newCode } });
|
||||||
|
return { scenario: updated, file: fileResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDestinations(scenarioCode: string) {
|
||||||
|
const scenario = await this.get(scenarioCode);
|
||||||
|
return this.prisma.scenarioDestination.findMany({
|
||||||
|
where: { scenarioId: scenario.id },
|
||||||
|
include: { database: { select: { code: true, name: true, active: true } } },
|
||||||
|
orderBy: { databaseCode: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDestination(scenarioCode: string, databaseCode: string) {
|
||||||
|
const scenario = await this.get(scenarioCode);
|
||||||
|
return this.prisma.scenarioDestination.upsert({
|
||||||
|
where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } },
|
||||||
|
create: { scenarioId: scenario.id, databaseCode },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDestination(scenarioCode: string, databaseCode: string) {
|
||||||
|
const scenario = await this.get(scenarioCode);
|
||||||
|
try {
|
||||||
|
await this.prisma.scenarioDestination.delete({
|
||||||
|
where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException(`El escenario '${scenarioCode}' no tiene destino '${databaseCode}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMappings(scenarioCode: string, fieldName?: string) {
|
||||||
|
const scenario = await this.get(scenarioCode);
|
||||||
|
return this.prisma.scenarioValueMapping.findMany({
|
||||||
|
where: { scenarioId: scenario.id, ...(fieldName ? { fieldName } : {}) },
|
||||||
|
orderBy: [{ fieldName: 'asc' }, { databaseCode: 'asc' }, { sourceValue: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMapping(scenarioCode: string, input: { fieldName: string; databaseCode: string; sourceValue: string; targetValue: string }) {
|
||||||
|
const scenario = await this.get(scenarioCode);
|
||||||
|
return this.prisma.scenarioValueMapping.upsert({
|
||||||
|
where: {
|
||||||
|
scenarioId_fieldName_databaseCode_sourceValue: {
|
||||||
|
scenarioId: scenario.id,
|
||||||
|
fieldName: input.fieldName,
|
||||||
|
databaseCode: input.databaseCode,
|
||||||
|
sourceValue: input.sourceValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { scenarioId: scenario.id, ...input },
|
||||||
|
update: { targetValue: input.targetValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSource(scenarioCode: string) {
|
||||||
|
await this.get(scenarioCode); // 404 consistente si el escenario no existe en Postgres
|
||||||
|
const source = readScenarioSource(scenarioCode);
|
||||||
|
if (!source) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`No se encontró src/scenarios/${scenarioFileSlug(scenarioCode)}.ts en sap-intercompany-worker`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "src/generated"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user