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
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""sync_missing_contacts_to_brand.py
Sincroniza al CRM de Marca los contactos que existen en sucursal y NO tienen
contraparte en Marca (bucket "contacts_in_branch_not_in_brand" del audit
audit_brand_vs_branches_totals).
Flujo por cada contacto de sucursal sin contraparte en Marca:
1. Doble verificacion en Marca antes de crear, buscando por:
a) telefono normalizado (ultimos 10 digitos)
b) email (lowercase)
c) nombre completo (normalizado: sin acentos, lowercase, espacios colapsados)
2. Si match en cualquiera de los 3 criterios: skip (ya existe, falso positivo
del audit que solo busca por phone/email).
3. Si no existe: crea el contacto en Marca con todos los datos basicos y los
custom fields mapeados por nombre del schema de la sucursal al de Marca.
NO crea oportunidades. Si el contacto tenia opps en sucursal, esas se sincronizan
con `sync_missing_opps_to_brand.py` (que ya gestiona contactos faltantes ad-hoc).
Modos:
- dry-run (default): no escribe nada en GHL, solo planea.
- --apply: ejecuta las escrituras y registra cada cambio en script_audit
(rollback disponible desde el dashboard).
Uso CLI:
python scripts/sync_missing_contacts_to_brand.py
python scripts/sync_missing_contacts_to_brand.py --apply --yes
python scripts/sync_missing_contacts_to_brand.py --apply --run-id <ID>
python scripts/sync_missing_contacts_to_brand.py --only-contact <id>
python scripts/sync_missing_contacts_to_brand.py --json
Para uso programatico (desde el endpoint /api/comparativa/sync-missing-contacts):
from scripts.sync_missing_contacts_to_brand import run_sync
result = run_sync(dry_run=True)
"""
import argparse
import json
import os
import sqlite3
import sys
from datetime import datetime
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)
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPTS_DIR not in sys.path:
sys.path.insert(0, SCRIPTS_DIR)
import script_audit # noqa: E402
import sync_engine # noqa: E402
from audit_brand_vs_branches_totals import ( # noqa: E402
run_audit,
normalize_phone,
normalize_email,
resolve_contact_link_field_id,
extract_contact_link_value,
)
from sync_missing_opps_to_brand import ( # noqa: E402
BRAND_LOCATION_ID,
DB_PATH,
build_brand_contact_payload,
find_brand_contact,
index_brand_contacts,
load_branch_contact,
load_brand_contacts,
load_schemas_id_to_name,
load_schemas_name_to_id,
normalize_name,
safe_print,
upsert_brand_contact_in_db,
)
SCRIPT_NAME = os.path.basename(__file__)
def run_sync(contact_ids=None, dry_run=True, log=None, run_id=None):
"""Ejecuta la creacion masiva de contactos en Marca. Devuelve dict serializable.
Args:
contact_ids: lista opcional de contact_ids de sucursal a procesar.
None = todos los del bucket contacts_in_branch_not_in_brand.
dry_run: True (default) = no escribe en GHL.
log: funcion opcional log(line) para streaming.
run_id: id de script_audit para registrar cambios cuando apply.
"""
if log is None:
log = safe_print
if not os.path.exists(DB_PATH):
raise FileNotFoundError(f"No existe {DB_PATH}. Corre una sincronizacion global primero.")
log(f"[{datetime.now().strftime('%H:%M:%S')}] === sync_missing_contacts_to_brand ===")
log(f"Modo: {'DRY-RUN (no escribe)' if dry_run else 'APPLY (escribe en GHL)'}")
log("Calculando bucket contacts_in_branch_not_in_brand desde audit...")
audit_data = run_audit(limit_missing=None)
missing = audit_data["missing"]["contacts_in_branch_not_in_brand"]
targets = missing["items"]
if contact_ids:
wanted = set(contact_ids)
targets = [t for t in targets if t["id"] in wanted]
log(f"Contactos candidatos: {len(targets)} (total en bucket: {missing['total']})")
if not targets:
return {
"dry_run": dry_run,
"summary": {
"candidates": 0,
"contacts_created": 0,
"skipped_already_in_brand": 0,
"errors": 0,
},
"items": [],
}
tokens = sync_engine.get_tokens_map()
brand_token = tokens.get(BRAND_LOCATION_ID)
if not brand_token:
raise RuntimeError("No se encontro token para la cuenta de Marca en el CSV de tokens.")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
brand_contact_schema_name_to_id = load_schemas_name_to_id(conn, BRAND_LOCATION_ID, "contact")
if not brand_contact_schema_name_to_id:
log("ADVERTENCIA: schema de contactos de Marca vacio. Corre sync de metadata para que los custom fields se mapeen.")
brand_contacts = load_brand_contacts(conn)
idx_phone, idx_email, idx_name = index_brand_contacts(brand_contacts)
log(f"Indice Marca: {len(brand_contacts)} contactos.")
# Índice DETERMINÍSTICO por "ID Contacto Sucursal" (paralelo al match por id
# de opps). Match principal antes de la cascada phone/email/name.
brand_contact_link_field_id = resolve_contact_link_field_id(conn, BRAND_LOCATION_ID)
brand_contacts_by_link = {}
if brand_contact_link_field_id:
for bc in brand_contacts:
v = extract_contact_link_value(bc.get("custom_fields_json"), brand_contact_link_field_id)
if v:
brand_contacts_by_link.setdefault(v, bc)
log(f"Índice por 'ID Contacto Sucursal': {len(brand_contacts_by_link)} contactos Marca con link poblado.")
results = []
summary = {
"candidates": len(targets),
"contacts_created": 0,
"skipped_already_in_brand": 0,
"errors": 0,
}
for idx, target in enumerate(targets, 1):
branch_loc_id = target["branch_location_id"]
branch_name = target.get("branch_name", "")
branch_contact_id = target["id"]
display_name = target.get("name") or "(sin nombre)"
log(f"\n[{idx}/{len(targets)}] Contacto {branch_contact_id} | {display_name} | sucursal: {branch_name}")
item = {
"branch_contact_id": branch_contact_id,
"branch_location_id": branch_loc_id,
"branch_name": branch_name,
"name": display_name,
"phone": target.get("phone"),
"email": target.get("email"),
"opps_in_branch": target.get("opps_in_branch", 0),
"actions": [],
"status": "pending",
"error": None,
}
try:
branch_contact = load_branch_contact(conn, branch_loc_id, branch_contact_id)
if not branch_contact:
raise RuntimeError(f"No se encontro el contacto {branch_contact_id} en SQLite")
# Match PRINCIPAL determinístico por "ID Contacto Sucursal".
# Si una opp de Marca tiene como link el id de este contacto de
# sucursal -> ya existe en Marca, skip (el audit pudo dejarlo en
# missing por sync de SQLite viejo).
linked = brand_contacts_by_link.get(branch_contact_id)
if linked:
log(f" Match por 'ID Contacto Sucursal' -> contacto Marca {linked.get('id')}. Skip (ya existe).")
item["status"] = "skipped_already_in_brand"
item["actions"].append({
"action": "skip_already_in_brand",
"strategy": "id_contacto_sucursal",
"brand_contact_id": linked.get("id"),
})
summary["skipped_already_in_brand"] += 1
results.append(item)
continue
# Double-check: el audit busca por phone/email; aqui agregamos nombre.
match, strategy, collision = find_brand_contact(branch_contact, idx_phone, idx_email, idx_name)
if collision and not match:
# Phone idéntico a un contacto en Marca pero el nombre
# diverge — caso pareja con mismo número. Crear este
# contacto en Marca de forma normal NO es seguro porque
# algunas integraciones lo detectarán como duplicado y
# mergearán/eliminarán. Reportamos y dejamos para
# revisión manual.
log(
f" COLISION TELEFONO con contacto Marca {collision.get('id')} "
f"({(collision.get('first_name') or '') + ' ' + (collision.get('last_name') or '')!r}). "
"Skip para revision manual."
)
item["status"] = "skipped_phone_collision"
item["actions"].append({
"action": "skip_phone_collision",
"colliding_brand_contact_id": collision.get("id"),
"colliding_brand_name": f"{collision.get('first_name') or ''} {collision.get('last_name') or ''}".strip(),
})
summary.setdefault("phone_collisions_unresolved", 0)
summary["phone_collisions_unresolved"] += 1
if run_id:
try:
script_audit.record_change(
run_id, BRAND_LOCATION_ID, "contact", branch_contact_id,
"", "skipped_phone_collision", None,
{
"branch_contact_id": branch_contact_id,
"branch_location_id": branch_loc_id,
"colliding_brand_contact_id": collision.get("id"),
"phone": branch_contact.get("phone"),
},
)
except Exception as audit_exc:
log(f" WARN: no se pudo registrar la colision en script_audit: {audit_exc}")
results.append(item)
continue
if match:
log(f" YA EXISTE en Marca por {strategy}: {match['id']}. Skip.")
item["status"] = "skipped"
item["actions"].append({
"action": "skip_already_in_brand",
"strategy": strategy,
"brand_contact_id": match["id"],
})
summary["skipped_already_in_brand"] += 1
results.append(item)
continue
# Construir payload y crear.
branch_contact_schema_id_to_name = load_schemas_id_to_name(conn, branch_loc_id, "contact")
payload = build_brand_contact_payload(
branch_contact,
branch_contact_schema_id_to_name,
brand_contact_schema_name_to_id,
)
log(f" Plan: CREAR en Marca (cf_count={len(payload.get('customFields', []))})")
item["actions"].append({
"action": "create_contact",
"payload_preview": _preview_payload(payload),
})
if not dry_run:
res = sync_engine.ghl_client.create_contact(brand_token, payload)
brand_contact_id = (res.get("contact") or {}).get("id") or res.get("id")
if not brand_contact_id:
raise RuntimeError(f"GHL no devolvio id de contacto creado. Respuesta: {res}")
summary["contacts_created"] += 1
log(f" Contacto creado en Marca: {brand_contact_id}")
item["actions"][-1]["result"] = {"brand_contact_id": brand_contact_id}
# Replicar en SQLite local para mantener snapshot sincronizado.
try:
upsert_brand_contact_in_db(conn, brand_contact_id, payload)
except Exception as db_exc:
log(f" WARN: no se pudo upsert contacto en SQLite: {db_exc}")
# Refresh autoritativo desde Bucéfalo para garantizar 1:1
# (CFs auto-poblados por GHL, tags, dateAdded reales).
try:
ref = sync_engine.refresh_contact_in_db(brand_token, brand_contact_id, BRAND_LOCATION_ID)
if not ref.get("ok"):
log(f" WARN: refresh_contact_in_db fallo: {ref.get('error')}")
summary.setdefault("local_refresh_errors", 0)
summary["local_refresh_errors"] += 1
except Exception as ref_exc:
log(f" WARN: refresh_contact_in_db excepcion: {ref_exc}")
# Indexar el nuevo en memoria para detectar duplicados intra-batch.
new_c = {
"id": brand_contact_id,
"first_name": branch_contact.get("first_name"),
"last_name": branch_contact.get("last_name"),
"phone": branch_contact.get("phone"),
"email": branch_contact.get("email"),
}
p = normalize_phone(new_c.get("phone"))
e = normalize_email(new_c.get("email"))
n = normalize_name(new_c.get("first_name"), new_c.get("last_name"))
if p: idx_phone.setdefault(p, new_c)
if e: idx_email.setdefault(e, new_c)
if n: idx_name.setdefault(n, new_c)
if run_id:
cid = script_audit.record_change(
run_id, BRAND_LOCATION_ID, "contact", brand_contact_id,
"", "created", None,
{"phone": new_c["phone"], "email": new_c["email"],
"name": f"{new_c['first_name']} {new_c['last_name']}".strip(),
"source_branch": branch_loc_id},
)
if cid:
script_audit.mark_change(cid, "applied")
else:
summary["contacts_created"] += 1
item["status"] = "created"
results.append(item)
except Exception as e:
summary["errors"] += 1
item["status"] = "error"
item["error"] = str(e)
log(f" ERROR: {e}")
results.append(item)
log(f"\n=== RESUMEN ===")
log(f" Candidatos : {summary['candidates']}")
log(f" Contactos {'a crear' if dry_run else 'creados'}: {summary['contacts_created']}")
log(f" Ya existian en Marca: {summary['skipped_already_in_brand']}")
if summary.get("phone_collisions_unresolved"):
log(f" Colisiones telefono sin match (revision manual): {summary['phone_collisions_unresolved']}")
log(f" Errores : {summary['errors']}")
return {
"dry_run": dry_run,
"summary": summary,
"items": results,
}
finally:
conn.close()
def _preview_payload(payload):
cf_count = len(payload.get("customFields", []))
p = {k: v for k, v in payload.items() if k != "customFields"}
if cf_count:
p["customFields_count"] = cf_count
return p
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--apply", action="store_true", help="Ejecuta las escrituras en GHL. Por default es dry-run.")
parser.add_argument("--yes", action="store_true", help="Skip confirmacion interactiva.")
parser.add_argument("--only-contact", action="append", default=[], help="Procesa solo el contact_id dado (puede repetirse).")
parser.add_argument("--run-id", type=str, default=None, help="Id de script_audit. Solo aplica con --apply.")
parser.add_argument("--json", action="store_true", help="Imprime el resultado como JSON al final.")
args = parser.parse_args()
dry_run = not args.apply
if not dry_run and not args.yes:
confirm = input("Esto escribira en GHL. Continuar? (y/N): ").strip().lower()
if confirm not in ("y", "yes", "s", "si", ""):
print("Cancelado.")
sys.exit(0)
try:
result = run_sync(
contact_ids=args.only_contact or None,
dry_run=dry_run,
log=safe_print,
run_id=args.run_id,
)
except FileNotFoundError as e:
safe_print(f"ERROR: {e}")
sys.exit(2)
except RuntimeError as e:
safe_print(f"ERROR: {e}")
sys.exit(3)
if args.json:
safe_print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
if __name__ == "__main__":
main()