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