feat: Implementar PWA, Analytics, Reportes PDF y mejoras OCR

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>
This commit is contained in:
2026-01-19 03:26:16 +00:00
parent ed1658eb2b
commit 9936deaa90
25 changed files with 5501 additions and 282 deletions

View File

@@ -0,0 +1,532 @@
"""
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()