Saltar a contenido

API de Reportes PDF

1. Resumen

El módulo de reportes genera documentos PDF (pedidos, facturas, notas de entrega) desde SAP Business One utilizando Crystal Reports.

Servicio Puerto Función Autenticación
Service Layer 50000 Consultar tipos y layouts Sesión del usuario (SSO)
API Gateway 60000 Generar PDFs Cuenta de servicio

2. Arquitectura

C4Context
    title Sistema de Reportes PDF - Diagrama de Contexto

    Person(user, "Usuario", "Vendedor / Técnico / Jefe Técnico")

    System(oms, "OMS Backend", "NestJS - Puerto 3301")

    System_Ext(sl, "SAP Service Layer", "Puerto 50000")
    System_Ext(gw, "SAP API Gateway", "Puerto 60000")
    System_Ext(cr, "Crystal Reports", "Motor de reportes")

    Rel(user, oms, "Solicita PDF", "HTTPS + JWT")
    Rel(oms, sl, "Consulta layouts", "REST + Cookies SSO")
    Rel(oms, gw, "Genera PDF", "REST + Sesión sistema")
    Rel(gw, cr, "Renderiza", "Interno")

Componentes Internos

C4Component
    title OMS Backend - Módulo de Reportes

    Container_Boundary(backend, "OMS Backend") {
        Component(ctrl, "ReportsController", "NestJS Controller", "Endpoints REST")
        Component(svc, "ReportsService", "NestJS Service", "Lógica de negocio")
        Component(gws, "ApiGatewaySessionService", "NestJS Service", "Gestión de sesión API Gateway")
        Component(sap, "SapClientModule", "NestJS Module", "Cliente Service Layer")
    }

    System_Ext(sl, "Service Layer", "Metadatos de reportes")
    System_Ext(gw, "API Gateway", "Generación PDF")

    Rel(ctrl, svc, "Usa")
    Rel(svc, sap, "Consulta layouts")
    Rel(svc, gws, "Solicita PDF")
    Rel(sap, sl, "REST")
    Rel(gws, gw, "REST")

3. Autenticación

El sistema utiliza tres niveles de autenticación:

Nivel Tramo Credenciales Gestión
1 Frontend → Backend JWT del usuario JwtAuthGuard
2 Backend → Service Layer Cookies SSO del usuario SapClientModule
3 Backend → API Gateway Cuenta de servicio (.env) ApiGatewaySessionService

Seguridad en Endpoints de Reportes

Importante: Todos los endpoints de reportes requieren un JWT válido del usuario. El API Gateway es interno al backend y no es accesible directamente desde el frontend.

flowchart LR
    subgraph Frontend
        A[Request PDF]
    end

    subgraph Backend
        B{JWT válido?}
        C[ReportsService]
        D[ApiGatewaySession]
    end

    subgraph SAP
        E[API Gateway]
    end

    A -->|"Authorization: Bearer <jwt>"| B
    B -->|No| F[401 Unauthorized]
    B -->|Sí| C
    C --> D
    D -->|"Sesión de sistema"| E
    E --> G[PDF]
    G --> A

    style F fill:#ffcdd2
    style G fill:#c8e6c9

Flujo de seguridad:

  1. Frontend envía request con header Authorization: Bearer <jwt>
  2. JwtAuthGuard valida el token:
  3. Token inválido/expirado → 401 Unauthorized
  4. Token válido → continúa al controller
  5. ReportsController procesa la solicitud
  6. ApiGatewaySessionService usa credenciales de sistema (no del usuario) para conectar con SAP
  7. API Gateway genera el PDF y lo retorna al frontend

El usuario nunca interactúa directamente con el API Gateway. El JWT protege el acceso a la ruta, y el backend internamente usa una cuenta de servicio para generar el PDF.

Flujo de Sesión del API Gateway (Interno)

flowchart TD
    A[Request de PDF] --> B{¿Sesión válida<br/>en cache?}

    B -->|Sí| C{¿Expira en<br/>menos de 60s?}
    B -->|No| D{¿Login en<br/>progreso?}

    C -->|No| E[Usar sesión existente]
    C -->|Sí| F[Renovar sesión]

    D -->|Sí| G[Esperar login actual]
    D -->|No| H[POST /login<br/>con credenciales .env]

    H --> I[Guardar cookies<br/>en cache]
    I --> E
    F --> E
    G --> E

    E --> J[Ejecutar request<br/>con cookies]

    style A fill:#e1f5fe
    style J fill:#c8e6c9

Renovación Automática

Tiempo ──────────────────────────────────────────────────────────────►

    Login                                           Buffer    Expiración
      │                                               │            │
      ▼                                               ▼            ▼
      ├───────────────────────────────────────────────┼────────────┤
      │           Sesión válida (usar cache)          │  Renovar   │
      │◄─────────────────── 30 min ──────────────────►│◄── 60s ───►│

4. Flujo de Generación de PDF

sequenceDiagram
    autonumber

    participant F as Frontend
    participant J as JwtAuthGuard
    participant C as ReportsController
    participant S as ReportsService
    participant G as ApiGatewaySession
    participant API as SAP API Gateway

    F->>J: GET /api/reports/orders/123/pdf<br/>Authorization: Bearer <jwt>
    activate J

    J->>J: Validar JWT

    alt JWT inválido o expirado
        J-->>F: 401 Unauthorized
    end

    J->>C: Request válido + user context
    deactivate J
    activate C

    C->>S: exportOrderToPdf(123, 456)
    activate S

    S->>G: getCookies()
    activate G

    alt Sin sesión válida en cache
        G->>API: POST /login (credenciales sistema)
        API-->>G: Set-Cookie: Session=xxx
        G->>G: Guardar en cache
    end

    G-->>S: { Session, ROUTEID }
    deactivate G

    S->>API: POST /rs/v1/ExportPDFData
    Note over S,API: Cookie: Session=xxx; ROUTEID=.node1

    API-->>S: { data: "JVBERi0xLjQ..." }

    S->>S: Base64 → Buffer

    S-->>C: PDF Buffer
    deactivate S

    C-->>F: application/pdf
    deactivate C

    Note over F: Content-Disposition: inline;<br/>filename="pedido-456.pdf"

Nota: El JWT del usuario (pasos 1-3) y la sesión del API Gateway (pasos 6-8) son independientes. El JWT autentica al usuario, la sesión de sistema genera el PDF.


5. Referencia de API

Documentación interactiva: GET /api-docs (Swagger UI)

Autenticación Requerida

Todos los endpoints de este módulo requieren autenticación JWT:

Authorization: Bearer <jwt_token>

Si el token es inválido o está expirado, el servidor retorna 401 Unauthorized.

Consultas de Metadatos

Método Endpoint Descripción
GET /api/reports/types Lista todos los tipos de reportes
GET /api/reports/types/:typeCode/default Layout por defecto de un tipo
GET /api/reports/types/:typeCode/layouts Todos los layouts de un tipo

Generación de PDF

Endpoint principal (parametrizado):

Método Endpoint Descripción
GET /api/reports/documents/:documentType/:docEntry/pdf PDF de cualquier tipo de documento

Aliases para retrocompatibilidad:

Método Endpoint Equivalente
GET /api/reports/orders/:docEntry/pdf documents/order/:docEntry/pdf
GET /api/reports/invoices/:docEntry/pdf documents/invoice/:docEntry/pdf

Tipos de Documento

documentType Documento Layout Default Nombre archivo
order Pedido de Venta RCRI0026 pedido-{docNum}.pdf
invoice Factura de Ventas RCRI0029 factura-{docNum}.pdf
payment Recibo de Cobro RCRI0030 recibo-{docNum}.pdf
delivery Nota de Entrega RCRI0031 entrega-{docNum}.pdf
creditNote Nota de Crédito RCRI0032 nota-credito-{docNum}.pdf

Parámetros

Parámetro Ubicación Requerido Descripción
documentType path ✅ Tipo de documento (ver tabla arriba)
docEntry path ✅ DocEntry del documento en SAP
docNum query ✅ Número de documento (FolioNum)
reportCode query ❌ Código del layout (usa default si se omite)
format query ❌ base64 para JSON, omitir para binario
download query ❌ true para Content-Disposition: attachment

Ejemplos

PDF binario - Pedido (visualización en navegador):

GET /api/reports/documents/order/33008/pdf?docNum=15629
Authorization: Bearer <jwt>

PDF binario - Factura:

GET /api/reports/documents/invoice/45000/pdf?docNum=78901
Authorization: Bearer <jwt>

PDF en Base64 (para apps móviles):

GET /api/reports/documents/payment/12000/pdf?docNum=5500&format=base64
Authorization: Bearer <jwt>

{
  "success": true,
  "filename": "recibo-5500.pdf",
  "contentType": "application/pdf",
  "data": "JVBERi0xLjQKJeLjz9MK..."
}

PDF con layout específico:

GET /api/reports/documents/invoice/45000/pdf?docNum=78901&reportCode=RCRI0050
Authorization: Bearer <jwt>

Usando alias (retrocompatibilidad):

GET /api/reports/orders/33008/pdf?docNum=15629
GET /api/reports/invoices/45000/pdf?docNum=78901


6. Configuración

Variables de Entorno

# Service Layer (consultas de layouts)
SAP_SERVICE_LAYER_URL=https://sap-server:50000/b1s/v2

# API Gateway (generación de PDFs)
SAP_COMPANY_DB=NOMBRE_EMPRESA
SAP_SL_USER=usuario_servicio
SAP_SL_PASSWORD=password_servicio

Nota: Las credenciales SAP_SL_USER y SAP_SL_PASSWORD son de una cuenta de servicio con permisos para generar todos los reportes, no de usuarios finales.


7. Manejo de Errores

flowchart LR
    A[Error] --> B{Código}

    B -->|400| C[Parámetros faltantes<br/>Verificar docNum, reportCode]
    B -->|401| D[JWT inválido o sesión expirada<br/>Re-autenticar usuario]
    B -->|403| E[Sin permisos<br/>Verificar roles]
    B -->|404| F[Reporte no encontrado<br/>Verificar código]
    B -->|500| G[Error API Gateway<br/>Revisar logs SAP]

    style C fill:#fff3e0
    style D fill:#ffebee
    style E fill:#ffebee
    style F fill:#fff3e0
    style G fill:#ffcdd2

Retry Automático

Cuando el API Gateway retorna 401 (sesión expirada):

  1. ApiGatewaySessionService invalida la sesión cacheada
  2. El siguiente request automáticamente hace re-login
  3. El request original se reintenta con la nueva sesión

8. Decisiones de Diseño

¿Por qué JWT + sesión de sistema (dos capas)?

Capa Propósito Credenciales
JWT Autorizar acceso a la ruta Token del usuario
Sesión sistema Generar PDF en SAP Cuenta de servicio

Problema: Crystal Reports (API Gateway) no soporta autenticación por usuario individual.

Solución: 1. El JWT garantiza que solo usuarios autenticados pueden solicitar PDFs 2. El backend usa una cuenta de servicio con permisos para generar cualquier reporte 3. La validación de permisos (qué documentos puede ver el usuario) se hace en el backend antes de generar

¿Por qué dos servicios SAP diferentes?

Aspecto Service Layer API Gateway
Propósito CRUD de datos Generación de reportes
Autenticación Soporta SSO por usuario Solo credenciales fijas
Permisos Respeta permisos del usuario Cuenta con acceso total

Decisión: Usar sesión de usuario para consultas (respeta permisos) y sesión de sistema para generar PDFs (Crystal Reports no soporta SSO).

¿Por qué login lazy?

El API Gateway solo se usa cuando alguien solicita un PDF. Hacer login al iniciar el servidor: - Desperdiciaría una sesión si nadie genera PDFs - Complicaría el manejo de errores de conexión inicial

Decisión: Login lazy con cache y renovación automática 60 segundos antes de expirar.

¿Por qué layouts hardcodeados?

Actualmente los endpoints tienen layouts por defecto en código: - Pedidos: RCRI0026 - Facturas: RCRI0029

Mejora propuesta: Consultar el layout por defecto desde SAP si no se especifica reportCode.


9. Alcance y Límites del Módulo

Casos de Uso

flowchart TB
    subgraph current["Alcance Actual"]
        A[Frontend/Mobile] -->|"solicita PDF"| B[Backend]
        B -->|"genera"| C[API Gateway]
        C -->|"base64"| B
        B -->|"PDF binario o base64"| A
        A -->|"visualiza/descarga"| D[Usuario]
    end

    subgraph future["Integración Futura: PrintNode"]
        E[Frontend/Mobile] -->|"solicita impresión"| F[Backend]
        F -->|"genera"| G[API Gateway]
        G -->|"base64"| F
        F -->|"envía base64"| H[PrintNode API]
        H -->|"imprime"| I[Impresora Física]
        F -->|"confirma jobId"| E
    end

    style current fill:#e8f5e9
    style future fill:#fff3e0
Caso de Uso Estado Salida del Backend
Visualización en navegador ✅ Implementado PDF binario (application/pdf)
Descarga en móvil ✅ Implementado PDF en base64 (JSON)
Impresión directa 🔜 Futuro Confirmación + jobId

Responsabilidades del Módulo

Responsabilidad ¿Está en alcance?
Autenticar usuario (JWT) ✅ Sí
Gestionar sesión con API Gateway ✅ Sí
Generar PDF desde Crystal Reports ✅ Sí
Retornar PDF al cliente ✅ Sí
Enviar PDF a PrintNode ❌ No (futuro)
Gestionar impresoras ❌ No (futuro)
Almacenar historial de impresiones ❌ No (futuro)

Integración Futura: PrintNode

PrintNode es un servicio de impresión en la nube que permite enviar trabajos a impresoras físicas remotas.

Formatos soportados por PrintNode:

Formato ContentType Uso
Base64 pdf_base64 PDF codificado en base64
URL pdf_uri URL pública donde PrintNode descarga el PDF
Raw raw_base64 Datos crudos para impresoras específicas

Compatibilidad con el módulo actual:

El API Gateway ya retorna el PDF en base64:

{
  "data": "JVBERi0xLjQ..."
}

Este formato es exactamente lo que PrintNode necesita (pdf_base64), por lo que no se requieren cambios en la generación de PDF.

Flujo propuesto para impresión:

sequenceDiagram
    autonumber

    participant F as Frontend
    participant B as Backend
    participant G as API Gateway
    participant P as PrintNode API

    F->>B: GET /orders/123/pdf?action=print&printerId=456

    B->>G: POST /ExportPDFData
    G-->>B: { data: "JVBERi0..." }

    B->>P: POST /printjobs
    Note over B,P: { printerId: 456,<br/>contentType: "pdf_base64",<br/>content: "JVBERi0..." }

    P-->>B: { id: 789, state: "new" }

    B-->>F: { success: true, printJobId: 789 }

Cambios requeridos para implementar:

Componente Cambio
.env Añadir PRINTNODE_API_KEY
PrintNodeService Nuevo servicio para comunicación con API
ReportsController Nuevo parámetro ?action=print o endpoint separado
PrintersModule Gestión de impresoras por sucursal/usuario

Endpoints futuros (propuesta):

Método Endpoint Descripción
GET /api/printers Lista impresoras disponibles
POST /api/print/orders/:docEntry Imprimir pedido
POST /api/print/invoices/:docEntry Imprimir factura
GET /api/print/jobs/:jobId Estado de un trabajo de impresión

10. Implementación (Clean Code)

Esta sección documenta la estructura fundamental del código siguiendo principios de Clean Code.

Paradigma: Configuration-Driven Design

El módulo usa un diseño basado en configuración donde un único flujo genérico maneja todos los tipos de documentos, diferenciándolos solo por configuración.

Problema: Código Repetitivo

Sin parametrización, cada tipo de documento requiere su propio método:

classDiagram
    class ReportsService {
        +exportOrderToPdf()
        +exportInvoiceToPdf()
        +exportPaymentToPdf()
        +exportDeliveryToPdf()
        +exportCreditNoteToPdf()
    }

    note for ReportsService "❌ 5 métodos casi idénticos\n❌ Código duplicado\n❌ Difícil de mantener"

Solución: Configuración + Método Genérico

classDiagram
    class ReportsService {
        -DOCUMENT_CONFIG: Map
        +exportToPdf(documentType, docEntry, options)
    }

    class DocumentTypeConfig {
        <<interface>>
        +defaultLayout: string
        +displayName: string
        +buildParams()
    }

    ReportsService --> DocumentTypeConfig : usa configuración

    note for ReportsService "✅ 1 método genérico\n✅ Configuración centralizada\n✅ Fácil de extender"

Flujo de Datos (UML Activity Diagram)

flowchart TD
    subgraph Input
        A[documentType: 'order']
        B[docEntry: 123]
        C[options: docNum, reportCode?]
    end

    subgraph "ReportsService.exportToPdf()"
        D[Buscar en DOCUMENT_CONFIG]
        E{¿Existe<br/>configuración?}
        F[Obtener defaultLayout]
        G[Obtener displayName]
        H[Ejecutar buildParams]
        I[Generar PDF]
        J[Construir PdfResult]
    end

    subgraph Output
        K["PdfResult {<br/>  buffer: PDF,<br/>  filename: 'pedido-456.pdf'<br/>}"]
    end

    A --> D
    B --> D
    C --> D
    D --> E
    E -->|No| L[BadRequestException]
    E -->|Sí| F
    F --> G
    G --> H
    H --> I
    I --> J
    J --> K

    style L fill:#ffcdd2
    style K fill:#c8e6c9

Estructura de la Configuración (UML Object Diagram)

classDiagram
    class DOCUMENT_CONFIG {
        <<Map~string, DocumentTypeConfig~>>
    }

    class order {
        defaultLayout = "RCRI0026"
        displayName = "pedido"
        buildParams = buildStandardParams()
    }

    class invoice {
        defaultLayout = "RCRI0029"
        displayName = "factura"
        buildParams = buildStandardParams()
    }

    class payment {
        defaultLayout = "RCRI0030"
        displayName = "recibo"
        buildParams = buildStandardParams()
    }

    class delivery {
        defaultLayout = "RCRI0031"
        displayName = "entrega"
        buildParams = buildStandardParams()
    }

    class creditNote {
        defaultLayout = "RCRI0032"
        displayName = "nota-credito"
        buildParams = buildStandardParams()
    }

    DOCUMENT_CONFIG --> order
    DOCUMENT_CONFIG --> invoice
    DOCUMENT_CONFIG --> payment
    DOCUMENT_CONFIG --> delivery
    DOCUMENT_CONFIG --> creditNote

Secuencia de Llamadas (UML Sequence Diagram)

sequenceDiagram
    autonumber

    participant C as Controller
    participant S as ReportsService
    participant CFG as DOCUMENT_CONFIG
    participant G as ApiGatewaySession
    participant API as SAP API Gateway

    Note over C: GET /documents/order/123/pdf?docNum=456

    C->>S: exportToPdf('order', 123, {docNum: 456})
    activate S

    S->>CFG: getDocumentConfig('order')
    CFG-->>S: { defaultLayout: 'RCRI0026', displayName: 'pedido', buildParams: fn }

    Note over S: reportCode = options.reportCode ?? config.defaultLayout<br/>= 'RCRI0026'

    S->>S: config.buildParams(123, 456)
    Note over S: params = [{ name: 'Dockey@', value: '123' }, ...]

    S->>G: getCookies()
    G-->>S: "Session=xxx; ROUTEID=.node1"

    S->>API: POST /ExportPDFData { ReportCode: 'RCRI0026', params }
    API-->>S: { data: "JVBERi0..." }

    S->>S: new PdfResult(buffer, 'pedido-456.pdf')

    S-->>C: PdfResult
    deactivate S

    Note over C: Retorna PDF binario o base64 según query.format

Principios Aplicados

Principio Aplicación
Single Responsibility Controller solo maneja HTTP, Service solo lógica de negocio
Open/Closed Abierto a extensión (nuevo tipo = nueva config), cerrado a modificación
Dependency Injection Services inyectados, facilita testing
DRY Un método genérico en vez de N métodos repetitivos
Configuration over Code Comportamiento definido en datos, no en código

Estructura de Archivos

src/modules/reports/
├── reports.module.ts
├── reports.controller.ts
├── reports.service.ts
├── dto/
│   ├── export-pdf.dto.ts
│   └── pdf-response.dto.ts
├── interfaces/
│   └── report-layout.interface.ts
└── services/
    └── api-gateway-session.service.ts

Controller

El controller solo maneja concerns HTTP: recibir request, validar parámetros, delegar al service, formatear response.

Diseño parametrizado: Un único endpoint genérico maneja todos los tipos de documentos.

@Controller('reports')
export class ReportsController {
  constructor(private readonly reportsService: ReportsService) {}

  /**
   * GET /api/reports/documents/:documentType/:docEntry/pdf
   *
   * Endpoint genérico para generar PDF de cualquier tipo de documento.
   *
   * @param documentType - order | invoice | payment | delivery | creditNote
   * @param docEntry - DocEntry del documento en SAP
   * @param query - docNum (requerido), reportCode (opcional), format (opcional)
   *
   * @example
   * GET /api/reports/documents/order/123/pdf?docNum=456
   * GET /api/reports/documents/invoice/789/pdf?docNum=101&format=base64
   * GET /api/reports/documents/payment/555/pdf?docNum=200&reportCode=CUSTOM
   */
  @Get('documents/:documentType/:docEntry/pdf')
  async getDocumentPdf(
    @Param('documentType') documentType: DocumentType,
    @Param('docEntry', ParseIntPipe) docEntry: number,
    @Query() query: ExportPdfQueryDto,
    @Res() res: Response,
  ): Promise<void> {
    const result = await this.reportsService.exportToPdf(
      documentType,
      docEntry,
      query,
    );

    if (query.format === 'base64') {
      res.json(result.toBase64Response());
      return;
    }

    this.sendPdfResponse(res, result, query.download);
  }

  /**
   * Aliases para retrocompatibilidad (opcional).
   * Redirigen al endpoint genérico.
   */
  @Get('orders/:docEntry/pdf')
  async getOrderPdf(
    @Param('docEntry', ParseIntPipe) docEntry: number,
    @Query() query: ExportPdfQueryDto,
    @Res() res: Response,
  ): Promise<void> {
    return this.getDocumentPdf('order', docEntry, query, res);
  }

  @Get('invoices/:docEntry/pdf')
  async getInvoicePdf(
    @Param('docEntry', ParseIntPipe) docEntry: number,
    @Query() query: ExportPdfQueryDto,
    @Res() res: Response,
  ): Promise<void> {
    return this.getDocumentPdf('invoice', docEntry, query, res);
  }

  /**
   * Método privado para enviar PDF binario.
   */
  private sendPdfResponse(
    res: Response,
    result: PdfResult,
    download: boolean,
  ): void {
    const disposition = download ? 'attachment' : 'inline';

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `${disposition}; filename="${result.filename}"`,
      'Content-Length': result.buffer.length,
    });

    res.send(result.buffer);
  }
}

Principios en el Controller: - No contiene lógica de negocio - Delega al service inmediatamente - Métodos pequeños y específicos - sendPdfResponse extrae código repetido

Service

El service contiene toda la lógica de negocio. No conoce HTTP (no usa Request/Response).

Diseño parametrizado: Un único método exportToPdf maneja todos los tipos de documentos usando configuración.

@Injectable()
export class ReportsService {
  /**
   * Configuración de tipos de documento.
   * Centraliza layouts por defecto y construcción de parámetros.
   */
  private readonly DOCUMENT_CONFIG: Record<DocumentType, DocumentTypeConfig> = {
    order: {
      defaultLayout: 'RCRI0026',
      displayName: 'pedido',
      buildParams: (docEntry, docNum) => this.buildStandardParams(docEntry, docNum),
    },
    invoice: {
      defaultLayout: 'RCRI0029',
      displayName: 'factura',
      buildParams: (docEntry, docNum) => this.buildStandardParams(docEntry, docNum),
    },
    payment: {
      defaultLayout: 'RCRI0030',
      displayName: 'recibo',
      buildParams: (docEntry, docNum) => this.buildStandardParams(docEntry, docNum),
    },
    delivery: {
      defaultLayout: 'RCRI0031',
      displayName: 'entrega',
      buildParams: (docEntry, docNum) => this.buildStandardParams(docEntry, docNum),
    },
    creditNote: {
      defaultLayout: 'RCRI0032',
      displayName: 'nota-credito',
      buildParams: (docEntry, docNum) => this.buildStandardParams(docEntry, docNum),
    },
  };

  constructor(
    private readonly apiGatewaySession: ApiGatewaySessionService,
    private readonly httpService: HttpService,
  ) {}

  /**
   * Genera PDF de cualquier tipo de documento.
   *
   * @param documentType - Tipo de documento (order, invoice, payment, etc.)
   * @param docEntry - DocEntry del documento en SAP
   * @param options - Opciones de exportación (docNum, reportCode, format)
   * @returns PdfResult con buffer y metadata
   *
   * @example
   * // Usa layout por defecto
   * exportToPdf('order', 123, { docNum: 456 })
   *
   * // Usa layout específico
   * exportToPdf('invoice', 789, { docNum: 101, reportCode: 'CUSTOM01' })
   */
  async exportToPdf(
    documentType: DocumentType,
    docEntry: number,
    options: ExportPdfQueryDto,
  ): Promise<PdfResult> {
    const config = this.getDocumentConfig(documentType);
    const reportCode = options.reportCode ?? config.defaultLayout;
    const params = config.buildParams(docEntry, options.docNum);

    const pdfData = await this.generatePdf(reportCode, params);

    return new PdfResult(
      pdfData,
      `${config.displayName}-${options.docNum}.pdf`,
    );
  }

  /**
   * Obtiene configuración de un tipo de documento.
   * Lanza error si el tipo no existe.
   */
  private getDocumentConfig(documentType: DocumentType): DocumentTypeConfig {
    const config = this.DOCUMENT_CONFIG[documentType];

    if (!config) {
      throw new BadRequestException(
        `Tipo de documento no soportado: ${documentType}. ` +
        `Tipos válidos: ${Object.keys(this.DOCUMENT_CONFIG).join(', ')}`,
      );
    }

    return config;
  }

  /**
   * Método central de generación de PDF.
   */
  private async generatePdf(
    reportCode: string,
    params: ReportParam[],
  ): Promise<Buffer> {
    const cookies = await this.apiGatewaySession.getCookies();
    const response = await this.callExportApi(reportCode, params, cookies);

    return Buffer.from(response.data, 'base64');
  }

  /**
   * Construye parámetros estándar (DocKey + FolioNum).
   * Usado por la mayoría de documentos.
   */
  private buildStandardParams(docEntry: number, docNum: number): ReportParam[] {
    return [
      { name: 'Dockey@', type: 'xsd:string', value: [[String(docEntry)]] },
      { name: 'FolioNum@', type: 'xsd:string', value: [[String(docNum)]] },
    ];
  }

  /**
   * Llama al API Gateway para generar el PDF.
   */
  private async callExportApi(
    reportCode: string,
    params: ReportParam[],
    cookies: string,
  ): Promise<ExportApiResponse> {
    const { data } = await firstValueFrom(
      this.httpService.post(
        `${this.apiGatewayUrl}/rs/v1/ExportPDFData`,
        { ReportCode: reportCode, Parameters: params },
        { headers: { Cookie: cookies } },
      ),
    );

    return data;
  }
}

Principios en el Service: - Cada método hace una sola cosa - Nombres que describen la acción (buildOrderParams, callExportApi) - Constantes con nombre (DEFAULT_LAYOUTS) - Método central generatePdf evita duplicación - No conoce HTTP

Types e Interfaces

Tipos centrales para el diseño parametrizado.

// types/document-type.ts
export const DOCUMENT_TYPES = [
  'order',
  'invoice',
  'payment',
  'delivery',
  'creditNote',
] as const;

export type DocumentType = typeof DOCUMENT_TYPES[number];

// interfaces/document-type-config.interface.ts
export interface DocumentTypeConfig {
  /** Código de layout por defecto en SAP */
  defaultLayout: string;

  /** Nombre para el archivo (ej: 'pedido' → pedido-123.pdf) */
  displayName: string;

  /** Función que construye los parámetros para el reporte */
  buildParams: (docEntry: number, docNum: number) => ReportParam[];
}

DTOs

Validación y tipado en la frontera del sistema.

// dto/export-pdf-query.dto.ts
export class ExportPdfQueryDto {
  @IsInt()
  @Type(() => Number)
  docNum: number;

  @IsOptional()
  @IsString()
  reportCode?: string;

  @IsOptional()
  @IsIn(['base64'])
  format?: 'base64';

  @IsOptional()
  @Transform(({ value }) => value === 'true')
  @IsBoolean()
  download?: boolean;
}

// dto/document-type-param.dto.ts
export class DocumentTypeParamDto {
  @IsIn(DOCUMENT_TYPES, {
    message: `Tipo inválido. Valores permitidos: ${DOCUMENT_TYPES.join(', ')}`,
  })
  documentType: DocumentType;
}

// dto/pdf-result.ts
export class PdfResult {
  constructor(
    public readonly buffer: Buffer,
    public readonly filename: string,
    public readonly contentType = 'application/pdf',
  ) {}

  toBase64Response(): PdfBase64Response {
    return {
      success: true,
      filename: this.filename,
      contentType: this.contentType,
      data: this.buffer.toString('base64'),
    };
  }
}

ApiGatewaySessionService

Servicio dedicado a gestionar la sesión con el API Gateway.

@Injectable()
export class ApiGatewaySessionService {
  private session: GatewaySession | null = null;
  private loginPromise: Promise<GatewaySession> | null = null;

  constructor(
    private readonly httpService: HttpService,
    private readonly configService: ConfigService,
  ) {}

  /**
   * Obtiene cookies de sesión válidas.
   * - Si hay sesión válida en cache, la retorna
   * - Si no, hace login y cachea
   * - Thread-safe: múltiples requests esperan el mismo login
   */
  async getCookies(): Promise<string> {
    const session = await this.getValidSession();
    return session.cookies;
  }

  private async getValidSession(): Promise<GatewaySession> {
    // Sesión válida en cache
    if (this.isSessionValid()) {
      return this.session!;
    }

    // Login en progreso, esperar
    if (this.loginPromise) {
      return this.loginPromise;
    }

    // Iniciar nuevo login
    return this.performLogin();
  }

  private isSessionValid(): boolean {
    if (!this.session) return false;

    const bufferMs = 60 * 1000; // 60 segundos de buffer
    return Date.now() < this.session.expiresAt - bufferMs;
  }

  private async performLogin(): Promise<GatewaySession> {
    this.loginPromise = this.doLogin();

    try {
      this.session = await this.loginPromise;
      return this.session;
    } finally {
      this.loginPromise = null;
    }
  }

  private async doLogin(): Promise<GatewaySession> {
    const response = await firstValueFrom(
      this.httpService.post(`${this.gatewayUrl}/login`, {
        CompanyDB: this.configService.get('SAP_COMPANY_DB'),
        UserName: this.configService.get('SAP_SL_USER'),
        Password: this.configService.get('SAP_SL_PASSWORD'),
      }),
    );

    return this.parseSessionFromResponse(response);
  }
}

Principios en ApiGatewaySessionService: - Responsabilidad única: gestionar sesión - Encapsula complejidad de cache y login - Thread-safe con loginPromise - Métodos pequeños con nombres claros

Interfaces

// interfaces/report-param.interface.ts
export interface ReportParam {
  name: string;
  type: 'xsd:string' | 'xsd:int';
  value: string[][];
}

// interfaces/gateway-session.interface.ts
export interface GatewaySession {
  cookies: string;
  expiresAt: number;
}

// interfaces/export-api-response.interface.ts
export interface ExportApiResponse {
  data: string; // PDF en base64
}

Diagrama de Clases

classDiagram
    class ReportsController {
        -reportsService: ReportsService
        +getDocumentPdf(documentType, docEntry, query, res)
        +getOrderPdf(docEntry, query, res)
        +getInvoicePdf(docEntry, query, res)
        -sendPdfResponse(res, result, download)
    }

    class ReportsService {
        -apiGatewaySession: ApiGatewaySessionService
        -httpService: HttpService
        -DOCUMENT_CONFIG: Record~DocumentType, DocumentTypeConfig~
        +exportToPdf(documentType, docEntry, options)
        -getDocumentConfig(documentType)
        -generatePdf(reportCode, params)
        -buildStandardParams(docEntry, docNum)
        -callExportApi(reportCode, params, cookies)
    }

    class DocumentTypeConfig {
        <<interface>>
        +defaultLayout: string
        +displayName: string
        +buildParams: Function
    }

    class ApiGatewaySessionService {
        -session: GatewaySession
        -loginPromise: Promise
        +getCookies()
        -getValidSession()
        -isSessionValid()
        -performLogin()
        -doLogin()
    }

    class PdfResult {
        +buffer: Buffer
        +filename: string
        +contentType: string
        +toBase64Response()
    }

    class ExportPdfQueryDto {
        +docNum: number
        +reportCode?: string
        +format?: string
        +download?: boolean
    }

    ReportsController --> ReportsService
    ReportsService --> ApiGatewaySessionService
    ReportsService --> DocumentTypeConfig
    ReportsService --> PdfResult
    ReportsController --> ExportPdfQueryDto

Testing

La separación de responsabilidades facilita el testing:

// reports.service.spec.ts
describe('ReportsService', () => {
  let service: ReportsService;
  let mockApiGatewaySession: jest.Mocked<ApiGatewaySessionService>;
  let mockHttpService: jest.Mocked<HttpService>;

  beforeEach(() => {
    mockApiGatewaySession = {
      getCookies: jest.fn().mockResolvedValue('Session=xxx'),
    };
    service = new ReportsService(mockApiGatewaySession, mockHttpService);
  });

  describe('exportToPdf', () => {
    it('should use default layout for order', async () => {
      mockHttpService.post.mockReturnValue(of({ data: { data: 'base64...' } }));

      await service.exportToPdf('order', 123, { docNum: 456 });

      expect(mockHttpService.post).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({ ReportCode: 'RCRI0026' }),
        expect.any(Object),
      );
    });

    it('should use default layout for invoice', async () => {
      mockHttpService.post.mockReturnValue(of({ data: { data: 'base64...' } }));

      await service.exportToPdf('invoice', 789, { docNum: 101 });

      expect(mockHttpService.post).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({ ReportCode: 'RCRI0029' }),
        expect.any(Object),
      );
    });

    it('should use custom layout when specified', async () => {
      mockHttpService.post.mockReturnValue(of({ data: { data: 'base64...' } }));

      await service.exportToPdf('order', 123, {
        docNum: 456,
        reportCode: 'CUSTOM01'
      });

      expect(mockHttpService.post).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({ ReportCode: 'CUSTOM01' }),
        expect.any(Object),
      );
    });

    it('should throw BadRequestException for invalid document type', async () => {
      await expect(
        service.exportToPdf('invalid' as any, 123, { docNum: 456 }),
      ).rejects.toThrow(BadRequestException);
    });

    it('should generate correct filename', async () => {
      mockHttpService.post.mockReturnValue(of({ data: { data: 'base64...' } }));

      const result = await service.exportToPdf('payment', 100, { docNum: 999 });

      expect(result.filename).toBe('recibo-999.pdf');
    });
  });
});

Añadir Nuevo Tipo de Documento

Para añadir un nuevo tipo (ej: purchaseOrder), solo se modifica DOCUMENT_CONFIG:

// En ReportsService
private readonly DOCUMENT_CONFIG: Record<DocumentType, DocumentTypeConfig> = {
  // ... tipos existentes ...

  // Nuevo tipo
  purchaseOrder: {
    defaultLayout: 'RCRI0040',
    displayName: 'orden-compra',
    buildParams: (docEntry, docNum) => this.buildStandardParams(docEntry, docNum),
  },
};

// En types/document-type.ts
export const DOCUMENT_TYPES = [
  // ... tipos existentes ...
  'purchaseOrder',
] as const;

No se requieren cambios en Controller ni en endpoints.