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>
963 lines
38 KiB
Python
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!'}
|