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,301 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
audit_brand_contact_opportunity_map.py
Mapea la relación entre contactos y oportunidades en la cuenta de Marca Principal
e identifica discrepancias:
- Contactos sin ninguna oportunidad
- Contactos con más de una oportunidad (duplicados potenciales)
- Oportunidades huérfanas (contact_id no existe en la BD)
- Breakdown por estado de oportunidad
Uso:
python scripts/audit_brand_contact_opportunity_map.py
python scripts/audit_brand_contact_opportunity_map.py --show-no-opp # lista contactos sin opp
python scripts/audit_brand_contact_opportunity_map.py --show-multi-opp # lista contactos con 2+ opps
python scripts/audit_brand_contact_opportunity_map.py --show-orphan-opp # lista opps sin contacto
python scripts/audit_brand_contact_opportunity_map.py --all # muestra todo
"""
import argparse
import os
import sys
import sqlite3
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)
from paths import DB_PATH
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
# ---------------------------------------------------------------------------
# Utilidades
# ---------------------------------------------------------------------------
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 normalize_text(text):
if not text:
return ""
nfkd = unicodedata.normalize("NFD", str(text))
clean = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
return " ".join(clean.lower().split())
def normalize_phone(phone):
return re.sub(r"\D+", "", str(phone)) if phone else ""
def fmt_contact(c):
name = f"{c.get('first_name') or ''} {c.get('last_name') or ''}".strip() or "Sin nombre"
phone = c.get("phone") or "—"
email = c.get("email") or "—"
return name, phone, email
def status_label(s):
return (s or "open").upper()
# ---------------------------------------------------------------------------
# Carga de datos
# ---------------------------------------------------------------------------
def load_data(conn):
contacts = {
row["id"]: dict(row)
for row in conn.execute(
"SELECT * FROM contacts WHERE location_id = ?", (BRAND_LOCATION_ID,)
).fetchall()
}
opps = 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
WHERE o.location_id = ?
""", (BRAND_LOCATION_ID,)).fetchall()
opps = [dict(row) for row in opps]
return contacts, opps
# ---------------------------------------------------------------------------
# Análisis
# ---------------------------------------------------------------------------
def analyze(contacts, opps):
# Agrupar oportunidades por contact_id
opps_by_contact = {}
orphan_opps = [] # opp cuyo contact_id no existe en la BD de Marca
for o in opps:
cid = o.get("contact_id")
if not cid or cid not in contacts:
orphan_opps.append(o)
else:
opps_by_contact.setdefault(cid, []).append(o)
# Clasificar contactos
no_opp = [] # 0 oportunidades
one_opp = [] # exactamente 1
multi_opp = [] # 2 o más
for cid, c in contacts.items():
c_opps = opps_by_contact.get(cid, [])
if len(c_opps) == 0:
no_opp.append(c)
elif len(c_opps) == 1:
one_opp.append((c, c_opps[0]))
else:
multi_opp.append((c, c_opps))
# Conteo de estados entre todas las opps válidas
status_count = {}
pipeline_count = {}
for o in opps:
if o not in orphan_opps:
s = status_label(o.get("status"))
status_count[s] = status_count.get(s, 0) + 1
pl = o.get("pipeline_name") or "Sin Pipeline"
pipeline_count[pl] = pipeline_count.get(pl, 0) + 1
return {
"contacts_total": len(contacts),
"opps_total": len(opps),
"no_opp": no_opp,
"one_opp": one_opp,
"multi_opp": multi_opp,
"orphan_opps": orphan_opps,
"status_count": status_count,
"pipeline_count": pipeline_count,
"opps_by_contact": opps_by_contact,
}
# ---------------------------------------------------------------------------
# Impresión
# ---------------------------------------------------------------------------
def print_summary(r):
W = 72
total_c = r["contacts_total"]
total_o = r["opps_total"]
no_opp = r["no_opp"]
one_opp = r["one_opp"]
multi_opp = r["multi_opp"]
orphans = r["orphan_opps"]
gap = total_c - total_o
safe_print("\n" + "=" * W)
safe_print("MAPA CONTACTO <-> OPORTUNIDAD | CUENTA DE MARCA PRINCIPAL")
safe_print("=" * W)
# Números brutos
safe_print(f"\n Contactos en Marca: {total_c:>5}")
safe_print(f" Oportunidades en Marca: {total_o:>5}")
safe_print(f" Diferencia (contactos - opps): {gap:>+5}")
# Relación 1:N
safe_print(f"\n {'─' * 60}")
safe_print(f" Contactos con 0 oportunidades: {len(no_opp):>5} {'<-- SIN OPP' if no_opp else ''}")
safe_print(f" Contactos con 1 oportunidad: {len(one_opp):>5} (estado normal)")
safe_print(f" Contactos con 2+ oportunidades: {len(multi_opp):>5} {'<-- REVISAR' if multi_opp else ''}")
safe_print(f" Oportunidades sin contacto (BD): {len(orphans):>5} {'<-- HUERFANAS' if orphans else ''}")
# Desglose de opps válidas por estado
if r["status_count"]:
safe_print(f"\n {'─' * 60}")
safe_print(" Oportunidades por Estado:")
for s, cnt in sorted(r["status_count"].items(), key=lambda x: -x[1]):
bar = "#" * min(cnt, 40)
safe_print(f" {s:<14} {cnt:>4} {bar}")
# Desglose por pipeline
if r["pipeline_count"]:
safe_print(f"\n {'─' * 60}")
safe_print(" Oportunidades por Pipeline:")
for pl, cnt in sorted(r["pipeline_count"].items(), key=lambda x: -x[1]):
safe_print(f" {pl:<46} {cnt:>4}")
# Multi-opp: resumen rápido de cuántas opps tienen
if multi_opp:
from collections import Counter
dist = Counter(len(opps) for _, opps in multi_opp)
safe_print(f"\n {'─' * 60}")
safe_print(" Distribucion de contactos con 2+ opps:")
for n, cnt in sorted(dist.items()):
safe_print(f" {n} oportunidades: {cnt} contactos")
safe_print("\n" + "=" * W + "\n")
def print_no_opp(no_opp, limit=200):
W = 90
safe_print(f"\n{'=' * W}")
safe_print(f"CONTACTOS SIN OPORTUNIDAD ({len(no_opp)} total)")
safe_print(f"{'=' * W}")
for i, c in enumerate(sorted(no_opp, key=lambda x: normalize_text(f"{x.get('first_name','')} {x.get('last_name','')}")), 1):
name, phone, email = fmt_contact(c)
safe_print(f" {i:03d}. {name:<40} Tel: {phone:<18} Email: {email}")
if i >= limit:
safe_print(f" ... [{len(no_opp) - limit} mas omitidos]")
break
safe_print("=" * W + "\n")
def print_multi_opp(multi_opp, limit=100):
W = 90
safe_print(f"\n{'=' * W}")
safe_print(f"CONTACTOS CON 2+ OPORTUNIDADES ({len(multi_opp)} total)")
safe_print(f"{'=' * W}")
shown = 0
for c, opps in sorted(multi_opp, key=lambda x: -len(x[1])):
name, phone, email = fmt_contact(c)
safe_print(f"\n {name} | Tel: {phone} | Email: {email}")
safe_print(f" ID Contacto: {c['id']} | Oportunidades: {len(opps)}")
for o in sorted(opps, key=lambda x: x.get("date_added") or ""):
val = f"${(o.get('monetary_value') or 0):,.0f}"
safe_print(f" - [{status_label(o.get('status')):<10}] {(o.get('name') or 'Sin nombre'):<40} {val:>10} (ID: {o['id']})")
shown += 1
if shown >= limit:
safe_print(f"\n ... [{len(multi_opp) - limit} contactos mas omitidos]")
break
safe_print("\n" + "=" * W + "\n")
def print_orphan_opps(orphan_opps, limit=100):
W = 90
safe_print(f"\n{'=' * W}")
safe_print(f"OPORTUNIDADES SIN CONTACTO EN BD ({len(orphan_opps)} total)")
safe_print(f"{'=' * W}")
for i, o in enumerate(orphan_opps, 1):
val = f"${(o.get('monetary_value') or 0):,.0f}"
safe_print(f" {i:03d}. [{status_label(o.get('status')):<10}] {(o.get('name') or 'Sin nombre'):<40} {val:>10}")
safe_print(f" Contact ID: {o.get('contact_id') or '—'} | Opp ID: {o['id']}")
if i >= limit:
safe_print(f" ... [{len(orphan_opps) - limit} mas omitidos]")
break
safe_print("=" * W + "\n")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Mapea contactos vs oportunidades en Marca y detecta discrepancias."
)
parser.add_argument("--show-no-opp", action="store_true", help="Lista contactos sin oportunidad.")
parser.add_argument("--show-multi-opp", action="store_true", help="Lista contactos con 2+ oportunidades.")
parser.add_argument("--show-orphan-opp", action="store_true", help="Lista oportunidades sin contacto en la BD.")
parser.add_argument("--all", action="store_true", help="Muestra todos los detalles.")
parser.add_argument("--limit", type=int, default=200,
help="Max de filas en listados detallados. Default: 200.")
args = parser.parse_args()
if not os.path.exists(DB_PATH):
safe_print(f"Error: DB no encontrada en {DB_PATH}.")
sys.exit(1)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
contacts, opps = load_data(conn)
r = analyze(contacts, opps)
print_summary(r)
show_no_opp = args.all or args.show_no_opp
show_multi_opp = args.all or args.show_multi_opp
show_orphan_opp = args.all or args.show_orphan_opp
if show_no_opp and r["no_opp"]:
print_no_opp(r["no_opp"], limit=args.limit)
if show_multi_opp and r["multi_opp"]:
print_multi_opp(r["multi_opp"], limit=args.limit)
if show_orphan_opp and r["orphan_opps"]:
print_orphan_opps(r["orphan_opps"], limit=args.limit)
if not (show_no_opp or show_multi_opp or show_orphan_opp):
safe_print(" Tip: agrega --show-no-opp, --show-multi-opp, --show-orphan-opp o --all para ver el detalle.\n")
finally:
conn.close()
if __name__ == "__main__":
main()