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:
- Frontend envía request con header
Authorization: Bearer <jwt> - JwtAuthGuard valida el token:
- Token inválido/expirado →
401 Unauthorized - Token válido → continúa al controller
- ReportsController procesa la solicitud
- ApiGatewaySessionService usa credenciales de sistema (no del usuario) para conectar con SAP
- 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_USERySAP_SL_PASSWORDson 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):
ApiGatewaySessionServiceinvalida la sesión cacheada- El siguiente request automáticamente hace re-login
- 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.