FASE 1 - PWA y Frontend: - Crear templates/base.html, dashboard.html, analytics.html, executive.html - Crear static/css/main.css con diseño responsivo - Agregar static/js/app.js, pwa.js, camera.js, charts.js - Implementar manifest.json y service-worker.js para PWA - Soporte para captura de tickets desde cámara móvil FASE 2 - Analytics: - Crear módulo analytics/ con predictions.py, trends.py, comparisons.py - Implementar predicción básica con promedio móvil + tendencia lineal - Agregar endpoints /api/analytics/trends, predictions, comparisons - Integrar Chart.js para gráficas interactivas FASE 3 - Reportes PDF: - Crear módulo reports/ con pdf_generator.py - Implementar SalesReportPDF con generar_reporte_diario y ejecutivo - Agregar comando /reporte [diario|semanal|ejecutivo] - Agregar endpoints /api/reports/generate y /api/reports/download FASE 4 - Mejoras OCR: - Crear módulo ocr/ con processor.py, preprocessor.py, patterns.py - Implementar AmountDetector con patrones múltiples de montos - Agregar preprocesador adaptativo con pipelines para diferentes condiciones - Soporte para corrección de rotación (deskew) y threshold Otsu Dependencias agregadas: - reportlab, matplotlib (PDF) - scipy, pandas (analytics) - imutils, deskew, cachetools (OCR) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
533 lines
19 KiB
Python
533 lines
19 KiB
Python
"""
|
|
PDF Report Generator for Sales Bot
|
|
Uses ReportLab for PDF generation and Matplotlib for charts
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Try to import ReportLab
|
|
try:
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import letter, A4
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import inch, cm
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
|
Image, PageBreak, HRFlowable
|
|
)
|
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
|
REPORTLAB_AVAILABLE = True
|
|
except ImportError:
|
|
REPORTLAB_AVAILABLE = False
|
|
logger.warning("ReportLab not available, PDF generation disabled")
|
|
|
|
# Try to import Matplotlib for charts
|
|
try:
|
|
import matplotlib
|
|
matplotlib.use('Agg') # Use non-interactive backend
|
|
import matplotlib.pyplot as plt
|
|
import matplotlib.dates as mdates
|
|
MATPLOTLIB_AVAILABLE = True
|
|
except ImportError:
|
|
MATPLOTLIB_AVAILABLE = False
|
|
logger.warning("Matplotlib not available, charts in PDF disabled")
|
|
|
|
# Mexico timezone
|
|
TZ_MEXICO = timezone(timedelta(hours=-6))
|
|
|
|
|
|
class SalesReportPDF:
|
|
"""
|
|
Generates PDF reports for sales data.
|
|
"""
|
|
|
|
# Color scheme matching the dashboard
|
|
COLORS = {
|
|
'primary': colors.HexColor('#00d4ff'),
|
|
'secondary': colors.HexColor('#00ff88'),
|
|
'warning': colors.HexColor('#ffaa00'),
|
|
'dark': colors.HexColor('#1a1a2e'),
|
|
'text': colors.HexColor('#333333'),
|
|
'muted': colors.HexColor('#666666'),
|
|
}
|
|
|
|
def __init__(self, ventas: List[Dict], stats: Dict, vendedor: str = None):
|
|
"""
|
|
Initialize the PDF generator.
|
|
|
|
Args:
|
|
ventas: List of sales data
|
|
stats: Statistics dictionary
|
|
vendedor: Optional vendor username (None for all)
|
|
"""
|
|
if not REPORTLAB_AVAILABLE:
|
|
raise ImportError("ReportLab is required for PDF generation")
|
|
|
|
self.ventas = ventas or []
|
|
self.stats = stats or {}
|
|
self.vendedor = vendedor
|
|
self.styles = getSampleStyleSheet()
|
|
self._setup_custom_styles()
|
|
|
|
def _setup_custom_styles(self):
|
|
"""Setup custom paragraph styles."""
|
|
# Title style
|
|
self.styles.add(ParagraphStyle(
|
|
'CustomTitle',
|
|
parent=self.styles['Heading1'],
|
|
fontSize=24,
|
|
textColor=self.COLORS['dark'],
|
|
spaceAfter=20,
|
|
alignment=TA_CENTER
|
|
))
|
|
|
|
# Subtitle style
|
|
self.styles.add(ParagraphStyle(
|
|
'CustomSubtitle',
|
|
parent=self.styles['Normal'],
|
|
fontSize=12,
|
|
textColor=self.COLORS['muted'],
|
|
spaceAfter=10,
|
|
alignment=TA_CENTER
|
|
))
|
|
|
|
# Section header
|
|
self.styles.add(ParagraphStyle(
|
|
'SectionHeader',
|
|
parent=self.styles['Heading2'],
|
|
fontSize=14,
|
|
textColor=self.COLORS['primary'],
|
|
spaceBefore=15,
|
|
spaceAfter=10
|
|
))
|
|
|
|
# KPI value style
|
|
self.styles.add(ParagraphStyle(
|
|
'KPIValue',
|
|
parent=self.styles['Normal'],
|
|
fontSize=18,
|
|
textColor=self.COLORS['dark'],
|
|
alignment=TA_CENTER
|
|
))
|
|
|
|
# KPI label style
|
|
self.styles.add(ParagraphStyle(
|
|
'KPILabel',
|
|
parent=self.styles['Normal'],
|
|
fontSize=10,
|
|
textColor=self.COLORS['muted'],
|
|
alignment=TA_CENTER
|
|
))
|
|
|
|
def generar_reporte_diario(self) -> bytes:
|
|
"""
|
|
Generates a daily sales report.
|
|
|
|
Returns:
|
|
PDF content as bytes
|
|
"""
|
|
buffer = io.BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=letter,
|
|
rightMargin=0.75*inch,
|
|
leftMargin=0.75*inch,
|
|
topMargin=0.75*inch,
|
|
bottomMargin=0.75*inch
|
|
)
|
|
|
|
elements = []
|
|
fecha_hoy = datetime.now(TZ_MEXICO).strftime('%d de %B de %Y')
|
|
|
|
# Title
|
|
elements.append(Paragraph(
|
|
"Reporte Diario de Ventas",
|
|
self.styles['CustomTitle']
|
|
))
|
|
elements.append(Paragraph(
|
|
f"{fecha_hoy}",
|
|
self.styles['CustomSubtitle']
|
|
))
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# KPIs Section
|
|
elements.append(self._create_kpi_section())
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Trend Chart (if matplotlib available)
|
|
if MATPLOTLIB_AVAILABLE and self.ventas:
|
|
chart_image = self._create_trend_chart()
|
|
if chart_image:
|
|
elements.append(Paragraph("Tendencia de Ventas (Últimos 7 días)", self.styles['SectionHeader']))
|
|
elements.append(Image(chart_image, width=6*inch, height=3*inch))
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Top Sellers Section
|
|
elements.append(Paragraph("Top Vendedores del Día", self.styles['SectionHeader']))
|
|
elements.append(self._create_top_sellers_table())
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Sales Detail
|
|
if len(self.ventas) <= 20: # Only include if not too many
|
|
elements.append(Paragraph("Detalle de Ventas", self.styles['SectionHeader']))
|
|
elements.append(self._create_sales_table())
|
|
|
|
# Footer
|
|
elements.append(Spacer(1, 30))
|
|
elements.append(HRFlowable(width="100%", thickness=1, color=self.COLORS['muted']))
|
|
elements.append(Spacer(1, 10))
|
|
elements.append(Paragraph(
|
|
f"Generado por Sales Bot - {datetime.now(TZ_MEXICO).strftime('%Y-%m-%d %H:%M')}",
|
|
self.styles['CustomSubtitle']
|
|
))
|
|
|
|
doc.build(elements)
|
|
return buffer.getvalue()
|
|
|
|
def generar_reporte_ejecutivo(self) -> bytes:
|
|
"""
|
|
Generates an executive summary report.
|
|
|
|
Returns:
|
|
PDF content as bytes
|
|
"""
|
|
buffer = io.BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=letter,
|
|
rightMargin=0.75*inch,
|
|
leftMargin=0.75*inch,
|
|
topMargin=0.75*inch,
|
|
bottomMargin=0.75*inch
|
|
)
|
|
|
|
elements = []
|
|
fecha_hoy = datetime.now(TZ_MEXICO).strftime('%d de %B de %Y')
|
|
mes_actual = datetime.now(TZ_MEXICO).strftime('%B %Y')
|
|
|
|
# Title
|
|
elements.append(Paragraph(
|
|
"Reporte Ejecutivo",
|
|
self.styles['CustomTitle']
|
|
))
|
|
elements.append(Paragraph(
|
|
f"{mes_actual}",
|
|
self.styles['CustomSubtitle']
|
|
))
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Executive KPIs
|
|
elements.append(self._create_executive_kpi_section())
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Monthly Trend Chart
|
|
if MATPLOTLIB_AVAILABLE and self.ventas:
|
|
chart_image = self._create_monthly_chart()
|
|
if chart_image:
|
|
elements.append(Paragraph("Tendencia Mensual", self.styles['SectionHeader']))
|
|
elements.append(Image(chart_image, width=6*inch, height=3*inch))
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Top Performers
|
|
elements.append(Paragraph("Top Performers del Mes", self.styles['SectionHeader']))
|
|
elements.append(self._create_top_performers_table())
|
|
elements.append(Spacer(1, 20))
|
|
|
|
# Comparison Section
|
|
elements.append(Paragraph("Comparativa", self.styles['SectionHeader']))
|
|
elements.append(self._create_comparison_section())
|
|
|
|
# Footer
|
|
elements.append(Spacer(1, 30))
|
|
elements.append(HRFlowable(width="100%", thickness=1, color=self.COLORS['muted']))
|
|
elements.append(Spacer(1, 10))
|
|
elements.append(Paragraph(
|
|
f"Generado por Sales Bot - {datetime.now(TZ_MEXICO).strftime('%Y-%m-%d %H:%M')}",
|
|
self.styles['CustomSubtitle']
|
|
))
|
|
|
|
doc.build(elements)
|
|
return buffer.getvalue()
|
|
|
|
def _create_kpi_section(self) -> Table:
|
|
"""Creates KPI cards section."""
|
|
total_ventas = len(self.ventas)
|
|
monto_total = sum(float(v.get('monto', 0) or 0) for v in self.ventas)
|
|
tubos_total = self.stats.get('tubos_totales', 0)
|
|
comision_total = self.stats.get('comision_total', 0)
|
|
|
|
data = [
|
|
[
|
|
Paragraph(f"{total_ventas}", self.styles['KPIValue']),
|
|
Paragraph(f"${monto_total:,.2f}", self.styles['KPIValue']),
|
|
Paragraph(f"{tubos_total}", self.styles['KPIValue']),
|
|
Paragraph(f"${comision_total:,.2f}", self.styles['KPIValue']),
|
|
],
|
|
[
|
|
Paragraph("Ventas", self.styles['KPILabel']),
|
|
Paragraph("Monto Total", self.styles['KPILabel']),
|
|
Paragraph("Tubos", self.styles['KPILabel']),
|
|
Paragraph("Comisiones", self.styles['KPILabel']),
|
|
]
|
|
]
|
|
|
|
table = Table(data, colWidths=[1.5*inch]*4)
|
|
table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f8f9fa')),
|
|
('BOX', (0, 0), (-1, -1), 1, self.COLORS['primary']),
|
|
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e9ecef')),
|
|
('TOPPADDING', (0, 0), (-1, -1), 12),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 12),
|
|
]))
|
|
|
|
return table
|
|
|
|
def _create_executive_kpi_section(self) -> Table:
|
|
"""Creates executive KPI section with more metrics."""
|
|
total_ventas = len(self.ventas)
|
|
monto_total = sum(float(v.get('monto', 0) or 0) for v in self.ventas)
|
|
promedio_ticket = monto_total / total_ventas if total_ventas > 0 else 0
|
|
vendedores_activos = len(set(v.get('vendedor_username') for v in self.ventas))
|
|
|
|
data = [
|
|
[
|
|
Paragraph(f"${monto_total:,.2f}", self.styles['KPIValue']),
|
|
Paragraph(f"{total_ventas}", self.styles['KPIValue']),
|
|
Paragraph(f"${promedio_ticket:,.2f}", self.styles['KPIValue']),
|
|
Paragraph(f"{vendedores_activos}", self.styles['KPIValue']),
|
|
],
|
|
[
|
|
Paragraph("Monto Total", self.styles['KPILabel']),
|
|
Paragraph("Total Ventas", self.styles['KPILabel']),
|
|
Paragraph("Ticket Promedio", self.styles['KPILabel']),
|
|
Paragraph("Vendedores", self.styles['KPILabel']),
|
|
]
|
|
]
|
|
|
|
table = Table(data, colWidths=[1.5*inch]*4)
|
|
table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#e3f2fd')),
|
|
('BACKGROUND', (0, 1), (-1, 1), colors.HexColor('#f8f9fa')),
|
|
('BOX', (0, 0), (-1, -1), 1, self.COLORS['primary']),
|
|
('TOPPADDING', (0, 0), (-1, -1), 15),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 15),
|
|
]))
|
|
|
|
return table
|
|
|
|
def _create_top_sellers_table(self) -> Table:
|
|
"""Creates top sellers table."""
|
|
# Group sales by vendor
|
|
vendors = {}
|
|
for venta in self.ventas:
|
|
username = venta.get('vendedor_username', 'Desconocido')
|
|
if username not in vendors:
|
|
vendors[username] = {'ventas': 0, 'monto': 0}
|
|
vendors[username]['ventas'] += 1
|
|
vendors[username]['monto'] += float(venta.get('monto', 0) or 0)
|
|
|
|
# Sort by sales count
|
|
sorted_vendors = sorted(vendors.items(), key=lambda x: x[1]['monto'], reverse=True)[:5]
|
|
|
|
data = [['#', 'Vendedor', 'Ventas', 'Monto']]
|
|
for i, (username, stats) in enumerate(sorted_vendors, 1):
|
|
medal = ['🥇', '🥈', '🥉', '4.', '5.'][i-1]
|
|
data.append([
|
|
medal,
|
|
username,
|
|
str(stats['ventas']),
|
|
f"${stats['monto']:,.2f}"
|
|
])
|
|
|
|
if len(data) == 1:
|
|
data.append(['', 'Sin datos', '', ''])
|
|
|
|
table = Table(data, colWidths=[0.5*inch, 2.5*inch, 1*inch, 1.5*inch])
|
|
table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['dark']),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, 0), 10),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
|
('BACKGROUND', (0, 1), (-1, -1), colors.HexColor('#f8f9fa')),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
|
|
('TOPPADDING', (0, 1), (-1, -1), 8),
|
|
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
|
|
]))
|
|
|
|
return table
|
|
|
|
def _create_top_performers_table(self) -> Table:
|
|
"""Creates top performers table for executive report."""
|
|
# Similar to top sellers but with more metrics
|
|
return self._create_top_sellers_table()
|
|
|
|
def _create_sales_table(self) -> Table:
|
|
"""Creates detailed sales table."""
|
|
data = [['ID', 'Fecha', 'Vendedor', 'Cliente', 'Monto']]
|
|
|
|
for venta in self.ventas[:15]: # Limit to 15 rows
|
|
fecha_str = venta.get('fecha_venta', '')
|
|
try:
|
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
|
fecha_formatted = fecha.strftime('%d/%m %H:%M')
|
|
except:
|
|
fecha_formatted = fecha_str[:16] if fecha_str else ''
|
|
|
|
data.append([
|
|
str(venta.get('Id', '')),
|
|
fecha_formatted,
|
|
venta.get('vendedor_username', '')[:15],
|
|
(venta.get('cliente', '') or 'N/A')[:20],
|
|
f"${float(venta.get('monto', 0) or 0):,.2f}"
|
|
])
|
|
|
|
table = Table(data, colWidths=[0.5*inch, 1*inch, 1.5*inch, 1.5*inch, 1*inch])
|
|
table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['dark']),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 9),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
|
('BACKGROUND', (0, 1), (-1, -1), colors.white),
|
|
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f8f9fa')]),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
|
|
('TOPPADDING', (0, 1), (-1, -1), 6),
|
|
('BOTTOMPADDING', (0, 1), (-1, -1), 6),
|
|
]))
|
|
|
|
return table
|
|
|
|
def _create_comparison_section(self) -> Table:
|
|
"""Creates comparison section."""
|
|
# Placeholder data - would be filled with real comparison data
|
|
data = [
|
|
['Métrica', 'Período Actual', 'Período Anterior', 'Cambio'],
|
|
['Ventas', str(len(self.ventas)), '-', '-'],
|
|
['Monto', f"${sum(float(v.get('monto', 0) or 0) for v in self.ventas):,.2f}", '-', '-'],
|
|
['Vendedores', str(len(set(v.get('vendedor_username') for v in self.ventas))), '-', '-'],
|
|
]
|
|
|
|
table = Table(data, colWidths=[1.5*inch, 1.25*inch, 1.25*inch, 1*inch])
|
|
table.setStyle(TableStyle([
|
|
('BACKGROUND', (0, 0), (-1, 0), self.COLORS['primary']),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 10),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 10),
|
|
('BACKGROUND', (0, 1), (-1, -1), colors.white),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#dee2e6')),
|
|
('TOPPADDING', (0, 1), (-1, -1), 8),
|
|
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
|
|
]))
|
|
|
|
return table
|
|
|
|
def _create_trend_chart(self) -> Optional[io.BytesIO]:
|
|
"""Creates a trend chart image."""
|
|
if not MATPLOTLIB_AVAILABLE:
|
|
return None
|
|
|
|
try:
|
|
# Group sales by date
|
|
sales_by_date = {}
|
|
for venta in self.ventas:
|
|
fecha_str = venta.get('fecha_venta', '')
|
|
try:
|
|
fecha = datetime.fromisoformat(fecha_str.replace('Z', '+00:00'))
|
|
date_key = fecha.strftime('%Y-%m-%d')
|
|
if date_key not in sales_by_date:
|
|
sales_by_date[date_key] = 0
|
|
sales_by_date[date_key] += float(venta.get('monto', 0) or 0)
|
|
except:
|
|
continue
|
|
|
|
if not sales_by_date:
|
|
return None
|
|
|
|
# Sort by date
|
|
sorted_dates = sorted(sales_by_date.keys())[-7:] # Last 7 days
|
|
values = [sales_by_date.get(d, 0) for d in sorted_dates]
|
|
labels = [datetime.strptime(d, '%Y-%m-%d').strftime('%d/%m') for d in sorted_dates]
|
|
|
|
# Create chart
|
|
fig, ax = plt.subplots(figsize=(8, 4))
|
|
ax.plot(labels, values, color='#00d4ff', linewidth=2, marker='o')
|
|
ax.fill_between(labels, values, alpha=0.3, color='#00d4ff')
|
|
ax.set_xlabel('Fecha')
|
|
ax.set_ylabel('Monto ($)')
|
|
ax.set_title('Tendencia de Ventas')
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
# Format y-axis as currency
|
|
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))
|
|
|
|
plt.tight_layout()
|
|
|
|
# Save to buffer
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
|
|
buf.seek(0)
|
|
plt.close(fig)
|
|
|
|
return buf
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating trend chart: {str(e)}")
|
|
return None
|
|
|
|
def _create_monthly_chart(self) -> Optional[io.BytesIO]:
|
|
"""Creates a monthly trend chart."""
|
|
return self._create_trend_chart() # Use same logic for now
|
|
|
|
|
|
# Convenience functions
|
|
def generar_reporte_diario(ventas: List[Dict], stats: Dict, vendedor: str = None) -> bytes:
|
|
"""
|
|
Genera un reporte diario en PDF.
|
|
|
|
Args:
|
|
ventas: Lista de ventas
|
|
stats: Estadísticas
|
|
vendedor: Username del vendedor (opcional)
|
|
|
|
Returns:
|
|
Contenido del PDF en bytes
|
|
"""
|
|
if not REPORTLAB_AVAILABLE:
|
|
raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab")
|
|
|
|
report = SalesReportPDF(ventas, stats, vendedor)
|
|
return report.generar_reporte_diario()
|
|
|
|
|
|
def generar_reporte_ejecutivo(ventas: List[Dict], stats: Dict) -> bytes:
|
|
"""
|
|
Genera un reporte ejecutivo en PDF.
|
|
|
|
Args:
|
|
ventas: Lista de ventas
|
|
stats: Estadísticas
|
|
|
|
Returns:
|
|
Contenido del PDF en bytes
|
|
"""
|
|
if not REPORTLAB_AVAILABLE:
|
|
raise ImportError("ReportLab is required for PDF generation. Install with: pip install reportlab")
|
|
|
|
report = SalesReportPDF(ventas, stats)
|
|
return report.generar_reporte_ejecutivo()
|