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:
1 parent
74743503f0
commit
2a29c63e7d
13 files changed
+774
-2
No files matched your search
@@ -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"` });
|
||||
}
|
||||
}
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user