Primer commit
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user