Files
sales-bot-stacks/sales-bot/nocodb_client.py
consultoria-as ed1658eb2b feat: Implementar mejoras de funcionalidad y UX del Sales Bot
Nuevas funcionalidades:
- /cancelar: Cancelar ventas propias con motivo opcional
- /deshacer: Deshacer última venta (dentro de 5 minutos)
- /editar: Editar monto y cliente de ventas propias
- /comisiones: Historial de comisiones de últimos 6 meses
- /racha: Sistema de bonos por días consecutivos cumpliendo meta
- /exportar: Exportar ventas a Excel o CSV

Sistema de confirmación obligatoria:
- Todas las ventas requieren confirmación explícita (si/no)
- Preview de venta antes de registrar
- Timeout de 2 minutos para ventas pendientes

Scheduler de notificaciones:
- Recordatorio de mediodía para vendedores sin meta
- Resumen diario automático al final del día
- Resumen semanal los lunes

Otras mejoras:
- Soporte para múltiples imágenes en una venta
- Autocompletado de clientes frecuentes
- Metas personalizadas por vendedor
- Bonos por racha: $20 (3 días), $50 (5 días), $150 (10 días)

Archivos nuevos:
- export_utils.py: Generación de Excel y CSV
- scheduler.py: Tareas programadas con APScheduler

Dependencias nuevas:
- APScheduler==3.10.4
- openpyxl==3.1.2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 02:57:27 +00:00

963 lines
38 KiB
Python

import requests
import logging
import os
from datetime import datetime, date, timezone, timedelta
logger = logging.getLogger(__name__)
# Zona horaria de México (UTC-6)
TZ_MEXICO = timezone(timedelta(hours=-6))
class NocoDBClient:
def __init__(self, url, token):
self.url = url.rstrip('/')
self.token = token
self.headers = {
'xc-token': self.token,
'Content-Type': 'application/json'
}
# IDs de tablas desde variables de entorno
self.table_vendedores = os.getenv('NOCODB_TABLE_VENDEDORES')
self.table_ventas = os.getenv('NOCODB_TABLE_VENTAS')
self.table_metas = os.getenv('NOCODB_TABLE_METAS')
self.table_detalle = os.getenv('NOCODB_TABLE_VENTAS_DETALLE')
# NUEVA CONFIGURACIÓN DE COMISIONES
self.META_DIARIA_TUBOS = 3 # Meta: 3 tubos diarios
self.COMISION_POR_TUBO = 10.0 # $10 por tubo después del 3ro
def test_connection(self):
"""Prueba la conexión con NocoDB"""
try:
response = requests.get(
f"{self.url}/api/v2/meta/bases",
headers=self.headers,
timeout=10
)
response.raise_for_status()
bases = response.json()
logger.info(f"Conexión exitosa con NocoDB. Bases: {len(bases.get('list', []))}")
return {
'status': 'success',
'bases_count': len(bases.get('list', []))
}
except Exception as e:
logger.error(f"Error conectando con NocoDB: {str(e)}")
return {'status': 'error', 'message': str(e)}
def get_vendedor(self, username):
"""Obtiene información de un vendedor por username"""
try:
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
params={'limit': 100},
timeout=10
)
response.raise_for_status()
vendedores = response.json().get('list', [])
for vendedor in vendedores:
if vendedor.get('username') == username:
return vendedor
return None
except Exception as e:
logger.error(f"Error obteniendo vendedor {username}: {str(e)}")
return None
def crear_vendedor(self, username, nombre_completo, email, meta_diaria_tubos=3):
"""Crea un nuevo vendedor con meta diaria de tubos"""
try:
payload = {
'username': username,
'nombre_completo': nombre_completo,
'email': email,
'meta_diaria_tubos': meta_diaria_tubos, # Nueva: meta en tubos por día
'activo': True,
'fecha_registro': datetime.now(TZ_MEXICO).isoformat()
}
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
logger.info(f"Vendedor {username} creado con meta diaria de {meta_diaria_tubos} tubos")
return response.json()
except Exception as e:
logger.error(f"Error creando vendedor: {str(e)}")
return None
def registrar_venta(self, vendedor_username, monto, cliente, producto=None,
descripcion=None, mensaje_id=None, canal=None, imagen_url=None):
"""Registra una nueva venta con URL de imagen opcional"""
try:
payload = {
'vendedor_username': vendedor_username,
'monto': float(monto),
'cliente': cliente,
'fecha_venta': datetime.now(TZ_MEXICO).isoformat(),
'estado': 'confirmada',
'mensaje_id': mensaje_id,
'canal': canal
}
if producto:
payload['producto'] = producto
if descripcion:
payload['descripcion'] = descripcion
if imagen_url:
filename = imagen_url.split('/')[-1].split('?')[0] if '/' in imagen_url else 'ticket.jpg'
payload['imagen_ticket'] = [
{
"url": imagen_url,
"title": filename,
"mimetype": "image/jpeg"
}
]
logger.info(f"Guardando imagen con URL: {imagen_url[:50]}...")
logger.info(f"Registrando venta - Monto: ${monto}, Cliente: {cliente}")
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
json=payload,
timeout=30
)
response.raise_for_status()
venta = response.json()
logger.info(f"Venta registrada: ${monto} - {cliente} - vendedor: {vendedor_username}")
# NUEVO: No actualizar meta mensual, ahora se calcula al consultar
return venta
except Exception as e:
logger.error(f"Error registrando venta: {str(e)}", exc_info=True)
if 'response' in locals():
logger.error(f"Response: {response.text}")
return None
def get_ventas_dia(self, vendedor_username=None, fecha=None):
"""Obtiene ventas de un día específico (convierte UTC a hora México)"""
try:
if fecha is None:
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
elif isinstance(fecha, date):
fecha = fecha.strftime('%Y-%m-%d')
# Obtener todas las ventas y filtrar
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todas_ventas = response.json().get('list', [])
# Filtrar por día (convertir UTC a México)
ventas_filtradas = []
for venta in todas_ventas:
fecha_venta_str = venta.get('fecha_venta', '')
vendedor = venta.get('vendedor_username', '')
# Convertir fecha UTC a México
try:
# Parsear fecha UTC de NocoDB
if '+' in fecha_venta_str:
fecha_venta_utc = datetime.fromisoformat(fecha_venta_str.replace('+00:00', '+0000'))
else:
fecha_venta_utc = datetime.fromisoformat(fecha_venta_str)
# Convertir a hora de México
fecha_venta_mexico = fecha_venta_utc.astimezone(TZ_MEXICO)
fecha_venta_local = fecha_venta_mexico.strftime('%Y-%m-%d')
except:
fecha_venta_local = fecha_venta_str[:10] if fecha_venta_str else ''
fecha_match = fecha_venta_local == fecha
if vendedor_username:
vendedor_match = vendedor == vendedor_username
else:
vendedor_match = True
if fecha_match and vendedor_match:
ventas_filtradas.append(venta)
logger.info(f"Ventas del día {fecha} para {vendedor_username or 'todos'}: {len(ventas_filtradas)}")
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del día: {str(e)}")
return []
def get_ventas_mes(self, vendedor_username=None, mes=None):
"""Obtiene ventas del mes actual o especificado"""
try:
if mes is None:
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todas_ventas = response.json().get('list', [])
ventas_filtradas = []
for venta in todas_ventas:
fecha_venta = venta.get('fecha_venta', '')
vendedor = venta.get('vendedor_username', '')
fecha_match = fecha_venta.startswith(mes)
if vendedor_username:
vendedor_match = vendedor == vendedor_username
else:
vendedor_match = True
if fecha_match and vendedor_match:
ventas_filtradas.append(venta)
logger.info(f"Ventas del mes {mes}: {len(ventas_filtradas)}")
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas del mes: {str(e)}")
return []
def contar_tubos_vendidos_dia(self, vendedor_username, fecha=None):
"""
Cuenta los tubos de tinte vendidos en un día específico
Busca en la tabla de detalle de ventas usando el campo numérico venta_id_num
"""
try:
if fecha is None:
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
elif isinstance(fecha, date):
fecha = fecha.strftime('%Y-%m-%d')
logger.info(f"Contando tubos para {vendedor_username} del día {fecha}")
# Obtener ventas del día
ventas_dia = self.get_ventas_dia(vendedor_username, fecha)
if not ventas_dia:
logger.info(f"No hay ventas para {vendedor_username} el {fecha}")
return 0
# Obtener IDs de las ventas
venta_ids = [v.get('Id') for v in ventas_dia if v.get('Id')]
logger.info(f"IDs de ventas a buscar: {venta_ids}")
if not venta_ids:
return 0
# Obtener todos los detalles de productos
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_detalle}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todos_detalles = response.json().get('list', [])
logger.info(f"Total detalles en tabla: {len(todos_detalles)}")
# Contar tubos de tinte (productos que sean tintes)
tubos_vendidos = 0
detalles_encontrados = 0
for detalle in todos_detalles:
# Usar el campo numérico venta_id_num para verificar la relación
venta_id_num = detalle.get('venta_id_num')
if venta_id_num is None:
continue
# Verificar si este detalle pertenece a alguna de las ventas del día
if int(venta_id_num) in venta_ids:
detalles_encontrados += 1
# Detectar si es un tubo de tinte
producto = str(detalle.get('producto', '')).lower()
marca = str(detalle.get('marca', '')).lower()
nombre_completo = f"{marca} {producto}".lower()
logger.info(f" Analizando: {marca} {producto} (venta_id_num={venta_id_num})")
# Si contiene "tinte" o "cromatique" o es registro manual, contar
if 'tinte' in nombre_completo or 'cromatique' in nombre_completo or 'manual' in marca:
cantidad = int(detalle.get('cantidad', 0))
tubos_vendidos += cantidad
logger.info(f" ✓ Tubo detectado: {cantidad}x {marca} {producto}")
logger.info(f"Detalles encontrados: {detalles_encontrados}")
logger.info(f"Total tubos vendidos por {vendedor_username} el {fecha}: {tubos_vendidos}")
return tubos_vendidos
except Exception as e:
logger.error(f"Error contando tubos: {str(e)}", exc_info=True)
return 0
def calcular_comision_dia(self, vendedor_username, fecha=None):
"""
Calcula la comisión del día para un vendedor
Fórmula: $10 por cada tubo vendido después del 3ro
"""
try:
tubos_vendidos = self.contar_tubos_vendidos_dia(vendedor_username, fecha)
if tubos_vendidos <= self.META_DIARIA_TUBOS:
comision = 0.0
tubos_comisionables = 0
else:
tubos_comisionables = tubos_vendidos - self.META_DIARIA_TUBOS
comision = tubos_comisionables * self.COMISION_POR_TUBO
logger.info(
f"Comisión {vendedor_username}: "
f"{tubos_vendidos} tubos vendidos, "
f"{tubos_comisionables} comisionables = ${comision:.2f}"
)
return {
'tubos_vendidos': tubos_vendidos,
'tubos_comisionables': tubos_comisionables,
'comision': comision,
'meta_diaria': self.META_DIARIA_TUBOS,
'comision_por_tubo': self.COMISION_POR_TUBO
}
except Exception as e:
logger.error(f"Error calculando comisión: {str(e)}")
return {
'tubos_vendidos': 0,
'tubos_comisionables': 0,
'comision': 0.0,
'meta_diaria': self.META_DIARIA_TUBOS,
'comision_por_tubo': self.COMISION_POR_TUBO
}
def get_estadisticas_vendedor_dia(self, vendedor_username, fecha=None):
"""
Obtiene estadísticas completas del vendedor para un día
"""
try:
if fecha is None:
fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-%d')
# Contar tubos y calcular comisión
resultado = self.calcular_comision_dia(vendedor_username, fecha)
# Obtener monto total vendido
ventas = self.get_ventas_dia(vendedor_username, fecha)
monto_total = sum(float(v.get('monto', 0)) for v in ventas)
estadisticas = {
**resultado,
'monto_total_dia': monto_total,
'cantidad_ventas': len(ventas),
'fecha': fecha,
'vendedor': vendedor_username
}
return estadisticas
except Exception as e:
logger.error(f"Error obteniendo estadísticas: {str(e)}")
return None
def get_estadisticas_vendedor_mes(self, vendedor_username, mes=None):
"""
Obtiene estadísticas del mes completo
Calcula comisiones acumuladas día por día
"""
try:
if mes is None:
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Obtener ventas del mes
ventas_mes = self.get_ventas_mes(vendedor_username, mes)
# Agrupar por día (convertir UTC a México)
dias = {}
for venta in ventas_mes:
fecha_venta = venta.get('fecha_venta', '')
if fecha_venta:
# Convertir fecha UTC a México
try:
fecha_utc = datetime.fromisoformat(fecha_venta.replace('+00:00', '+00:00').replace('Z', '+00:00'))
if fecha_utc.tzinfo is None:
fecha_utc = fecha_utc.replace(tzinfo=timezone.utc)
fecha_mexico = fecha_utc.astimezone(TZ_MEXICO)
dia = fecha_mexico.strftime('%Y-%m-%d')
except:
dia = fecha_venta[:10] # Fallback
if dia not in dias:
dias[dia] = []
dias[dia].append(venta)
# Calcular comisiones día por día
comision_total_mes = 0.0
tubos_totales_mes = 0
dias_meta_cumplida = 0
for dia in sorted(dias.keys()):
stats_dia = self.get_estadisticas_vendedor_dia(vendedor_username, dia)
if stats_dia:
comision_total_mes += stats_dia['comision']
tubos_totales_mes += stats_dia['tubos_vendidos']
if stats_dia['tubos_vendidos'] >= self.META_DIARIA_TUBOS:
dias_meta_cumplida += 1
monto_total_mes = sum(float(v.get('monto', 0)) for v in ventas_mes)
return {
'mes': mes,
'vendedor': vendedor_username,
'tubos_totales': tubos_totales_mes,
'comision_total': comision_total_mes,
'monto_total': monto_total_mes,
'cantidad_ventas': len(ventas_mes),
'dias_activos': len(dias),
'dias_meta_cumplida': dias_meta_cumplida,
'promedio_tubos_dia': tubos_totales_mes / len(dias) if dias else 0,
'meta_diaria': self.META_DIARIA_TUBOS
}
except Exception as e:
logger.error(f"Error obteniendo estadísticas del mes: {str(e)}", exc_info=True)
return None
def get_ranking_vendedores(self, mes=None):
"""Obtiene ranking de vendedores por tubos vendidos en el mes"""
try:
if mes is None:
mes = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Obtener todos los vendedores
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
params={'limit': 100},
timeout=10
)
response.raise_for_status()
vendedores = response.json().get('list', [])
# Calcular estadísticas para cada vendedor
ranking = []
for vendedor in vendedores:
if not vendedor.get('activo', True):
continue
username = vendedor.get('username')
if not username:
continue
stats = self.get_estadisticas_vendedor_mes(username, mes)
if stats:
ranking.append({
'vendedor_username': username,
'nombre_completo': vendedor.get('nombre_completo', username),
**stats
})
# Ordenar por tubos vendidos (descendente)
ranking_ordenado = sorted(
ranking,
key=lambda x: x.get('tubos_totales', 0),
reverse=True
)
return ranking_ordenado
except Exception as e:
logger.error(f"Error obteniendo ranking: {str(e)}")
return []
def guardar_productos_venta(self, venta_id, productos):
"""Guarda el detalle de productos de una venta usando campo numérico venta_id_num"""
try:
if not self.table_detalle:
logger.warning("No se configuró NOCODB_TABLE_VENTAS_DETALLE")
return None
productos_guardados = []
for producto in productos:
# Crear registro del producto con venta_id_num (campo numérico simple)
payload = {
'producto': producto.get('producto', ''),
'marca': producto.get('marca', 'Sin marca'),
'cantidad': producto.get('cantidad', 1),
'precio_unitario': float(producto.get('precio_unitario', 0)),
'importe': float(producto.get('importe', 0)),
'detectado_ocr': True,
'venta_id_num': int(venta_id) # Campo numérico simple en lugar de link
}
logger.info(f"Guardando producto con venta_id_num={venta_id}: {producto.get('marca')} {producto.get('producto')}")
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_detalle}/records",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
detalle = response.json()
detalle_id = detalle.get('Id')
logger.info(f"Producto guardado ID={detalle_id}: {producto.get('marca')} {producto.get('producto')} -> venta_id_num={venta_id}")
productos_guardados.append(detalle)
logger.info(f"Total productos guardados: {len(productos_guardados)} para venta {venta_id}")
return productos_guardados
except Exception as e:
logger.error(f"Error guardando productos: {str(e)}", exc_info=True)
return None
def actualizar_meta_vendedor(self, vendedor_username):
"""Actualiza o crea el registro de metas del vendedor para el mes actual"""
try:
if not self.table_metas:
logger.warning("No se configuró NOCODB_TABLE_METAS")
return None
# Formato para búsqueda: 2026-01 (año-mes)
mes_busqueda = datetime.now(TZ_MEXICO).strftime('%Y-%m')
# Formato para guardar en BD: 2026-01-01 (campo Date requiere YYYY-MM-DD)
mes_fecha = datetime.now(TZ_MEXICO).strftime('%Y-%m-01')
# Obtener estadísticas del mes (ya convierte fechas UTC a México)
stats = self.get_estadisticas_vendedor_mes(vendedor_username, mes_busqueda)
if not stats:
logger.warning(f"No se pudieron obtener estadísticas para {vendedor_username}")
return None
tubos_vendidos = stats.get('tubos_totales', 0)
comision = stats.get('comision_total', 0)
dias_activos = stats.get('dias_activos', 0)
dias_cumplida = stats.get('dias_meta_cumplida', 0)
logger.info(f"Stats para metas: tubos={tubos_vendidos}, comision={comision}, dias={dias_activos}")
# Buscar registro existente del mes
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_metas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
registros = response.json().get('list', [])
registro_existente = None
for reg in registros:
if reg.get('vendedor_username') == vendedor_username:
fecha_reg = str(reg.get('mes', ''))[:7]
if fecha_reg == mes_busqueda:
registro_existente = reg
break
payload = {
'vendedor_username': vendedor_username,
'mes': mes_fecha,
'tubos_vendidos': tubos_vendidos,
'comision_ganada': comision,
'dias_activos': dias_activos,
'dias_meta_cumplida': dias_cumplida
}
if registro_existente:
# Actualizar registro existente
response = requests.patch(
f"{self.url}/api/v2/tables/{self.table_metas}/records",
headers=self.headers,
json=[{"Id": registro_existente['Id'], **payload}],
timeout=10
)
else:
# Crear nuevo registro
response = requests.post(
f"{self.url}/api/v2/tables/{self.table_metas}/records",
headers=self.headers,
json=payload,
timeout=10
)
response.raise_for_status()
logger.info(f"Meta actualizada para {vendedor_username}: {tubos_vendidos} tubos, ${comision} comisión")
return response.json()
except Exception as e:
logger.error(f"Error actualizando meta: {str(e)}", exc_info=True)
return None
def get_meta_vendedor(self, vendedor_username, mes=None):
"""Obtiene las estadísticas del vendedor para el mes"""
return self.get_estadisticas_vendedor_mes(vendedor_username, mes)
# ==================== NUEVOS MÉTODOS FASE 1 ====================
def get_venta_por_id(self, venta_id):
"""Obtiene una venta específica por su ID"""
try:
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_ventas}/records/{venta_id}",
headers=self.headers,
timeout=10
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error obteniendo venta {venta_id}: {str(e)}")
return None
def cancelar_venta(self, venta_id, username_solicitante, motivo=None):
"""
Cancela una venta existente.
Solo el vendedor dueño puede cancelar su propia venta.
"""
try:
# Verificar que la venta existe
venta = self.get_venta_por_id(venta_id)
if not venta:
return {'error': 'Venta no encontrada', 'success': False}
# Verificar permisos: solo el dueño puede cancelar
if venta.get('vendedor_username') != username_solicitante:
return {'error': 'Solo puedes cancelar tus propias ventas', 'success': False}
# Verificar que no esté ya cancelada
if venta.get('estado') == 'cancelada':
return {'error': 'Esta venta ya está cancelada', 'success': False}
# Actualizar estado
payload = {
'Id': venta_id,
'estado': 'cancelada',
'motivo_cancelacion': motivo or 'Sin motivo especificado',
'fecha_cancelacion': datetime.now(TZ_MEXICO).isoformat()
}
response = requests.patch(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
json=[payload],
timeout=10
)
response.raise_for_status()
# Recalcular metas del vendedor
self.actualizar_meta_vendedor(username_solicitante)
logger.info(f"Venta {venta_id} cancelada por {username_solicitante}")
return {
'success': True,
'venta_id': venta_id,
'monto': venta.get('monto'),
'cliente': venta.get('cliente')
}
except Exception as e:
logger.error(f"Error cancelando venta {venta_id}: {str(e)}")
return {'error': f'Error al cancelar: {str(e)}', 'success': False}
def editar_venta(self, venta_id, username_solicitante, campos_actualizar):
"""
Edita una venta existente.
Solo el vendedor dueño puede editar su propia venta.
Guarda historial de cambios.
"""
try:
# Verificar que la venta existe
venta = self.get_venta_por_id(venta_id)
if not venta:
return {'error': 'Venta no encontrada', 'success': False}
# Verificar permisos
if venta.get('vendedor_username') != username_solicitante:
return {'error': 'Solo puedes editar tus propias ventas', 'success': False}
# Verificar que no esté cancelada
if venta.get('estado') == 'cancelada':
return {'error': 'No puedes editar una venta cancelada', 'success': False}
# Guardar historial de cambios
cambios = []
for campo, nuevo_valor in campos_actualizar.items():
valor_anterior = venta.get(campo)
if valor_anterior != nuevo_valor:
cambios.append({
'campo': campo,
'anterior': valor_anterior,
'nuevo': nuevo_valor
})
if not cambios:
return {'error': 'No hay cambios para aplicar', 'success': False}
# Actualizar venta
payload = {'Id': venta_id, **campos_actualizar}
response = requests.patch(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
json=[payload],
timeout=10
)
response.raise_for_status()
# Recalcular metas
self.actualizar_meta_vendedor(username_solicitante)
logger.info(f"Venta {venta_id} editada por {username_solicitante}: {cambios}")
return {
'success': True,
'venta_id': venta_id,
'cambios': cambios
}
except Exception as e:
logger.error(f"Error editando venta {venta_id}: {str(e)}")
return {'error': f'Error al editar: {str(e)}', 'success': False}
def get_ultima_venta_usuario(self, username, minutos=5):
"""
Obtiene la última venta del usuario dentro de los últimos N minutos.
Útil para el comando /deshacer.
"""
try:
ahora = datetime.now(TZ_MEXICO)
limite = ahora - timedelta(minutes=minutos)
# Obtener ventas recientes del usuario
ventas = self.get_ventas_dia(username)
# Filtrar por tiempo y estado
ventas_recientes = []
for venta in ventas:
if venta.get('estado') == 'cancelada':
continue
fecha_str = venta.get('fecha_venta', '')
try:
fecha_venta = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00'))
if fecha_venta.tzinfo is None:
fecha_venta = fecha_venta.replace(tzinfo=timezone.utc)
fecha_mexico = fecha_venta.astimezone(TZ_MEXICO)
if fecha_mexico >= limite:
ventas_recientes.append({
**venta,
'fecha_parseada': fecha_mexico
})
except:
continue
if not ventas_recientes:
return None
# Ordenar por fecha y retornar la más reciente
ventas_recientes.sort(key=lambda x: x['fecha_parseada'], reverse=True)
return ventas_recientes[0]
except Exception as e:
logger.error(f"Error obteniendo última venta de {username}: {str(e)}")
return None
def get_clientes_frecuentes(self, username, query=None, limit=5):
"""
Obtiene los clientes más frecuentes de un vendedor.
Útil para autocompletado.
"""
try:
# Obtener todas las ventas del usuario
ventas = self.get_ventas_mes(username)
# Contar frecuencia de clientes
clientes = {}
for venta in ventas:
cliente = venta.get('cliente', '').strip()
if cliente and cliente != 'Cliente sin nombre':
if query and query.lower() not in cliente.lower():
continue
clientes[cliente] = clientes.get(cliente, 0) + 1
# Ordenar por frecuencia
clientes_ordenados = sorted(clientes.items(), key=lambda x: x[1], reverse=True)
return [c[0] for c in clientes_ordenados[:limit]]
except Exception as e:
logger.error(f"Error obteniendo clientes frecuentes: {str(e)}")
return []
def get_historial_comisiones(self, username, meses=6):
"""
Obtiene el historial de comisiones de los últimos N meses.
"""
try:
historial = []
ahora = datetime.now(TZ_MEXICO)
for i in range(meses):
# Calcular mes
fecha = ahora - timedelta(days=30 * i)
mes = fecha.strftime('%Y-%m')
# Obtener estadísticas del mes
stats = self.get_estadisticas_vendedor_mes(username, mes)
if stats:
historial.append({
'mes': mes,
'mes_nombre': fecha.strftime('%B %Y'),
'tubos_totales': stats.get('tubos_totales', 0),
'comision_total': stats.get('comision_total', 0),
'monto_total': stats.get('monto_total', 0),
'cantidad_ventas': stats.get('cantidad_ventas', 0),
'dias_activos': stats.get('dias_activos', 0)
})
return historial
except Exception as e:
logger.error(f"Error obteniendo historial de comisiones: {str(e)}")
return []
def get_vendedores_activos(self):
"""Obtiene lista de vendedores activos"""
try:
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_vendedores}/records",
headers=self.headers,
params={'limit': 100},
timeout=10
)
response.raise_for_status()
vendedores = response.json().get('list', [])
return [v for v in vendedores if v.get('activo', True)]
except Exception as e:
logger.error(f"Error obteniendo vendedores activos: {str(e)}")
return []
def get_ventas_semana(self, vendedor_username=None):
"""Obtiene ventas de la semana actual (lunes a domingo)"""
try:
ahora = datetime.now(TZ_MEXICO)
# Calcular inicio de semana (lunes)
inicio_semana = ahora - timedelta(days=ahora.weekday())
inicio_semana = inicio_semana.replace(hour=0, minute=0, second=0, microsecond=0)
response = requests.get(
f"{self.url}/api/v2/tables/{self.table_ventas}/records",
headers=self.headers,
params={'limit': 1000},
timeout=10
)
response.raise_for_status()
todas_ventas = response.json().get('list', [])
ventas_filtradas = []
for venta in todas_ventas:
fecha_str = venta.get('fecha_venta', '')
vendedor = venta.get('vendedor_username', '')
try:
fecha_utc = datetime.fromisoformat(fecha_str.replace('+00:00', '+00:00'))
if fecha_utc.tzinfo is None:
fecha_utc = fecha_utc.replace(tzinfo=timezone.utc)
fecha_mexico = fecha_utc.astimezone(TZ_MEXICO)
if fecha_mexico >= inicio_semana:
if vendedor_username is None or vendedor == vendedor_username:
ventas_filtradas.append(venta)
except:
continue
return ventas_filtradas
except Exception as e:
logger.error(f"Error obteniendo ventas de la semana: {str(e)}")
return []
# ==================== MÉTODOS PARA RACHAS ====================
def verificar_racha(self, username):
"""
Verifica y actualiza la racha del vendedor.
Retorna información de la racha y bonus si aplica.
"""
try:
ahora = datetime.now(TZ_MEXICO)
dias_consecutivos = 0
fecha_check = ahora.date()
# Verificar días consecutivos hacia atrás
while True:
fecha_str = fecha_check.strftime('%Y-%m-%d')
stats = self.get_estadisticas_vendedor_dia(username, fecha_str)
if stats and stats.get('tubos_vendidos', 0) >= self.META_DIARIA_TUBOS:
dias_consecutivos += 1
fecha_check -= timedelta(days=1)
else:
break
# Límite de búsqueda
if dias_consecutivos > 30:
break
# Calcular bonus
bonus = 0
bonus_3 = float(os.getenv('BONUS_3_DIAS', 20))
bonus_5 = float(os.getenv('BONUS_5_DIAS', 50))
bonus_10 = float(os.getenv('BONUS_10_DIAS', 150))
if dias_consecutivos >= 10:
bonus = bonus_10
elif dias_consecutivos >= 5:
bonus = bonus_5
elif dias_consecutivos >= 3:
bonus = bonus_3
return {
'dias_consecutivos': dias_consecutivos,
'bonus': bonus,
'meta_diaria': self.META_DIARIA_TUBOS,
'proximo_bonus': self._calcular_proximo_bonus(dias_consecutivos)
}
except Exception as e:
logger.error(f"Error verificando racha de {username}: {str(e)}")
return {'dias_consecutivos': 0, 'bonus': 0}
def _calcular_proximo_bonus(self, dias_actuales):
"""Calcula cuántos días faltan para el próximo bonus"""
if dias_actuales < 3:
return {'dias_faltan': 3 - dias_actuales, 'bonus': float(os.getenv('BONUS_3_DIAS', 20))}
elif dias_actuales < 5:
return {'dias_faltan': 5 - dias_actuales, 'bonus': float(os.getenv('BONUS_5_DIAS', 50))}
elif dias_actuales < 10:
return {'dias_faltan': 10 - dias_actuales, 'bonus': float(os.getenv('BONUS_10_DIAS', 150))}
else:
return {'dias_faltan': 0, 'bonus': 0, 'mensaje': '¡Ya tienes el bonus máximo!'}