422 lines
17 KiB
Python
422 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""fix_brand_sucursal_from_form.py
|
|
|
|
Corrige los campos 'Sucursal' y 'TIENDA' del contacto en la cuenta de Marca
|
|
cuando el formulario original (form_submissions) apunta claramente a otra
|
|
sucursal y existe un duplicado fisico del contacto en esa sucursal correcta.
|
|
|
|
Es el paso 1 del flujo de remediacion para los casos de DISCREPANCIA detectados
|
|
por audit_brand_sucursal_vs_form.py. NO mueve oportunidades, NO elimina
|
|
contactos: solo realinea la etiqueta en Marca para que coincida con lo que el
|
|
cliente dijo en el formulario y donde fisicamente existe.
|
|
|
|
Condiciones que tiene que cumplir un contacto para entrar al plan:
|
|
1. Tiene submission de formulario con sucursal_value extraida.
|
|
2. La similitud entre form_sucursal y la Sucursal actual de Marca es
|
|
menor al umbral de verify (default 0.30 = DISCREPANCIA dura).
|
|
3. Existe duplicado del mismo contacto (por telefono normalizado) en la
|
|
sucursal cuyo nombre matchea el form_sucursal (similitud >= 0.60).
|
|
4. El verificador de sucursales tiene TIENDA y SUCURSAL para esa location.
|
|
|
|
Dry-run por default. Usa --apply para escribir cambios en GHL. Registra cada
|
|
cambio en script_audit cuando se pasa --run-id (auditoria reversible desde el
|
|
dashboard).
|
|
|
|
Uso:
|
|
# Plan (dry-run) para todos los casos de DISCREPANCIA:
|
|
python scripts/fix_brand_sucursal_from_form.py
|
|
|
|
# Solo casos donde el form pidio Queretaro:
|
|
python scripts/fix_brand_sucursal_from_form.py --filter-form-sucursal queretaro
|
|
|
|
# Solo un contacto especifico:
|
|
python scripts/fix_brand_sucursal_from_form.py --only-cid ksIuffpYapCRa1v5lj4t
|
|
|
|
# Aplicar:
|
|
python scripts/fix_brand_sucursal_from_form.py --apply --yes
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import unicodedata
|
|
|
|
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 db # noqa: E402
|
|
import script_audit # noqa: E402
|
|
import sync_engine # noqa: E402
|
|
from audit_brand_sucursal_vs_form import ( # noqa: E402
|
|
similarity, get_contact_sucursal, resolve_sucursal_field_id_from_db,
|
|
latest_submission_per_contact, strip_accents,
|
|
)
|
|
|
|
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
|
VERIFIER_CSV = os.path.join(ROOT_DIR, "Monte Providencia - Verificador de sucursales y correos - Sucursales.csv")
|
|
SCRIPT_NAME = os.path.basename(__file__)
|
|
|
|
# Umbral para considerar que el form_sucursal "matchea" un nombre de sucursal.
|
|
TARGET_LOCATION_MATCH_THRESHOLD = 0.60
|
|
|
|
|
|
def norm_phone(p):
|
|
digits = re.sub(r"\D+", "", str(p or ""))
|
|
return digits[-10:] if len(digits) >= 10 else digits
|
|
|
|
|
|
def norm_email(e):
|
|
return (e or "").strip().lower()
|
|
|
|
|
|
def load_verifier():
|
|
"""{location_id: {tienda, sucursal_label}}"""
|
|
mapping = {}
|
|
if not os.path.exists(VERIFIER_CSV):
|
|
return mapping
|
|
with open(VERIFIER_CSV, encoding="utf-8-sig", newline="") as fh:
|
|
for row in csv.DictReader(fh):
|
|
loc = (row.get("ID LOCATION BUCEFALO") or "").strip()
|
|
if not loc:
|
|
continue
|
|
tienda = (row.get("TIENDA") or "").strip()
|
|
sucursal = (row.get("SUCURSAL") or "").strip()
|
|
mapping[loc] = {
|
|
"tienda": tienda.upper() if tienda else None,
|
|
"sucursal": sucursal if sucursal and sucursal != "-" else None,
|
|
}
|
|
return mapping
|
|
|
|
|
|
def resolve_tienda_field_id(brand_location_id):
|
|
"""Lee object_schemas para encontrar el field id de 'TIENDA' en Marca."""
|
|
conn = db.get_db_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT field_id, field_name FROM object_schemas WHERE location_id=? AND object_key='contact'",
|
|
(brand_location_id,)
|
|
).fetchall()
|
|
for r in rows:
|
|
if strip_accents(r["field_name"]).lower().strip() == "tienda":
|
|
return r["field_id"]
|
|
return None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def resolve_target_location(form_value, accounts, verifier):
|
|
"""Devuelve (location_id, info_verifier, similitud) para la sucursal que mejor
|
|
matchea el form_value. Si la similitud es menor al umbral, devuelve None."""
|
|
best_score = 0.0
|
|
best_lid = None
|
|
best_info = None
|
|
for acc in accounts:
|
|
lid = acc["location_id"]
|
|
if lid == BRAND_LOCATION_ID:
|
|
continue
|
|
info = verifier.get(lid)
|
|
if not info:
|
|
# fallback al nombre de la cuenta (ej. '85975 - MP - Queretaro')
|
|
name = acc.get("nombre") or ""
|
|
parts = name.split(" - ")
|
|
tag = parts[2].strip() if len(parts) >= 3 else name
|
|
info = {"tienda": tag.upper(), "sucursal": tag}
|
|
# comparar contra tanto tienda como sucursal y quedarnos con el max
|
|
for candidate in (info.get("tienda"), info.get("sucursal")):
|
|
if not candidate:
|
|
continue
|
|
s = similarity(form_value, candidate)
|
|
if s > best_score:
|
|
best_score = s
|
|
best_lid = lid
|
|
best_info = info
|
|
if best_score >= TARGET_LOCATION_MATCH_THRESHOLD:
|
|
return best_lid, best_info, best_score
|
|
return None, None, best_score
|
|
|
|
|
|
def build_branch_lookups(conn):
|
|
"""Devuelve (phone_idx, email_idx) sobre todas las sucursales (no Marca).
|
|
Cada idx es {valor_normalizado: [(contact_id, location_id), ...]}. Permite
|
|
validar duplicado fisico cuando el cliente uso phone diferente pero mismo
|
|
email en distintas sucursales (caso comun de clientes que reintentan)."""
|
|
phone_idx, email_idx = {}, {}
|
|
for r in conn.execute(
|
|
"SELECT id, location_id, phone, email FROM contacts WHERE location_id != ?",
|
|
(BRAND_LOCATION_ID,)
|
|
):
|
|
p = norm_phone(r["phone"])
|
|
e = norm_email(r["email"])
|
|
if p:
|
|
phone_idx.setdefault(p, []).append((r["id"], r["location_id"]))
|
|
if e:
|
|
email_idx.setdefault(e, []).append((r["id"], r["location_id"]))
|
|
return phone_idx, email_idx
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--apply", action="store_true",
|
|
help="Aplica los PATCH reales en GHL. Sin esto solo simula (default).")
|
|
parser.add_argument("--verify-threshold", type=float, default=0.30,
|
|
help="Limite superior de similitud para considerar DISCREPANCIA. Default 0.30.")
|
|
parser.add_argument("--filter-form-sucursal",
|
|
help="Procesa solo casos donde el form_sucursal contenga este texto (sin acentos, case-insensitive).")
|
|
parser.add_argument("--only-cid", action="append", default=[],
|
|
help="Procesa solo estos contact_ids (puedes repetir el flag).")
|
|
parser.add_argument("--require-duplicate", action="store_true", default=True,
|
|
help="Solo planifica si existe duplicado en la sucursal correcta (default ON).")
|
|
parser.add_argument("--no-require-duplicate", dest="require_duplicate", action="store_false",
|
|
help="Permite re-etiquetar aunque no haya duplicado fisico (peligroso).")
|
|
parser.add_argument("--run-id", help="Audit run ID. Permite revertir desde el dashboard.")
|
|
parser.add_argument("--yes", action="store_true",
|
|
help="Salta la confirmacion interactiva en --apply.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
args = parse_args()
|
|
|
|
# 1. Cargar datos
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
brand_account = next((a for a in accounts if a["location_id"] == BRAND_LOCATION_ID), None)
|
|
if not brand_account:
|
|
raise SystemExit("No se encontro la cuenta de Marca en el CSV de tokens.")
|
|
brand_token = brand_account["token"]
|
|
|
|
verifier = load_verifier()
|
|
if not verifier:
|
|
print("WARN: verificador de sucursales no disponible; usare fallback al nombre de la cuenta.")
|
|
|
|
suc_fid = resolve_sucursal_field_id_from_db(BRAND_LOCATION_ID)
|
|
if not suc_fid:
|
|
raise SystemExit("No encontre el field 'Sucursal' en object_schemas para Marca. Corre el sync global primero.")
|
|
tienda_fid = resolve_tienda_field_id(BRAND_LOCATION_ID)
|
|
if not tienda_fid:
|
|
print("WARN: no encontre el field 'TIENDA' en Marca; solo voy a actualizar 'Sucursal'.")
|
|
|
|
submissions = db.get_form_submissions(location_id=BRAND_LOCATION_ID, with_sucursal_only=True)
|
|
if not submissions:
|
|
raise SystemExit("No hay form_submissions con sucursal. Corre primero scripts/sync_forms_brand.py")
|
|
latest_by_contact = latest_submission_per_contact(submissions)
|
|
|
|
conn = db.get_db_connection()
|
|
contacts = {r["id"]: dict(r) for r in conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=?", (BRAND_LOCATION_ID,)
|
|
).fetchall()}
|
|
phone_to_branches, email_to_branches = build_branch_lookups(conn)
|
|
accounts_by_lid = {a["location_id"]: a for a in accounts}
|
|
conn.close()
|
|
|
|
filter_norm = strip_accents(args.filter_form_sucursal).lower() if args.filter_form_sucursal else None
|
|
only_cids = set(args.only_cid) if args.only_cid else None
|
|
|
|
# 2. Construir plan
|
|
plans = []
|
|
skipped_no_target = []
|
|
skipped_no_duplicate = []
|
|
|
|
for cid, sub in latest_by_contact.items():
|
|
if only_cids and cid not in only_cids:
|
|
continue
|
|
contact = contacts.get(cid)
|
|
if not contact:
|
|
continue
|
|
marca_val = get_contact_sucursal(contact, suc_fid)
|
|
if not marca_val:
|
|
continue
|
|
form_val = sub.get("sucursal_value") or ""
|
|
if not form_val:
|
|
continue
|
|
sim = similarity(form_val, marca_val)
|
|
if sim >= args.verify_threshold:
|
|
continue # no es DISCREPANCIA, no tocamos
|
|
if filter_norm and filter_norm not in strip_accents(form_val).lower():
|
|
continue
|
|
|
|
target_lid, target_info, target_sim = resolve_target_location(form_val, accounts, verifier)
|
|
target_name = accounts_by_lid.get(target_lid, {}).get("nombre") if target_lid else None
|
|
|
|
base_row = {
|
|
"cid": cid,
|
|
"name": ((contact.get("first_name") or "") + " " + (contact.get("last_name") or "")).strip(),
|
|
"phone": contact.get("phone"),
|
|
"form_sucursal": form_val,
|
|
"marca_sucursal": marca_val,
|
|
"similarity_marca_form": round(sim, 3),
|
|
"target_location_id": target_lid,
|
|
"target_location_name": target_name,
|
|
"target_sim": round(target_sim, 3),
|
|
"target_tienda": (target_info or {}).get("tienda"),
|
|
"target_sucursal_label": (target_info or {}).get("sucursal"),
|
|
}
|
|
|
|
if not target_lid:
|
|
skipped_no_target.append(base_row)
|
|
continue
|
|
|
|
# Validar duplicado fisico en la sucursal correcta. Cruzamos phone Y
|
|
# email porque algunos clientes reintentan con otro numero pero el
|
|
# mismo correo (caso tipico de jorge arroyo: phone distinto en Qro,
|
|
# email identico).
|
|
phone = norm_phone(contact.get("phone"))
|
|
email = norm_email(contact.get("email"))
|
|
dup_by_phone = any(loc == target_lid for (_, loc) in phone_to_branches.get(phone, []))
|
|
dup_by_email = any(loc == target_lid for (_, loc) in email_to_branches.get(email, []))
|
|
has_duplicate = dup_by_phone or dup_by_email
|
|
match_method = (
|
|
"phone+email" if dup_by_phone and dup_by_email
|
|
else "phone" if dup_by_phone
|
|
else "email" if dup_by_email
|
|
else None
|
|
)
|
|
if args.require_duplicate and not has_duplicate:
|
|
base_row["reason"] = f"no existe contacto duplicado en {target_name}"
|
|
skipped_no_duplicate.append(base_row)
|
|
continue
|
|
|
|
base_row["has_duplicate_in_target"] = has_duplicate
|
|
base_row["match_method"] = match_method
|
|
plans.append(base_row)
|
|
|
|
# 3. Reporte
|
|
print("=" * 78)
|
|
print("FIX BRAND SUCURSAL FROM FORM")
|
|
print("=" * 78)
|
|
print(f"Modo: {'APPLY (PATCH real en GHL)' if args.apply else 'DRY-RUN (sin cambios)'}")
|
|
print(f"Verify threshold: {args.verify_threshold} (similitud < threshold = DISCREPANCIA)")
|
|
if filter_norm:
|
|
print(f"Filtro form_sucursal: {args.filter_form_sucursal!r}")
|
|
if only_cids:
|
|
print(f"Solo contact_ids: {sorted(only_cids)}")
|
|
print(f"\nA corregir: {len(plans)}")
|
|
print(f"Saltados (sin target claro): {len(skipped_no_target)}")
|
|
print(f"Saltados (sin duplicado fisico): {len(skipped_no_duplicate)}")
|
|
|
|
if plans:
|
|
print("\n" + "-" * 78)
|
|
print("PLAN DE CORRECCIONES")
|
|
print("-" * 78)
|
|
for p in plans:
|
|
new_tienda = p["target_tienda"] or "(sin TIENDA en verificador)"
|
|
new_suc = p["target_sucursal_label"] or "(sin SUCURSAL en verificador)"
|
|
print(f" {p['name']} (cid={p['cid']})")
|
|
print(f" Form dijo: {p['form_sucursal']!r}")
|
|
print(f" Marca actual: Sucursal={p['marca_sucursal']!r}")
|
|
print(f" -> Cambiar a: Sucursal={new_suc!r} TIENDA={new_tienda!r}")
|
|
print(f" Target sucursal: {p['target_location_name']} (sim={p['target_sim']:.2f}, duplicado={p['has_duplicate_in_target']} via {p.get('match_method')})")
|
|
|
|
if skipped_no_duplicate:
|
|
print("\n" + "-" * 78)
|
|
print("SALTADOS POR FALTA DE DUPLICADO FISICO (revisar manualmente)")
|
|
print("-" * 78)
|
|
for p in skipped_no_duplicate:
|
|
print(f" {p['name']}: form pedia {p['form_sucursal']!r} -> target {p['target_location_name']}, "
|
|
f"pero el contacto no existe ahi. {p.get('reason','')}")
|
|
|
|
if skipped_no_target:
|
|
print("\n" + "-" * 78)
|
|
print("SALTADOS POR FALTA DE TARGET CLARO (form value no matchea ninguna sucursal)")
|
|
print("-" * 78)
|
|
for p in skipped_no_target:
|
|
print(f" {p['name']}: form_sucursal={p['form_sucursal']!r} best_sim={p['target_sim']:.2f}")
|
|
|
|
if not plans:
|
|
print("\nNada que aplicar. Termino.")
|
|
return
|
|
|
|
# 4. Aplicar si --apply
|
|
if not args.apply:
|
|
print("\n" + "=" * 78)
|
|
print("DRY-RUN: no se escribio nada en GHL. Usa --apply --yes para aplicar.")
|
|
return
|
|
|
|
if not args.yes:
|
|
if sys.stdin.isatty():
|
|
print(f"\nVas a actualizar Sucursal/TIENDA en {len(plans)} contacto(s) de la cuenta de Marca.")
|
|
response = input("Escribe CONFIRMO para continuar: ").strip()
|
|
if response != "CONFIRMO":
|
|
raise SystemExit("Cancelado por el usuario.")
|
|
else:
|
|
raise SystemExit("--apply requiere --yes en entornos no TTY (dashboard). Abortando por seguridad.")
|
|
|
|
if args.run_id:
|
|
script_audit.create_run(
|
|
args.run_id,
|
|
SCRIPT_NAME,
|
|
arguments=" ".join(sys.argv[1:]),
|
|
locations=[BRAND_LOCATION_ID],
|
|
execution_mode="sequential",
|
|
)
|
|
|
|
applied = 0
|
|
failed = 0
|
|
for p in plans:
|
|
if args.run_id and not script_audit.wait_if_paused_or_stopped(args.run_id):
|
|
break
|
|
cid = p["cid"]
|
|
custom_fields = []
|
|
change_records = [] # (change_id, field_id, field_name, old_value, new_value)
|
|
|
|
if p["target_sucursal_label"]:
|
|
custom_fields.append({"id": suc_fid, "value": p["target_sucursal_label"]})
|
|
change_records.append((suc_fid, "Sucursal", p["marca_sucursal"], p["target_sucursal_label"]))
|
|
if tienda_fid and p["target_tienda"]:
|
|
# leer valor actual de TIENDA del contacto en Marca
|
|
try:
|
|
cfs = json.loads(contacts[cid].get("custom_fields_json") or "[]")
|
|
except Exception:
|
|
cfs = []
|
|
current_tienda = None
|
|
for f in cfs:
|
|
if isinstance(f, dict) and (f.get("id") or f.get("fieldId")) == tienda_fid:
|
|
current_tienda = f.get("value")
|
|
break
|
|
custom_fields.append({"id": tienda_fid, "value": p["target_tienda"]})
|
|
change_records.append((tienda_fid, "TIENDA", current_tienda, p["target_tienda"]))
|
|
|
|
if not custom_fields:
|
|
continue
|
|
|
|
change_ids = []
|
|
if args.run_id:
|
|
for (fid, fname, old, new) in change_records:
|
|
change_id = script_audit.record_change(
|
|
args.run_id, BRAND_LOCATION_ID, "contact", cid, fid, fname, old, new
|
|
)
|
|
if change_id is not None:
|
|
change_ids.append(change_id)
|
|
|
|
try:
|
|
sync_engine.ghl_client.update_contact(brand_token, cid, {"customFields": custom_fields})
|
|
applied += 1
|
|
print(f" OK {p['name']} -> Sucursal={p['target_sucursal_label']!r} TIENDA={p['target_tienda']!r}")
|
|
for change_id in change_ids:
|
|
script_audit.mark_change(change_id, "applied")
|
|
except Exception as exc:
|
|
failed += 1
|
|
print(f" ERR {p['name']}: {exc}")
|
|
for change_id in change_ids:
|
|
script_audit.mark_change(change_id, "failed", str(exc))
|
|
|
|
print("\n" + "=" * 78)
|
|
print(f"APPLIED: {applied} FAILED: {failed}")
|
|
if args.run_id:
|
|
script_audit.update_run_status(args.run_id, "failed" if failed else "success")
|
|
if failed:
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|