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
+421
View File
@@ -0,0 +1,421 @@
#!/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()