Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
@@ -0,0 +1,865 @@
#!/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 <id>
python scripts/fill_sucursal_tienda_from_location.py --all
python scripts/fill_sucursal_tienda_from_location.py --location <id> --apply
python scripts/fill_sucursal_tienda_from_location.py --all --apply
python scripts/fill_sucursal_tienda_from_location.py --location <id> --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 <id> 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()