Files
MP-Manager/scripts/sync_contact_sucursal_to_opportunity.py
T
2026-05-30 14:31:19 -06:00

317 lines
11 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Copy contact custom field Sucursal into each linked opportunity."""
import argparse
import csv
import json
import os
import sqlite3
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
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 db # noqa: E402
import script_audit # noqa: E402
import sync_engine # noqa: E402
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
FIELD_NAME = "Sucursal"
def clean(value):
return str(value or "").strip()
def load_accounts():
if not os.path.exists(sync_engine.CSV_PATH):
raise FileNotFoundError(f"No se encontro el CSV de tokens: {sync_engine.CSV_PATH}")
accounts = {}
with open(sync_engine.CSV_PATH, mode="r", encoding="utf-8-sig", newline="") as fh:
for row in csv.DictReader(fh):
location_id = clean(row.get("Location_ID"))
token = clean(row.get("API_token"))
if not location_id or not token:
continue
accounts[location_id] = {
"location_id": location_id,
"nombre": clean(row.get("Nombre")) or location_id,
"token": token,
"type": "brand" if location_id == BRAND_LOCATION_ID else "branch",
}
return list(accounts.values())
def select_accounts(args):
accounts = load_accounts()
if args.location:
selected = [account for account in accounts if account["location_id"] == args.location]
if not selected:
raise SystemExit(f"Location {args.location} no existe en el CSV de tokens")
return selected
if args.all:
if args.include_main:
return accounts
return [account for account in accounts if account.get("type") == "branch"]
raise SystemExit("Especifica --location <id> o --all. Usa --apply para escribir en GHL.")
def get_schema_field_id(location_id, token, object_key):
schema = sync_engine.ghl_client.get_object_schema(token, location_id, object_key)
return schema.get(FIELD_NAME)
def get_custom_field_value(record, field_id):
# GHL devuelve customFields con keys distintas según el objeto:
# contacts → "value"
# opportunities → "fieldValue"
# Algunos endpoints históricos también devuelven "fieldValueString".
if not field_id:
return None
custom_fields = record.get("customFields") or record.get("custom_fields") or []
if isinstance(custom_fields, dict):
return custom_fields.get(field_id)
for field in custom_fields:
if not isinstance(field, dict):
continue
if field.get("id") != field_id and field.get("fieldId") != field_id:
continue
for key in ("value", "fieldValue", "fieldValueString"):
val = field.get(key)
if val is not None:
return val
return None
return None
def parse_custom_fields(raw_json):
try:
value = json.loads(raw_json or "[]")
except json.JSONDecodeError:
return []
return value if isinstance(value, list) else []
def load_contact_sucursal_by_id(location_id, contact_field_id):
if not os.path.exists(db.DB_PATH):
raise FileNotFoundError(f"No existe {db.DB_PATH}. Ejecuta una sincronizacion previa.")
if not contact_field_id:
return {}
conn = sqlite3.connect(db.DB_PATH)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"""
SELECT id, custom_fields_json
FROM contacts
WHERE location_id = ?
""",
(location_id,),
).fetchall()
finally:
conn.close()
values = {}
for row in rows:
for field in parse_custom_fields(row["custom_fields_json"]):
if field.get("id") != contact_field_id:
continue
sucursal = clean(field.get("value"))
if sucursal:
values[row["id"]] = sucursal
break
return values
def iter_opportunities(location_id, token, limit):
page = 1
seen = 0
while limit is None or seen < limit:
page_limit = 100 if limit is None else min(100, limit - seen)
data = sync_engine.ghl_client.search_opportunities(token, location_id, limit=page_limit, page=page)
batch = data.get("opportunities", [])
if not batch:
break
for opportunity in batch:
yield opportunity
seen += 1
if limit is not None and seen >= limit:
break
total_reported = data.get("total", 0) or 0
if len(batch) < page_limit or (total_reported > 0 and seen >= total_reported):
break
page += 1
def update_opportunity_sucursal(token, opportunity_id, opportunity_field_id, sucursal):
return sync_engine.ghl_client.update_opportunity(
token,
opportunity_id,
{"customFields": [{"id": opportunity_field_id, "value": sucursal}]},
)
def process_location(account, args):
location_id = account["location_id"]
token = account["token"]
dry_run = not args.apply
stats = {
"opportunities": 0,
"contacts_missing": 0,
"contact_sucursal_missing": 0,
"already_ok": 0,
"planned": 0,
"updated": 0,
"errors": 0,
}
print(f"\n=== {account['nombre']} ({location_id}) ===")
contact_field_id = get_schema_field_id(location_id, token, "contact")
opportunity_field_id = get_schema_field_id(location_id, token, "opportunity")
if not contact_field_id:
print("SKIP: campo Sucursal no existe en schema de contact")
stats["errors"] += 1
return stats
if not opportunity_field_id:
print("SKIP: campo Sucursal no existe en schema de opportunity")
stats["errors"] += 1
return stats
contact_sucursal_by_id = load_contact_sucursal_by_id(location_id, contact_field_id)
print(f"Contactos con Sucursal en SQLite: {len(contact_sucursal_by_id)}")
for opportunity in iter_opportunities(location_id, token, args.limit):
if not script_audit.wait_if_paused_or_stopped(args.run_id):
break
stats["opportunities"] += 1
opportunity_id = opportunity.get("id")
contact_id = opportunity.get("contactId") or opportunity.get("contact_id")
if not opportunity_id:
stats["errors"] += 1
continue
if not contact_id:
stats["contacts_missing"] += 1
continue
target_sucursal = contact_sucursal_by_id.get(contact_id)
if not target_sucursal:
stats["contact_sucursal_missing"] += 1
continue
current_sucursal = clean(get_custom_field_value(opportunity, opportunity_field_id))
if current_sucursal == target_sucursal:
stats["already_ok"] += 1
continue
change_id = script_audit.record_change(
args.run_id,
location_id,
"opportunity",
opportunity_id,
opportunity_field_id,
FIELD_NAME,
current_sucursal,
target_sucursal,
)
label = opportunity.get("name") or opportunity.get("contactName") or opportunity_id
if dry_run:
stats["planned"] += 1
print(f"PLAN opp {opportunity_id} | {label} | {current_sucursal!r} -> {target_sucursal!r}")
continue
try:
update_opportunity_sucursal(token, opportunity_id, opportunity_field_id, target_sucursal)
script_audit.mark_change(change_id, "applied")
stats["updated"] += 1
print(f"OK opp {opportunity_id} | {label} | {current_sucursal!r} -> {target_sucursal!r}")
except Exception as exc:
script_audit.mark_change(change_id, "failed", str(exc))
stats["errors"] += 1
print(f"ERROR opp {opportunity_id}: {exc}")
print(f"Stats {location_id}: {stats}")
return stats
def add_stats(total, current):
for key, value in current.items():
total[key] = total.get(key, 0) + value
def parse_args():
parser = argparse.ArgumentParser(
description="Copia el campo Sucursal del contacto hacia oportunidades vinculadas. Dry-run por defecto."
)
parser.add_argument("--location", help="Location ID especifico")
parser.add_argument("--all", action="store_true", help="Procesa todas las sucursales del CSV")
parser.add_argument("--include-main", action="store_true", help="Incluye la cuenta de marca con --all")
parser.add_argument("--apply", action="store_true", help="Aplica PUT reales en GHL. Sin este flag solo simula")
parser.add_argument("--limit", type=int, help="Limite de oportunidades por location")
parser.add_argument("--workers", type=int, default=1, help="Paralelismo interno para --all. Default: 1")
parser.add_argument("--run-id", help="Audit run ID suministrado por dashboard")
args = parser.parse_args()
if args.limit is not None and args.limit < 1:
raise SystemExit("--limit debe ser mayor a 0")
if args.workers < 1:
raise SystemExit("--workers debe ser mayor a 0")
return args
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
args = parse_args()
accounts = select_accounts(args)
dry_run = not args.apply
print("=" * 70)
print("SYNC CONTACT SUCURSAL -> OPPORTUNITY")
print("=" * 70)
print(f"Modo: {'DRY-RUN (sin cambios)' if dry_run else 'APPLY (PUT real en GHL)'}")
print("Fuente contacto: SQLite local; destino: oportunidades live GHL")
print(f"Locations: {len(accounts)} | Workers: {min(args.workers, len(accounts))}")
grand_total = {
"opportunities": 0,
"contacts_missing": 0,
"contact_sucursal_missing": 0,
"already_ok": 0,
"planned": 0,
"updated": 0,
"errors": 0,
}
if args.workers > 1 and len(accounts) > 1:
with ThreadPoolExecutor(max_workers=min(args.workers, len(accounts))) as executor:
futures = {executor.submit(process_location, account, args): account for account in accounts}
for future in as_completed(futures):
add_stats(grand_total, future.result())
else:
for account in accounts:
add_stats(grand_total, process_location(account, args))
print("\n" + "=" * 70)
print(f"TOTAL: {grand_total}")
if dry_run:
print("Dry-run terminado. Usa --apply para escribir los cambios planeados.")
if grand_total.get("errors"):
raise SystemExit(1)
if __name__ == "__main__":
main()