feat(api): módulo del API Gateway (reportes Crystal): sesión y cola propias, matrícula de reportes y ejecución con PDF

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 17:46:09 -05:00
1 parent 74743503f0
commit 2a29c63e7d
13 files changed
+774 -2

No files matched your search

+2 -1
View File
@@ -3,13 +3,14 @@ import { AuditAdminModule } from './audit/audit-admin.module.js';
import { AuthModule } from './auth/auth.module.js'; import { AuthModule } from './auth/auth.module.js';
import { AppConfigModule } from './config/config.module.js'; import { AppConfigModule } from './config/config.module.js';
import { DatabaseModule } from './database/database.module.js'; import { DatabaseModule } from './database/database.module.js';
import { GatewayModule } from './gateway/gateway.module.js';
import { HealthController } from './health/health.controller.js'; import { HealthController } from './health/health.controller.js';
import { PermissionsModule } from './permissions/permissions.module.js'; import { PermissionsModule } from './permissions/permissions.module.js';
import { SapModule } from './sap/sap.module.js'; import { SapModule } from './sap/sap.module.js';
import { SlModule } from './sl/sl.module.js'; import { SlModule } from './sl/sl.module.js';
@Module({ @Module({
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule], imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule],
controllers: [HealthController], controllers: [HealthController],
}) })
export class AppModule {} export class AppModule {}
+25
View File
@@ -56,4 +56,29 @@ describe('validateEnv', () => {
).toThrow(/producción/); ).toThrow(/producción/);
expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false); expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false);
}); });
describe('API Gateway', () => {
it('es opcional: sin URL queda sin configurar y con valores por defecto', () => {
const env = validateEnv(sap);
expect(env.APIGATEWAY_URL).toBeUndefined();
expect(env.APIGATEWAY_TIMEOUT).toBe(60);
expect(env.APIGATEWAY_MAX_CONCURRENCY).toBe(2);
expect(env.APIGATEWAY_VERIFY_SSL).toBe(true);
});
it('con URL pide usuario, clave y base, y quita la barra final', () => {
expect(() => validateEnv({ ...sap, APIGATEWAY_URL: 'https://gw.local:60000/' })).toThrow(/APIGATEWAY_USER/);
const env = validateEnv({ ...sap, APIGATEWAY_URL: 'https://gw.local:60000/', APIGATEWAY_USER: 'u', APIGATEWAY_PASSWORD: 'p', APIGATEWAY_COMPANYDB: 'DB' });
expect(env.APIGATEWAY_URL).toBe('https://gw.local:60000');
});
it('un valor vacío del .env cuenta como ausente', () => {
expect(validateEnv({ ...sap, APIGATEWAY_URL: '', APIGATEWAY_USER: '' }).APIGATEWAY_URL).toBeUndefined();
});
it('no se permite desactivar la validación SSL en producción', () => {
expect(() => validateEnv({ ...sap, NODE_ENV: 'production', APIGATEWAY_VERIFY_SSL: 'false' })).toThrow(/APIGATEWAY_VERIFY_SSL/);
});
});
}); });
+24
View File
@@ -50,6 +50,21 @@ export const envSchema = z
// CA adicional de confianza (PEM): certificado autofirmado del Service // CA adicional de confianza (PEM): certificado autofirmado del Service
// Layer o CA de un firewall que inspecciona SSL. Opcional. // Layer o CA de un firewall que inspecciona SSL. Opcional.
SAPB1_SL_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()), SAPB1_SL_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()),
// --- SAP Business One API Gateway (reportes Crystal): opcional; sin URL el módulo queda sin configurar ---
APIGATEWAY_URL: z.preprocess(emptyToUndefined, z.url().optional()).transform((v) => v?.replace(/\/+$/, '')),
APIGATEWAY_USER: z.preprocess(emptyToUndefined, z.string().optional()),
APIGATEWAY_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()),
APIGATEWAY_COMPANYDB: z.preprocess(emptyToUndefined, z.string().optional()),
APIGATEWAY_DBINSTANCE: z.preprocess(emptyToUndefined, z.string().optional()),
// Espera máxima (segundos) de cada llamada: generar un reporte puede tardar.
APIGATEWAY_TIMEOUT: z.coerce.number().positive().default(60),
// Reportes que se generan a la vez; el resto espera su turno.
APIGATEWAY_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
APIGATEWAY_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()),
APIGATEWAY_VERIFY_SSL: z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
SAPB1_SL_VERIFY_SSL: z SAPB1_SL_VERIFY_SSL: z
.enum(['true', 'false']) .enum(['true', 'false'])
.default('true') .default('true')
@@ -63,6 +78,15 @@ export const envSchema = z
message: `no existe el archivo ${resolve(env.SAPB1_SL_CA_FILE)}`, message: `no existe el archivo ${resolve(env.SAPB1_SL_CA_FILE)}`,
}); });
} }
if (env.APIGATEWAY_CA_FILE && !existsSync(resolve(env.APIGATEWAY_CA_FILE))) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_CA_FILE'], message: `no existe el archivo ${resolve(env.APIGATEWAY_CA_FILE)}` });
}
if (env.APIGATEWAY_URL && !(env.APIGATEWAY_USER && env.APIGATEWAY_PASSWORD && env.APIGATEWAY_COMPANYDB)) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_URL'], message: 'con APIGATEWAY_URL hacen falta APIGATEWAY_USER, APIGATEWAY_PASSWORD y APIGATEWAY_COMPANYDB' });
}
if (env.NODE_ENV === 'production' && !env.APIGATEWAY_VERIFY_SSL) {
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use APIGATEWAY_CA_FILE' });
}
if (env.NODE_ENV === 'production' && !env.SAPB1_SL_VERIFY_SSL) { if (env.NODE_ENV === 'production' && !env.SAPB1_SL_VERIFY_SSL) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
@@ -0,0 +1,158 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { rootCertificates } from 'node:tls';
import { BadGatewayException, GatewayTimeoutException, HttpException, Injectable, Logger, OnModuleDestroy, ServiceUnavailableException } from '@nestjs/common';
import { Agent, request } from 'undici';
import { AppConfigService } from '../config/app-config.service.js';
interface Session {
cookie: string;
}
/** Respuesta ya leída de una llamada al API Gateway. */
export interface GatewayResponse {
status: number;
text: string;
}
/**
* Cliente del API Gateway de SAP (Crystal Reports): su propio login y su propia sesión, separados del Service Layer, y
* una cola propia: solo `APIGATEWAY_MAX_CONCURRENCY` llamadas a la vez (generar un reporte pesa), el resto espera turno.
*/
@Injectable()
export class GatewayClientService implements OnModuleDestroy {
private readonly logger = new Logger(GatewayClientService.name);
private session?: Session;
private loginInFlight?: Promise<Session>;
private active = 0;
private readonly waiting: (() => void)[] = [];
private dispatcher?: Agent;
constructor(private readonly config: AppConfigService) {}
/** Hay URL, usuario y base: sin ellos el módulo responde 503 en vez de fallar al arrancar. */
get configured(): boolean {
return !!(this.config.get('APIGATEWAY_URL') && this.config.get('APIGATEWAY_USER') && this.config.get('APIGATEWAY_PASSWORD') && this.config.get('APIGATEWAY_COMPANYDB'));
}
private agent(): Agent {
if (!this.dispatcher) {
const caFile = this.config.get('APIGATEWAY_CA_FILE');
this.dispatcher = new Agent({
connections: this.config.get('APIGATEWAY_MAX_CONCURRENCY'),
connect: { ca: caFile ? [...rootCertificates, readFileSync(resolve(caFile), 'utf8')] : undefined, rejectUnauthorized: this.config.get('APIGATEWAY_VERIFY_SSL') },
});
}
return this.dispatcher;
}
/** Espera un turno de la cola y lo suelta al terminar. */
private async turn<T>(work: () => Promise<T>): Promise<T> {
if (this.active >= this.config.get('APIGATEWAY_MAX_CONCURRENCY')) await new Promise<void>((go) => this.waiting.push(go));
this.active++;
try {
return await work();
} finally {
this.active--;
this.waiting.shift()?.();
}
}
async call(method: 'GET' | 'POST', path: string, opts: { query?: Record<string, string>; body?: unknown } = {}): Promise<GatewayResponse> {
if (!this.configured) throw new ServiceUnavailableException('El API Gateway no está configurado (APIGATEWAY_URL, usuario y base en el .env)');
return this.turn(async () => {
let session = await this.getSession();
let res = await this.send(session, method, path, opts);
if (res.status === 401) {
this.logger.warn(`Sesión del API Gateway expirada (401) en ${path}; reautenticando`);
session = await this.renew(session);
res = await this.send(session, method, path, opts);
}
if (res.status >= 400) throw new HttpException(`Error del API Gateway (HTTP ${res.status}): ${res.text.slice(0, 300)}`, res.status >= 500 ? 502 : res.status);
return res;
});
}
private getSession(): Promise<Session> {
return this.session ? Promise.resolve(this.session) : this.login();
}
private renew(stale: Session): Promise<Session> {
if (this.session && this.session !== stale) return Promise.resolve(this.session);
this.session = undefined;
return this.login();
}
private login(): Promise<Session> {
this.loginInFlight ??= this.doLogin().finally(() => {
this.loginInFlight = undefined;
});
return this.loginInFlight;
}
private async doLogin(): Promise<Session> {
const timeout = this.config.get('APIGATEWAY_TIMEOUT') * 1000;
this.logger.log('Iniciando sesión en el API Gateway');
try {
const res = await request(`${this.config.get('APIGATEWAY_URL')}/login`, {
method: 'POST',
dispatcher: this.agent(),
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
CompanyDB: this.config.get('APIGATEWAY_COMPANYDB'),
UserName: this.config.get('APIGATEWAY_USER'),
Password: this.config.get('APIGATEWAY_PASSWORD'),
...(this.config.get('APIGATEWAY_DBINSTANCE') ? { DBInstance: this.config.get('APIGATEWAY_DBINSTANCE') } : {}),
}),
headersTimeout: timeout,
bodyTimeout: timeout,
});
await res.body.dump();
if (res.statusCode !== 200) {
// un problema de configuración del servidor, no de la sesión del usuario web: 502, nunca 401
this.logger.error(`Login del API Gateway rechazado (HTTP ${res.statusCode})`);
throw new BadGatewayException('No se pudo iniciar sesión en el API Gateway con el usuario de servicio');
}
const raw = res.headers['set-cookie'];
const cookie = (Array.isArray(raw) ? raw : raw ? [raw] : []).map((c) => c.split(';')[0].trim()).filter(Boolean).join('; ');
this.session = { cookie };
this.logger.log('Sesión del API Gateway establecida');
return this.session;
} catch (err) {
if (err instanceof HttpException) throw err;
throw this.describe(err);
}
}
private async send(session: Session, method: 'GET' | 'POST', path: string, opts: { query?: Record<string, string>; body?: unknown }): Promise<GatewayResponse> {
const timeout = this.config.get('APIGATEWAY_TIMEOUT') * 1000;
const query = opts.query ? `?${new URLSearchParams(opts.query).toString()}` : '';
const started = Date.now();
try {
const res = await request(`${this.config.get('APIGATEWAY_URL')}/${path}${query}`, {
method,
dispatcher: this.agent(),
headers: { 'content-type': 'application/json', accept: '*/*', ...(session.cookie ? { cookie: session.cookie } : {}) },
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
headersTimeout: timeout,
bodyTimeout: timeout,
});
const text = await res.body.text();
this.logger.debug(`${method} ${path} -> ${res.statusCode} (${Date.now() - started} ms)`);
return { status: res.statusCode, text };
} catch (err) {
throw this.describe(err);
}
}
private describe(err: unknown): HttpException {
const code = (err as { code?: string }).code ?? '';
if (['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'ETIMEDOUT'].includes(code)) return new GatewayTimeoutException('Tiempo de espera agotado con el API Gateway');
if (code.includes('CERT') || code.includes('SELF_SIGNED') || code.includes('UNABLE_TO_VERIFY')) return new BadGatewayException(`No se pudo validar el certificado del API Gateway (${code}). Configure APIGATEWAY_CA_FILE.`);
return new BadGatewayException('No se pudo conectar con el API Gateway');
}
async onModuleDestroy(): Promise<void> {
await this.dispatcher?.close();
}
}
@@ -0,0 +1,31 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
/** Un reporte del API Gateway matriculado: el que los usuarios ven en la lista para ejecutar. */
@Entity('gateway_reports')
export class GatewayReportEntity {
/** Código del reporte en el API Gateway (`RCRI0001`). */
@PrimaryColumn({ type: 'varchar' })
code: string;
@Column({ type: 'varchar' })
title: string;
@Column({ type: 'varchar' })
category: string;
@Column({ type: 'varchar', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: true })
enabled: boolean;
/** JSON con la lista de `GatewayParam`. */
@Column({ type: 'text' })
params: string;
@Column({ type: 'varchar', nullable: true })
updatedBy: string | null;
@Column({ type: 'varchar' })
updatedAt: string;
}
@@ -0,0 +1,96 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Req, StreamableFile, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.types.js';
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
import { AdminGuard } from '../permissions/admin.guard.js';
import { GatewayService } from './gateway.service.js';
const codeSchema = z.string().regex(/^[A-Za-z0-9_.-]{1,60}$/, 'Código de reporte inválido');
const text = (max: number) => z.string().trim().max(max);
const paramSchema = z.object({
name: text(200).min(1),
type: text(60).min(1),
label: text(120).min(1),
kind: z.enum(['text', 'number', 'date', 'select', 'lookup']),
required: z.boolean(),
range: z.boolean().optional(),
multiple: z.boolean().optional(),
defaultValue: text(200).optional(),
placeholder: text(120).optional(),
options: z.array(z.object({ value: text(200), label: text(200) })).max(100).optional(),
lookup: text(60).optional(),
});
const reportSchema = z.object({
title: text(160).min(1),
category: text(60).min(1),
description: text(500).optional(),
enabled: z.boolean(),
params: z.array(paramSchema).max(60),
});
type ReportDto = z.infer<typeof reportSchema>;
const exportSchema = z.object({ values: z.record(z.string(), z.union([z.string().max(500), z.array(z.string().max(500)).max(200)])) });
type ExportDto = z.infer<typeof exportSchema>;
/** Reportes del API Gateway: matrícula (administradores) y ejecución (permiso de menú «Reportes»). */
@Controller('gateway')
export class GatewayController {
constructor(private readonly gateway: GatewayService) {}
// --- matrícula (solo acceso total) ----------------------------------------------------------
@Get('authorized')
@UseGuards(AdminGuard)
authorized() {
return this.gateway.authorized();
}
@Get('authorized/:code/parameters')
@UseGuards(AdminGuard)
parameters(@Param('code', new ZodValidationPipe(codeSchema)) code: string) {
return this.gateway.parameters(code);
}
@Get('registry')
@UseGuards(AdminGuard)
registry() {
return this.gateway.registry();
}
@Put('registry/:code')
@UseGuards(AdminGuard)
save(
@Req() req: AuthenticatedRequest,
@Param('code', new ZodValidationPipe(codeSchema)) code: string,
@Body(new ZodValidationPipe(reportSchema)) body: ReportDto,
) {
return this.gateway.save(code, body, req.user, req.ip ?? '');
}
@Delete('registry/:code')
@HttpCode(204)
@UseGuards(AdminGuard)
async remove(@Req() req: AuthenticatedRequest, @Param('code', new ZodValidationPipe(codeSchema)) code: string) {
await this.gateway.remove(code, req.user, req.ip ?? '');
}
// --- ejecución -------------------------------------------------------------------------------
@Get('reports')
reports(@Req() req: AuthenticatedRequest) {
return this.gateway.enabled(req.user);
}
@Post('reports/:code/export')
@HttpCode(200)
async export(
@Req() req: AuthenticatedRequest,
@Param('code', new ZodValidationPipe(codeSchema)) code: string,
@Body(new ZodValidationPipe(exportSchema)) body: ExportDto,
) {
const pdf = await this.gateway.export(req.user, code, body.values, req.ip ?? '');
return new StreamableFile(pdf, { type: 'application/pdf', disposition: `inline; filename="${code}.pdf"` });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module.js';
import { PermissionsModule } from '../permissions/permissions.module.js';
import { GatewayClientService } from './gateway-client.service.js';
import { GatewayController } from './gateway.controller.js';
import { GatewayReportEntity } from './gateway-report.entity.js';
import { GatewayService } from './gateway.service.js';
/** Reportes Crystal por el API Gateway de SAP B1: su propia sesión y su propia cola. */
@Module({
imports: [TypeOrmModule.forFeature([GatewayReportEntity]), AuditModule, PermissionsModule],
controllers: [GatewayController],
providers: [GatewayClientService, GatewayService],
})
export class GatewayModule {}
@@ -0,0 +1,126 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { type GatewayParam } from '@basic-erp/sap-types';
import { vi } from 'vitest';
import type { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import type { PermissionsService } from '../permissions/permissions.service.js';
import type { GatewayClientService } from './gateway-client.service.js';
import type { GatewayReportEntity } from './gateway-report.entity.js';
import { GatewayService, decodePdf, gatewayMessage } from './gateway.service.js';
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
const PDF = Buffer.from('%PDF-1.4 contenido');
const params: GatewayParam[] = [
{ name: 'Dockey@', type: 'xsd:decimal', label: 'Documento', kind: 'lookup', required: true, lookup: 'doc:DeliveryNotes' },
{ name: 'CANTIDAD', type: 'xsd:decimal', label: 'Cantidad', kind: 'number', required: false },
];
describe('decodePdf', () => {
it('acepta el base64 tal cual y entre comillas', () => {
expect(decodePdf(PDF.toString('base64')).equals(PDF)).toBe(true);
expect(decodePdf(JSON.stringify(PDF.toString('base64'))).equals(PDF)).toBe(true);
});
});
describe('gatewayMessage', () => {
it('muestra el texto de error del gateway o el «(---)» de un reporte sin resultado', () => {
expect(gatewayMessage('Error code name: Unparseable date: "31/01/2026"')).toContain('Unparseable date');
expect(gatewayMessage('(---)')).toBe('(---)');
expect(gatewayMessage(Buffer.from('mensaje en base64').toString('base64'))).toBe('mensaje en base64');
});
});
describe('GatewayService', () => {
let rows: Map<string, GatewayReportEntity>;
let call: ReturnType<typeof vi.fn>;
let record: ReturnType<typeof vi.fn>;
let paths: string[];
let service: GatewayService;
beforeEach(() => {
rows = new Map();
call = vi.fn(async (_m: string, path: string) => {
if (path.endsWith('LoadAuthorizedCRList')) return { status: 200, text: JSON.stringify({ resultSet: [{ code: 'DLN20015', name: 'Etiquetas', root_name: '#1#4', root_guid: '44' }] }) };
if (path.endsWith('LoadCR')) return { status: 200, text: JSON.stringify({ error: false, resultSet: [{ name: 'Dockey@', type: 'xsd:decimal', description: 'Documento', isOptionalPrompt: 'false' }] }) };
return { status: 200, text: PDF.toString('base64') };
});
record = vi.fn(async () => undefined);
paths = ['*'];
const repo = {
find: async (opts?: { where?: { enabled?: boolean } }) => [...rows.values()].filter((r) => opts?.where?.enabled === undefined || r.enabled === opts.where.enabled),
findOneBy: async ({ code }: { code: string }) => rows.get(code) ?? null,
save: async (e: GatewayReportEntity) => void rows.set(e.code, e),
remove: async (e: GatewayReportEntity) => void rows.delete(e.code),
};
service = new GatewayService(
{ call } as unknown as GatewayClientService,
repo as never,
{ getPaths: async () => paths } as unknown as PermissionsService,
{ record } as unknown as AuditService,
);
});
const register = () => service.save('DLN20015', { title: 'Etiquetas', category: 'inventario', enabled: true, params }, user, '10.0.0.5');
it('lista lo que el gateway autoriza y arma los parámetros de un reporte desde LoadCR', async () => {
expect((await service.authorized()).map((r) => r.code)).toEqual(['DLN20015']);
expect(await service.parameters('DLN20015')).toEqual([{ name: 'Dockey@', type: 'xsd:decimal', label: 'Documento', kind: 'number', required: true }]);
});
it('solo se matricula un reporte que el gateway autoriza, y queda registrado en la auditoría', async () => {
await expect(service.save('NOEXISTE', { title: 'x', category: 'otros', enabled: true, params: [] }, user, '10.0.0.5')).rejects.toThrow(BadRequestException);
await register();
expect((await service.registry())[0]).toMatchObject({ code: 'DLN20015', category: 'inventario', params });
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'report_register', resource: 'DLN20015' }));
// volver a guardar uno ya matriculado no consulta al gateway
call.mockClear();
await register();
expect(call).not.toHaveBeenCalled();
});
it('desmatricular quita el reporte y avisa si no estaba', async () => {
await register();
await service.remove('DLN20015', user, '10.0.0.5');
expect(await service.registry()).toEqual([]);
await expect(service.remove('DLN20015', user, '10.0.0.5')).rejects.toThrow(NotFoundException);
});
it('ejecutar exige el permiso de menú y que el reporte esté matriculado y habilitado', async () => {
await register();
paths = ['/ventas/*'];
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(ForbiddenException);
paths = ['/reportes/ejecutar'];
await expect(service.export(user, 'OTRO', {}, '10.0.0.5')).rejects.toThrow(NotFoundException);
await service.save('DLN20015', { title: 'Etiquetas', category: 'inventario', enabled: false, params }, user, '10.0.0.5');
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(NotFoundException);
});
it('genera el PDF con el cuerpo que arman los parámetros matriculados y audita solo sus nombres', async () => {
await register();
const pdf = await service.export(user, 'DLN20015', { 'Dockey@': '137138', CANTIDAD: '5', Intruso: 'x' }, '10.0.0.5');
expect(pdf.equals(PDF)).toBe(true);
expect(call).toHaveBeenLastCalledWith('POST', 'rs/v1/ExportPDFData', {
query: { DocCode: 'DLN20015' },
body: [
{ name: 'Dockey@', type: 'xsd:decimal', value: [['137138']] },
{ name: 'CANTIDAD', type: 'xsd:decimal', value: [['5']] },
],
});
expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'report_export', detail: { params: ['Dockey@', 'CANTIDAD'] } }));
expect(JSON.stringify(record.mock.calls)).not.toContain('137138');
});
it('un parámetro obligatorio sin valor no llega al gateway', async () => {
await register();
call.mockClear();
await expect(service.export(user, 'DLN20015', { CANTIDAD: '5' }, '10.0.0.5')).rejects.toThrow(/Documento/);
expect(call).not.toHaveBeenCalled();
});
it('si el gateway no devuelve un PDF lo rechaza y lo deja en la auditoría', async () => {
await register();
call.mockResolvedValueOnce({ status: 200, text: Buffer.from('no es pdf').toString('base64') });
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(/no devolvió un PDF: no es pdf/);
expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'report_export', outcome: 'error' }));
});
});
+139
View File
@@ -0,0 +1,139 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type AuthorizedReport, type GatewayParam, type GatewayReport, type GatewayValues, exportBody, isPathAllowed, paramsFromLoadCr } from '@basic-erp/sap-types';
import { Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
import { PermissionsService } from '../permissions/permissions.service.js';
import { GatewayClientService } from './gateway-client.service.js';
import { GatewayReportEntity } from './gateway-report.entity.js';
/** Ruta de menú que permite ejecutar los reportes matriculados. */
export const EXECUTE_PATH = '/reportes/ejecutar';
const toReport = (e: GatewayReportEntity): GatewayReport => ({
code: e.code,
title: e.title,
category: e.category,
...(e.description ? { description: e.description } : {}),
enabled: e.enabled,
params: JSON.parse(e.params) as GatewayParam[],
});
/** El API Gateway devuelve el PDF en base64, a veces entre comillas (un texto JSON). */
export function decodePdf(text: string): Buffer {
let body = text.trim();
if (body.startsWith('"')) {
try {
body = JSON.parse(body) as string;
} catch {
/* se intenta tal cual */
}
}
return Buffer.from(body, 'base64');
}
/**
* Lo que dice el API Gateway cuando no devuelve un PDF: un mensaje de error en texto («Unparseable date…») o `(---)`
* cuando el reporte no produjo nada.
*/
export function gatewayMessage(text: string): string {
const raw = text.trim().replace(/^"|"$/g, '');
const decoded = Buffer.from(raw, 'base64').toString('utf8');
const readable = decoded.length > 0 && !decoded.includes('\ufffd') && /^[\x09\x0a\x0d\x20-\x7e\u00a0-\uffff]+$/.test(decoded) ? decoded : raw;
return readable.slice(0, 300);
}
/**
* Reportes Crystal del API Gateway: lo que el gateway autoriza, la matrícula (qué reportes se usan, con qué
* parámetros y cómo se piden) y su ejecución. Matricular es de administradores; ejecutar, de quien tenga el permiso de menú.
*/
@Injectable()
export class GatewayService {
constructor(
private readonly client: GatewayClientService,
@InjectRepository(GatewayReportEntity) private readonly repo: Repository<GatewayReportEntity>,
private readonly permissions: PermissionsService,
private readonly audit: AuditService,
) {}
// --- lo que el API Gateway autoriza ---------------------------------------------------------
async authorized(): Promise<AuthorizedReport[]> {
const res = await this.client.call('GET', 'rs/v1/LoadAuthorizedCRList');
const body = JSON.parse(res.text) as { resultSet?: AuthorizedReport[] } | AuthorizedReport[];
return Array.isArray(body) ? body : (body.resultSet ?? []);
}
/** Los parámetros de un reporte como definiciones de matrícula (el administrador las ajusta). */
async parameters(code: string): Promise<GatewayParam[]> {
const res = await this.client.call('GET', 'rs/v1/LoadCR', { query: { DocCode: code } });
const body = JSON.parse(res.text) as { error?: boolean; resultSet?: Parameters<typeof paramsFromLoadCr>[0] };
if (body.error) throw new BadRequestException(`El API Gateway no pudo cargar el reporte ${code}`);
return paramsFromLoadCr(body.resultSet ?? []);
}
// --- matrícula -------------------------------------------------------------------------------
async registry(): Promise<GatewayReport[]> {
return (await this.repo.find({ order: { category: 'ASC', title: 'ASC' } })).map(toReport);
}
/** Los reportes habilitados, para la lista de ejecución. */
async enabled(user: AuthUser): Promise<GatewayReport[]> {
await this.assertCanExecute(user);
return (await this.repo.find({ where: { enabled: true }, order: { category: 'ASC', title: 'ASC' } })).map(toReport);
}
async save(code: string, report: Omit<GatewayReport, 'code'>, user: AuthUser, ip: string): Promise<GatewayReport> {
const exists = await this.repo.findOneBy({ code });
// un reporte nuevo debe ser uno de los que el API Gateway autoriza
if (!exists && !(await this.authorized()).some((r) => r.code === code)) throw new BadRequestException(`El reporte ${code} no está autorizado en el API Gateway`);
await this.repo.save({
code,
title: report.title,
category: report.category,
description: report.description ?? null,
enabled: report.enabled,
params: JSON.stringify(report.params),
updatedBy: user.webUser,
updatedAt: new Date().toISOString(),
});
await this.audit.record({ action: 'report_register', webUser: user.webUser, ip, resource: code, detail: { params: report.params.map((p) => p.name), enabled: report.enabled } });
return { code, ...report };
}
async remove(code: string, user: AuthUser, ip: string): Promise<void> {
const found = await this.repo.findOneBy({ code });
if (!found) throw new NotFoundException(`El reporte ${code} no está matriculado`);
await this.repo.remove(found);
await this.audit.record({ action: 'report_unregister', webUser: user.webUser, ip, resource: code });
}
// --- ejecución -------------------------------------------------------------------------------
private async assertCanExecute(user: AuthUser): Promise<void> {
if (!isPathAllowed(await this.permissions.getPaths(user.webUser), EXECUTE_PATH)) throw new ForbiddenException('No tiene permisos para ejecutar reportes');
}
/** Genera el PDF de un reporte matriculado con los valores que escribió el usuario. */
async export(user: AuthUser, code: string, values: GatewayValues, ip: string): Promise<Buffer> {
await this.assertCanExecute(user);
const found = await this.repo.findOneBy({ code });
if (!found || !found.enabled) throw new NotFoundException(`El reporte ${code} no está disponible`);
const { body, missing } = exportBody(toReport(found).params, values);
if (missing.length) throw new BadRequestException(`Falta indicar: ${missing.join(', ')}`);
// solo los nombres de los parámetros: los valores pueden ser datos de clientes
const detail = { params: body.map((b) => b.name) };
try {
const res = await this.client.call('POST', 'rs/v1/ExportPDFData', { query: { DocCode: code }, body });
const pdf = decodePdf(res.text);
if (pdf.subarray(0, 4).toString('latin1') !== '%PDF') throw new BadRequestException(`El reporte no devolvió un PDF: ${gatewayMessage(res.text)}`);
await this.audit.record({ action: 'report_export', webUser: user.webUser, ip, resource: code, detail });
return pdf;
} catch (e) {
await this.audit.record({ action: 'report_export', webUser: user.webUser, ip, resource: code, outcome: 'error', detail: { ...detail, error: e instanceof Error ? e.message.slice(0, 300) : String(e) } });
throw e;
}
}
}
+4 -1
View File
@@ -8,7 +8,10 @@ export type AuditAction =
| 'sl_action' | 'sl_action'
| 'sl_denied' | 'sl_denied'
| 'permissions_set' | 'permissions_set'
| 'permissions_remove'; | 'permissions_remove'
| 'report_export'
| 'report_register'
| 'report_unregister';
export type AuditOutcome = 'ok' | 'error' | 'denied'; export type AuditOutcome = 'ok' | 'error' | 'denied';
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { type GatewayParam, exportBody, paramsFromLoadCr } from './gateway.js';
describe('paramsFromLoadCr', () => {
it('convierte lo que describe LoadCR en definiciones de matrícula', () => {
const params = paramsFromLoadCr([
{ name: 'PWZRunDate', type: 'xsd:date', description: 'Fecha de ejecución de pago:', isOptionalPrompt: 'true', valueRangeKind: 'Range', allowMultiValue: 'false', defaultValues: [] },
{ name: 'Dockey@', type: 'xsd:decimal', description: 'Especificar Dockey@:', isOptionalPrompt: 'false', valueRangeKind: 'Discrete', allowMultiValue: 'false' },
{ name: 'BP@From OCRD', type: 'xsd:string', description: 'Socios de negocios', isOptionalPrompt: 'true', allowMultiValue: 'true', defaultValues: ['C1'] },
{ type: 'xsd:string' },
]);
expect(params).toEqual([
{ name: 'PWZRunDate', type: 'xsd:date', label: 'Fecha de ejecución de pago', kind: 'date', required: false, range: true },
{ name: 'Dockey@', type: 'xsd:decimal', label: 'Dockey@', kind: 'number', required: true },
{ name: 'BP@From OCRD', type: 'xsd:string', label: 'Socios de negocios', kind: 'text', required: false, multiple: true, defaultValue: 'C1' },
]);
});
});
describe('exportBody', () => {
const p = (over: Partial<GatewayParam>): GatewayParam => ({ name: 'P', type: 'xsd:string', label: 'Parámetro', kind: 'text', required: false, ...over });
it('un valor va como lista de listas, con el nombre y el tipo del parámetro', () => {
expect(exportBody([p({ name: 'Dockey@', type: 'xsd:decimal', required: true })], { 'Dockey@': ' 137138 ' }).body).toEqual([{ name: 'Dockey@', type: 'xsd:decimal', value: [['137138']] }]);
});
it('varios valores van uno por lista y un rango, desde y hasta en una sola', () => {
expect(exportBody([p({ multiple: true })], { P: ['A', ' ', 'B'] }).body[0].value).toEqual([['A'], ['B']]);
expect(exportBody([p({ range: true })], { P: ['2026-01-01', '2026-01-31'] }).body[0].value).toEqual([['2026-01-01', '2026-01-31']]);
expect(exportBody([p({ range: true })], { P: ['2026-01-01', ''] }).body[0].value).toEqual([['2026-01-01', '2026-01-01']]); // sin «hasta», el mismo día
});
it('un opcional vacío no se envía y un obligatorio vacío se reporta', () => {
const r = exportBody([p({ name: 'A', label: 'Cliente', required: true }), p({ name: 'B' })], { A: '', B: '' });
expect(r.body).toEqual([]);
expect(r.missing).toEqual(['Cliente']);
});
it('ignora valores de parámetros que no están matriculados', () => {
expect(exportBody([p({ name: 'A' })], { A: '1', Otro: '2' }).body.map((b) => b.name)).toEqual(['A']);
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Reportes del API Gateway de SAP B1 (Crystal Reports): cómo se matriculan, qué parámetros piden y cómo se arma lo
* que se envía a `ExportPDFData`. Lo comparten la API y la web.
*/
export type GatewayParamKind = 'text' | 'number' | 'date' | 'select' | 'lookup';
/** Un parámetro de un reporte matriculado. */
export interface GatewayParam {
/** Nombre del parámetro en Crystal, con su sufijo (`Item@Select * from OITM`, `Dockey@`). */
name: string;
/** Tipo de Crystal (`xsd:string`, `xsd:decimal`, `xsd:date`…); se envía tal cual. */
type: string;
label: string;
/** Cómo se pide al usuario: texto, número, fecha, lista fija o ventana de selección. */
kind: GatewayParamKind;
required: boolean;
/** Se piden dos valores, desde y hasta. */
range?: boolean;
/** Se admiten varios valores. */
multiple?: boolean;
defaultValue?: string;
placeholder?: string;
/** Opciones de una lista fija (`kind = select`). */
options?: { value: string; label: string }[];
/** Ventana de selección (`kind = lookup`): identificador de su catálogo en la web (`socios`, `articulos`, `doc:Invoices`…). */
lookup?: string;
}
/** Un reporte matriculado: el que los usuarios ven en la lista para ejecutar. */
export interface GatewayReport {
/** Código del reporte en el API Gateway (`RCRI0001`, `DLN20015`). */
code: string;
title: string;
category: string;
description?: string;
enabled: boolean;
params: GatewayParam[];
}
/** Un reporte que el API Gateway autoriza (`LoadAuthorizedCRList`). */
export interface AuthorizedReport {
code: string;
name: string;
/** Carpeta de Crystal (`#1000004#4`). */
root_name: string;
root_guid: string;
}
/** Lo que el usuario escribió: un texto, o una lista si el parámetro es de rango o admite varios valores. */
export type GatewayValues = Readonly<Record<string, string | readonly string[] | undefined>>;
/** Un parámetro tal como lo describe `LoadCR`. */
interface CrystalParameter {
name?: string;
type?: string;
description?: string;
isOptionalPrompt?: string;
valueRangeKind?: string;
allowMultiValue?: string;
defaultValues?: unknown[];
}
const NUMERIC = /(decimal|int|double|float|number|currency)/i;
/** Los parámetros de un reporte (respuesta de `LoadCR`) como definiciones de matrícula, para completarlas y ajustarlas. */
export function paramsFromLoadCr(resultSet: readonly CrystalParameter[]): GatewayParam[] {
return resultSet
.filter((p): p is CrystalParameter & { name: string } => typeof p.name === 'string' && p.name !== '')
.map((p) => {
const type = p.type ?? 'xsd:string';
const first = p.defaultValues?.[0];
return {
name: p.name,
type,
label: (p.description ?? p.name).replace(/[:\s]+$/, '').replace(/^Especificar\s+/i, '') || p.name,
kind: /date/i.test(type) ? 'date' : NUMERIC.test(type) ? 'number' : 'text',
required: p.isOptionalPrompt !== 'true',
...(p.valueRangeKind === 'Range' ? { range: true } : {}),
...(p.allowMultiValue === 'true' ? { multiple: true } : {}),
...(first !== undefined && first !== null && first !== '' ? { defaultValue: String(first) } : {}),
};
});
}
const isBlank = (v: string | readonly string[] | undefined): boolean => v === undefined || (typeof v === 'string' ? v.trim() === '' : v.every((x) => x.trim() === ''));
/**
* Cuerpo de `ExportPDFData`: por cada parámetro matriculado `{ name, type, value }`, con el valor como lista de listas
* (`[["137138"]]` un valor, `[["a"],["b"]]` varios, `[["desde","hasta"]]` un rango). Un parámetro opcional sin valor no se
* envía. Devuelve los parámetros obligatorios que faltan por separado.
*/
export function exportBody(params: readonly GatewayParam[], values: GatewayValues): { body: { name: string; type: string; value: string[][] }[]; missing: string[] } {
const body: { name: string; type: string; value: string[][] }[] = [];
const missing: string[] = [];
for (const p of params) {
const raw = values[p.name];
if (isBlank(raw)) {
if (p.required) missing.push(p.label);
continue;
}
const list = (typeof raw === 'string' ? [raw] : [...(raw as readonly string[])]).map((x) => x.trim());
let value: string[][];
if (p.range) value = [[list[0] ?? '', list[1] || list[0] || '']];
else if (p.multiple) value = list.filter((x) => x !== '').map((x) => [x]);
else value = [[list[0]]];
body.push({ name: p.name, type: p.type, value });
}
return { body, missing };
}
+1
View File
@@ -4,3 +4,4 @@ export * from './company.js';
export * from './permissions.js'; export * from './permissions.js';
export * from './sl.js'; export * from './sl.js';
export * from './menu.js'; export * from './menu.js';
export * from './gateway.js';