Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
@@ -0,0 +1,880 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Search and trace opportunities between Brand (Marca) and Branches (Sucursal) using verifier rules."""
import argparse
import os
import sys
import sqlite3
import json
import re
import unicodedata
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)
import sync_engine # noqa: E402
from paths import DB_PATH
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
_schema_cache = {}
_tokens_map = None
def safe_print(*args, **kwargs):
sep = kwargs.get("sep", " ")
end = kwargs.get("end", "\n")
text = sep.join(str(arg) for arg in args)
encoding = sys.stdout.encoding or "utf-8"
try:
sys.stdout.write(text + end)
sys.stdout.flush()
except UnicodeEncodeError:
try:
safe_text = text.encode(encoding, errors="replace").decode(encoding)
sys.stdout.write(safe_text + end)
sys.stdout.flush()
except Exception:
try:
safe_text = text.encode("ascii", errors="replace").decode("ascii")
sys.stdout.write(safe_text + end)
sys.stdout.flush()
except Exception:
pass
def normalize_phone(phone):
if not phone:
return ""
return re.sub(r"\D+", "", str(phone))
def phone_last_digits(phone, n=10):
"""Retorna los ultimos n digitos del numero normalizado (util para ignorar codigo de pais)."""
norm = normalize_phone(phone)
return norm[-n:] if len(norm) >= n else norm
def normalize_text(text):
"""Normaliza texto removiendo acentos y espacios extra para comparaciones robustas."""
if not text:
return ""
nfkd = unicodedata.normalize("NFD", str(text))
without_accents = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
return " ".join(without_accents.lower().split())
def load_verifier_map():
path = os.path.join(ROOT_DIR, "Monte Providencia - Verificador de sucursales y correos - Sucursales.csv")
tienda_to_location = {}
location_to_tienda = {}
location_to_sc_name = {}
if os.path.exists(path):
import csv
try:
with open(path, mode="r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
tienda = (row.get("TIENDA") or "").strip().upper()
loc_id = (row.get("ID LOCATION BUCEFALO") or "").strip()
sc_name = (row.get("SC BUCEFALO") or "").strip()
if tienda and loc_id:
tienda_to_location[tienda] = loc_id
location_to_tienda[loc_id] = tienda
location_to_sc_name[loc_id] = sc_name
except Exception as e:
safe_print(f"Advertencia al leer verificador de sucursales: {e}")
return tienda_to_location, location_to_tienda, location_to_sc_name
def get_location_token(location_id):
global _tokens_map
if _tokens_map is None:
_tokens_map = sync_engine.get_tokens_map()
return _tokens_map.get(location_id)
def get_schema_field_id(location_id, object_key, field_name):
cache_key = (location_id, object_key)
if cache_key not in _schema_cache:
token = get_location_token(location_id)
if not token:
_schema_cache[cache_key] = {}
else:
_schema_cache[cache_key] = sync_engine.ghl_client.get_object_schema(token, location_id, object_key)
return _schema_cache[cache_key].get(field_name)
def extract_custom_field_value(cf_json, field_id):
if not field_id:
return None
if not cf_json:
return None
try:
cfs = json.loads(cf_json)
if isinstance(cfs, list):
for cf in cfs:
if cf.get("id") == field_id or cf.get("fieldId") == field_id:
return cf.get("value")
except Exception:
pass
return None
def extract_tienda_from_custom_fields(cf_json, tienda_to_location, tienda_field_id):
val = str(extract_custom_field_value(cf_json, tienda_field_id) or "").strip().upper()
if not val:
return None
if val in tienda_to_location:
return val
for tienda in tienda_to_location:
if tienda in val or val in tienda:
return tienda
return None
def calculate_opportunity_similarity(so, bo):
"""
Calcula un puntaje de similitud (0 a 100) entre la oportunidad de sucursal (so)
y la de marca (bo) para encontrar el par mas semejante.
"""
if so["id"] == bo["id"]:
return 100
score = 0
so_name = normalize_text(so["name"] or "")
bo_name = normalize_text(bo["name"] or "")
# 1. Match de Nombre Exacto (normalizado — ignora acentos y espacios)
if so_name == bo_name:
score += 60
# 2. Match de Nombre Parcial
elif so_name and bo_name:
if so_name in bo_name or bo_name in so_name:
score += 40
else:
# Overlap de palabras
so_words = set(so_name.split())
bo_words = set(bo_name.split())
overlap = so_words.intersection(bo_words)
if len(overlap) > 0:
score += min(35, len(overlap) * 10)
# 3. Match de Valor Monetario
so_val = so["monetary_value"] or 0.0
bo_val = bo["monetary_value"] or 0.0
if abs(so_val - bo_val) < 0.01:
score += 25
elif max(so_val, bo_val) > 0 and abs(so_val - bo_val) / max(1.0, so_val, bo_val) < 0.2:
score += 15
# 4. Match de Estado (Status)
if so["status"] == bo["status"]:
score += 15
return score
def search_brand_to_branch_opportunities(query_term):
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 sincronizacion global primero desde el dashboard.")
sys.exit(1)
tienda_to_location, location_to_tienda, location_to_sc_name = load_verifier_map()
brand_tienda_field_id = get_schema_field_id(BRAND_LOCATION_ID, "contact", "TIENDA")
if not brand_tienda_field_id:
safe_print("ALERTA: campo TIENDA no existe en el schema de contactos de Marca; no se inferira sucursal asignada.")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
safe_print("\n" + "=" * 90)
safe_print("BUSQUEDA Y AUDITORIA DE TRAZABILIDAD (MONTE PROVIDENCIA): MARCA -> SUCURSAL")
safe_print("=" * 90)
safe_print(f"Criterio de busqueda: '{query_term}'\n")
# 1. Encontrar todos los contactos que coincidan en la Marca Principal (Marca de Control)
q_like = f"%{query_term}%"
phone_digits = normalize_phone(query_term)
q_phone = f"%{phone_digits}%" if phone_digits else "____invalid____"
brand_contacts_sql = """
SELECT c.*
FROM contacts c
WHERE c.location_id = ?
AND (
c.first_name LIKE ?
OR c.last_name LIKE ?
OR c.email LIKE ?
OR c.phone LIKE ?
OR (? != '____invalid____' AND c.phone LIKE ?)
)
"""
brand_contacts = conn.execute(brand_contacts_sql, (BRAND_LOCATION_ID, q_like, q_like, q_like, q_like, q_phone, q_phone)).fetchall()
if not brand_contacts:
safe_print("No se encontraron contactos en la cuenta de MARCA PRINCIPAL que coincidan con la busqueda.")
safe_print("Buscando en sucursales...")
# Buscar en sucursales directamente si no se encontro en marca
all_contacts_sql = """
SELECT c.*, a.nombre as account_name
FROM contacts c
JOIN accounts a ON c.location_id = a.location_id
WHERE c.location_id != ?
AND (
c.first_name LIKE ?
OR c.last_name LIKE ?
OR c.email LIKE ?
OR c.phone LIKE ?
OR (? != '____invalid____' AND c.phone LIKE ?)
)
"""
branch_contacts = conn.execute(all_contacts_sql, (BRAND_LOCATION_ID, q_like, q_like, q_like, q_like, q_phone, q_phone)).fetchall()
if not branch_contacts:
safe_print("No se encontro el contacto en ninguna cuenta.")
return
# Si se encontro en sucursales, mostrar resumen simplificado
safe_print(f"Se encontraron {len(branch_contacts)} contactos en sucursales (pero no en la Marca Principal).\n")
for bc in branch_contacts:
safe_print(f"Cliente: {bc['first_name'] or ''} {bc['last_name'] or ''}".strip())
safe_print(f" Cuenta/Sucursal: {bc['account_name']}")
safe_print(f" Telefono: {bc['phone'] or 'N/A'}")
safe_print(f" Email: {bc['email'] or 'N/A'}")
safe_print("-" * 60)
return
safe_print(f"Se encontraron {len(brand_contacts)} clientes en Marca Principal. Analizando ruta de trazabilidad...\n")
for idx, bc in enumerate(brand_contacts, 1):
client_name = f"{bc['first_name'] or ''} {bc['last_name'] or ''}".strip() or "Sin nombre"
client_phone = bc['phone'] or ""
client_email = bc['email'] or ""
cf_json = bc['custom_fields_json']
safe_print(f"CLIENTE #{idx:02d}: {client_name}")
if client_phone: safe_print(f" Telefono: {client_phone}")
if client_email: safe_print(f" Email: {client_email}")
# Detectar tienda asignada en los campos personalizados
assigned_tienda = extract_tienda_from_custom_fields(cf_json, tienda_to_location, brand_tienda_field_id)
assigned_location_id = tienda_to_location.get(assigned_tienda) if assigned_tienda else None
assigned_sc_name = location_to_sc_name.get(assigned_location_id, assigned_tienda) if assigned_location_id else None
if assigned_tienda and assigned_location_id:
safe_print(f" --> SUCURSAL ASIGNADA EN MARCA: '{assigned_tienda}' ({assigned_sc_name})")
else:
safe_print(" --> SUCURSAL ASIGNADA EN MARCA: [ALERTA] No se ha definido tienda/sucursal en los campos personalizados de Marca.")
safe_print("-" * 90)
# Paso 1: Oportunidades del cliente en la Marca Principal
opps_brand_sql = """
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
WHERE o.location_id = ? AND o.contact_id = ?
"""
brand_opps = conn.execute(opps_brand_sql, (BRAND_LOCATION_ID, bc["id"])).fetchall()
safe_print(f" Paso 1: Oportunidades en Marca Principal: {len(brand_opps)}")
for bo in brand_opps:
safe_print(f" * ID: {bo['id']}")
safe_print(f" Nombre: {bo['name'] or 'Sin nombre'}")
safe_print(f" Estado: {bo['status'].upper() if bo['status'] else 'OPEN'}")
safe_print(f" Valor: ${(bo['monetary_value'] or 0.0):,.2f}")
safe_print(f" Etapa: {bo['pipeline_name'] or 'Standar'} (Stage: {bo['pipeline_stage_id']})")
safe_print()
# Paso 2: Verificar existencia del contacto en la Sucursal Asignada
if assigned_location_id:
# Buscar contacto en la sucursal asignada (por email, teléfono o nombre)
norm_p = normalize_phone(client_phone)
phone_suf = phone_last_digits(client_phone)
branch_contact_sql = """
SELECT * FROM contacts
WHERE location_id = ?
AND (
(email IS NOT NULL AND email != '' AND LOWER(email) = ?)
OR (phone IS NOT NULL AND phone != '' AND (
REPLACE(REPLACE(REPLACE(REPLACE(phone,'+',''),'-',''),' ',''),'(','') = ?
OR phone LIKE '%' || ?
))
OR (LOWER(first_name || ' ' || last_name) = ?)
)
"""
branch_contact_row = conn.execute(branch_contact_sql, (assigned_location_id, client_email.lower(), norm_p, phone_suf, client_name.lower())).fetchone()
if branch_contact_row:
safe_print(f" Paso 2: Busqueda en Sucursal '{assigned_tienda}': [EXITO] Contacto encontrado.")
safe_print(f" * ID en Sucursal: {branch_contact_row['id']}")
# Paso 3: Verificar oportunidad en la sucursal asignada
branch_opps_sql = """
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
WHERE o.location_id = ? AND o.contact_id = ?
"""
branch_opps = conn.execute(branch_opps_sql, (assigned_location_id, branch_contact_row["id"])).fetchall()
safe_print(f" Paso 3: Oportunidades en Sucursal '{assigned_tienda}': {len(branch_opps)}")
for so in branch_opps:
safe_print(f" * ID: {so['id']}")
safe_print(f" Nombre: {so['name'] or 'Sin nombre'}")
safe_print(f" Estado: {so['status'].upper() if so['status'] else 'OPEN'}")
safe_print(f" Valor: ${(so['monetary_value'] or 0.0):,.2f}")
safe_print(f" Etapa: {so['pipeline_name'] or 'Standar'} (Stage: {so['pipeline_stage_id']})")
# Paso 4: Trazabilidad / Sincronizacion de vuelta a Marca
synced_in_brand = False
for bo in brand_opps:
if bo["id"] == so["id"]:
synced_in_brand = True
break
if bo["name"] and so["name"] and normalize_text(bo["name"]) == normalize_text(so["name"]):
synced_in_brand = True
break
if synced_in_brand:
safe_print(f" --> STATUS TRAZABILIDAD: [OK] Sincronizacion Completa (Existe en Sucursal y Marca)")
else:
# Intentar buscar la más semejante
best_bo = None
best_score = 0
for bo in brand_opps:
score = calculate_opportunity_similarity(so, bo)
if score > best_score:
best_score = score
best_bo = bo
if best_bo and best_score >= 35:
safe_print(f" --> STATUS TRAZABILIDAD: [DISCREPANCIA] Detectada paridad parcial con '{best_bo['name']}' (Similitud: {best_score}%)")
safe_print(f" Campos discrepantes:")
if (so["name"] or "").strip().lower() != (best_bo["name"] or "").strip().lower():
safe_print(f" - Nombre: '{so['name']}' vs '{best_bo['name']}'")
if so["status"] != best_bo["status"]:
safe_print(f" - Estado: {so['status']} vs {best_bo['status']}")
if abs((so["monetary_value"] or 0) - (best_bo["monetary_value"] or 0)) >= 0.01:
safe_print(f" - Valor: ${(so['monetary_value'] or 0.0):,.2f} vs ${(best_bo['monetary_value'] or 0.0):,.2f}")
else:
safe_print(f" --> STATUS TRAZABILIDAD: [ALERTA] Oportunidad existe en sucursal pero NO se ha sincronizado de vuelta a la Marca Principal.")
if not branch_opps:
safe_print(f" * [ALERTA] El contacto existe en la sucursal '{assigned_tienda}' pero NO tiene oportunidad creada.")
else:
safe_print(f" Paso 2: Busqueda en Sucursal '{assigned_tienda}': [ALERTA] El contacto no existe en la sucursal asignada.")
safe_print(f" * Accion sugerida: Se requiere disparar sincronizacion del contacto de Marca a la sucursal '{assigned_tienda}'.")
else:
safe_print(" Paso 2: Busqueda en Sucursal: [ALERTA] No se puede auditar la sucursal porque el contacto no tiene asignada ninguna 'TIENDA' en Marca.")
safe_print("\n" + "=" * 90 + "\n")
finally:
conn.close()
def run_global_traceability_audit():
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 sincronizacion global primero desde el dashboard.")
sys.exit(1)
tienda_to_location, location_to_tienda, location_to_sc_name = load_verifier_map()
brand_tienda_field_id = get_schema_field_id(BRAND_LOCATION_ID, "contact", "TIENDA")
if not brand_tienda_field_id:
safe_print("ALERTA: campo TIENDA no existe en el schema de contactos de Marca; no se inferira sucursal asignada.")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
safe_print("\n" + "=" * 90)
safe_print("AUDITORIA GLOBAL DE TRAZABILIDAD DE OPORTUNIDADES (MONTE PROVIDENCIA): MARCA -> SUCURSAL")
safe_print("=" * 90)
safe_print("Iniciando escaneo masivo de la base de datos...\n")
# 1. Obtener todos los contactos de la Marca Principal
brand_contacts = conn.execute("SELECT * FROM contacts WHERE location_id = ?", (BRAND_LOCATION_ID,)).fetchall()
# 2. Obtener todas las oportunidades de Marca y de Sucursales indexadas en memoria para busqueda ultra-rapida
brand_opps_rows = conn.execute("SELECT * FROM opportunities WHERE location_id = ?", (BRAND_LOCATION_ID,)).fetchall()
brand_opps_by_contact = {}
for row in brand_opps_rows:
brand_opps_by_contact.setdefault(row["contact_id"], []).append(dict(row))
all_branch_opps_rows = conn.execute("SELECT o.*, a.nombre as branch_name FROM opportunities o JOIN accounts a ON o.location_id = a.location_id WHERE o.location_id != ?", (BRAND_LOCATION_ID,)).fetchall()
# Obtener todos los contactos en sucursales
all_branch_contacts = conn.execute("SELECT c.* FROM contacts c WHERE c.location_id != ?", (BRAND_LOCATION_ID,)).fetchall()
# Indexar contactos de sucursales para busqueda rapida instantanea
branch_contacts_by_phone = {}
branch_contacts_by_phone_suffix = {}
branch_contacts_by_email = {}
branch_contacts_by_name = {}
branch_contacts_by_norm_name = {}
for bc in all_branch_contacts:
loc = bc["location_id"]
phone = normalize_phone(bc["phone"])
suffix = phone_last_digits(bc["phone"])
email = (bc["email"] or "").strip().lower()
name = f"{bc['first_name'] or ''} {bc['last_name'] or ''}".strip().lower()
norm_name = normalize_text(f"{bc['first_name'] or ''} {bc['last_name'] or ''}")
if phone:
branch_contacts_by_phone[(loc, phone)] = dict(bc)
if suffix and suffix != phone:
branch_contacts_by_phone_suffix[(loc, suffix)] = dict(bc)
if email:
branch_contacts_by_email[(loc, email)] = dict(bc)
if name:
branch_contacts_by_name[(loc, name)] = dict(bc)
if norm_name and norm_name != name:
branch_contacts_by_norm_name[(loc, norm_name)] = dict(bc)
# Indexar oportunidades de sucursales por (location_id, contact_id)
branch_opps_by_contact_id = {}
for row in all_branch_opps_rows:
branch_opps_by_contact_id.setdefault((row["location_id"], row["contact_id"]), []).append(dict(row))
# Variables para métricas
total_audited_contacts = len(brand_contacts)
contacts_with_assigned_branch = 0
contacts_without_assigned_branch = 0
contacts_found_in_assigned_branch = 0
contacts_missing_in_assigned_branch = 0
total_brand_opportunities = len(brand_opps_rows)
total_branch_opportunities = len(all_branch_opps_rows)
opportunities_synced = 0
opps_with_discrepancies = 0
opps_only_in_brand = 0
opps_only_in_branch = 0
contacts_in_branch_without_opp = 0
discrepancies = [] # list of dicts with alerts
for bc in brand_contacts:
client_name = f"{bc['first_name'] or ''} {bc['last_name'] or ''}".strip() or "Sin nombre"
client_phone = bc['phone'] or ""
client_email = bc['email'] or ""
cf_json = bc['custom_fields_json']
brand_cid = bc["id"]
assigned_tienda = extract_tienda_from_custom_fields(cf_json, tienda_to_location, brand_tienda_field_id)
assigned_location_id = tienda_to_location.get(assigned_tienda) if assigned_tienda else None
if not assigned_location_id:
contacts_without_assigned_branch += 1
continue
contacts_with_assigned_branch += 1
# 1. Obtener oportunidades de este contacto en Marca
opps_in_brand = brand_opps_by_contact.get(brand_cid, [])
# 2. Buscar contacto en la sucursal asignada usando los índices rápidos
norm_p = normalize_phone(client_phone)
p_suffix = phone_last_digits(client_phone)
norm_name = normalize_text(client_name)
branch_c = None
if norm_p and (assigned_location_id, norm_p) in branch_contacts_by_phone:
branch_c = branch_contacts_by_phone[(assigned_location_id, norm_p)]
elif p_suffix and (assigned_location_id, p_suffix) in branch_contacts_by_phone_suffix:
branch_c = branch_contacts_by_phone_suffix[(assigned_location_id, p_suffix)]
elif client_email and (assigned_location_id, client_email.lower()) in branch_contacts_by_email:
branch_c = branch_contacts_by_email[(assigned_location_id, client_email.lower())]
elif (assigned_location_id, client_name.lower()) in branch_contacts_by_name:
branch_c = branch_contacts_by_name[(assigned_location_id, client_name.lower())]
elif norm_name and (assigned_location_id, norm_name) in branch_contacts_by_norm_name:
branch_c = branch_contacts_by_norm_name[(assigned_location_id, norm_name)]
if not branch_c:
contacts_missing_in_assigned_branch += 1
discrepancies.append({
"cliente": client_name,
"contacto_marca_id": brand_cid,
"telefono": client_phone,
"email": client_email,
"sucursal_asignada": assigned_tienda,
"tipo_alerta": "CONTACTO FALTANTE EN SUCURSAL",
"descripcion": f"El contacto existe en Marca pero no se encuentra en la sucursal asignada '{assigned_tienda}'."
})
continue
contacts_found_in_assigned_branch += 1
branch_cid = branch_c["id"]
# 3. Obtener oportunidades de este contacto en la sucursal asignada
opps_in_branch = branch_opps_by_contact_id.get((assigned_location_id, branch_cid), [])
if not opps_in_branch and (opps_in_brand or "sucursal" in str(bc.get("tags", "")).lower()):
contacts_in_branch_without_opp += 1
discrepancies.append({
"cliente": client_name,
"contacto_marca_id": brand_cid,
"telefono": client_phone,
"email": client_email,
"sucursal_asignada": assigned_tienda,
"tipo_alerta": "CONTACTO SIN OPORTUNIDAD EN SUCURSAL",
"descripcion": f"El contacto existe en '{assigned_tienda}' pero no tiene ninguna oportunidad creada en esa sucursal."
})
# 4. Cruzar oportunidades para verificar sincronización
for bo in opps_in_brand:
matched = False
for so in opps_in_branch:
if bo["id"] == so["id"] or (bo["name"] and so["name"] and normalize_text(bo["name"]) == normalize_text(so["name"])):
matched = True
opportunities_synced += 1
break
if not matched:
# Buscar la más semejante
best_so = None
best_score = 0
for so in opps_in_branch:
score = calculate_opportunity_similarity(so, bo)
if score > best_score:
best_score = score
best_so = so
if not best_so or best_score < 35:
opps_only_in_brand += 1
discrepancies.append({
"cliente": client_name,
"contacto_marca_id": brand_cid,
"telefono": client_phone,
"email": client_email,
"sucursal_asignada": assigned_tienda,
"tipo_alerta": "OPORTUNIDAD SOLO EN MARCA",
"descripcion": f"Oportunidad '{bo['name']}' (ID: {bo['id']}) existe en Marca pero no se encuentra en la sucursal '{assigned_tienda}'."
})
for so in opps_in_branch:
matched = False
for bo in opps_in_brand:
if bo["id"] == so["id"] or (bo["name"] and so["name"] and normalize_text(bo["name"]) == normalize_text(so["name"])):
matched = True
break
if not matched:
# Buscar la más semejante para ver si es una discrepancia de campos o falta total
best_bo = None
best_score = 0
for bo in opps_in_brand:
score = calculate_opportunity_similarity(so, bo)
if score > best_score:
best_score = score
best_bo = bo
if best_bo and best_score >= 35:
opps_with_discrepancies += 1
disc_fields = []
if (so["name"] or "").strip().lower() != (best_bo["name"] or "").strip().lower():
disc_fields.append("Nombre")
if so["status"] != best_bo["status"]:
disc_fields.append("Estado")
if abs((so["monetary_value"] or 0) - (best_bo["monetary_value"] or 0)) >= 0.01:
disc_fields.append("Valor")
discrepancies.append({
"cliente": client_name,
"contacto_marca_id": brand_cid,
"telefono": client_phone,
"email": client_email,
"sucursal_asignada": assigned_tienda,
"tipo_alerta": "DISCREPANCIA EN CAMPOS",
"descripcion": f"Oportunidad parcial encontrada en Marca '{best_bo['name']}' pero difiere con la Sucursal en: {', '.join(disc_fields)} (Coincidencia: {best_score}%)."
})
else:
opps_only_in_branch += 1
discrepancies.append({
"cliente": client_name,
"contacto_marca_id": brand_cid,
"telefono": client_phone,
"email": client_email,
"sucursal_asignada": assigned_tienda,
"tipo_alerta": "OPORTUNIDAD SOLO EN SUCURSAL",
"descripcion": f"Oportunidad '{so['name']}' (ID: {so['id']}) existe en la sucursal '{assigned_tienda}' pero falta sincronizar a la Marca."
})
# Imprimir reporte de discrepancias
safe_print("=== DETALLE DE DISCREPANCIAS Y ALERTAS DETECTADAS ===")
safe_print("-" * 110)
if not discrepancies:
safe_print("EXCELENTE: No se detecto ninguna discrepancia de trazabilidad en el sistema.")
else:
limit = 100
for idx, d in enumerate(discrepancies[:limit], 1):
safe_print(f"{idx:03d}. [{d['tipo_alerta']}] - Cliente: {d['cliente']}")
safe_print(f" Sucursal Asignada: {d['sucursal_asignada']}")
if d['telefono']: safe_print(f" Telefono: {d['telefono']}")
safe_print(f" Detalle: {d['descripcion']}")
safe_print("-" * 110)
if len(discrepancies) > limit:
safe_print(f"... [INFO] Se omitieron {len(discrepancies) - limit} alertas adicionales en consola para agilizar la visualizacion.")
safe_print("-" * 110)
# Imprimir resumen ejecutivo global
safe_print("\n=== RESUMEN EJECUTIVO DE TRAZABILIDAD GLOBAL ===")
safe_print(f" Total Clientes Auditados en Marca: {total_audited_contacts}")
safe_print(f" Clientes con Sucursal Asignada: {contacts_with_assigned_branch}")
safe_print(f" Clientes sin Sucursal Asignada: {contacts_without_assigned_branch}")
safe_print(f" Clientes Localizados en su Sucursal: {contacts_found_in_assigned_branch}")
safe_print(f" Clientes Faltantes en su Sucursal: {contacts_missing_in_assigned_branch}")
safe_print("-" * 50)
safe_print(f" Total Oportunidades en Marca Principal: {total_brand_opportunities}")
safe_print(f" Total Oportunidades en Sucursales: {total_branch_opportunities}")
safe_print(f" Oportunidades Sincronizadas Correctamente: {opportunities_synced}")
safe_print(f" Oportunidades con Discrepancias de Datos: {opps_with_discrepancies}")
safe_print(f" Oportunidades Huerfanas en Marca: {opps_only_in_brand}")
safe_print(f" Oportunidades No Sincronizadas en Sucursal: {opps_only_in_branch}")
safe_print(f" Clientes en Sucursal sin Oportunidad: {contacts_in_branch_without_opp}")
safe_print("=" * 90 + "\n")
except Exception as e:
safe_print(f"Error critico durante la auditoria global: {e}")
finally:
conn.close()
def run_reverse_traceability_audit():
"""
Auditoría Inversa: Escanea todas las oportunidades creadas en las sucursales
y verifica si se han sincronizado correctamente hacia la cuenta de Marca Principal.
Usa el motor de emparejamiento por similitud para reconciliar discrepancias parciales de datos.
"""
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 sincronizacion global primero desde el dashboard.")
sys.exit(1)
tienda_to_location, location_to_tienda, location_to_sc_name = load_verifier_map()
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
safe_print("\n" + "=" * 90)
safe_print("AUDITORIA INVERSA DE TRAZABILIDAD DE OPORTUNIDADES (MONTE PROVIDENCIA): SUCURSAL -> MARCA")
safe_print("=" * 90)
safe_print("Iniciando escaneo masivo de oportunidades en sucursales...\n")
# 1. Obtener todos los contactos y oportunidades de la Marca Principal
brand_contacts = conn.execute("SELECT * FROM contacts WHERE location_id = ?", (BRAND_LOCATION_ID,)).fetchall()
brand_opps_rows = conn.execute("SELECT * FROM opportunities WHERE location_id = ?", (BRAND_LOCATION_ID,)).fetchall()
# Indexar contactos de la Marca Principal para búsqueda ultra-rápida
brand_contacts_by_phone = {}
brand_contacts_by_phone_suffix = {}
brand_contacts_by_email = {}
brand_contacts_by_name = {}
brand_contacts_by_norm_name = {}
for bc in brand_contacts:
phone = normalize_phone(bc["phone"])
suffix = phone_last_digits(bc["phone"])
email = (bc["email"] or "").strip().lower()
name = f"{bc['first_name'] or ''} {bc['last_name'] or ''}".strip().lower()
norm_name = normalize_text(f"{bc['first_name'] or ''} {bc['last_name'] or ''}")
if phone:
brand_contacts_by_phone[phone] = dict(bc)
if suffix and suffix != phone:
brand_contacts_by_phone_suffix[suffix] = dict(bc)
if email:
brand_contacts_by_email[email] = dict(bc)
if name:
brand_contacts_by_name[name] = dict(bc)
if norm_name and norm_name != name:
brand_contacts_by_norm_name[norm_name] = dict(bc)
# Indexar oportunidades de la Marca por contact_id
brand_opps_by_contact_id = {}
for bo in brand_opps_rows:
brand_opps_by_contact_id.setdefault(bo["contact_id"], []).append(dict(bo))
# 2. Obtener todos los contactos y oportunidades de las Sucursales
branch_contacts_rows = conn.execute("SELECT c.*, a.nombre as branch_name FROM contacts c JOIN accounts a ON c.location_id = a.location_id WHERE c.location_id != ?", (BRAND_LOCATION_ID,)).fetchall()
branch_contacts_by_id = { (bc["location_id"], bc["id"]): dict(bc) for bc in branch_contacts_rows }
branch_opps_rows = conn.execute("SELECT o.*, a.nombre as branch_name FROM opportunities o JOIN accounts a ON o.location_id = a.location_id WHERE o.location_id != ?", (BRAND_LOCATION_ID,)).fetchall()
# Métricas de Auditoría Inversa
total_branch_opps_audited = len(branch_opps_rows)
opps_successfully_synced = 0
opps_with_discrepancies = 0
opps_missing_in_brand = 0
branch_contacts_missing_in_brand = 0
discrepancies = []
per_location_stats = {} # loc_id -> {nombre, total, sinc, discrepancias, faltantes, contactos_faltantes}
for so in branch_opps_rows:
loc_id = so["location_id"]
contact_id = so["contact_id"]
opp_name = so["name"] or "Sin nombre"
branch_name = so["branch_name"]
# Determinar nombre de sucursal antes del early-continue para contabilizar todos los casos
tienda = location_to_tienda.get(loc_id, branch_name)
if loc_id not in per_location_stats:
per_location_stats[loc_id] = {
"nombre": location_to_sc_name.get(loc_id) or tienda,
"total": 0, "sinc": 0, "discrepancias": 0, "faltantes": 0, "contactos_faltantes": 0
}
per_location_stats[loc_id]["total"] += 1
# Obtener el contacto de la sucursal
sc = branch_contacts_by_id.get((loc_id, contact_id))
if not sc:
continue
client_name = f"{sc['first_name'] or ''} {sc['last_name'] or ''}".strip() or "Sin nombre"
client_phone = sc["phone"] or ""
client_email = sc["email"] or ""
# Buscar el contacto correspondiente en la Marca Principal
norm_p = normalize_phone(client_phone)
p_suffix = phone_last_digits(client_phone)
norm_client_name = normalize_text(client_name)
bc = None
if norm_p and norm_p in brand_contacts_by_phone:
bc = brand_contacts_by_phone[norm_p]
elif p_suffix and p_suffix in brand_contacts_by_phone_suffix:
bc = brand_contacts_by_phone_suffix[p_suffix]
elif client_email and client_email.lower() in brand_contacts_by_email:
bc = brand_contacts_by_email[client_email.lower()]
elif client_name and client_name.lower() in brand_contacts_by_name:
bc = brand_contacts_by_name[client_name.lower()]
elif norm_client_name and norm_client_name in brand_contacts_by_norm_name:
bc = brand_contacts_by_norm_name[norm_client_name]
if not bc:
branch_contacts_missing_in_brand += 1
per_location_stats[loc_id]["contactos_faltantes"] += 1
discrepancies.append({
"cliente": client_name,
"sucursal": tienda,
"telefono": client_phone,
"email": client_email,
"tipo_alerta": "CONTACTO SUCURSAL FALTANTE EN MARCA",
"descripcion": f"El cliente existe en '{tienda}' con oportunidad '{opp_name}' pero el contacto no se ha replicado en la Marca Principal."
})
continue
# Si el contacto existe en Marca, validar si existe la oportunidad en Marca
brand_opps = brand_opps_by_contact_id.get(bc["id"], [])
synced = False
for bo in brand_opps:
if bo["id"] == so["id"]:
synced = True
break
if bo["name"] and so["name"] and normalize_text(bo["name"]) == normalize_text(so["name"]):
synced = True
break
if synced:
opps_successfully_synced += 1
per_location_stats[loc_id]["sinc"] += 1
else:
# RECONCILIACIÓN POR SIMILITUD DE CAMPOS (100% de datos de contacto coincidentes, buscando par de oportunidad mas cercano)
best_bo = None
best_score = 0
for bo in brand_opps:
score = calculate_opportunity_similarity(so, bo)
if score > best_score:
best_score = score
best_bo = bo
if best_bo and best_score >= 35:
opps_with_discrepancies += 1
per_location_stats[loc_id]["discrepancias"] += 1
disc_fields = []
if normalize_text(so["name"] or "") != normalize_text(best_bo["name"] or ""):
disc_fields.append("Nombre")
if so["status"] != best_bo["status"]:
disc_fields.append("Estado")
if abs((so["monetary_value"] or 0) - (best_bo["monetary_value"] or 0)) >= 0.01:
disc_fields.append("Valor")
discrepancies.append({
"cliente": client_name,
"sucursal": tienda,
"telefono": client_phone,
"email": client_email,
"tipo_alerta": "DISCREPANCIA EN CAMPOS DE SINC",
"descripcion": f"Oportunidad encontrada en Marca '{best_bo['name']}' pero difiere con la Sucursal '{tienda}' en: {', '.join(disc_fields)} (Coincidencia: {best_score}%)."
})
else:
opps_missing_in_brand += 1
per_location_stats[loc_id]["faltantes"] += 1
discrepancies.append({
"cliente": client_name,
"sucursal": tienda,
"telefono": client_phone,
"email": client_email,
"tipo_alerta": "OPORTUNIDAD SUCURSAL NO SINCRONIZADA",
"descripcion": f"Oportunidad '{opp_name}' (ID: {so['id']}) existe en la sucursal '{tienda}' pero falta sincronizar en la Marca Principal."
})
# Imprimir reporte de alertas inversas
safe_print("=== DETALLE DE DISCREPANCIAS Y ALERTAS DETECTADAS (SUCURSAL -> MARCA) ===")
safe_print("-" * 110)
if not discrepancies:
safe_print("EXCELENTE: Todas las oportunidades de sucursales estan correctamente sincronizadas en Marca.")
else:
limit = 100
for idx, d in enumerate(discrepancies[:limit], 1):
safe_print(f"{idx:03d}. [{d['tipo_alerta']}] - Cliente: {d['cliente']}")
safe_print(f" Sucursal de Origen: {d['sucursal']}")
if d['telefono']: safe_print(f" Telefono: {d['telefono']}")
safe_print(f" Detalle: {d['descripcion']}")
safe_print("-" * 110)
if len(discrepancies) > limit:
safe_print(f"... [INFO] Se omitieron {len(discrepancies) - limit} alertas adicionales en consola para agilizar la visualizacion.")
safe_print("-" * 110)
# Imprimir resumen de la Auditoría Inversa
safe_print("\n=== RESUMEN EJECUTIVO DE TRAZABILIDAD INVERSA (SALUD SUCURSALES) ===")
safe_print(f" Total Oportunidades Auditadas en Sucursales: {total_branch_opps_audited}")
safe_print(f" Oportunidades Sincronizadas Correctamente: {opps_successfully_synced}")
safe_print(f" Oportunidades con Discrepancias de Datos: {opps_with_discrepancies}")
safe_print(f" Oportunidades Faltantes de Sincronizar: {opps_missing_in_brand}")
safe_print(f" Clientes de Sucursal Faltantes en Marca: {branch_contacts_missing_in_brand}")
safe_print("-" * 50)
safe_print(" Regla de Sincronizacion: La Sucursal tiene prioridad (Unidireccional Sucursal -> Marca).")
# Tabla de resumen por sucursal (location)
if per_location_stats:
safe_print("\n--- DESGLOSE POR SUCURSAL (LOCATION) ---")
col_w = 38
safe_print(f" {'Sucursal (Location)':<{col_w}} {'Total':>6} {'Sinc.':>6} {'Discr.':>6} {'Falt.':>6} {'C.Falt':>6}")
safe_print(" " + "-" * (col_w + 36))
for loc_id, s in sorted(per_location_stats.items(), key=lambda x: x[1]["nombre"]):
nombre = s["nombre"] or loc_id
if len(nombre) > col_w:
nombre = nombre[:col_w - 1] + "…"
safe_print(
f" {nombre:<{col_w}} {s['total']:>6} {s['sinc']:>6} {s['discrepancias']:>6} {s['faltantes']:>6} {s['contactos_faltantes']:>6}"
)
safe_print(" " + "-" * (col_w + 36))
safe_print("=" * 90 + "\n")
except Exception as e:
safe_print(f"Error critico durante la auditoria inversa: {e}")
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="Busca y traza las oportunidades entre la cuenta de Marca y las Sucursales usando el Verificador.")
parser.add_argument("query", nargs="*", help="Termino de busqueda (Nombre, Email, Telefono, ID de Oportunidad o Cliente). Si se omite, realiza una auditoria.")
parser.add_argument("--reverse", action="store_true", help="Realiza la auditoria de trazabilidad a la inversa (Sucursal -> Marca).")
args = parser.parse_args()
if not args.query:
if args.reverse:
run_reverse_traceability_audit()
else:
run_global_traceability_audit()
else:
query_term = " ".join(args.query)
search_brand_to_branch_opportunities(query_term)
if __name__ == "__main__":
main()