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)