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:
@@ -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!'}
|
||||
|
||||
Reference in New Issue
Block a user