Primer commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user