#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ find_test_contacts.py Busca y clasifica contactos de test, pruebas, demo o E3 en la base de datos local SQLite. También detecta si estos contactos tienen oportunidades asociadas. Uso: python scripts/find_test_contacts.py --all python scripts/find_test_contacts.py --location GbKkBpCmKu2QmloKFHy3 python scripts/find_test_contacts.py --all --include-main --csv report.csv """ import argparse import os import sys import sqlite3 import json import csv # Agregar directorio raíz al path para importar contact_classifier ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if ROOT_DIR not in sys.path: sys.path.insert(0, ROOT_DIR) # Single source of truth: patrones y logica de clasificacion viven en contact_classifier. from contact_classifier import ( # noqa: E402 classify_contact, normalize_text, SPANISH_PATTERNS, ENGLISH_PATTERNS, E3_PATTERNS, GENERIC_PATTERNS, ) from paths import DB_PATH BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3" def safe_print(*args, **kwargs): sep = kwargs.get("sep", " ") end = kwargs.get("end", "\n") text = sep.join(str(a) for a in args) encoding = sys.stdout.encoding or "utf-8" try: sys.stdout.write(text + end) sys.stdout.flush() except UnicodeEncodeError: sys.stdout.write(text.encode(encoding, errors="replace").decode(encoding) + end) sys.stdout.flush() def main(): parser = argparse.ArgumentParser( description="Busca y clasifica contactos de test, pruebas, demo o E3 en SQLite." ) parser.add_argument("--all", action="store_true", help="Buscar en todas las sucursales.") parser.add_argument("--location", type=str, help="Filtrar por una ubicación (locationId) específica.") parser.add_argument("--include-main", action="store_true", help="Incluir la cuenta de Marca Principal en la búsqueda global.") parser.add_argument("--csv", type=str, help="Exportar resultados a un archivo CSV (ruta).") parser.add_argument("--limit", type=int, default=200, help="Límite máximo de resultados a imprimir en pantalla. Default: 200.") args = parser.parse_args() if not os.path.exists(DB_PATH): safe_print(f"Error: La base de datos local no existe en {DB_PATH}.") safe_print("Por favor, ejecuta la sincronización global primero desde el dashboard.") sys.exit(1) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: # Construir consulta SQL sql_contacts = """ SELECT c.*, a.nombre as branch_name, a.type as branch_type FROM contacts c JOIN accounts a ON c.location_id = a.location_id """ params = [] conditions = [] if args.location: conditions.append("c.location_id = ?") params.append(args.location) else: # Buscar en todos. Si no se incluye la principal, omitir BRAND_LOCATION_ID if not args.include_main: conditions.append("c.location_id != ?") params.append(BRAND_LOCATION_ID) if conditions: sql_contacts += " WHERE " + " AND ".join(conditions) sql_contacts += " ORDER BY c.date_added DESC" # Obtener todos los contactos candidatos contacts_rows = conn.execute(sql_contacts, params).fetchall() # Obtener todas las oportunidades para mapear si tienen opps_rows = conn.execute(""" SELECT o.*, p.name as pipeline_name FROM opportunities o LEFT JOIN pipelines p ON o.pipeline_id = p.id AND o.location_id = p.location_id """).fetchall() # Agrupar oportunidades por contact_id y location_id opps_by_contact = {} for row in opps_rows: key = (row["contact_id"], row["location_id"]) opps_by_contact.setdefault(key, []).append(dict(row)) matched_contacts = [] stats_by_class = {} stats_by_branch = {} for row in contacts_rows: tags = [] if row["tags"]: try: tags = json.loads(row["tags"]) except Exception: tags = [] reasons, classes = classify_contact(row["first_name"], row["last_name"], row["email"], tags) if reasons: c_dict = dict(row) c_dict["tags_parsed"] = tags c_dict["reasons"] = reasons c_dict["classifications"] = classes # Obtener oportunidades key = (row["id"], row["location_id"]) c_dict["opportunities"] = opps_by_contact.get(key, []) matched_contacts.append(c_dict) # Estadísticas for cls in classes: stats_by_class[cls] = stats_by_class.get(cls, 0) + 1 branch_key = f"{row['branch_name']} ({row['location_id']})" stats_by_branch[branch_key] = stats_by_branch.get(branch_key, 0) + 1 # Imprimir reporte W = 100 safe_print("=" * W) safe_print("REPORTE: BÚSQUEDA DE CONTACTOS DE TEST / PRUEBAS / E3".center(W)) safe_print("=" * W) safe_print(f" Total contactos analizados en SQLite: {len(contacts_rows)}") safe_print(f" Total contactos de test detectados: {len(matched_contacts)}") if matched_contacts: # Desglose por clasificación safe_print("\n" + "─" * W) safe_print(" Desglose por clasificación:") for cls, count in sorted(stats_by_class.items(), key=lambda x: -x[1]): safe_print(f" - {cls:<25}: {count:>4} contactos") # Desglose por sucursal safe_print("\n" + "─" * W) safe_print(" Top Sucursales con registros de test:") for branch, count in sorted(stats_by_branch.items(), key=lambda x: -x[1])[:10]: safe_print(f" - {branch:<55}: {count:>4} contactos") if len(stats_by_branch) > 10: safe_print(f" ... y otras {len(stats_by_branch) - 10} sucursales más.") # Listado de contactos (hasta el límite) safe_print("\n" + "=" * W) safe_print(f"LISTADO DE COINCIDENCIAS (Mostrando primeros {min(len(matched_contacts), args.limit)})".center(W)) safe_print("=" * W) for i, c in enumerate(matched_contacts[:args.limit], 1): name = f"{c['first_name'] or ''} {c['last_name'] or ''}".strip() or "Sin Nombre" email = c["email"] or "—" phone = c["phone"] or "—" reasons_str = ", ".join(c["reasons"]) class_str = "/".join(c["classifications"]) safe_print(f"\n{i:03d}. {name} | Sucursal: {c['branch_name']}") safe_print(f" ID GHL: {c['id']}") safe_print(f" Clase/Motivo: [{class_str}] — {reasons_str}") safe_print(f" Email: {email} | Tel: {phone}") if c["tags_parsed"]: safe_print(f" Etiquetas: {', '.join(c['tags_parsed'])}") # Mostrar oportunidades vinculadas si tiene if c["opportunities"]: safe_print(f" Oportunidades vinculadas ({len(c['opportunities'])}):") for o in c["opportunities"]: val = f"${(o.get('monetary_value') or 0):,.0f}" safe_print(f" • [{o.get('status', 'open').upper()}] {o.get('name') or 'Sin Nombre'} | Valor: {val} | Etapa: {o.get('pipeline_stage_id')}") if len(matched_contacts) > args.limit: safe_print(f"\n... [{len(matched_contacts) - args.limit} contactos de prueba omitidos en consola por límite. Use --limit para aumentar, o exporte a CSV]") # Exportar a CSV si se solicita if args.csv: csv_path = args.csv # Si no es absoluta, resolverla contra el directorio actual o raíz if not os.path.isabs(csv_path): csv_path = os.path.join(os.getcwd(), csv_path) try: with open(csv_path, mode='w', encoding='utf-8', newline='') as f: writer = csv.writer(f) writer.writerow([ "ID Contacto", "Nombre", "Email", "Telefono", "Location ID", "Sucursal", "Tipo Sucursal", "Clasificaciones", "Detalle Motivos", "Etiquetas", "Cant Opps", "Detalle Opps", "Fecha Creacion" ]) for c in matched_contacts: name = f"{c['first_name'] or ''} {c['last_name'] or ''}".strip() opps_info = [] for o in c["opportunities"]: opps_info.append(f"[{o.get('status','open').upper()}] {o.get('name')} (${o.get('monetary_value',0)})") opps_str = " | ".join(opps_info) writer.writerow([ c["id"], name, c["email"] or "", c["phone"] or "", c["location_id"], c["branch_name"], c["branch_type"], ",".join(c["classifications"]), "; ".join(c["reasons"]), ",".join(c["tags_parsed"]), len(c["opportunities"]), opps_str, c["date_added"] or "" ]) safe_print(f"\n[ÉXITO] Reporte exportado correctamente a CSV: {csv_path}") except Exception as e: safe_print(f"\n[ERROR] No se pudo exportar a CSV: {e}") else: safe_print("\n ¡Excelente! No se encontraron contactos con nomenclaturas de test, prueba o E3.") safe_print("\n" + "=" * W) finally: conn.close() if __name__ == "__main__": main()