""" 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()