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