179 lines
7.7 KiB
Python
179 lines
7.7 KiB
Python
#!/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()
|