diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6070135..36449c2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -3,13 +3,14 @@ import { AuditAdminModule } from './audit/audit-admin.module.js'; import { AuthModule } from './auth/auth.module.js'; import { AppConfigModule } from './config/config.module.js'; import { DatabaseModule } from './database/database.module.js'; +import { GatewayModule } from './gateway/gateway.module.js'; import { HealthController } from './health/health.controller.js'; import { PermissionsModule } from './permissions/permissions.module.js'; import { SapModule } from './sap/sap.module.js'; import { SlModule } from './sl/sl.module.js'; @Module({ - imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule], + imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule], controllers: [HealthController], }) export class AppModule {} diff --git a/apps/api/src/config/env.schema.spec.ts b/apps/api/src/config/env.schema.spec.ts index 3faf36d..cfbfb82 100644 --- a/apps/api/src/config/env.schema.spec.ts +++ b/apps/api/src/config/env.schema.spec.ts @@ -56,4 +56,29 @@ describe('validateEnv', () => { ).toThrow(/producción/); 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/); + }); + }); }); + diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index 50b44bd..fbb21fe 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -50,6 +50,21 @@ export const envSchema = z // CA adicional de confianza (PEM): certificado autofirmado del Service // Layer o CA de un firewall que inspecciona SSL. Opcional. 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 .enum(['true', 'false']) .default('true') @@ -63,6 +78,15 @@ export const envSchema = z 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) { ctx.addIssue({ code: 'custom', diff --git a/apps/api/src/gateway/gateway-client.service.ts b/apps/api/src/gateway/gateway-client.service.ts new file mode 100644 index 0000000..e2f3e5b --- /dev/null +++ b/apps/api/src/gateway/gateway-client.service.ts @@ -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; + 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(work: () => Promise): Promise { + if (this.active >= this.config.get('APIGATEWAY_MAX_CONCURRENCY')) await new Promise((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; body?: unknown } = {}): Promise { + 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 { + return this.session ? Promise.resolve(this.session) : this.login(); + } + + private renew(stale: Session): Promise { + if (this.session && this.session !== stale) return Promise.resolve(this.session); + this.session = undefined; + return this.login(); + } + + private login(): Promise { + this.loginInFlight ??= this.doLogin().finally(() => { + this.loginInFlight = undefined; + }); + return this.loginInFlight; + } + + private async doLogin(): Promise { + 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; body?: unknown }): Promise { + 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 { + await this.dispatcher?.close(); + } +} diff --git a/apps/api/src/gateway/gateway-report.entity.ts b/apps/api/src/gateway/gateway-report.entity.ts new file mode 100644 index 0000000..56d4e29 --- /dev/null +++ b/apps/api/src/gateway/gateway-report.entity.ts @@ -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; +} diff --git a/apps/api/src/gateway/gateway.controller.ts b/apps/api/src/gateway/gateway.controller.ts new file mode 100644 index 0000000..45636bc --- /dev/null +++ b/apps/api/src/gateway/gateway.controller.ts @@ -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; + +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; + +/** 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"` }); + } +} diff --git a/apps/api/src/gateway/gateway.module.ts b/apps/api/src/gateway/gateway.module.ts new file mode 100644 index 0000000..bf73014 --- /dev/null +++ b/apps/api/src/gateway/gateway.module.ts @@ -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 {} diff --git a/apps/api/src/gateway/gateway.service.spec.ts b/apps/api/src/gateway/gateway.service.spec.ts new file mode 100644 index 0000000..3b63819 --- /dev/null +++ b/apps/api/src/gateway/gateway.service.spec.ts @@ -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; + let call: ReturnType; + let record: ReturnType; + 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' })); + }); +}); diff --git a/apps/api/src/gateway/gateway.service.ts b/apps/api/src/gateway/gateway.service.ts new file mode 100644 index 0000000..dc9c830 --- /dev/null +++ b/apps/api/src/gateway/gateway.service.ts @@ -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, + private readonly permissions: PermissionsService, + private readonly audit: AuditService, + ) {} + + // --- lo que el API Gateway autoriza --------------------------------------------------------- + + async authorized(): Promise { + 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 { + const res = await this.client.call('GET', 'rs/v1/LoadCR', { query: { DocCode: code } }); + const body = JSON.parse(res.text) as { error?: boolean; resultSet?: Parameters[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 { + 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 { + 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, user: AuthUser, ip: string): Promise { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/libs/sap-types/src/audit.ts b/libs/sap-types/src/audit.ts index fc0ba3f..59a8946 100644 --- a/libs/sap-types/src/audit.ts +++ b/libs/sap-types/src/audit.ts @@ -8,7 +8,10 @@ export type AuditAction = | 'sl_action' | 'sl_denied' | 'permissions_set' - | 'permissions_remove'; + | 'permissions_remove' + | 'report_export' + | 'report_register' + | 'report_unregister'; export type AuditOutcome = 'ok' | 'error' | 'denied'; diff --git a/libs/sap-types/src/gateway.spec.ts b/libs/sap-types/src/gateway.spec.ts new file mode 100644 index 0000000..ca96347 --- /dev/null +++ b/libs/sap-types/src/gateway.spec.ts @@ -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 => ({ 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']); + }); +}); diff --git a/libs/sap-types/src/gateway.ts b/libs/sap-types/src/gateway.ts new file mode 100644 index 0000000..e7fb0b6 --- /dev/null +++ b/libs/sap-types/src/gateway.ts @@ -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>; + +/** 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 }; +} diff --git a/libs/sap-types/src/index.ts b/libs/sap-types/src/index.ts index 900de67..78ea4bf 100644 --- a/libs/sap-types/src/index.ts +++ b/libs/sap-types/src/index.ts @@ -4,3 +4,4 @@ export * from './company.js'; export * from './permissions.js'; export * from './sl.js'; export * from './menu.js'; +export * from './gateway.js';