descripción
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Investigacion read-only: opp de Marca hNPaauyuqM1Epycwj0wj (ERIKA RUBI CONCHA).
|
||||
Imprime CF resueltos, contactId, link a sucursal y busca el contacto en sucursales."""
|
||||
import os, sys
|
||||
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
|
||||
from scripts import common
|
||||
|
||||
gc = sync_engine.ghl_client
|
||||
BRAND = common.BRAND_LOCATION_ID
|
||||
OPP_ID = "hNPaauyuqM1Epycwj0wj"
|
||||
|
||||
accounts = {a["location_id"]: a for a in common.load_accounts()}
|
||||
brand_token = accounts[BRAND]["token"]
|
||||
|
||||
|
||||
def keymap(token, loc, obj):
|
||||
fields = gc.get_object_schema_fields(token, loc, obj)
|
||||
return {f["id"]: f.get("fieldKey") for f in fields if f.get("id")}
|
||||
|
||||
|
||||
def cfval(cf):
|
||||
for k in ("fieldValue", "fieldValueString", "value"):
|
||||
if cf.get(k) is not None:
|
||||
return cf[k]
|
||||
return None
|
||||
|
||||
|
||||
opp = (gc.get_opportunity(brand_token, OPP_ID) or {}).get("opportunity") or {}
|
||||
print("=== OPP MARCA ===")
|
||||
print("name:", opp.get("name"))
|
||||
print("status:", opp.get("status"), "value:", opp.get("monetaryValue"))
|
||||
print("contactId:", opp.get("contactId") or (opp.get("contact") or {}).get("id"))
|
||||
id2key = keymap(brand_token, BRAND, "opportunity")
|
||||
print("--- customFields (opp Marca) ---")
|
||||
link = None
|
||||
for cf in opp.get("customFields") or []:
|
||||
k = id2key.get(cf.get("id"))
|
||||
v = cfval(cf)
|
||||
print(f" {k} = {v!r}")
|
||||
if k == "opportunity.id_oportunidad_sucursal":
|
||||
link = v
|
||||
print("LINK id_oportunidad_sucursal:", link)
|
||||
|
||||
# contacto de Marca
|
||||
cid = opp.get("contactId") or (opp.get("contact") or {}).get("id")
|
||||
if cid:
|
||||
c = (gc._request("GET", f"/contacts/{cid}", brand_token) or {}).get("contact") or {}
|
||||
print("\n=== CONTACTO MARCA ===")
|
||||
print("name:", c.get("contactName") or f"{c.get('firstName')} {c.get('lastName')}")
|
||||
print("phone:", c.get("phone"), "email:", c.get("email"))
|
||||
ck = keymap(brand_token, BRAND, "contact")
|
||||
for cf in c.get("customFields") or []:
|
||||
k = ck.get(cf.get("id"))
|
||||
if k in ("contact.id_contacto_sucursal", "contact.sucursal", "contact.tienda",
|
||||
"contact.fuente_de_posible_cliente", "contact.fuente_de_prospecto"):
|
||||
print(f" {k} = {cf.get('value')!r}")
|
||||
|
||||
# buscar la opp link en cada sucursal para identificar la sucursal de origen
|
||||
if link:
|
||||
print("\n=== BUSCANDO opp", link, "EN SUCURSALES ===")
|
||||
for loc, acc in accounts.items():
|
||||
if loc == BRAND or acc.get("type") == "brand":
|
||||
continue
|
||||
try:
|
||||
bo = (gc.get_opportunity(acc["token"], link) or {}).get("opportunity") or {}
|
||||
except Exception:
|
||||
bo = {}
|
||||
if bo:
|
||||
print(f" ENCONTRADA en {acc['nombre']} ({loc})")
|
||||
bk = keymap(acc["token"], loc, "opportunity")
|
||||
for cf in bo.get("customFields") or []:
|
||||
k = bk.get(cf.get("id"))
|
||||
if k in ("opportunity.sucursal", "opportunity.tienda",
|
||||
"opportunity.fuente_de_posible_cliente", "opportunity.fuente_de_prospecto"):
|
||||
print(f" {k} = {cfval(cf)!r}")
|
||||
break
|
||||
else:
|
||||
print(" No encontrada por GET directo en ninguna sucursal.")
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read-only: clasifica la incoherencia Canal de Origen / Fuente de Prospecto.
|
||||
|
||||
Detecta en SUCURSALES los dos patrones incoherentes y, para cada contacto,
|
||||
lee ``createdBy.source`` EN VIVO (solo viene en el GET individual) para decidir
|
||||
la verdad del lead:
|
||||
|
||||
Patron A: Canal=SUCURSAL & Fuente=LEAD DIGITAL (viola AGENTS Cap.3)
|
||||
- createdBy in {WEB_USER, MOBILE_USER} -> manual sucursal -> arreglar FUENTE (->SUCURSAL)
|
||||
- otro (INTEGRATION/form/etc.) -> lead digital -> arreglar CANAL (->FORMULARIO/FACEBOOK)
|
||||
Patron B: Fuente=REDES SOCIALES -> lead digital -> Canal=FACEBOOK + Fuente=LEAD DIGITAL
|
||||
|
||||
No escribe nada. Imprime la particion y el plan por contacto.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
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 paths # noqa: E402
|
||||
from tag_canal_origen_workflow import ( # noqa: E402
|
||||
MAIN_LOCATION_ID,
|
||||
contact_display_name,
|
||||
ghl_request,
|
||||
load_locations,
|
||||
)
|
||||
from canal_origen_resolver import classify_source # noqa: E402
|
||||
|
||||
USER_SOURCES = {"WEB_USER", "MOBILE_USER"}
|
||||
|
||||
|
||||
def cfval(custom_fields, fid):
|
||||
for f in custom_fields or []:
|
||||
if f.get("id") == fid:
|
||||
for k in ("value", "fieldValue", "fieldValueString"):
|
||||
if f.get(k) is not None:
|
||||
return f[k]
|
||||
return None
|
||||
|
||||
|
||||
def field_maps(conn, location_id):
|
||||
m = {}
|
||||
for r in conn.execute(
|
||||
"select field_id, field_name from object_schemas where location_id=? and object_key='contact'",
|
||||
(location_id,),
|
||||
):
|
||||
m[r[1].strip().lower()] = r[0]
|
||||
return m
|
||||
|
||||
|
||||
def incoherent_from_cache(conn, location_id):
|
||||
"""Devuelve [(contact_id, patron, canal, fuente)] desde la cache."""
|
||||
fm = field_maps(conn, location_id)
|
||||
canal_id = fm.get("canal de origen")
|
||||
fuente_id = fm.get("fuente de prospecto")
|
||||
out = []
|
||||
for r in conn.execute(
|
||||
"select id, custom_fields_json from contacts where location_id=?",
|
||||
(location_id,),
|
||||
):
|
||||
cf = json.loads(r[1] or "[]")
|
||||
canal = cfval(cf, canal_id)
|
||||
fuente = cfval(cf, fuente_id)
|
||||
if canal == "SUCURSAL" and fuente == "LEAD DIGITAL":
|
||||
out.append((r[0], "A", canal, fuente))
|
||||
elif fuente == "REDES SOCIALES":
|
||||
out.append((r[0], "B", canal, fuente))
|
||||
return out
|
||||
|
||||
|
||||
def get_contact_full(contact_id, token):
|
||||
data = ghl_request("GET", f"/contacts/{contact_id}", token)
|
||||
inner = data.get("contact")
|
||||
return inner if isinstance(inner, dict) else data
|
||||
|
||||
|
||||
def main():
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Audit read-only de incoherencia origen/fuente")
|
||||
parser.add_argument("--all", action="store_true", help="Todas las sucursales productivas")
|
||||
parser.add_argument("--location", help="Una location especifica")
|
||||
args = parser.parse_args()
|
||||
|
||||
accounts = load_locations(include_main=False)
|
||||
accounts = [a for a in accounts if a["location_id"] != MAIN_LOCATION_ID]
|
||||
if args.location:
|
||||
accounts = [a for a in accounts if a["location_id"] == args.location]
|
||||
elif not args.all:
|
||||
raise SystemExit("Usa --all o --location <id>")
|
||||
|
||||
conn = sqlite3.connect(paths.DB_PATH)
|
||||
|
||||
grand = Counter()
|
||||
for acc in accounts:
|
||||
loc = acc["location_id"]
|
||||
token = acc["token"]
|
||||
targets = incoherent_from_cache(conn, loc)
|
||||
if not targets:
|
||||
continue
|
||||
print(f"\n{'='*70}\n{acc['nombre']} ({loc}) - {len(targets)} incoherentes\n{'='*70}")
|
||||
for cid, patron, canal, fuente in targets:
|
||||
full = get_contact_full(cid, token)
|
||||
created = (full.get("createdBy") or {}).get("source")
|
||||
src = full.get("source")
|
||||
name = contact_display_name(full)
|
||||
if patron == "B":
|
||||
plan = "Canal->FACEBOOK + Fuente->LEAD DIGITAL"
|
||||
bucket = "B_redes->digital"
|
||||
else:
|
||||
if created in USER_SOURCES:
|
||||
plan = "Fuente->SUCURSAL (manual sucursal)"
|
||||
bucket = "A_manual->fuente"
|
||||
else:
|
||||
# createdBy no-usuario => digital; canal segun source
|
||||
src_tag = classify_source(src)
|
||||
canal_target = "FACEBOOK" if src_tag == "facebook-ads" else "FORMULARIO"
|
||||
plan = f"Canal->{canal_target} (digital)"
|
||||
bucket = f"A_digital->canal({canal_target})"
|
||||
grand[bucket] += 1
|
||||
print(f" {name:35.35} | createdBy={created or '-':12} source={src or '-':12} | {plan}")
|
||||
|
||||
print(f"\n{'='*70}\nRESUMEN GLOBAL (plan)\n{'='*70}")
|
||||
for k, v in grand.most_common():
|
||||
print(f" {v:4} {k}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Backfill de custom fields descriptivos en una opp de MARCA que quedó "solo
|
||||
enlace" (solo `opportunity.id_oportunidad_sucursal`), tomando los valores de la
|
||||
opp de sucursal enlazada y, como respaldo, del CONTACTO de sucursal.
|
||||
|
||||
Caso de origen: la opp de Marca `8HITkGkOn3gN23Tl8LBr` (Miguel Temixco) quedó sin
|
||||
Sucursal / TIENDA / Canal de Origen / Vehículo. El nodo de réplica las copia por
|
||||
`fieldKey`, pero esta opp se creó fuera del flujo normal. Mismo `fieldKey`
|
||||
canónico (sufijo) en contact y opportunity.
|
||||
|
||||
Prioridad por campo: (a) valor en la opp de sucursal → (b) valor en el contacto
|
||||
de sucursal. Solo rellena campos que estén VACÍOS en la opp de Marca (no
|
||||
sobreescribe). dry-run por defecto; snapshot + script_audit para rollback.
|
||||
|
||||
Uso:
|
||||
python scripts/backfill_brand_opp_cf_from_source.py \\
|
||||
--brand-opp-id 8HITkGkOn3gN23Tl8LBr --branch-location-id yjqKxoO02rsdwdJZSPmD
|
||||
# añade --apply para escribir en Bucéfalo
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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
|
||||
from scripts import common # noqa: E402
|
||||
from paths import MIGRATIONS_DIR # noqa: E402
|
||||
|
||||
gc = sync_engine.ghl_client
|
||||
BRAND_LOCATION_ID = common.BRAND_LOCATION_ID
|
||||
LINK_KEY = "opportunity.id_oportunidad_sucursal"
|
||||
|
||||
# (opp fieldKey canónico, contact fieldKey de respaldo o None).
|
||||
TARGETS = [
|
||||
("opportunity.sucursal", "contact.sucursal"),
|
||||
("opportunity.tienda", "contact.tienda"),
|
||||
("opportunity.fuente_de_posible_cliente", "contact.fuente_de_posible_cliente"), # CANAL DE ORIGEN
|
||||
("opportunity.fuente_de_prospecto", "contact.fuente_de_prospecto"),
|
||||
("opportunity.vehiculo", None), # el contacto guarda marca/versión/año por separado
|
||||
]
|
||||
|
||||
|
||||
def clean(v):
|
||||
return "" if v is None else str(v).strip()
|
||||
|
||||
|
||||
def opp_cf_value(cf):
|
||||
for k in ("fieldValue", "fieldValueString", "value"):
|
||||
if cf.get(k) is not None:
|
||||
return cf[k]
|
||||
return None
|
||||
|
||||
|
||||
def schema_key_by_id(token, location_id, object_key):
|
||||
"""id -> fieldKey usando el schema dinámico de la location."""
|
||||
fields = gc.get_object_schema_fields(token, location_id, object_key)
|
||||
return {f["id"]: f.get("fieldKey") for f in fields if f.get("id")}, \
|
||||
{f.get("fieldKey"): f for f in fields if f.get("fieldKey")}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Backfill CF descriptivos en opp de Marca desde la opp/contacto de sucursal.")
|
||||
ap.add_argument("--brand-opp-id", required=True)
|
||||
ap.add_argument("--branch-location-id", required=True)
|
||||
ap.add_argument("--brand-location-id", default=BRAND_LOCATION_ID)
|
||||
ap.add_argument("--apply", action="store_true")
|
||||
ap.add_argument("--run-id", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
accounts = {a["location_id"]: a for a in common.load_accounts()}
|
||||
brand = accounts.get(args.brand_location_id)
|
||||
branch = accounts.get(args.branch_location_id)
|
||||
if not brand or not branch:
|
||||
raise SystemExit("No se encontró el token de la location de Marca o sucursal en el CSV.")
|
||||
brand_token, branch_token = brand["token"], branch["token"]
|
||||
|
||||
# 1. Opp de Marca (estado actual).
|
||||
brand_opp = (gc.get_opportunity(brand_token, args.brand_opp_id) or {}).get("opportunity") or {}
|
||||
if not brand_opp:
|
||||
raise SystemExit(f"No se pudo leer la opp de Marca {args.brand_opp_id}.")
|
||||
brand_id2key, brand_key2def = schema_key_by_id(brand_token, args.brand_location_id, "opportunity")
|
||||
brand_val_by_key = {}
|
||||
for cf in brand_opp.get("customFields") or []:
|
||||
k = brand_id2key.get(cf.get("id"))
|
||||
if k:
|
||||
brand_val_by_key[k] = opp_cf_value(cf)
|
||||
|
||||
# 2. Resolver la opp de sucursal enlazada (del CF link de Marca).
|
||||
branch_opp_id = clean(brand_val_by_key.get(LINK_KEY))
|
||||
if not branch_opp_id:
|
||||
raise SystemExit(f"La opp de Marca no tiene {LINK_KEY}; no se puede resolver el origen.")
|
||||
branch_opp = (gc.get_opportunity(branch_token, branch_opp_id) or {}).get("opportunity") or {}
|
||||
if not branch_opp:
|
||||
raise SystemExit(f"No se pudo leer la opp de sucursal {branch_opp_id}.")
|
||||
branch_id2key, _ = schema_key_by_id(branch_token, args.branch_location_id, "opportunity")
|
||||
branch_opp_val_by_key = {}
|
||||
for cf in branch_opp.get("customFields") or []:
|
||||
k = branch_id2key.get(cf.get("id"))
|
||||
if k:
|
||||
branch_opp_val_by_key[k] = opp_cf_value(cf)
|
||||
|
||||
# 3. Contacto de sucursal (respaldo).
|
||||
branch_contact_val_by_key = {}
|
||||
cid = branch_opp.get("contactId") or branch_opp.get("contact", {}).get("id")
|
||||
if cid:
|
||||
contact = (gc._request("GET", f"/contacts/{cid}", branch_token) or {}).get("contact") or {}
|
||||
c_id2key, _ = schema_key_by_id(branch_token, args.branch_location_id, "contact")
|
||||
for cf in contact.get("customFields") or []:
|
||||
k = c_id2key.get(cf.get("id"))
|
||||
if k:
|
||||
branch_contact_val_by_key[k] = cf.get("value")
|
||||
|
||||
# 4. Calcular el backfill (solo campos vacíos en Marca).
|
||||
run_id = args.run_id or f"backfill-opp-cf-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||
plan = []
|
||||
for opp_key, contact_key in TARGETS:
|
||||
if clean(brand_val_by_key.get(opp_key)) != "":
|
||||
continue # ya tiene valor, no sobreescribir
|
||||
value = clean(branch_opp_val_by_key.get(opp_key))
|
||||
source = "opp_sucursal"
|
||||
if value == "" and contact_key:
|
||||
value = clean(branch_contact_val_by_key.get(contact_key))
|
||||
source = "contacto_sucursal"
|
||||
if value == "":
|
||||
continue
|
||||
bdef = brand_key2def.get(opp_key)
|
||||
if not bdef:
|
||||
print(f" WARN: Marca no tiene el campo {opp_key}; se omite.")
|
||||
continue
|
||||
plan.append({"opp_key": opp_key, "field_id": bdef["id"], "name": bdef.get("name"),
|
||||
"value": value, "source": source})
|
||||
|
||||
print(f"Opp Marca: {args.brand_opp_id} <- opp sucursal: {branch_opp_id} (contacto {cid})")
|
||||
if not plan:
|
||||
print("Nada que rellenar (todos los campos objetivo ya tienen valor o no hay fuente).")
|
||||
return
|
||||
print(f"Campos a rellenar ({len(plan)}):")
|
||||
for p in plan:
|
||||
print(f" {p['name']:20} [{p['opp_key']}] = {p['value']!r} (fuente: {p['source']})")
|
||||
|
||||
if not args.apply:
|
||||
print("\nDRY-RUN. Vuelve a correr con --apply para escribir en Bucéfalo.")
|
||||
return
|
||||
|
||||
# 5. Snapshot + audit + PUT.
|
||||
os.makedirs(MIGRATIONS_DIR, exist_ok=True)
|
||||
snap_path = os.path.join(MIGRATIONS_DIR, f"backfill_opp_cf_{args.brand_opp_id}_{run_id}.json")
|
||||
with open(snap_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({"run_id": run_id, "brand_opp_id": args.brand_opp_id, "before": brand_opp, "plan": plan},
|
||||
fh, ensure_ascii=False, indent=2)
|
||||
print(f" snapshot -> {snap_path}")
|
||||
|
||||
script_audit.create_run(run_id, "backfill_brand_opp_cf_from_source", arguments=" ".join(sys.argv[1:]),
|
||||
locations=[args.brand_location_id])
|
||||
change_ids = []
|
||||
for p in plan:
|
||||
cidc = script_audit.record_change(run_id, args.brand_location_id, "opportunity", args.brand_opp_id,
|
||||
p["field_id"], p["name"], None, p["value"])
|
||||
change_ids.append(cidc)
|
||||
|
||||
payload = {"customFields": [{"id": p["field_id"], "key": p["opp_key"], "field_value": p["value"]} for p in plan]}
|
||||
gc.update_opportunity(brand_token, args.brand_opp_id, payload)
|
||||
for cidc in change_ids:
|
||||
script_audit.mark_change(cidc, "applied")
|
||||
script_audit.update_run_status(run_id, "success")
|
||||
print(f" PUT aplicado. run_id={run_id} (reversible desde el dashboard).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user