#!/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 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 .") 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()