477 lines
20 KiB
Python
477 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
GHL Analytics - Parallel branch analysis for Monte Providencia
|
|
Enhanced with custom field validation.
|
|
|
|
Usage: python3 ghl_branch_analysis.py [--location LOC_ID] [--brief] [--full]
|
|
|
|
Analyzes per branch:
|
|
- Contact source distribution (SUCURSAL, Formulario, FACEBOOK, etc.)
|
|
- Canal de Origen (contact & opportunity) — validates against expected values
|
|
- Fuente de Prospecto (contact & opportunity) — validates against expected values
|
|
- Contact / Opportunity counts and status breakdown
|
|
"""
|
|
|
|
import json
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import requests
|
|
|
|
# Set project root in sys.path
|
|
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
|
|
|
|
# ─── Valid values for custom fields ───────────────────────────────
|
|
VALID_CONTACT_CANAL = {"FACEBOOK", "SUCURSAL", "FORMULARIO"}
|
|
VALID_CONTACT_FUENTE = {"SUCURSAL", "LEAD DIGITAL"}
|
|
VALID_OPP_CANAL = {"Facebook", "Sucursal", "Formulario"}
|
|
VALID_OPP_FUENTE = {"SUCURSAL", "LEAD DIGITAL"}
|
|
|
|
# Credentials are dynamically loaded from sync_engine (Bucéfalo - Mesa de control - API Tokens - MP.csv)
|
|
|
|
# ─── Field name candidates in different locations ─────────────────
|
|
OPP_CANAL_NAMES = ["CANAL DE ORIGEN", "Canal de Origen de la Oportunidad", "Canal de Origen"]
|
|
OPP_FUENTE_NAMES = ["Fuente de Prospecto", "Fuente del Prospecto", "FUENTE DE PROSPECTO"]
|
|
|
|
|
|
# ─── API Helpers ──────────────────────────────────────────────────
|
|
|
|
def get_object_schema(loc_id, token, obj_key):
|
|
"""GET /objects/ then /objects/{key}?locationId= -> {fieldName: fieldId}"""
|
|
try:
|
|
headers = {
|
|
'Accept': 'application/json',
|
|
'Authorization': f'Bearer {token}',
|
|
'Version': '2021-04-15'
|
|
}
|
|
params = {"locationId": loc_id}
|
|
catalog_url = "https://services.leadconnectorhq.com/objects/"
|
|
requests.get(catalog_url, headers=headers, params=params, timeout=30)
|
|
url = f"https://services.leadconnectorhq.com/objects/{obj_key}"
|
|
res = requests.get(url, headers=headers, params=params, timeout=30)
|
|
if res.status_code != 200:
|
|
return {}
|
|
data = res.json()
|
|
if 'error' in data:
|
|
return {}
|
|
return {f['name']: f['id'] for f in (data.get('fields') or [])}
|
|
except Exception as e:
|
|
return {}
|
|
|
|
|
|
def get_contacts(loc_id, token):
|
|
"""POST /contacts/search → list of contacts with customFields (no additionalFields needed)"""
|
|
try:
|
|
headers = {
|
|
'Accept': 'application/json',
|
|
'Authorization': f'Bearer {token}',
|
|
'Version': '2021-07-28',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
url = "https://services.leadconnectorhq.com/contacts/search"
|
|
body = {"locationId": loc_id, "pageLimit": 500}
|
|
res = requests.post(url, headers=headers, json=body, timeout=30)
|
|
if res.status_code not in (200, 201):
|
|
return []
|
|
data = res.json()
|
|
return data.get('contacts') or []
|
|
except Exception as e:
|
|
return []
|
|
|
|
|
|
def get_opportunities(loc_id, token):
|
|
"""POST /opportunities/search → list of all opportunities with customFields"""
|
|
try:
|
|
headers = {
|
|
'Accept': 'application/json',
|
|
'Authorization': f'Bearer {token}',
|
|
'Version': '2021-07-28',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
url = "https://services.leadconnectorhq.com/opportunities/search"
|
|
body = {"locationId": loc_id, "limit": 100}
|
|
res = requests.post(url, headers=headers, json=body, timeout=30)
|
|
if res.status_code not in (200, 201):
|
|
return []
|
|
data = res.json()
|
|
return data.get('opportunities') or []
|
|
except Exception as e:
|
|
return []
|
|
|
|
|
|
def get_field_value(obj, field_id):
|
|
"""
|
|
Extract a custom field value from a contact or opportunity.
|
|
Contacts use 'value', opportunities use 'fieldValueString' (or 'value').
|
|
"""
|
|
custom_fields = obj.get('customFields') or []
|
|
for cf in custom_fields:
|
|
if cf.get('id') == field_id:
|
|
val = cf.get('fieldValueString') or cf.get('value')
|
|
return val if val else None
|
|
return None
|
|
|
|
|
|
def find_field_id(schema, names):
|
|
"""Try multiple field names in schema, return (name, id) or (None, None)."""
|
|
for name in names:
|
|
fid = schema.get(name)
|
|
if fid:
|
|
return name, fid
|
|
return None, None
|
|
|
|
|
|
# ─── Analysis ─────────────────────────────────────────────────────
|
|
|
|
def analyze_location(loc_id, token, name, brief=False):
|
|
"""
|
|
Full analysis of a single location:
|
|
- Contacts: count, source distribution, Canal de Origen, Fuente de Prospecto
|
|
- Opportunities: count, status, CANAL DE ORIGEN, Fuente de Prospecto
|
|
- Validation: flag invalid values
|
|
"""
|
|
try:
|
|
# ── Schemas ──
|
|
contact_schema = get_object_schema(loc_id, token, "contact")
|
|
opp_schema = get_object_schema(loc_id, token, "opportunity")
|
|
|
|
c_canal_id = contact_schema.get("Canal de Origen")
|
|
c_fuente_id = contact_schema.get("Fuente de Prospecto")
|
|
|
|
opp_canal_name, o_canal_id = find_field_id(opp_schema, OPP_CANAL_NAMES)
|
|
opp_fuente_name, o_fuente_id = find_field_id(opp_schema, OPP_FUENTE_NAMES)
|
|
|
|
# ── Data ──
|
|
contacts = get_contacts(loc_id, token)
|
|
opportunities = get_opportunities(loc_id, token)
|
|
|
|
# ── Contact analysis ──
|
|
total_contacts = len(contacts)
|
|
|
|
# Source distribution
|
|
source_dist = {}
|
|
for c in contacts:
|
|
src = c.get('source') or '(none)'
|
|
source_dist[src] = source_dist.get(src, 0) + 1
|
|
|
|
# Contact Canal de Origen
|
|
c_canal_valid = 0
|
|
c_canal_invalid = 0
|
|
c_canal_empty = 0
|
|
c_canal_dist = {}
|
|
if c_canal_id:
|
|
for c in contacts:
|
|
val = get_field_value(c, c_canal_id)
|
|
if not val:
|
|
c_canal_empty += 1
|
|
elif val in VALID_CONTACT_CANAL:
|
|
c_canal_valid += 1
|
|
c_canal_dist[val] = c_canal_dist.get(val, 0) + 1
|
|
else:
|
|
c_canal_invalid += 1
|
|
c_canal_dist[val] = c_canal_dist.get(val, 0) + 1
|
|
|
|
# Contact Fuente de Prospecto
|
|
c_fuente_valid = 0
|
|
c_fuente_invalid = 0
|
|
c_fuente_empty = 0
|
|
c_fuente_dist = {}
|
|
if c_fuente_id:
|
|
for c in contacts:
|
|
val = get_field_value(c, c_fuente_id)
|
|
if not val:
|
|
c_fuente_empty += 1
|
|
elif val in VALID_CONTACT_FUENTE:
|
|
c_fuente_valid += 1
|
|
c_fuente_dist[val] = c_fuente_dist.get(val, 0) + 1
|
|
else:
|
|
c_fuente_invalid += 1
|
|
c_fuente_dist[val] = c_fuente_dist.get(val, 0) + 1
|
|
|
|
# ── Opportunity analysis ──
|
|
total_opps = len(opportunities)
|
|
opp_by_status = {}
|
|
for o in opportunities:
|
|
s = o.get('status', 'unknown')
|
|
opp_by_status[s] = opp_by_status.get(s, 0) + 1
|
|
|
|
# Opp CANAL DE ORIGEN
|
|
o_canal_valid = 0
|
|
o_canal_invalid = 0
|
|
o_canal_empty = 0
|
|
o_canal_dist = {}
|
|
if o_canal_id:
|
|
for o in opportunities:
|
|
val = get_field_value(o, o_canal_id)
|
|
if not val:
|
|
o_canal_empty += 1
|
|
elif val in VALID_OPP_CANAL:
|
|
o_canal_valid += 1
|
|
o_canal_dist[val] = o_canal_dist.get(val, 0) + 1
|
|
else:
|
|
o_canal_invalid += 1
|
|
o_canal_dist[val] = o_canal_dist.get(val, 0) + 1
|
|
|
|
# Opp Fuente de Prospecto
|
|
o_fuente_valid = 0
|
|
o_fuente_invalid = 0
|
|
o_fuente_empty = 0
|
|
o_fuente_dist = {}
|
|
if o_fuente_id:
|
|
for o in opportunities:
|
|
val = get_field_value(o, o_fuente_id)
|
|
if not val:
|
|
o_fuente_empty += 1
|
|
elif val in VALID_OPP_FUENTE:
|
|
o_fuente_valid += 1
|
|
o_fuente_dist[val] = o_fuente_dist.get(val, 0) + 1
|
|
else:
|
|
o_fuente_invalid += 1
|
|
o_fuente_dist[val] = o_fuente_dist.get(val, 0) + 1
|
|
|
|
# Contacts with / without opportunities (embedded in contact data)
|
|
contacts_with_opps = 0
|
|
embedded_opps = 0
|
|
for c in contacts:
|
|
opps = c.get('opportunities') or []
|
|
if opps:
|
|
contacts_with_opps += 1
|
|
embedded_opps += len(opps)
|
|
|
|
if brief:
|
|
return {
|
|
'name': name,
|
|
'contacts': total_contacts,
|
|
'opportunities': total_opps,
|
|
'open': opp_by_status.get('open', 0),
|
|
'won': opp_by_status.get('won', 0),
|
|
'lost': opp_by_status.get('lost', 0),
|
|
'abandoned': opp_by_status.get('abandoned', 0),
|
|
'c_canal_invalid': c_canal_invalid + c_canal_empty,
|
|
'c_fuente_invalid': c_fuente_invalid + c_fuente_empty,
|
|
'o_canal_invalid': o_canal_invalid + o_canal_empty,
|
|
'o_fuente_invalid': o_fuente_invalid + o_fuente_empty,
|
|
}
|
|
|
|
return {
|
|
'name': name,
|
|
'locId': loc_id,
|
|
'contacts': total_contacts,
|
|
'contactsWithOpps': contacts_with_opps,
|
|
'noOpps': total_contacts - contacts_with_opps,
|
|
'embeddedOpps': embedded_opps,
|
|
'opportunities': total_opps,
|
|
'byStatus': opp_by_status,
|
|
'sourceDist': source_dist,
|
|
'cCanal': {'dist': c_canal_dist, 'valid': c_canal_valid, 'invalid': c_canal_invalid, 'empty': c_canal_empty},
|
|
'cFuente': {'dist': c_fuente_dist, 'valid': c_fuente_valid, 'invalid': c_fuente_invalid, 'empty': c_fuente_empty},
|
|
'oCanal': {'dist': o_canal_dist, 'valid': o_canal_valid, 'invalid': o_canal_invalid, 'empty': o_canal_empty},
|
|
'oFuente': {'dist': o_fuente_dist, 'valid': o_fuente_valid, 'invalid': o_fuente_invalid, 'empty': o_fuente_empty},
|
|
'schemas': {
|
|
'c_canal_id': c_canal_id,
|
|
'c_fuente_id': c_fuente_id,
|
|
'o_canal_id': o_canal_id,
|
|
'o_fuente_id': o_fuente_id,
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
return {'name': name, 'locId': loc_id, 'error': str(e)}
|
|
|
|
|
|
# ─── Output Formatters ────────────────────────────────────────────
|
|
|
|
def print_brief(results):
|
|
"""Print summary table with validation columns."""
|
|
totals = {'contacts': 0, 'opportunities': 0, 'open': 0, 'won': 0, 'lost': 0,
|
|
'abandoned': 0, 'c_canal_invalid': 0, 'c_fuente_invalid': 0,
|
|
'o_canal_invalid': 0, 'o_fuente_invalid': 0}
|
|
|
|
print("\n" + "=" * 120)
|
|
print("GHL ANALYTICS - MONTE PROVIDENCIA — FIELD VALIDATION")
|
|
print("=" * 120)
|
|
|
|
header = (f"\n{'Sucursal':<20} {'Cont':>5} {'Opps':>5} {'Abrt':>5} {'Gand':>5} "
|
|
f"{'Diff':>5} {'❌C.Cnl':>7} {'❌C.Fte':>7} {'❌O.Cnl':>7} {'❌O.Fte':>7}")
|
|
print(header)
|
|
print("-" * 120)
|
|
|
|
for r in sorted(results, key=lambda x: x.get('name', '')):
|
|
diff = r.get('opportunities', 0) - r.get('contacts', 0)
|
|
diff_str = f"+{diff}" if diff > 0 else str(diff)
|
|
|
|
cci = r.get('c_canal_invalid', 0)
|
|
cfi = r.get('c_fuente_invalid', 0)
|
|
oci = r.get('o_canal_invalid', 0)
|
|
ofi = r.get('o_fuente_invalid', 0)
|
|
|
|
# Highlight branches with issues
|
|
flag = " ⚠️" if (cci + cfi + oci + ofi) > 0 else ""
|
|
|
|
print(f"{r['name']:<20} {r['contacts']:>5} {r['opportunities']:>5} "
|
|
f"{r.get('open', 0):>5} {r.get('won', 0):>5} {diff_str:>5} "
|
|
f"{cci:>7} {cfi:>7} {oci:>7} {ofi:>7}{flag}")
|
|
|
|
for k in totals:
|
|
totals[k] += r.get(k, 0)
|
|
|
|
print("-" * 120)
|
|
total_diff = totals['opportunities'] - totals['contacts']
|
|
diff_str = f"+{total_diff}" if total_diff > 0 else str(total_diff)
|
|
print(f"{'TOTAL':<20} {totals['contacts']:>5} {totals['opportunities']:>5} "
|
|
f"{totals['open']:>5} {totals['won']:>5} {diff_str:>5} "
|
|
f"{totals['c_canal_invalid']:>7} {totals['c_fuente_invalid']:>7} "
|
|
f"{totals['o_canal_invalid']:>7} {totals['o_fuente_invalid']:>7}")
|
|
|
|
print(f"\n ❌C.Cnl = Contactos con Canal de Origen inválido/vacío")
|
|
print(f" ❌C.Fte = Contactos con Fuente de Prospecto inválido/vacío")
|
|
print(f" ❌O.Cnl = Oportunidades con CANAL DE ORIGEN inválido/vacío")
|
|
print(f" ❌O.Fte = Oportunidades con Fuente de Prospecto inválido/vacío")
|
|
print(f"\n Status: Abiertos={totals['open']} | Ganados={totals['won']} | "
|
|
f"Perdidos={totals['lost']} | Abandonados={totals['abandoned']}")
|
|
|
|
|
|
def print_full(results):
|
|
"""Print full detailed report per branch."""
|
|
print("\n" + "=" * 80)
|
|
print("GHL ANALYTICS - MONTE PROVIDENCIA — FULL REPORT")
|
|
print("=" * 80)
|
|
|
|
totals = {'contacts': 0, 'opps': 0, 'open': 0, 'won': 0, 'lost': 0, 'abandoned': 0}
|
|
|
|
for r in sorted(results, key=lambda x: x.get('name', '')):
|
|
if 'error' in r:
|
|
print(f"\n❌ {r['name']}: ERROR - {r['error']}")
|
|
continue
|
|
|
|
s = r.get('byStatus', {})
|
|
src = r.get('sourceDist', {})
|
|
ccan = r.get('cCanal', {})
|
|
cfue = r.get('cFuente', {})
|
|
ocan = r.get('oCanal', {})
|
|
ofue = r.get('oFuente', {})
|
|
|
|
print(f"\n{'─' * 80}")
|
|
print(f"📊 {r['name']} ({r['locId']})")
|
|
print(f"{'─' * 80}")
|
|
|
|
# Counts
|
|
print(f" Contactos: {r['contacts']} | Con opps: {r['contactsWithOpps']} | Sin opps: {r['noOpps']}")
|
|
print(f" Oportunidades: {r['opportunities']} (embebidas en contacts: {r.get('embeddedOpps', '?')})")
|
|
print(f" Status: Abiertos={s.get('open', 0)} | Ganados={s.get('won', 0)} | "
|
|
f"Perdidos={s.get('lost', 0)} | Abandonados={s.get('abandoned', 0)}")
|
|
|
|
# Source
|
|
if src:
|
|
print(f"\n 📋 Contact Source:")
|
|
for v, c in sorted(src.items(), key=lambda x: -x[1]):
|
|
bar = "█" * min(c, 50)
|
|
print(f" {v:<15} {c:>4} {bar}")
|
|
|
|
# Contact Canal de Origen
|
|
if ccan:
|
|
print(f"\n 🔵 Contacto — Canal de Origen (válidos: {', '.join(sorted(VALID_CONTACT_CANAL))}):")
|
|
print(f" ✅ Válidos: {ccan.get('valid', 0)} | ❌ Inválidos: {ccan.get('invalid', 0)} | ⚪ Vacíos: {ccan.get('empty', 0)}")
|
|
for v, c in sorted(ccan.get('dist', {}).items(), key=lambda x: -x[1]):
|
|
valid_mark = "✅" if v in VALID_CONTACT_CANAL else "❌"
|
|
print(f" {valid_mark} {v:<20} {c:>4}")
|
|
|
|
# Contact Fuente de Prospecto
|
|
if cfue:
|
|
print(f"\n 🟢 Contacto — Fuente de Prospecto (válidos: {', '.join(sorted(VALID_CONTACT_FUENTE))}):")
|
|
print(f" ✅ Válidos: {cfue.get('valid', 0)} | ❌ Inválidos: {cfue.get('invalid', 0)} | ⚪ Vacíos: {cfue.get('empty', 0)}")
|
|
for v, c in sorted(cfue.get('dist', {}).items(), key=lambda x: -x[1]):
|
|
valid_mark = "✅" if v in VALID_CONTACT_FUENTE else "❌"
|
|
print(f" {valid_mark} {v:<20} {c:>4}")
|
|
|
|
# Opp CANAL DE ORIGEN
|
|
if ocan:
|
|
print(f"\n 🔵 Oportunidad — CANAL DE ORIGEN (válidos: {', '.join(sorted(VALID_OPP_CANAL))}):")
|
|
print(f" ✅ Válidos: {ocan.get('valid', 0)} | ❌ Inválidos: {ocan.get('invalid', 0)} | ⚪ Vacíos: {ocan.get('empty', 0)}")
|
|
for v, c in sorted(ocan.get('dist', {}).items(), key=lambda x: -x[1]):
|
|
valid_mark = "✅" if v in VALID_OPP_CANAL else "❌"
|
|
print(f" {valid_mark} {v:<20} {c:>4}")
|
|
|
|
# Opp Fuente de Prospecto
|
|
if ofue:
|
|
print(f"\n 🟢 Oportunidad — Fuente de Prospecto (válidos: {', '.join(sorted(VALID_OPP_FUENTE))}):")
|
|
print(f" ✅ Válidos: {ofue.get('valid', 0)} | ❌ Inválidos: {ofue.get('invalid', 0)} | ⚪ Vacíos: {ofue.get('empty', 0)}")
|
|
for v, c in sorted(ofue.get('dist', {}).items(), key=lambda x: -x[1]):
|
|
valid_mark = "✅" if v in VALID_OPP_FUENTE else "❌"
|
|
print(f" {valid_mark} {v:<20} {c:>4}")
|
|
|
|
# Totals
|
|
totals['contacts'] += r['contacts']
|
|
totals['opps'] += r['opportunities']
|
|
totals['open'] += s.get('open', 0)
|
|
totals['won'] += s.get('won', 0)
|
|
totals['lost'] += s.get('lost', 0)
|
|
totals['abandoned'] += s.get('abandoned', 0)
|
|
|
|
print(f"\n{'=' * 80}")
|
|
print(f"TOTALES: {totals['contacts']} contactos | {totals['opps']} oportunidades")
|
|
print(f"Abiertos: {totals['open']} | Ganados: {totals['won']} | Perdidos: {totals['lost']} | Abandonados: {totals['abandoned']}")
|
|
|
|
|
|
# ─── Main ─────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
# Configure stdout to use UTF-8 encoding to prevent UnicodeEncodeError on Windows
|
|
if sys.platform == "win32":
|
|
try:
|
|
sys.stdout.reconfigure(encoding='utf-8')
|
|
except AttributeError:
|
|
pass
|
|
|
|
parser = argparse.ArgumentParser(description='GHL Analytics - Branch analysis with field validation')
|
|
parser.add_argument('--location', help='Specific location ID to analyze')
|
|
parser.add_argument('--brief', action='store_true', help='Brief output (table with validation flags)')
|
|
args = parser.parse_args()
|
|
|
|
# Load dynamic credentials from sync_engine
|
|
try:
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
except Exception as e:
|
|
print(f"Error loading credentials from CSV: {e}")
|
|
sys.exit(1)
|
|
|
|
main_acc = next((acc for acc in accounts if acc["location_id"] == "GbKkBpCmKu2QmloKFHy3"), None)
|
|
branches = [acc for acc in accounts if acc["location_id"] != "GbKkBpCmKu2QmloKFHy3"]
|
|
all_branches = branches + ([main_acc] if main_acc else [])
|
|
|
|
if args.location:
|
|
loc = next((acc for acc in all_branches if acc["location_id"] == args.location), None)
|
|
if loc:
|
|
r = analyze_location(loc["location_id"], loc["token"], loc["nombre"], brief=args.brief)
|
|
if args.brief:
|
|
print_brief([r])
|
|
else:
|
|
print_full([r])
|
|
else:
|
|
print(f"Location {args.location} not found")
|
|
else:
|
|
# All locations in parallel
|
|
if not branches:
|
|
print("No branch locations found in CSV to analyze.")
|
|
sys.exit(0)
|
|
|
|
print(" 🔍 Analyzing all branches in parallel (10 workers)...")
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = {
|
|
executor.submit(analyze_location, acc["location_id"], acc["token"], acc["nombre"], args.brief): acc
|
|
for acc in branches
|
|
}
|
|
results = []
|
|
for future in as_completed(futures):
|
|
results.append(future.result())
|
|
|
|
if args.brief:
|
|
print_brief(results)
|
|
else:
|
|
print_full(results)
|