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>
This commit is contained in:
2026-01-19 02:57:27 +00:00
parent 5d9cbd4812
commit ed1658eb2b
8 changed files with 1808 additions and 148 deletions

View File

@@ -609,3 +609,354 @@ class NocoDBClient:
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!'}