#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ fill_sucursal_tienda_from_location.py Llena los campos personalizados "Sucursal" y "TIENDA" en contactos y oportunidades usando el Verificador CSV como fuente autoritativa. Mapeo: contacto/oportunidad.locationId -> CSV "ID LOCATION BUCEFALO" columna "SUCURSAL" -> campo personalizado "Sucursal" columna "TIENDA" -> campo personalizado "TIENDA" Regla de comparacion: El match contra el verificador CSV es 100% estricto (byte por byte). Cualquier diferencia (espacio extra, mayuscula distinta, acentos, espacios internos duplicados) se considera invalido y dispara la correccion forzosa. Modos de ejecucion: LOCAL (default) --> usa el cache SQLite y el verificador CSV para decidir que registros necesitan correccion. Solo hace PUT a la API para los que cambian. Mucho mas rapido. FRESH (--fresh) --> mantiene el flujo anterior: GET por contacto + GET por oportunidad contra la API en tiempo real. Util cuando se sospecha que el cache SQLite esta desactualizado. Frescura del cache (solo modo local): Si el ultimo synced_at de los contactos/oportunidades de la location es mayor a 24h, el script aborta con un warning. Pasa --force-stale para ignorar el chequeo y correr de todas formas con el cache disponible. Orden de ejecucion por location: 1. Contactos -> se llenan Sucursal + TIENDA 2. Oportunidades -> se llenan Sucursal + TIENDA Uso: python scripts/fill_sucursal_tienda_from_location.py --location python scripts/fill_sucursal_tienda_from_location.py --all python scripts/fill_sucursal_tienda_from_location.py --location --apply python scripts/fill_sucursal_tienda_from_location.py --all --apply python scripts/fill_sucursal_tienda_from_location.py --location --fresh python scripts/fill_sucursal_tienda_from_location.py --all --force-stale """ import argparse import concurrent.futures import csv import json import os import sqlite3 import sys import time import warnings from datetime import datetime, timedelta from urllib.parse import parse_qs, urlparse warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version!") import requests 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 import error_logging # noqa: E402 from paths import DB_PATH # noqa: E402 BASE_URL = "https://services.leadconnectorhq.com" API_VERSION = "2021-07-28" OBJECT_API_VERSION = "2021-04-15" VERIFIER_FILENAME = "Monte Providencia - Verificador de sucursales y correos - Sucursales.csv" # Nombres exactos de los campos en el object schema de GHL FIELD_SUCURSAL = "Sucursal" FIELD_TIENDA = "TIENDA" # Frescura: si el ultimo synced_at supera este umbral, el script se rehusa a # correr en modo local salvo que pase --force-stale. STALE_CACHE_HOURS = 24 # Paralelismo de PUTs entre registros de UNA misma location. Los PUTs comparten # token y por lo tanto comparten rate limit (110ms entre requests). Subir esto # arriba de ~3 no rinde nada porque _wait() los serializa de hecho. INTRA_LOCATION_PUT_WORKERS = 3 _last_request_by_token: dict = {} # --------------------------------------------------------------------------- # HTTP / rate limit # --------------------------------------------------------------------------- def _wait(token: str): now = time.time() elapsed = now - _last_request_by_token.get(token, 0) if elapsed < 0.110: time.sleep(0.110 - elapsed) _last_request_by_token[token] = time.time() def ghl_request(method: str, endpoint: str, token: str, *, params=None, json_body=None, version=API_VERSION): _wait(token) url = endpoint if endpoint.startswith("http") else f"{BASE_URL}{endpoint}" headers = { "Accept": "application/json", "Authorization": f"Bearer {token}", "Version": version, "Content-Type": "application/json", } resp = requests.request(method, url, headers=headers, params=params, json=json_body, timeout=45) try: resp.raise_for_status() except requests.HTTPError as exc: eid = error_logging.log_error("fill_sucursal_tienda_http_error", exc, { "method": method, "url": url, "status": resp.status_code, "body": resp.text[:2000], }) print(f" HTTP {resp.status_code} {method} {url} | error_id={eid}") if resp.text: print(f" GHL resp: {resp.text[:500]}") raise return {} if resp.status_code == 204 else resp.json() def clean(v) -> str: return str(v or "").strip() def values_match_strict(current, target) -> bool: """ Comparacion 100% estricta. None / ausencia se tratan como cadena vacia. Cualquier diferencia (espacios, mayusculas, acentos) cuenta como no-match. """ cur = "" if current is None else str(current) tgt = "" if target is None else str(target) return cur == tgt def get_custom_field_value(record_or_cfs, field_id): """ Acepta un dict de registro completo o una lista de customFields ya parseada. GHL devuelve keys distintas segun el objeto: contacts -> "value" opportunities -> "fieldValueString" / "fieldValue" """ if not field_id: return None if isinstance(record_or_cfs, dict): cfs = record_or_cfs.get("customFields") or record_or_cfs.get("custom_fields") or [] else: cfs = record_or_cfs or [] for f in cfs: if f.get("id") != field_id and f.get("fieldId") != field_id: continue for key in ("value", "fieldValue", "fieldValueString"): val = f.get(key) if val is not None: return val return None return None def record_label(record: dict) -> str: return ( record.get("contactName") or record.get("name") or " ".join(filter(None, [record.get("firstName"), record.get("lastName")])) or record.get("email") or record.get("phone") or record.get("id", "?") ) # --------------------------------------------------------------------------- # Verificador CSV # --------------------------------------------------------------------------- def load_verifier_map() -> dict: """ Retorna {location_id: {"sucursal": str, "tienda": str, "sc_name": str}} Ignora la fila de la cuenta principal (SUCURSAL == "-"). """ path = os.path.join(ROOT_DIR, VERIFIER_FILENAME) if not os.path.exists(path): raise FileNotFoundError(f"Verificador no encontrado: {path}") result = {} with open(path, mode="r", encoding="utf-8-sig", newline="") as fh: for row in csv.DictReader(fh): loc_id = clean(row.get("ID LOCATION BUCEFALO")) sucursal = clean(row.get("SUCURSAL")) tienda = clean(row.get("TIENDA")) sc_name = clean(row.get("SC BUCEFALO")) if not loc_id or sucursal == "-" or tienda in ("-", "CUENTA PRINCIPAL", ""): continue if loc_id not in result: result[loc_id] = {"sucursal": sucursal, "tienda": tienda, "sc_name": sc_name} return result # --------------------------------------------------------------------------- # Resolver de field IDs - local primero, fallback API # --------------------------------------------------------------------------- def resolve_field_ids_local(location_id: str, object_key: str) -> dict: """ Lee object_schemas en SQLite y retorna {field_name: field_id} para Sucursal y TIENDA. Devuelve dict vacio si no hay nada local. """ if not os.path.exists(DB_PATH): return {} conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: rows = conn.execute( "SELECT field_id, field_name FROM object_schemas " "WHERE location_id = ? AND object_key = ? " " AND field_name IN (?, ?)", (location_id, object_key, FIELD_SUCURSAL, FIELD_TIENDA), ).fetchall() return {r["field_name"]: r["field_id"] for r in rows} finally: conn.close() def resolve_field_ids_api(location_id: str, token: str, object_key: str) -> dict: """Fallback: pide el schema a GHL si SQLite no tiene cache.""" ghl_request("GET", "/objects/", token, params={"locationId": location_id}, version=OBJECT_API_VERSION) data = ghl_request("GET", f"/objects/{object_key}", token, params={"locationId": location_id}, version=OBJECT_API_VERSION) out = {} for f in data.get("fields", []): name = f.get("name") fid = f.get("id") if name in (FIELD_SUCURSAL, FIELD_TIENDA) and fid: out[name] = fid return out def resolve_field_ids(location_id: str, token: str, object_key: str, prefer_local: bool) -> dict: """En modo local intenta primero la tabla object_schemas; si falta algun campo, hace fallback a la API. En modo fresh siempre va a la API.""" if prefer_local: local = resolve_field_ids_local(location_id, object_key) if FIELD_SUCURSAL in local and FIELD_TIENDA in local: return local try: api = resolve_field_ids_api(location_id, token, object_key) # Mezclar: lo local manda, completar con API merged = dict(api) merged.update(local) return merged except Exception as exc: print(f" WARN no se pudo resolver schema via API ({object_key}): {exc}") return local return resolve_field_ids_api(location_id, token, object_key) # --------------------------------------------------------------------------- # Frescura del cache # --------------------------------------------------------------------------- def _parse_synced_at(value: str): if not value: return None for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): try: return datetime.strptime(value, fmt) except ValueError: continue return None def get_cache_freshness(location_id: str) -> dict: """ Retorna {contacts_synced_at, opps_synced_at, oldest, hours_old} para evaluar si vale la pena confiar en SQLite. """ if not os.path.exists(DB_PATH): return {"contacts_synced_at": None, "opps_synced_at": None, "oldest": None, "hours_old": None} conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: c_row = conn.execute( "SELECT MAX(synced_at) AS s FROM contacts WHERE location_id = ?", (location_id,), ).fetchone() o_row = conn.execute( "SELECT MAX(synced_at) AS s FROM opportunities WHERE location_id = ?", (location_id,), ).fetchone() finally: conn.close() c_ts = _parse_synced_at(c_row["s"]) if c_row and c_row["s"] else None o_ts = _parse_synced_at(o_row["s"]) if o_row and o_row["s"] else None timestamps = [t for t in (c_ts, o_ts) if t] oldest = min(timestamps) if timestamps else None hours_old = None if oldest: hours_old = (datetime.now() - oldest).total_seconds() / 3600.0 return { "contacts_synced_at": c_row["s"] if c_row else None, "opps_synced_at": o_row["s"] if o_row else None, "oldest": oldest, "hours_old": hours_old, } # --------------------------------------------------------------------------- # Lectura local desde SQLite # --------------------------------------------------------------------------- def load_contacts_local(location_id: str) -> list: if not os.path.exists(DB_PATH): return [] conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: rows = conn.execute( "SELECT id, first_name, last_name, email, phone, custom_fields_json " "FROM contacts WHERE location_id = ?", (location_id,), ).fetchall() finally: conn.close() out = [] for r in rows: try: cfs = json.loads(r["custom_fields_json"]) if r["custom_fields_json"] else [] except Exception: cfs = [] out.append({ "id": r["id"], "firstName": r["first_name"], "lastName": r["last_name"], "email": r["email"], "phone": r["phone"], "customFields": cfs, }) return out def load_opportunities_local(location_id: str) -> list: if not os.path.exists(DB_PATH): return [] conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: rows = conn.execute( "SELECT id, name, contact_id, custom_fields_json " "FROM opportunities WHERE location_id = ?", (location_id,), ).fetchall() finally: conn.close() out = [] for r in rows: try: cfs = json.loads(r["custom_fields_json"]) if r["custom_fields_json"] else [] except Exception: cfs = [] out.append({ "id": r["id"], "name": r["name"], "contactId": r["contact_id"], "customFields": cfs, }) return out # --------------------------------------------------------------------------- # Lectura FRESH desde API (flujo anterior, conservado como fallback) # --------------------------------------------------------------------------- def get_all_contacts_api(location_id: str, token: str) -> list: contacts, seen_ids = [], set() start_after = None start_after_id = None while True: params = {"locationId": location_id, "limit": 100} if start_after: params["startAfter"] = start_after if start_after_id: params["startAfterId"] = start_after_id data = ghl_request("GET", "/contacts/", token, params=params) batch = data.get("contacts", []) if not batch: break for c in batch: cid = c.get("id") if cid and cid not in seen_ids: seen_ids.add(cid) contacts.append(c) meta = data.get("meta", {}) total_reported = meta.get("total", 0) or 0 if len(batch) < 100 or (total_reported > 0 and len(contacts) >= total_reported): break next_url = meta.get("nextPageUrl") if not next_url: break queries = parse_qs(urlparse(next_url).query) cursors = queries.get("startAfter") cursor_ids = queries.get("startAfterId") start_after = cursors[0] if cursors else None start_after_id = cursor_ids[0] if cursor_ids else None if not start_after and not start_after_id: break return contacts def get_all_opportunities_api(location_id: str, token: str) -> list: opportunities = [] page = 1 while True: data = ghl_request("POST", "/opportunities/search", token, json_body={"locationId": location_id, "limit": 100, "page": page}) batch = data.get("opportunities", []) if not batch: break opportunities.extend(batch) total = (data.get("meta") or {}).get("total") or 0 if total and len(opportunities) >= total: break page += 1 return opportunities def get_contact_full_api(location_id: str, contact_id: str, token: str) -> dict: data = ghl_request("GET", f"/contacts/{contact_id}", token, params={"locationId": location_id}) return data.get("contact") if isinstance(data.get("contact"), dict) else data def get_opportunity_full_api(location_id: str, opp_id: str, token: str) -> dict: data = ghl_request("GET", f"/opportunities/{opp_id}", token, params={"locationId": location_id}) return data.get("opportunity") if isinstance(data.get("opportunity"), dict) else data # --------------------------------------------------------------------------- # Plan: que registros necesitan correccion # --------------------------------------------------------------------------- def plan_corrections(records: list, sucursal_field_id, tienda_field_id, sucursal_val: str, tienda_val: str) -> list: """ Para cada registro decide si necesita PUT. Retorna lista con: {record, field_updates: [(name, fid, current, target, was_wrong)], any_wrong} field_updates siempre incluye AMBOS campos (Sucursal y TIENDA) si tienen field_id, para que el PUT actualice los dos juntos (idempotente). """ plans = [] field_specs = [] if sucursal_field_id and sucursal_val: field_specs.append((FIELD_SUCURSAL, sucursal_field_id, sucursal_val)) if tienda_field_id and tienda_val: field_specs.append((FIELD_TIENDA, tienda_field_id, tienda_val)) if not field_specs: return plans for record in records: updates = [] any_wrong = False for name, fid, target in field_specs: current = get_custom_field_value(record, fid) was_wrong = not values_match_strict(current, target) if was_wrong: any_wrong = True updates.append((name, fid, current, target, was_wrong)) plans.append({ "record": record, "field_updates": updates, "any_wrong": any_wrong, }) return plans # --------------------------------------------------------------------------- # Ejecucion de un PUT # --------------------------------------------------------------------------- def apply_correction(*, plan, object_type, token, location_id, run_id, dry_run, quiet, log_lock=None): """Ejecuta un PUT para un plan ya construido. Retorna el resultado string.""" record = plan["record"] updates = plan["field_updates"] any_wrong = plan["any_wrong"] record_id = record.get("id") label = record_label(record) verb = "DRY" if dry_run else "PUT" # Logging lines = [] for name, _, current, target, was_wrong in updates: if was_wrong: lines.append(f" {verb} [{object_type}] {label} | " f"{name}: '{current or '(vacio)'}' -> '{target}' [CORRECCION]") elif not quiet: lines.append(f" {verb} [{object_type}] {label} | " f"{name}: '{target}' [confirmar]") if lines: msg = "\n".join(lines) if log_lock: with log_lock: print(msg) else: print(msg) if dry_run: return "planned_fix" if any_wrong else "planned_confirm" # Solo registramos cambio real en auditoria for name, fid, current, target, was_wrong in updates: if was_wrong: script_audit.record_change(run_id, location_id, object_type, record_id, fid, name, current, target) payload = [{"id": fid, "value": tgt} for _, fid, _, tgt, _ in updates] try: if object_type == "contact": ghl_request("PUT", f"/contacts/{record_id}", token, json_body={"customFields": payload}) else: ghl_request("PUT", f"/opportunities/{record_id}", token, json_body={"customFields": payload}) return "corregido" if any_wrong else "sin_cambio" except Exception as exc: err_msg = f" ERROR actualizando {object_type} {record_id}: {exc}" if log_lock: with log_lock: print(err_msg) else: print(err_msg) return "error" def execute_plans_parallel(plans, object_type, token, location_id, run_id, dry_run, quiet): """Ejecuta los plans en paralelo dentro de la misma location. El rate limit por token sigue siendo respetado por _wait().""" import threading stats = {} log_lock = threading.Lock() def _runner(plan): if not script_audit.wait_if_paused_or_stopped(run_id): return "stopped" return apply_correction(plan=plan, object_type=object_type, token=token, location_id=location_id, run_id=run_id, dry_run=dry_run, quiet=quiet, log_lock=log_lock) workers = 1 if dry_run else INTRA_LOCATION_PUT_WORKERS if workers <= 1 or len(plans) <= 1: for plan in plans: res = _runner(plan) if res == "stopped": break stats[res] = stats.get(res, 0) + 1 return stats with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex: futures = [ex.submit(_runner, p) for p in plans] for fut in concurrent.futures.as_completed(futures): try: res = fut.result() except Exception as exc: with log_lock: print(f" ERROR worker {object_type}: {exc}") res = "error" if res == "stopped": continue stats[res] = stats.get(res, 0) + 1 return stats # --------------------------------------------------------------------------- # Procesamiento por tipo # --------------------------------------------------------------------------- def process_records(*, account, entry, object_type, sucursal_field_id, tienda_field_id, dry_run, run_id, fresh, quiet): """Pipeline comun para contactos y oportunidades.""" location_id = account["location_id"] token = account["token"] sucursal_val = entry["sucursal"] tienda_val = entry["tienda"] if not sucursal_field_id and not tienda_field_id: print(f" SKIP {object_type}: campos Sucursal y TIENDA no existen en el schema.") return {} missing = [] if not sucursal_field_id: missing.append(FIELD_SUCURSAL) if not tienda_field_id: missing.append(FIELD_TIENDA) if missing: print(f" WARN {object_type}: campo(s) no encontrado(s) en schema: {missing}") plural = "Contactos" if object_type == "contact" else "Oportunidades" # 1. Listado base (local o fresh) if fresh: if object_type == "contact": base = get_all_contacts_api(location_id, token) else: base = get_all_opportunities_api(location_id, token) print(f" {plural} desde API: {len(base)}") else: if object_type == "contact": base = load_contacts_local(location_id) else: base = load_opportunities_local(location_id) print(f" {plural} desde cache local: {len(base)}") # 2. En modo fresh el listado no trae todos los campos -> GET por registro if fresh: full_records = [] for summary in base: if not script_audit.wait_if_paused_or_stopped(run_id): break rid = summary.get("id") if not rid: continue try: if object_type == "contact": full = get_contact_full_api(location_id, rid, token) else: full = get_opportunity_full_api(location_id, rid, token) full_records.append(full) except Exception as exc: print(f" ERROR GET {object_type} {rid}: {exc}") records = full_records else: records = base # 3. Plan de correcciones (puro CPU + JSON, sin IO) plans = plan_corrections(records, sucursal_field_id, tienda_field_id, sucursal_val, tienda_val) needs_fix = sum(1 for p in plans if p["any_wrong"]) confirms = len(plans) - needs_fix print(f" Plan {object_type}: {needs_fix} con correccion / {confirms} ya correctos") if not plans: return {} # 4. En dry-run NO filtramos: queremos ver tambien los [confirmar]. # En apply solo ejecutamos los que cambian, para reducir API calls. if dry_run: plans_to_run = plans else: plans_to_run = [p for p in plans if p["any_wrong"]] if not plans_to_run: return {"sin_cambio": len(plans)} # 5. Ejecucion (paralela si aplica) stats = execute_plans_parallel(plans_to_run, object_type, token, location_id, run_id, dry_run, quiet) # 6. Contabilizar los "confirmar" silenciados en apply if not dry_run: silent_ok = len(plans) - len(plans_to_run) if silent_ok: stats["sin_cambio"] = stats.get("sin_cambio", 0) + silent_ok return stats # --------------------------------------------------------------------------- # Procesamiento por location # --------------------------------------------------------------------------- def process_location(account: dict, verifier_map: dict, args) -> dict: location_id = account["location_id"] token = account["token"] name = account["nombre"] dry_run = not args.apply fresh = bool(args.fresh) print(f"\n{'=' * 72}") print(f"LOCATION: {name} ({location_id})") print(f"{'=' * 72}") entry = verifier_map.get(location_id) if not entry: print(" SKIP: location no encontrada en el verificador CSV.") return {} print(f" CSV -> SC: '{entry['sc_name']}' | Sucursal: '{entry['sucursal']}' | TIENDA: '{entry['tienda']}'") # Chequeo de frescura del cache (solo en modo local) if not fresh: fresh_info = get_cache_freshness(location_id) hours_old = fresh_info["hours_old"] if hours_old is None: print(" WARN: no hay datos en SQLite para esta location.") if not args.force_stale: print(" SKIP: corre una sincronizacion primero o usa --fresh / --force-stale.") return {"sin_cambio": 0, "_skipped_stale": 1} else: print(f" Cache: synced_at mas antiguo = {fresh_info['oldest']} ({hours_old:.1f}h)") if hours_old > STALE_CACHE_HOURS and not args.force_stale: print(f" SKIP: cache mayor a {STALE_CACHE_HOURS}h. " f"Sincroniza o usa --force-stale para ignorar.") return {"sin_cambio": 0, "_skipped_stale": 1} # Resolver field IDs (local primero salvo --fresh) prefer_local = not fresh try: c_ids = resolve_field_ids(location_id, token, "contact", prefer_local) except Exception as exc: print(f" WARN no se pudo obtener schema de contactos: {exc}") c_ids = {} try: o_ids = resolve_field_ids(location_id, token, "opportunity", prefer_local) except Exception as exc: print(f" WARN no se pudo obtener schema de oportunidades: {exc}") o_ids = {} c_sucursal_id = c_ids.get(FIELD_SUCURSAL) c_tienda_id = c_ids.get(FIELD_TIENDA) o_sucursal_id = o_ids.get(FIELD_SUCURSAL) o_tienda_id = o_ids.get(FIELD_TIENDA) print(f" Schema contacto -> '{FIELD_SUCURSAL}': {c_sucursal_id or 'NO ENCONTRADO'} " f"'{FIELD_TIENDA}': {c_tienda_id or 'NO ENCONTRADO'}") print(f" Schema oportunidad -> '{FIELD_SUCURSAL}': {o_sucursal_id or 'NO ENCONTRADO'} " f"'{FIELD_TIENDA}': {o_tienda_id or 'NO ENCONTRADO'}") grand = {} quiet = getattr(args, "quiet", False) # Contactos if not args.opportunities_only: print(f"\n [CONTACTOS]") try: stats = process_records( account=account, entry=entry, object_type="contact", sucursal_field_id=c_sucursal_id, tienda_field_id=c_tienda_id, dry_run=dry_run, run_id=args.run_id, fresh=fresh, quiet=quiet, ) print(f" Stats contactos: {stats}") for k, v in stats.items(): grand[k] = grand.get(k, 0) + v except Exception as exc: print(f" ERROR bloque contactos: {exc}") grand["error"] = grand.get("error", 0) + 1 # Oportunidades if not args.contacts_only: print(f"\n [OPORTUNIDADES]") try: stats = process_records( account=account, entry=entry, object_type="opportunity", sucursal_field_id=o_sucursal_id, tienda_field_id=o_tienda_id, dry_run=dry_run, run_id=args.run_id, fresh=fresh, quiet=quiet, ) print(f" Stats oportunidades: {stats}") for k, v in stats.items(): grand[k] = grand.get(k, 0) + v except Exception as exc: print(f" ERROR bloque oportunidades: {exc}") grand["error"] = grand.get("error", 0) + 1 return grand # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def select_locations(args) -> list: accounts = sync_engine.parse_accounts_csv() if args.location: matches = [a for a in accounts if a["location_id"] == args.location] if not matches: raise SystemExit(f"Location '{args.location}' no encontrada en el CSV de mesa de control.") return matches if args.all: if args.include_main: return list(accounts) return [a for a in accounts if a.get("type") == "branch"] raise SystemExit( "Especifica --location o --all.\n" "Por seguridad el script corre en DRY-RUN a menos que agregues --apply." ) def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser( description="Llena campos 'Sucursal' y 'TIENDA' en contactos y oportunidades " "usando el Verificador CSV. Por defecto consulta SQLite local " "para decidir que registros corregir y solo entonces hace PUT a la API." ) parser.add_argument("--location", help="Location ID especifico a procesar.") parser.add_argument("--all", action="store_true", help="Procesa todas las sucursales del CSV de mesa de control.") parser.add_argument("--include-main", action="store_true", help="Incluye la cuenta principal (Marca) cuando se usa --all.") parser.add_argument("--apply", action="store_true", help="Aplica cambios reales en GHL. Sin este flag corre en DRY-RUN.") parser.add_argument("--contacts-only", action="store_true", help="Solo procesa contactos (omite oportunidades).") parser.add_argument("--opportunities-only", action="store_true", help="Solo procesa oportunidades (omite contactos).") parser.add_argument("--fresh", action="store_true", help="Modo en vivo: ignora SQLite y consulta cada contacto/oportunidad " "directamente a la API GHL. Mas lento pero garantiza datos al instante.") parser.add_argument("--force-stale", action="store_true", help="Ignora el chequeo de antiguedad del cache local (>24h). " "Sin este flag, el script aborta si el cache esta desactualizado.") parser.add_argument("--run-id", help="Audit run ID suministrado por dashboard.") parser.add_argument("--quiet", action="store_true", help="Oculta los registros donde el valor ya era correcto.") args = parser.parse_args() if args.contacts_only and args.opportunities_only: raise SystemExit("No se puede combinar --contacts-only con --opportunities-only.") verifier_map = load_verifier_map() locations = select_locations(args) dry_run = not args.apply mode_label = "FRESH (API en vivo)" if args.fresh else "LOCAL (cache SQLite)" print("\n" + "=" * 72) print("FILL SUCURSAL + TIENDA DESDE VERIFICADOR CSV") print("=" * 72) print(f" Modo lectura: {mode_label}") print(f" Modo escritura: {'DRY-RUN (ningun cambio en GHL)' if dry_run else 'APPLY (escritura real)'}") print(f" Locations: {len(locations)}") print(f" Campos: '{FIELD_SUCURSAL}' y '{FIELD_TIENDA}' en contactos y oportunidades") print(f" Fuente CSV: {VERIFIER_FILENAME}") if args.force_stale and not args.fresh: print(" Cache: --force-stale activo (se ignora el chequeo de antiguedad).") if dry_run: print("\n [DRY-RUN] Solo se mostraran los cambios planeados.") print(" Usa --apply cuando quieras ejecutar los cambios reales.\n") grand_total: dict = {} for account in locations: stats = process_location(account, verifier_map, args) for k, v in stats.items(): grand_total[k] = grand_total.get(k, 0) + v print("\n" + "=" * 72) print(f"RESUMEN GLOBAL: {grand_total}") if dry_run: print("\nDry-run completado. Usa --apply para aplicar los cambios al CRM.") print("=" * 72 + "\n") if grand_total.get("error"): raise SystemExit(1) if __name__ == "__main__": main()