243 lines
9.4 KiB
Python
243 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Corrige la incoherencia Canal de Origen / Fuente de Prospecto en contactos
|
|
y propaga a TODAS sus oportunidades asociadas.
|
|
|
|
Detecta por el estado ACTUAL de los campos (idempotente) dos patrones:
|
|
|
|
Patron A Canal de Origen = SUCURSAL & Fuente de Prospecto = LEAD DIGITAL
|
|
(viola AGENTS Cap.3: si Canal=SUCURSAL, Fuente no puede ser LEAD DIGITAL)
|
|
-> Contacto: Fuente de Prospecto = SUCURSAL (Canal se conserva)
|
|
-> Opps: Canal de Origen de la Oportunidad = Sucursal, Fuente = SUCURSAL
|
|
|
|
Patron B Fuente de Prospecto = REDES SOCIALES
|
|
(REDES SOCIALES es un canal digital mal clasificado como sucursal)
|
|
-> Contacto: Canal de Origen = FACEBOOK, Fuente de Prospecto = LEAD DIGITAL
|
|
-> Opps: Canal de Origen de la Oportunidad = Facebook,
|
|
Tipo de Lead = Lead digital, Fuente de Prospecto = LEAD DIGITAL
|
|
|
|
Decision validada con el usuario el 2026-05-30 (createdBy.source en vivo confirmo
|
|
que los 28 del Patron A son captura manual de sucursal -> WEB_USER/MOBILE_USER).
|
|
|
|
Dry-run por defecto. Con --apply --run-id registra cada cambio en script_audit
|
|
(reversible desde el dashboard). Resuelve los IDs de campo dinamicamente por nombre
|
|
(FIELD_ALIASES), nunca hardcodea. Sirve igual para sucursales y para Marca: como
|
|
la deteccion es por estado, correrlo en Marca arregla el panel directamente sin
|
|
depender de la replicacion.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from collections import Counter
|
|
|
|
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 script_audit # noqa: E402
|
|
import sync_engine # noqa: E402
|
|
import common # noqa: E402
|
|
|
|
from tag_canal_origen_workflow import ( # noqa: E402
|
|
contact_display_name,
|
|
get_all_contacts,
|
|
get_all_opportunities_for_contact,
|
|
get_custom_field_value,
|
|
get_opportunity,
|
|
get_schemas,
|
|
load_locations,
|
|
resolve_opp_field_ids,
|
|
safe_update_contact_field,
|
|
safe_update_opportunity_field,
|
|
)
|
|
|
|
SCRIPT_NAME = "fix_origen_fuente_incoherencia.py"
|
|
|
|
# Plan de correccion por patron (alineado a TAG_TO_OPP_UPDATES del workflow canonico).
|
|
PLAN = {
|
|
"A": {
|
|
"contact": {"Fuente de Prospecto": "SUCURSAL"},
|
|
"opp": {
|
|
"Canal de Origen de la Oportunidad": "Sucursal",
|
|
"Fuente de Prospecto": "SUCURSAL",
|
|
},
|
|
},
|
|
"B": {
|
|
"contact": {"Canal de Origen": "FACEBOOK", "Fuente de Prospecto": "LEAD DIGITAL"},
|
|
"opp": {
|
|
"Canal de Origen de la Oportunidad": "Facebook",
|
|
"Tipo de Lead": "Lead digital",
|
|
"Fuente de Prospecto": "LEAD DIGITAL",
|
|
},
|
|
},
|
|
}
|
|
|
|
# Mapeo display-name de opp -> alias key de common.FIELD_ALIASES (tolerante a variantes).
|
|
OPP_FIELD_ALIAS_KEY = {
|
|
"Canal de Origen de la Oportunidad": "canal_origen",
|
|
"Tipo de Lead": "tipo_lead",
|
|
"Fuente de Prospecto": "fuente_prospecto",
|
|
}
|
|
|
|
|
|
def resolve_contact_field_id(contact_schema, display_name):
|
|
"""contact_schema es {name: id}. Resuelve por FIELD_ALIASES, tolerante a mayusculas."""
|
|
alias_key = {
|
|
"Canal de Origen": "canal_origen",
|
|
"Fuente de Prospecto": "fuente_prospecto",
|
|
}.get(display_name, display_name)
|
|
candidates = common.FIELD_ALIASES.get(alias_key, [display_name])
|
|
norm_to_id = {common.normalize_name(n): i for n, i in (contact_schema or {}).items()}
|
|
for cand in candidates:
|
|
fid = norm_to_id.get(common.normalize_name(cand))
|
|
if fid:
|
|
return fid
|
|
return None
|
|
|
|
|
|
def resolve_opp_field_ids_alias(opp_fields_list, display_name):
|
|
alias_key = OPP_FIELD_ALIAS_KEY.get(display_name, display_name)
|
|
candidates = common.FIELD_ALIASES.get(alias_key, [display_name])
|
|
target_norm = {common.normalize_name(n) for n in candidates}
|
|
return [
|
|
f["id"]
|
|
for f in (opp_fields_list or [])
|
|
if f.get("name") and f.get("id") and common.normalize_name(f["name"]) in target_norm
|
|
]
|
|
|
|
|
|
def classify_patron(canal, fuente):
|
|
if canal == "SUCURSAL" and fuente == "LEAD DIGITAL":
|
|
return "A"
|
|
if fuente == "REDES SOCIALES":
|
|
return "B"
|
|
return None
|
|
|
|
|
|
def process_location(account, *, dry_run, run_id):
|
|
location_id = account["location_id"]
|
|
token = account["token"]
|
|
name = account["nombre"]
|
|
local = Counter()
|
|
|
|
contact_schema = get_schemas(location_id, token, "contact")["contact"]
|
|
canal_id = resolve_contact_field_id(contact_schema, "Canal de Origen")
|
|
fuente_id = resolve_contact_field_id(contact_schema, "Fuente de Prospecto")
|
|
if not canal_id or not fuente_id:
|
|
print(f" SKIP {name}: faltan campos de contacto (Canal={bool(canal_id)}, Fuente={bool(fuente_id)})")
|
|
return local
|
|
|
|
opp_fields_list = sync_engine.ghl_client.get_object_schema_fields(token, location_id, "opportunity")
|
|
|
|
contacts = get_all_contacts(location_id, token)
|
|
targets = []
|
|
for c in contacts:
|
|
canal = get_custom_field_value(c, canal_id)
|
|
fuente = get_custom_field_value(c, fuente_id)
|
|
patron = classify_patron(canal, fuente)
|
|
if patron:
|
|
targets.append((c, patron))
|
|
|
|
if not targets:
|
|
return local
|
|
|
|
print(f"\n{'='*70}\n{name} ({location_id}) - {len(targets)} contactos a corregir\n{'='*70}")
|
|
|
|
for contact, patron in targets:
|
|
if not script_audit.wait_if_paused_or_stopped(run_id):
|
|
print("\n Detencion segura solicitada. Saliendo antes del siguiente contacto.")
|
|
break
|
|
cid = contact["id"]
|
|
display = contact_display_name(contact)
|
|
local[f"patron_{patron}"] += 1
|
|
|
|
# --- Contacto ---
|
|
for field_name, value in PLAN[patron]["contact"].items():
|
|
fid = canal_id if field_name == "Canal de Origen" else fuente_id
|
|
if safe_update_contact_field(run_id, location_id, contact, fid, field_name, value, token, dry_run):
|
|
local["contact_fields"] += 1
|
|
print(f" [contacto {patron}] {display} | {field_name} -> {value}")
|
|
|
|
# --- Oportunidades asociadas (todas) ---
|
|
for opp_summary in get_all_opportunities_for_contact(location_id, cid, token):
|
|
opp_id = opp_summary.get("id")
|
|
if not opp_id:
|
|
continue
|
|
opp = get_opportunity(location_id, opp_id, token) or opp_summary
|
|
opp_touched = False
|
|
for field_name, value in PLAN[patron]["opp"].items():
|
|
for fid in resolve_opp_field_ids_alias(opp_fields_list, field_name) or resolve_opp_field_ids(opp_fields_list, field_name):
|
|
if safe_update_opportunity_field(run_id, location_id, opp_id, opp, fid, field_name, value, token, dry_run):
|
|
opp_touched = True
|
|
if opp_touched:
|
|
local["opps"] += 1
|
|
print(f" [opp {patron}] {opp_id} | {opp.get('name') or display}")
|
|
|
|
print(f" -> {name}: A={local['patron_A']} B={local['patron_B']} | "
|
|
f"campos contacto={local['contact_fields']}, opps={local['opps']}")
|
|
return local
|
|
|
|
|
|
def select_locations(args):
|
|
accounts = load_locations(include_main=True)
|
|
if args.location:
|
|
m = [a for a in accounts if a["location_id"] == args.location]
|
|
if not m:
|
|
raise SystemExit(f"Location {args.location} no esta en el CSV")
|
|
return m
|
|
if args.all:
|
|
return accounts
|
|
raise SystemExit("Especifica --location <id> o --all. Sin --apply corre en dry-run.")
|
|
|
|
|
|
def main():
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
parser = argparse.ArgumentParser(description="Corrige incoherencia Canal/Fuente + propaga a opps")
|
|
parser.add_argument("--location", help="Location ID especifico (sucursal o Marca)")
|
|
parser.add_argument("--all", action="store_true", help="Todas las cuentas del CSV (sucursales + Marca)")
|
|
parser.add_argument("--apply", action="store_true", help="Aplica en el CRM. Sin este flag corre en dry-run.")
|
|
parser.add_argument("--run-id", help="ID de auditoria (lo da el dashboard; en CLI se crea si se aplica)")
|
|
args = parser.parse_args()
|
|
|
|
dry_run = not args.apply
|
|
run_id = args.run_id
|
|
accounts = select_locations(args)
|
|
|
|
print("=" * 70)
|
|
print("FIX INCOHERENCIA ORIGEN / FUENTE (+ propagacion a oportunidades)")
|
|
print("=" * 70)
|
|
print(f"Modo: {'DRY-RUN (sin cambios)' if dry_run else 'APPLY (escribe en el CRM)'}")
|
|
print(f"Cuentas objetivo: {len(accounts)}")
|
|
|
|
if not dry_run and run_id:
|
|
script_audit.create_run(run_id, SCRIPT_NAME, arguments=" ".join(sys.argv[1:]),
|
|
locations=[a["location_id"] for a in accounts])
|
|
|
|
grand = Counter()
|
|
errors = 0
|
|
for account in accounts:
|
|
try:
|
|
for k, v in process_location(account, dry_run=dry_run, run_id=run_id).items():
|
|
grand[k] += v
|
|
except Exception as exc: # noqa: BLE001
|
|
errors += 1
|
|
print(f"\nERROR en {account['nombre']}: {exc}")
|
|
|
|
print("\n" + "=" * 70)
|
|
print(f"RESUMEN: Patron A={grand['patron_A']}, Patron B={grand['patron_B']}, "
|
|
f"campos de contacto={grand['contact_fields']}, opps tocadas={grand['opps']}, errores={errors}")
|
|
if dry_run:
|
|
print("Dry-run terminado. Revisa el plan y vuelve a correr con --apply --run-id <uuid>.")
|
|
|
|
if not dry_run and run_id:
|
|
script_audit.update_run_status(run_id, "failed" if errors else "success",
|
|
f"{errors} errores" if errors else None)
|
|
if errors:
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|