- 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>
12 KiB
12 KiB
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
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
// 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
// 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
/* 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
// 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
// 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
# 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
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"
# 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:
Browsershot::url($url)
->timeout(300) // 5 minutos
->waitUntilNetworkIdle(false) // No esperar red
->save($fullPath);
Error: "Missing fonts"
# Instalar fuentes
sudo apt-get install -y fonts-liberation fonts-noto-color-emoji