866 lines
33 KiB
Python
866 lines
33 KiB
Python
#!/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()
|