#!/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()