Files
MP-Manager/scripts/sync_forms_brand.py
2026-05-30 14:31:19 -06:00

255 lines
10 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""sync_forms_brand.py
Sincroniza el catalogo de formularios y todos los submissions de la cuenta de
Marca Principal (GbKkBpCmKu2QmloKFHy3) hacia la base de datos local
(mp_manager.sqlite). El audit_brand_sucursal_vs_form.py consume estos datos.
Por que solo Marca:
En Monte Providencia los formularios publicos viven en la cuenta de Marca
y todos los envios pasan por ahi, sin importar la sucursal destino. Por eso
este script ignora sucursales por defecto. Usa --location para forzar otra.
Paginacion:
GET /forms/submissions acepta page (default 1), limit (max 100) y
opcionalmente startAt / endAt (formato 'YYYY-MM-DD' o ISO 8601). El cliente
itera hasta agotar meta.nextPage, deduplicando por id.
Backfill historico:
SIN startAt/endAt, GHL solo devuelve ~1 mes hacia atras desde la fecha
actual. Para histórico completo hay que pedir ventanas mensuales. Este
script automatiza eso con --backfill-months N: parte la peticion en
ventanas mensuales (mes 1, mes 2, ...) y acumula todo en SQLite.
Uso:
python scripts/sync_forms_brand.py # sync incremental (mes corriente)
python scripts/sync_forms_brand.py --backfill-months 12 # ultimo año por ventanas mensuales
python scripts/sync_forms_brand.py --start-at 2025-09-01 # rango explicito desde fecha
python scripts/sync_forms_brand.py --start-at 2025-09-01 --end-at 2025-12-31
python scripts/sync_forms_brand.py --max 200 --quiet
"""
import argparse
import os
import re
import sys
import unicodedata
from datetime import date, datetime, timedelta
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
import db # noqa: E402
import sync_engine # noqa: E402
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
# Field IDs historicos para el campo "Sucursal" en el formulario de Marca.
# Cuando se rediseña el form, GHL asigna un id nuevo al mismo campo conceptual.
# El primary se resuelve dinamicamente del schema del contacto; estos fallbacks
# cubren versiones viejas del form que ya no estan en el schema actual.
HISTORICAL_SUCURSAL_FIELD_IDS = {
BRAND_LOCATION_ID: ["ZVMoW8J0MqZNh3fEZ7Uu"],
}
def norm_field_name(name):
if not name:
return ""
t = unicodedata.normalize("NFKD", str(name).strip().lower())
t = "".join(ch for ch in t if not unicodedata.combining(ch))
return " ".join(t.split())
def resolve_sucursal_field_id(token, location_id):
"""Busca el field id del campo 'Sucursal' en el schema de contactos de la
location. Es el mismo id que aparece como key dentro de others en los
submissions, asi que vale para extraer el valor sin hardcodear."""
schema = sync_engine.ghl_client.get_object_schema(token, location_id, "contact")
for name, fid in schema.items():
if norm_field_name(name) == "sucursal":
return fid, name
return None, None
def parse_date_str(value):
"""Acepta YYYY-MM-DD o ISO 8601. Devuelve string normalizado para GHL."""
if not value:
return None
v = value.strip()
if re.match(r"^\d{4}-\d{2}-\d{2}$", v):
return v # GHL acepta YYYY-MM-DD directo
if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", v):
return v
raise SystemExit(f"Fecha invalida {value!r}. Usa YYYY-MM-DD o ISO 8601.")
def first_of_month(d):
return d.replace(day=1)
def add_months(d, months):
"""Suma N meses a un date (sin dependencia externa)."""
y = d.year + (d.month - 1 + months) // 12
m = (d.month - 1 + months) % 12 + 1
# day=1 para evitar errores como feb 30
return d.replace(year=y, month=m, day=1)
def build_windows(start_at, end_at, backfill_months):
"""Devuelve lista de (start_str, end_str) en orden cronologico.
Reglas:
- Si hay --start-at y/o --end-at explicitos, se usa esa ventana (una sola).
- Si hay --backfill-months N, se generan N ventanas mensuales hasta hoy.
- Si no hay nada, devuelve [(None, None)] = comportamiento default (sin filtro).
"""
if start_at or end_at:
return [(start_at, end_at)]
if backfill_months and backfill_months > 0:
today = date.today()
# Empezamos backfill_months atras (al dia 1 de ese mes) y avanzamos
# mes a mes hasta cubrir hoy.
start = add_months(first_of_month(today), -backfill_months + 1)
windows = []
cursor = start
for _ in range(backfill_months + 1):
end = add_months(cursor, 1)
# ventana semicerrada [cursor, end)
windows.append((cursor.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")))
cursor = end
if cursor > today:
break
return windows
return [(None, None)]
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--location", default=BRAND_LOCATION_ID,
help=f"Location de la que sincronizar formularios. Default: Marca ({BRAND_LOCATION_ID})")
parser.add_argument("--max", type=int, default=None,
help="Tope de submissions por formulario por ventana. Default: todos.")
parser.add_argument("--page-size", type=int, default=100,
help="Tamano de pagina (max 100). Default 100.")
parser.add_argument("--start-at", help="Fecha desde (YYYY-MM-DD o ISO). Filtra createdAt.")
parser.add_argument("--end-at", help="Fecha hasta (YYYY-MM-DD o ISO). Filtra createdAt.")
parser.add_argument("--backfill-months", type=int, default=0,
help="Backfill historico por N ventanas mensuales hacia atras. "
"Ignora si se pasa --start-at/--end-at.")
parser.add_argument("--reextract-only", action="store_true",
help="No descarga nada de GHL: solo re-procesa sucursal_value en "
"los submissions ya guardados usando los field_ids actual + historicos.")
parser.add_argument("--quiet", action="store_true", help="Reduce logs.")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if args.page_size < 1 or args.page_size > 100:
raise SystemExit("--page-size debe estar entre 1 y 100")
if args.backfill_months and args.backfill_months < 0:
raise SystemExit("--backfill-months debe ser >= 0")
start_at = parse_date_str(args.start_at)
end_at = parse_date_str(args.end_at)
windows = build_windows(start_at, end_at, args.backfill_months)
accounts = sync_engine.parse_accounts_csv()
account = next((a for a in accounts if a["location_id"] == args.location), None)
if not account:
raise SystemExit(f"Location {args.location} no esta en el CSV de tokens.")
token = account["token"]
location_id = account["location_id"]
print(f"=== SYNC FORMS: {account['nombre']} ({location_id}) ===")
print(f" Ventanas de fecha a consultar: {len(windows)}")
if len(windows) <= 3:
for s, e in windows:
print(f" [{s or '(sin filtro)'} -> {e or '(sin filtro)'}]")
sucursal_fid, sucursal_name = resolve_sucursal_field_id(token, location_id)
historical_fids = HISTORICAL_SUCURSAL_FIELD_IDS.get(location_id, [])
sucursal_field_ids = []
if sucursal_fid:
sucursal_field_ids.append(sucursal_fid)
print(f" Campo Sucursal actual: '{sucursal_name}' -> {sucursal_fid}")
else:
print(" WARN: no encontre el campo Sucursal actual en el schema de contactos.")
for hfid in historical_fids:
if hfid not in sucursal_field_ids:
sucursal_field_ids.append(hfid)
if historical_fids:
print(f" Field_ids historicos de Sucursal a tolerar: {historical_fids}")
# Modo re-extract: no hay sync nuevo, solo re-procesar registros existentes
if args.reextract_only:
if not sucursal_field_ids:
raise SystemExit("--reextract-only sin field_ids candidatos. Abortando.")
updated, total = db.reextract_sucursal_values(location_id, sucursal_field_ids)
print(f"\n=== RE-EXTRACT ===")
print(f" Submissions procesados: {total}")
print(f" Con sucursal poblada: {updated}")
print(f" Sin sucursal extraible: {total - updated}")
return
print("\n[1/2] Descargando catalogo de formularios...")
forms = sync_engine.ghl_client.get_forms(token, location_id)
print(f" Formularios: {len(forms)}")
for f in forms:
print(f" - {f.get('id')} {f.get('name')!r}")
db.save_forms(location_id, forms)
if not forms:
print("\nNo hay formularios. Nada que sincronizar.")
return
print("\n[2/2] Descargando submissions...")
total_synced = 0
seen_ids_global = set() # dedup cross-window
for form in forms:
form_id = form.get("id")
form_name = form.get("name") or form_id
for win_start, win_end in windows:
window_label = f"{win_start or 'open'}->{win_end or 'open'}"
def progress(page, page_count, accum, _form_name=form_name, _wl=window_label):
if args.quiet:
return
print(f" {_form_name} [{_wl}] page {page}: +{page_count} (acumulado {accum})")
subs = sync_engine.ghl_client.get_all_form_submissions(
token, location_id,
form_id=form_id,
page_size=args.page_size,
max_submissions=args.max,
start_at=win_start,
end_at=win_end,
progress_callback=progress,
)
# Dedup cross-window: el mismo submission puede aparecer en dos
# ventanas si las fechas se traslapan o si la API tiene jitter.
unique = [s for s in subs if s.get("id") and s["id"] not in seen_ids_global]
for s in unique:
seen_ids_global.add(s["id"])
if not unique:
if not args.quiet:
print(f" {form_name} [{window_label}]: 0 nuevos")
continue
n_saved = db.upsert_form_submissions(location_id, unique, sucursal_field_id=sucursal_field_ids)
total_synced += n_saved
print(f" {form_name} [{window_label}]: {n_saved} nuevos guardados (descargados {len(subs)}, unicos {len(unique)})")
print("\n" + "=" * 60)
db_total = db.count_form_submissions(location_id)
print(f"DONE. Submissions en SQLite para {location_id}: {db_total} (sync de hoy aporto {total_synced})")
if __name__ == "__main__":
main()