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