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>
224 lines
6.9 KiB
Python
224 lines
6.9 KiB
Python
"""
|
|
Ticket format patterns for Sales Bot OCR
|
|
Supports multiple ticket formats from different stores
|
|
"""
|
|
|
|
import re
|
|
from typing import Dict, List, Optional
|
|
|
|
# Ticket format configurations
|
|
TICKET_FORMATS = {
|
|
'oxxo': {
|
|
'identificadores': ['oxxo', 'femsa', 'cadena comercial'],
|
|
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
|
|
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
|
|
'patron_hora': r'(\d{2}:\d{2}:\d{2})',
|
|
'prioridad': 1
|
|
},
|
|
'walmart': {
|
|
'identificadores': ['walmart', 'walmex', 'wal-mart', 'bodega aurrera'],
|
|
'patron_total': r'total\s*\$\s*([\d,]+\.\d{2})',
|
|
'patron_fecha': r'(\d{2}-\d{2}-\d{4})',
|
|
'prioridad': 2
|
|
},
|
|
'soriana': {
|
|
'identificadores': ['soriana', 'mega soriana', 'city club'],
|
|
'patron_total': r'total\s*a?\s*pagar\s*\$?\s*([\d,]+\.\d{2})',
|
|
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
|
|
'prioridad': 3
|
|
},
|
|
'tienda_pintura': {
|
|
'identificadores': ['tinte', 'cromatique', 'oxidante', 'distribuidora',
|
|
'colorante', 'pintura', 'tono', 'decolorante', 'revelador'],
|
|
'patron_total': r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})',
|
|
'patron_productos': r'^(.+?)\s+(\d{1,3})\s+\$?\s*([\d,]+)',
|
|
'patron_tubos': r'(\d+)\s*(?:tubos?|pzas?|piezas?|unid)',
|
|
'prioridad': 0 # Highest priority for paint stores
|
|
},
|
|
'farmacia': {
|
|
'identificadores': ['farmacia', 'guadalajara', 'benavides', 'similares', 'ahorro'],
|
|
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
|
|
'patron_fecha': r'(\d{2}/\d{2}/\d{2,4})',
|
|
'prioridad': 4
|
|
},
|
|
'seven_eleven': {
|
|
'identificadores': ['7-eleven', '7eleven', '7 eleven', 'iconn'],
|
|
'patron_total': r'total\s*\$?\s*([\d,]+\.\d{2})',
|
|
'patron_fecha': r'(\d{2}/\d{2}/\d{4})',
|
|
'prioridad': 5
|
|
},
|
|
'generico': {
|
|
'identificadores': [], # Fallback - matches everything
|
|
'patron_total': r'total\s*\$?\s*([\d,]+[\s\.]?\d{0,2})',
|
|
'patron_fecha': r'(\d{2}[/-]\d{2}[/-]\d{2,4})',
|
|
'prioridad': 99
|
|
}
|
|
}
|
|
|
|
# Common patterns for amount extraction (in priority order)
|
|
AMOUNT_PATTERNS = [
|
|
# Explicit total patterns
|
|
(r'total\s*a\s*pagar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total_a_pagar', 1),
|
|
(r'gran\s*total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'gran_total', 2),
|
|
(r'total\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'total', 3),
|
|
|
|
# Payment related
|
|
(r'a\s*cobrar\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'a_cobrar', 4),
|
|
(r'importe\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'importe', 5),
|
|
(r'monto\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'monto', 6),
|
|
(r'suma\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'suma', 7),
|
|
|
|
# Subtotal (lower priority)
|
|
(r'subtotal\s*:?\s*\$?\s*([\d,]+[\s\.]?\d{0,2})', 'subtotal', 8),
|
|
|
|
# Last resort - currency amounts at end of lines
|
|
(r'\$\s*([\d,]+\.\d{2})\s*$', 'monto_final', 9),
|
|
]
|
|
|
|
# Date patterns
|
|
DATE_PATTERNS = [
|
|
r'(\d{2}/\d{2}/\d{4})', # DD/MM/YYYY
|
|
r'(\d{2}-\d{2}-\d{4})', # DD-MM-YYYY
|
|
r'(\d{4}-\d{2}-\d{2})', # YYYY-MM-DD
|
|
r'(\d{2}/\d{2}/\d{2})', # DD/MM/YY
|
|
r'(\d{1,2}\s+de\s+\w+\s+de\s+\d{4})', # D de Mes de YYYY
|
|
]
|
|
|
|
# Client name patterns
|
|
CLIENT_PATTERNS = [
|
|
r'cliente\s*:?\s*(.+?)(?:\n|$)',
|
|
r'nombre\s*:?\s*(.+?)(?:\n|$)',
|
|
r'sr\.?\s*(.+?)(?:\n|$)',
|
|
r'sra\.?\s*(.+?)(?:\n|$)',
|
|
]
|
|
|
|
|
|
def detectar_formato_ticket(texto: str) -> str:
|
|
"""
|
|
Detecta el formato del ticket basado en identificadores.
|
|
|
|
Args:
|
|
texto: Texto extraído del ticket
|
|
|
|
Returns:
|
|
Nombre del formato detectado
|
|
"""
|
|
texto_lower = texto.lower()
|
|
|
|
# Check formats by priority (lower number = higher priority)
|
|
formatos_encontrados = []
|
|
|
|
for formato, config in TICKET_FORMATS.items():
|
|
if formato == 'generico':
|
|
continue
|
|
|
|
for identificador in config.get('identificadores', []):
|
|
if identificador in texto_lower:
|
|
formatos_encontrados.append((formato, config.get('prioridad', 99)))
|
|
break
|
|
|
|
if formatos_encontrados:
|
|
# Sort by priority and return highest priority match
|
|
formatos_encontrados.sort(key=lambda x: x[1])
|
|
return formatos_encontrados[0][0]
|
|
|
|
return 'generico'
|
|
|
|
|
|
def get_patron_total(formato: str) -> str:
|
|
"""
|
|
Obtiene el patrón de total para un formato específico.
|
|
|
|
Args:
|
|
formato: Nombre del formato
|
|
|
|
Returns:
|
|
Patrón regex para extraer el total
|
|
"""
|
|
config = TICKET_FORMATS.get(formato, TICKET_FORMATS['generico'])
|
|
return config.get('patron_total', TICKET_FORMATS['generico']['patron_total'])
|
|
|
|
|
|
def extraer_fecha_ticket(texto: str) -> Optional[str]:
|
|
"""
|
|
Extrae la fecha del ticket.
|
|
|
|
Args:
|
|
texto: Texto del ticket
|
|
|
|
Returns:
|
|
Fecha encontrada o None
|
|
"""
|
|
for patron in DATE_PATTERNS:
|
|
match = re.search(patron, texto, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1)
|
|
return None
|
|
|
|
|
|
def extraer_cliente_ticket(texto: str) -> Optional[str]:
|
|
"""
|
|
Extrae el nombre del cliente del ticket.
|
|
|
|
Args:
|
|
texto: Texto del ticket
|
|
|
|
Returns:
|
|
Nombre del cliente o None
|
|
"""
|
|
for patron in CLIENT_PATTERNS:
|
|
match = re.search(patron, texto, re.IGNORECASE)
|
|
if match:
|
|
cliente = match.group(1).strip()
|
|
# Clean up common artifacts
|
|
cliente = re.sub(r'[^\w\s\-\.]', '', cliente)
|
|
if len(cliente) > 2: # Valid name should have at least 3 chars
|
|
return cliente
|
|
return None
|
|
|
|
|
|
def contar_tubos_texto(texto: str) -> int:
|
|
"""
|
|
Cuenta la cantidad de tubos mencionados en el ticket.
|
|
|
|
Args:
|
|
texto: Texto del ticket
|
|
|
|
Returns:
|
|
Cantidad de tubos detectados
|
|
"""
|
|
texto_lower = texto.lower()
|
|
total_tubos = 0
|
|
|
|
# Pattern for explicit tube counts
|
|
patrones_tubos = [
|
|
r'(\d+)\s*(?:tubos?|tbs?)',
|
|
r'(\d+)\s*(?:pzas?|piezas?)\s*(?:de\s+)?(?:tinte|color)',
|
|
r'(?:cantidad|qty|cant)\s*:?\s*(\d+)',
|
|
r'x\s*(\d+)\s*(?:tubos?)?',
|
|
]
|
|
|
|
for patron in patrones_tubos:
|
|
matches = re.findall(patron, texto_lower)
|
|
for match in matches:
|
|
try:
|
|
total_tubos += int(match)
|
|
except ValueError:
|
|
continue
|
|
|
|
# If no explicit count found, estimate from line items
|
|
if total_tubos == 0:
|
|
# Count lines that look like product entries
|
|
lineas = texto_lower.split('\n')
|
|
for linea in lineas:
|
|
if any(word in linea for word in ['tinte', 'color', 'tubo', 'cromatique']):
|
|
# Check for quantity at start of line or after product name
|
|
qty_match = re.search(r'^(\d+)\s+|x\s*(\d+)|(\d+)\s*pza', linea)
|
|
if qty_match:
|
|
qty = next((g for g in qty_match.groups() if g), '1')
|
|
total_tubos += int(qty)
|
|
else:
|
|
total_tubos += 1 # Assume 1 if no explicit quantity
|
|
|
|
return total_tubos
|