- Laravel 11 backend with API REST - React 18 + TypeScript + Vite frontend - Multi-parser architecture for accounting systems (CONTPAQi, Aspel, SAP) - 27+ financial metrics calculation - PDF report generation with Browsershot - Complete documentation (10 documents) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
480 lines
12 KiB
Markdown
480 lines
12 KiB
Markdown
# 9. Generación de PDF
|
|
|
|
## Descripción
|
|
|
|
El sistema genera reportes PDF profesionales de **32 páginas** usando Browsershot (wrapper de Puppeteer para Laravel).
|
|
|
|
---
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
┌─────────────────┐
|
|
│ ReporteController│
|
|
│ /pdf │
|
|
└────────┬────────┘
|
|
│
|
|
▼
|
|
┌─────────────────┐
|
|
│ GeneradorPdf │
|
|
└────────┬────────┘
|
|
│
|
|
▼
|
|
┌─────────────────┐
|
|
│ Browsershot │ ← Puppeteer headless Chrome
|
|
└────────┬────────┘
|
|
│
|
|
▼
|
|
┌─────────────────┐
|
|
│ React PdfView │ ← Componente optimizado para impresión
|
|
└────────┬────────┘
|
|
│
|
|
▼
|
|
┌─────────────────┐
|
|
│ PDF 32 págs │
|
|
└─────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Estructura del PDF (32 páginas)
|
|
|
|
| # | Sección | Contenido |
|
|
|---|---------|-----------|
|
|
| 1 | Portada | Logo, nombre empresa, periodo |
|
|
| 2 | Índice | Tabla de contenidos |
|
|
| 3-4 | Resumen Ejecutivo | KPIs principales, semáforos |
|
|
| 5-8 | Balance General | Activos, Pasivos, Capital |
|
|
| 9-12 | Estado de Resultados | Ingresos, Costos, Gastos |
|
|
| 13-14 | Flujo de Efectivo | Operación, Inversión, Financiamiento |
|
|
| 15-18 | Análisis de Márgenes | 7 métricas con gráficas |
|
|
| 19-20 | Análisis de Retorno | ROIC, ROE, ROA, ROCE |
|
|
| 21-22 | Análisis de Eficiencia | Rotaciones, ciclo conversión |
|
|
| 23-24 | Análisis de Liquidez | Ratios de liquidez |
|
|
| 25-26 | Análisis de Solvencia | Deuda, cobertura |
|
|
| 27-28 | Análisis de Gestión | Crecimiento, inversión |
|
|
| 29-30 | Comparativos | Tendencias históricas |
|
|
| 31 | Notas | Observaciones y anomalías |
|
|
| 32 | Glosario | Definiciones de métricas |
|
|
|
|
---
|
|
|
|
## Implementación Backend
|
|
|
|
### GeneradorPdf.php
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Reporte;
|
|
use Spatie\Browsershot\Browsershot;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class GeneradorPdf
|
|
{
|
|
public function generar(Reporte $reporte): string
|
|
{
|
|
// URL del frontend con datos del reporte
|
|
$url = config('app.frontend_url') . '/pdf/' . $reporte->id;
|
|
|
|
// Nombre del archivo
|
|
$filename = sprintf(
|
|
'reportes/%s/%s-%s.pdf',
|
|
$reporte->cliente_id,
|
|
$reporte->periodo_fin->format('Y-m'),
|
|
now()->timestamp
|
|
);
|
|
|
|
$fullPath = storage_path('app/public/' . $filename);
|
|
|
|
// Asegurar directorio existe
|
|
$dir = dirname($fullPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
|
|
// Generar PDF con Browsershot
|
|
Browsershot::url($url)
|
|
->setNodeBinary(config('browsershot.node_binary'))
|
|
->setNpmBinary(config('browsershot.npm_binary'))
|
|
->setChromePath(config('browsershot.chrome_path'))
|
|
->format('Letter')
|
|
->margins(10, 10, 10, 10)
|
|
->showBackground()
|
|
->waitUntilNetworkIdle()
|
|
->timeout(120)
|
|
->save($fullPath);
|
|
|
|
// Actualizar reporte
|
|
$reporte->update([
|
|
'pdf_path' => $filename,
|
|
'status' => 'completado',
|
|
]);
|
|
|
|
return $filename;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Configuración Browsershot
|
|
|
|
```php
|
|
// config/browsershot.php
|
|
return [
|
|
'node_binary' => env('NODE_BINARY', '/usr/bin/node'),
|
|
'npm_binary' => env('NPM_BINARY', '/usr/bin/npm'),
|
|
'chrome_path' => env('CHROME_PATH', null), // null = usa Chromium de Puppeteer
|
|
];
|
|
```
|
|
|
|
---
|
|
|
|
## Implementación Frontend
|
|
|
|
### PdfView Component
|
|
|
|
```tsx
|
|
// src/pages/PdfView/index.tsx
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { api } from '@/services/api';
|
|
import { Reporte } from '@/types';
|
|
|
|
// Importar secciones
|
|
import Portada from './sections/Portada';
|
|
import Indice from './sections/Indice';
|
|
import ResumenEjecutivo from './sections/ResumenEjecutivo';
|
|
import BalanceGeneral from './sections/BalanceGeneral';
|
|
import EstadoResultados from './sections/EstadoResultados';
|
|
import FlujoEfectivo from './sections/FlujoEfectivo';
|
|
import AnalisisMargenes from './sections/AnalisisMargenes';
|
|
import AnalisisRetorno from './sections/AnalisisRetorno';
|
|
import AnalisisEficiencia from './sections/AnalisisEficiencia';
|
|
import AnalisisLiquidez from './sections/AnalisisLiquidez';
|
|
import AnalisisSolvencia from './sections/AnalisisSolvencia';
|
|
import AnalisisGestion from './sections/AnalisisGestion';
|
|
import Comparativos from './sections/Comparativos';
|
|
import Notas from './sections/Notas';
|
|
import Glosario from './sections/Glosario';
|
|
|
|
export default function PdfView() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [reporte, setReporte] = useState<Reporte | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchReporte = async () => {
|
|
try {
|
|
const data = await api.reportes.get(Number(id));
|
|
setReporte(data);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchReporte();
|
|
}, [id]);
|
|
|
|
if (loading || !reporte) {
|
|
return <div>Cargando...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="pdf-container">
|
|
<Portada reporte={reporte} />
|
|
<Indice />
|
|
<ResumenEjecutivo reporte={reporte} />
|
|
<BalanceGeneral reporte={reporte} />
|
|
<EstadoResultados reporte={reporte} />
|
|
<FlujoEfectivo reporte={reporte} />
|
|
<AnalisisMargenes reporte={reporte} />
|
|
<AnalisisRetorno reporte={reporte} />
|
|
<AnalisisEficiencia reporte={reporte} />
|
|
<AnalisisLiquidez reporte={reporte} />
|
|
<AnalisisSolvencia reporte={reporte} />
|
|
<AnalisisGestion reporte={reporte} />
|
|
<Comparativos reporte={reporte} />
|
|
<Notas reporte={reporte} />
|
|
<Glosario />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Estilos de Impresión
|
|
|
|
```css
|
|
/* src/styles/pdf.css */
|
|
|
|
@media print {
|
|
/* Ocultar elementos no imprimibles */
|
|
.no-print {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Saltos de página */
|
|
.page-break {
|
|
page-break-after: always;
|
|
}
|
|
|
|
.page-break-before {
|
|
page-break-before: always;
|
|
}
|
|
|
|
.avoid-break {
|
|
page-break-inside: avoid;
|
|
}
|
|
}
|
|
|
|
/* Contenedor PDF */
|
|
.pdf-container {
|
|
width: 8.5in;
|
|
margin: 0 auto;
|
|
background: white;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
|
|
/* Página individual */
|
|
.pdf-page {
|
|
width: 8.5in;
|
|
min-height: 11in;
|
|
padding: 0.5in;
|
|
box-sizing: border-box;
|
|
position: relative;
|
|
}
|
|
|
|
/* Header de página */
|
|
.pdf-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding-bottom: 0.25in;
|
|
border-bottom: 2px solid #1A1F36;
|
|
margin-bottom: 0.25in;
|
|
}
|
|
|
|
/* Footer de página */
|
|
.pdf-footer {
|
|
position: absolute;
|
|
bottom: 0.25in;
|
|
left: 0.5in;
|
|
right: 0.5in;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 10px;
|
|
color: #666;
|
|
}
|
|
|
|
/* Tabla de datos */
|
|
.pdf-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.pdf-table th,
|
|
.pdf-table td {
|
|
padding: 6px 8px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.pdf-table th {
|
|
background: #f3f4f6;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Semáforos */
|
|
.indicator {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.indicator-muy_positivo { background: #059669; }
|
|
.indicator-positivo { background: #10b981; }
|
|
.indicator-neutral { background: #f59e0b; }
|
|
.indicator-negativo { background: #f97316; }
|
|
.indicator-muy_negativo { background: #ef4444; }
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes de Sección
|
|
|
|
### Portada
|
|
|
|
```tsx
|
|
// src/pages/PdfView/sections/Portada.tsx
|
|
|
|
interface PortadaProps {
|
|
reporte: Reporte;
|
|
}
|
|
|
|
export default function Portada({ reporte }: PortadaProps) {
|
|
return (
|
|
<div className="pdf-page flex flex-col items-center justify-center">
|
|
{/* Logo */}
|
|
<img
|
|
src={reporte.cliente.logo || '/logo-horux.png'}
|
|
alt="Logo"
|
|
className="w-48 mb-8"
|
|
/>
|
|
|
|
{/* Nombre empresa */}
|
|
<h1 className="text-4xl font-bold text-horux-dark mb-4">
|
|
{reporte.cliente.nombre_empresa}
|
|
</h1>
|
|
|
|
{/* Tipo de reporte */}
|
|
<h2 className="text-2xl text-gray-600 mb-8">
|
|
Reporte Financiero
|
|
</h2>
|
|
|
|
{/* Periodo */}
|
|
<p className="text-xl text-gray-500">
|
|
{formatPeriodo(reporte.periodo_tipo, reporte.periodo_inicio, reporte.periodo_fin)}
|
|
</p>
|
|
|
|
{/* Fecha generación */}
|
|
<p className="text-sm text-gray-400 mt-4">
|
|
Generado: {formatDate(reporte.fecha_generacion)}
|
|
</p>
|
|
|
|
{/* Branding */}
|
|
<div className="absolute bottom-8">
|
|
<p className="text-sm text-gray-400">
|
|
Powered by Horux 360
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Resumen Ejecutivo
|
|
|
|
```tsx
|
|
// src/pages/PdfView/sections/ResumenEjecutivo.tsx
|
|
|
|
export default function ResumenEjecutivo({ reporte }: { reporte: Reporte }) {
|
|
const { metricas, comparativos } = reporte.data_calculada;
|
|
|
|
const kpis = [
|
|
{ nombre: 'Ingresos', valor: metricas.ingresos, formato: 'currency' },
|
|
{ nombre: 'Margen EBITDA', valor: metricas.margen_ebitda, formato: 'percent' },
|
|
{ nombre: 'Margen Neto', valor: metricas.margen_neto, formato: 'percent' },
|
|
{ nombre: 'ROIC', valor: metricas.roic, formato: 'percent' },
|
|
{ nombre: 'Current Ratio', valor: metricas.current_ratio, formato: 'number' },
|
|
{ nombre: 'Net Debt/EBITDA', valor: metricas.net_debt_ebitda, formato: 'number' },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div className="pdf-page page-break">
|
|
<PdfHeader titulo="Resumen Ejecutivo" pagina={3} />
|
|
|
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
|
{kpis.map((kpi) => (
|
|
<KpiCard key={kpi.nombre} {...kpi} />
|
|
))}
|
|
</div>
|
|
|
|
<h3 className="text-lg font-bold mb-4">Semáforos de Rendimiento</h3>
|
|
<SemaforoTable metricas={metricas} />
|
|
|
|
<PdfFooter pagina={3} />
|
|
</div>
|
|
|
|
<div className="pdf-page page-break">
|
|
<PdfHeader titulo="Resumen Ejecutivo" pagina={4} />
|
|
|
|
<h3 className="text-lg font-bold mb-4">Tendencias Principales</h3>
|
|
<TrendCharts comparativos={comparativos} />
|
|
|
|
<PdfFooter pagina={4} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Requisitos del Servidor
|
|
|
|
### Instalación de Dependencias
|
|
|
|
```bash
|
|
# Node.js 18+
|
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
|
sudo apt-get install -y nodejs
|
|
|
|
# Puppeteer dependencies (Ubuntu/Debian)
|
|
sudo apt-get install -y \
|
|
libnss3 \
|
|
libatk1.0-0 \
|
|
libatk-bridge2.0-0 \
|
|
libcups2 \
|
|
libdrm2 \
|
|
libxkbcommon0 \
|
|
libxcomposite1 \
|
|
libxdamage1 \
|
|
libxfixes3 \
|
|
libxrandr2 \
|
|
libgbm1 \
|
|
libasound2
|
|
|
|
# Instalar Puppeteer
|
|
cd backend
|
|
npm install puppeteer
|
|
```
|
|
|
|
### Configuración en Producción
|
|
|
|
```env
|
|
# .env
|
|
NODE_BINARY=/usr/bin/node
|
|
NPM_BINARY=/usr/bin/npm
|
|
CHROME_PATH=/usr/bin/google-chrome-stable
|
|
FRONTEND_URL=https://app.horux360.com
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Error: "Unable to launch browser"
|
|
|
|
```bash
|
|
# Verificar Chrome instalado
|
|
which google-chrome-stable
|
|
|
|
# Instalar Chrome si no existe
|
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
|
sudo dpkg -i google-chrome-stable_current_amd64.deb
|
|
sudo apt-get -f install
|
|
```
|
|
|
|
### Error: "Timeout exceeded"
|
|
|
|
Aumentar timeout en `GeneradorPdf.php`:
|
|
|
|
```php
|
|
Browsershot::url($url)
|
|
->timeout(300) // 5 minutos
|
|
->waitUntilNetworkIdle(false) // No esperar red
|
|
->save($fullPath);
|
|
```
|
|
|
|
### Error: "Missing fonts"
|
|
|
|
```bash
|
|
# Instalar fuentes
|
|
sudo apt-get install -y fonts-liberation fonts-noto-color-emoji
|
|
```
|
|
|