Primer commit
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""audit_brand_sucursal_vs_form.py
|
||||
|
||||
Read-only. Compara el campo 'Sucursal' que el contacto envio originalmente en
|
||||
el formulario (cuenta de Marca) contra el valor actual del campo 'Sucursal' en
|
||||
el contacto de Marca. Usa similitud difusa para tolerar abreviaciones de
|
||||
estado de versiones viejas del form (ej. 'QRO' vs 'Queretaro, Queretaro').
|
||||
|
||||
Requisitos:
|
||||
1. Haber corrido el sync global (Sincronizar Todo) para tener contactos.
|
||||
2. Haber corrido `python scripts/sync_forms_brand.py` para tener
|
||||
form_submissions en SQLite.
|
||||
|
||||
Buckets (por defecto):
|
||||
- OK (similitud >= 0.60): el form y Marca apuntan a la misma sucursal o
|
||||
a una abreviacion razonable.
|
||||
- VERIFICAR (0.30 <= similitud < 0.60): nombres parecidos pero no
|
||||
identicos; conviene revisar manualmente.
|
||||
- DISCREPANCIA (similitud < 0.30): el contacto deberia estar en otra
|
||||
sucursal segun lo que el mismo cliente puso en el formulario.
|
||||
|
||||
Casos especiales:
|
||||
- Contacto sin campo Sucursal en Marca: bucket CONTACTO_SIN_SUCURSAL.
|
||||
- Submission con sucursal_value vacio: se ignora (no aporta evidencia).
|
||||
- Multiples submissions por contacto: usamos el ULTIMO (createdAt mas
|
||||
reciente) porque refleja la intencion mas actual del cliente.
|
||||
|
||||
Uso:
|
||||
python scripts/audit_brand_sucursal_vs_form.py
|
||||
python scripts/audit_brand_sucursal_vs_form.py --filter-marca queretaro
|
||||
python scripts/audit_brand_sucursal_vs_form.py --show all
|
||||
python scripts/audit_brand_sucursal_vs_form.py --xlsx
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
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)
|
||||
|
||||
import db # noqa: E402
|
||||
|
||||
from common import REPORT_DRIFT # noqa: E402
|
||||
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
EXPORTS_DIR = REPORT_DRIFT # mantenemos el nombre local por compat con el resto del script
|
||||
|
||||
# --- Helpers de normalizacion -------------------------------------------------
|
||||
|
||||
# Estados mexicanos con abreviaciones comunes que aparecen en forms viejos.
|
||||
STATE_ABBR = {
|
||||
"ags": "aguascalientes",
|
||||
"bc": "baja california",
|
||||
"bcs": "baja california sur",
|
||||
"camp": "campeche",
|
||||
"chis": "chiapas",
|
||||
"chih": "chihuahua",
|
||||
"coah": "coahuila",
|
||||
"col": "colima",
|
||||
"cdmx": "ciudad de mexico",
|
||||
"df": "ciudad de mexico",
|
||||
"dgo": "durango",
|
||||
"edomex": "estado de mexico",
|
||||
"mex": "estado de mexico",
|
||||
"gto": "guanajuato",
|
||||
"gro": "guerrero",
|
||||
"hgo": "hidalgo",
|
||||
"jal": "jalisco",
|
||||
"mich": "michoacan",
|
||||
"mor": "morelos",
|
||||
"nay": "nayarit",
|
||||
"nl": "nuevo leon",
|
||||
"oax": "oaxaca",
|
||||
"pue": "puebla",
|
||||
"qro": "queretaro",
|
||||
"qroo": "quintana roo",
|
||||
"slp": "san luis potosi",
|
||||
"sin": "sinaloa",
|
||||
"son": "sonora",
|
||||
"tab": "tabasco",
|
||||
"tamps": "tamaulipas",
|
||||
"tlax": "tlaxcala",
|
||||
"ver": "veracruz",
|
||||
"yuc": "yucatan",
|
||||
"zac": "zacatecas",
|
||||
}
|
||||
|
||||
|
||||
def strip_accents(text):
|
||||
if not text:
|
||||
return ""
|
||||
t = unicodedata.normalize("NFKD", str(text))
|
||||
return "".join(ch for ch in t if not unicodedata.combining(ch))
|
||||
|
||||
|
||||
def normalize_sucursal(value):
|
||||
"""Normaliza un string de sucursal:
|
||||
- minusculas, sin acentos, sin puntuacion
|
||||
- expande abreviaciones de estado conocidas
|
||||
- resultado es lista de tokens unicos ordenados
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
text = strip_accents(str(value)).lower()
|
||||
text = re.sub(r"[^\w\s]", " ", text)
|
||||
tokens = text.split()
|
||||
expanded = []
|
||||
for tok in tokens:
|
||||
if tok in STATE_ABBR:
|
||||
expanded.extend(STATE_ABBR[tok].split())
|
||||
else:
|
||||
expanded.append(tok)
|
||||
# quitar duplicados y stopwords irrelevantes
|
||||
stop = {"de", "la", "el", "los", "las", "mp", "monte", "providencia"}
|
||||
cleaned = [t for t in expanded if t and t not in stop]
|
||||
return " ".join(cleaned)
|
||||
|
||||
|
||||
def similarity(a, b):
|
||||
"""Devuelve un ratio 0-1. Prioriza overlap de tokens (Jaccard) sobre
|
||||
coincidencia caracter-a-caracter, porque dos sucursales completamente
|
||||
distintas (Puebla vs Queretaro) pueden compartir letras pero ningun token.
|
||||
|
||||
Reglas:
|
||||
- Mismo string normalizado -> 1.0
|
||||
- Jaccard de tokens == 0 (disjuntas) -> max 0.25 (forzamos DISCREPANCIA)
|
||||
- Si tokens se sobreponen -> promedio 50/50 Jaccard + SequenceMatcher
|
||||
"""
|
||||
na, nb = normalize_sucursal(a), normalize_sucursal(b)
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
if na == nb:
|
||||
return 1.0
|
||||
ta, tb = set(na.split()), set(nb.split())
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
jaccard = len(ta & tb) / len(ta | tb)
|
||||
seq = SequenceMatcher(None, na, nb).ratio()
|
||||
if jaccard == 0:
|
||||
# Sin ningun token en comun -> claramente sucursales distintas.
|
||||
# Cap a 0.25 para que caiga en DISCREPANCIA. Conservamos algo de seq
|
||||
# para distinguir errores tipograficos cercanos de errores burdos.
|
||||
return min(0.25, seq * 0.3)
|
||||
return 0.5 * jaccard + 0.5 * seq
|
||||
|
||||
|
||||
# --- Clasificacion de origen del contacto ------------------------------------
|
||||
# Regla (priorizada de mas a menos confiable):
|
||||
# 1. Si tiene submission en form_submissions + tag 'formulario' -> FORMULARIO (evidencia dura)
|
||||
# 2. Si tiene tag 'formulario' (sin submission registrado) -> FORMULARIO_SIN_RASTRO
|
||||
# 3. Si tiene tag 'sucursal' (sin tag 'formulario') -> SUCURSAL
|
||||
# 4. Si source nativo contiene 'web user' -> SUCURSAL (confirma sucursal)
|
||||
# 5. Si source contiene facebook/instagram/ads/google/lead -> DIGITAL
|
||||
# 6. Si source contiene 'integration' -> INTEGRATION (probable reajuste)
|
||||
# 7. Sin senales claras -> DESCONOCIDO
|
||||
|
||||
DIGITAL_KEYWORDS = ("facebook", "instagram", "google", "tiktok", "youtube",
|
||||
"ads", "lead", "landing", "messenger", "linkedin")
|
||||
|
||||
|
||||
def classify_origin(contact_tags, contact_source, has_form_submission):
|
||||
tags_lower = {str(t).strip().lower() for t in (contact_tags or []) if t}
|
||||
source_lower = str(contact_source or "").strip().lower()
|
||||
has_form_tag = "formulario" in tags_lower
|
||||
has_suc_tag = "sucursal" in tags_lower
|
||||
|
||||
if has_form_tag and has_form_submission:
|
||||
return "FORMULARIO"
|
||||
if has_form_tag:
|
||||
return "FORMULARIO_SIN_RASTRO"
|
||||
if has_suc_tag and "formulario" not in tags_lower:
|
||||
return "SUCURSAL"
|
||||
if "web user" in source_lower or "webuser" in source_lower:
|
||||
return "SUCURSAL"
|
||||
if "sucursal" in source_lower:
|
||||
return "SUCURSAL"
|
||||
if "form" in source_lower:
|
||||
# source = "Formulario" / "Formulario - Sitio Web" sin tag formulario
|
||||
return "FORMULARIO_SIN_RASTRO" if not has_form_submission else "FORMULARIO"
|
||||
if any(k in source_lower for k in DIGITAL_KEYWORDS):
|
||||
return "DIGITAL"
|
||||
if "integration" in source_lower:
|
||||
return "INTEGRATION"
|
||||
return "DESCONOCIDO"
|
||||
|
||||
|
||||
def origin_confidence(origin):
|
||||
"""Cuanto pesa la evidencia que aporta el origen al juicio sobre Sucursal."""
|
||||
return {
|
||||
"FORMULARIO": "alta", # el cliente mismo eligio la sucursal
|
||||
"FORMULARIO_SIN_RASTRO": "media",
|
||||
"SUCURSAL": "alta", # se creo manualmente en esa sucursal
|
||||
"DIGITAL": "media",
|
||||
"INTEGRATION": "baja", # probable reajuste historico
|
||||
"DESCONOCIDO": "baja",
|
||||
}.get(origin, "baja")
|
||||
|
||||
|
||||
def parse_tags(tags_value):
|
||||
"""tags se guarda como JSON string en SQLite."""
|
||||
if not tags_value:
|
||||
return []
|
||||
if isinstance(tags_value, list):
|
||||
return tags_value
|
||||
try:
|
||||
parsed = json.loads(tags_value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# --- Extraccion de Sucursal del contacto en Marca ----------------------------
|
||||
|
||||
SUCURSAL_CANONICAL = "sucursal"
|
||||
|
||||
|
||||
def extract_custom_value(field):
|
||||
for k in ("value", "fieldValueString", "fieldValueDate",
|
||||
"fieldValueNumber", "fieldValueArray",
|
||||
"fieldValueOptions", "fieldValueFile"):
|
||||
v = field.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, str) and not v.strip():
|
||||
continue
|
||||
if isinstance(v, (list, dict)) and not v:
|
||||
continue
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def resolve_sucursal_field_id_from_db(location_id):
|
||||
"""Lee object_schemas (poblada por el sync) para mapear nombre -> id sin
|
||||
pegarle a GHL otra vez."""
|
||||
conn = db.get_db_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT field_id, field_name FROM object_schemas WHERE location_id=? AND object_key='contact'",
|
||||
(location_id,)
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if strip_accents(r["field_name"]).lower().strip() == SUCURSAL_CANONICAL:
|
||||
return r["field_id"]
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_contact_sucursal(contact_row, sucursal_field_id):
|
||||
if not sucursal_field_id:
|
||||
return None
|
||||
try:
|
||||
cfs = json.loads(contact_row.get("custom_fields_json") or "[]")
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(cfs, list):
|
||||
return None
|
||||
for f in cfs:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
fid = f.get("id") or f.get("fieldId")
|
||||
if fid == sucursal_field_id:
|
||||
v = extract_custom_value(f)
|
||||
if isinstance(v, list):
|
||||
return ", ".join(str(x) for x in v if x is not None) or None
|
||||
if v is not None:
|
||||
return str(v).strip() or None
|
||||
return None
|
||||
|
||||
|
||||
# --- Buckets y reporte -------------------------------------------------------
|
||||
|
||||
def categorize(sim, ok_threshold, verify_threshold):
|
||||
if sim >= ok_threshold:
|
||||
return "OK"
|
||||
if sim >= verify_threshold:
|
||||
return "VERIFICAR"
|
||||
return "DISCREPANCIA"
|
||||
|
||||
|
||||
def latest_submission_per_contact(submissions):
|
||||
"""Mantiene la submission mas reciente por contact_id (cuando trae sucursal)."""
|
||||
by_contact = {}
|
||||
for s in submissions:
|
||||
cid = s.get("contact_id")
|
||||
if not cid:
|
||||
continue
|
||||
if not s.get("sucursal_value"):
|
||||
continue
|
||||
prev = by_contact.get(cid)
|
||||
if prev is None or (s.get("created_at") or "") > (prev.get("created_at") or ""):
|
||||
by_contact[cid] = s
|
||||
return by_contact
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--location", default=BRAND_LOCATION_ID,
|
||||
help=f"Location a auditar. Default: Marca ({BRAND_LOCATION_ID})")
|
||||
parser.add_argument("--ok-threshold", type=float, default=0.60,
|
||||
help="Similitud minima para considerar OK. Default 0.60")
|
||||
parser.add_argument("--verify-threshold", type=float, default=0.30,
|
||||
help="Similitud minima para 'verificar'. <esto = DISCREPANCIA. Default 0.30")
|
||||
parser.add_argument("--show", choices=["discrepancia", "verificar", "all", "none"],
|
||||
default="discrepancia",
|
||||
help="Que detalle imprimir. Default: solo DISCREPANCIA.")
|
||||
parser.add_argument("--filter-marca",
|
||||
help="Filtra solo contactos cuya Sucursal en Marca contenga este texto (case-insensitive, sin acentos). Ej: 'queretaro'")
|
||||
parser.add_argument("--origin",
|
||||
help="Filtra el detalle a uno o varios origenes (coma-separados): "
|
||||
"FORMULARIO, FORMULARIO_SIN_RASTRO, SUCURSAL, DIGITAL, INTEGRATION, DESCONOCIDO")
|
||||
parser.add_argument("--xlsx", dest="xlsx_path", nargs="?", const="",
|
||||
help="Exporta Excel. Sin argumento guarda en exports/ con timestamp.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
if not (0 <= args.verify_threshold < args.ok_threshold <= 1):
|
||||
raise SystemExit("Thresholds invalidos: 0 <= verify < ok <= 1")
|
||||
|
||||
location_id = args.location
|
||||
|
||||
# 1. submissions con sucursal (puede estar vacio, no es fatal: el audit
|
||||
# entonces se basa solo en tags + source para clasificar origen)
|
||||
submissions = db.get_form_submissions(location_id=location_id, with_sucursal_only=True)
|
||||
latest_by_contact = latest_submission_per_contact(submissions)
|
||||
print(f"Submissions con sucursal: {len(submissions)} "
|
||||
f"(unicos por contacto, ultimo: {len(latest_by_contact)})")
|
||||
|
||||
# set de contact_ids con submission (para clasificar origen)
|
||||
all_subs_any = db.get_form_submissions(location_id=location_id)
|
||||
contact_ids_with_submission = {s["contact_id"] for s in all_subs_any if s.get("contact_id")}
|
||||
|
||||
# 2. contactos de la location
|
||||
conn = db.get_db_connection()
|
||||
contact_rows = conn.execute(
|
||||
"SELECT * FROM contacts WHERE location_id=?", (location_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
contacts_by_id = {r["id"]: dict(r) for r in contact_rows}
|
||||
print(f"Contactos en {location_id}: {len(contacts_by_id)}")
|
||||
|
||||
# 3. field id de Sucursal
|
||||
sucursal_fid = resolve_sucursal_field_id_from_db(location_id)
|
||||
if not sucursal_fid:
|
||||
raise SystemExit(f"No encontre el field 'Sucursal' en object_schemas para {location_id}. "
|
||||
"Corre el sync global primero.")
|
||||
|
||||
# 4. comparar
|
||||
filter_norm = strip_accents(args.filter_marca).lower() if args.filter_marca else None
|
||||
|
||||
buckets = {"OK": [], "VERIFICAR": [], "DISCREPANCIA": [],
|
||||
"CONTACTO_SIN_SUCURSAL": [], "CONTACTO_NO_EN_DB": [],
|
||||
"SIN_SUBMISSION": []} # contactos sin form submission, solo clasificados por origen
|
||||
by_marca_sucursal = defaultdict(lambda: Counter())
|
||||
origin_counter = Counter()
|
||||
origin_x_bucket = defaultdict(Counter) # origin -> bucket -> count
|
||||
|
||||
def build_row(contact, cid, sub):
|
||||
marca_val = get_contact_sucursal(contact, sucursal_fid)
|
||||
tags = parse_tags(contact.get("tags"))
|
||||
source = contact.get("source")
|
||||
has_sub = cid in contact_ids_with_submission
|
||||
origin = classify_origin(tags, source, has_sub)
|
||||
return {
|
||||
"contact_id": cid,
|
||||
"name": (contact.get("first_name") or "") + " " + (contact.get("last_name") or ""),
|
||||
"email": contact.get("email"),
|
||||
"phone": contact.get("phone"),
|
||||
"tags": tags,
|
||||
"source": source,
|
||||
"origin": origin,
|
||||
"origin_confidence": origin_confidence(origin),
|
||||
"marca_sucursal": marca_val,
|
||||
"form_sucursal": (sub.get("sucursal_value") if sub else None) or None,
|
||||
"similarity": None,
|
||||
"bucket": None,
|
||||
"submission_at": (sub.get("created_at") if sub else None),
|
||||
"submission_id": (sub.get("id") if sub else None),
|
||||
}
|
||||
|
||||
# 4a. Contactos CON submission: comparamos form vs marca
|
||||
contacts_evaluated_via_form = set()
|
||||
for cid, sub in latest_by_contact.items():
|
||||
contact = contacts_by_id.get(cid)
|
||||
form_val = sub.get("sucursal_value") or ""
|
||||
if not contact:
|
||||
buckets["CONTACTO_NO_EN_DB"].append({
|
||||
"contact_id": cid,
|
||||
"name": sub.get("name"),
|
||||
"email": sub.get("email"),
|
||||
"phone": sub.get("phone"),
|
||||
"form_sucursal": form_val,
|
||||
"submission_at": sub.get("created_at"),
|
||||
"origin": "FORMULARIO",
|
||||
"origin_confidence": "alta",
|
||||
})
|
||||
continue
|
||||
row_data = build_row(contact, cid, sub)
|
||||
if filter_norm and (not row_data["marca_sucursal"] or
|
||||
filter_norm not in strip_accents(row_data["marca_sucursal"]).lower()):
|
||||
continue
|
||||
if not row_data["marca_sucursal"]:
|
||||
row_data["bucket"] = "CONTACTO_SIN_SUCURSAL"
|
||||
buckets["CONTACTO_SIN_SUCURSAL"].append(row_data)
|
||||
origin_x_bucket[row_data["origin"]]["CONTACTO_SIN_SUCURSAL"] += 1
|
||||
origin_counter[row_data["origin"]] += 1
|
||||
contacts_evaluated_via_form.add(cid)
|
||||
continue
|
||||
sim = similarity(form_val, row_data["marca_sucursal"])
|
||||
bucket = categorize(sim, args.ok_threshold, args.verify_threshold)
|
||||
row_data["similarity"] = round(sim, 3)
|
||||
row_data["bucket"] = bucket
|
||||
buckets[bucket].append(row_data)
|
||||
by_marca_sucursal[strip_accents(row_data["marca_sucursal"]).lower()][bucket] += 1
|
||||
origin_x_bucket[row_data["origin"]][bucket] += 1
|
||||
origin_counter[row_data["origin"]] += 1
|
||||
contacts_evaluated_via_form.add(cid)
|
||||
|
||||
# 4b. Contactos SIN submission: solo clasificamos por origen (no hay form
|
||||
# para comparar contra marca). Util para entender el universo total.
|
||||
for cid, contact in contacts_by_id.items():
|
||||
if cid in contacts_evaluated_via_form:
|
||||
continue
|
||||
row_data = build_row(contact, cid, None)
|
||||
if filter_norm and (not row_data["marca_sucursal"] or
|
||||
filter_norm not in strip_accents(row_data["marca_sucursal"]).lower()):
|
||||
continue
|
||||
row_data["bucket"] = "SIN_SUBMISSION"
|
||||
buckets["SIN_SUBMISSION"].append(row_data)
|
||||
origin_x_bucket[row_data["origin"]]["SIN_SUBMISSION"] += 1
|
||||
origin_counter[row_data["origin"]] += 1
|
||||
|
||||
# 5. resumen
|
||||
total_with_form = sum(len(buckets[k]) for k in ("OK", "VERIFICAR", "DISCREPANCIA",
|
||||
"CONTACTO_SIN_SUCURSAL"))
|
||||
total_overall = total_with_form + len(buckets["SIN_SUBMISSION"]) + len(buckets["CONTACTO_NO_EN_DB"])
|
||||
print("\n" + "=" * 72)
|
||||
print("RESUMEN (con submission del formulario = evidencia dura)")
|
||||
print("=" * 72)
|
||||
print(f"Contactos con submission y evaluados: {total_with_form}")
|
||||
print(f" OK (>= {args.ok_threshold:.2f}): {len(buckets['OK'])}")
|
||||
print(f" VERIFICAR ({args.verify_threshold:.2f}-{args.ok_threshold:.2f}): {len(buckets['VERIFICAR'])}")
|
||||
print(f" DISCREPANCIA (< {args.verify_threshold:.2f}): {len(buckets['DISCREPANCIA'])}")
|
||||
print(f" Contacto sin Sucursal en Marca: {len(buckets['CONTACTO_SIN_SUCURSAL'])}")
|
||||
print(f"\nContactos SIN submission (clasificados solo por origen): {len(buckets['SIN_SUBMISSION'])}")
|
||||
print(f"Submission sin contacto en DB: {len(buckets['CONTACTO_NO_EN_DB'])}")
|
||||
print(f"\nTotal universo: {total_overall}")
|
||||
|
||||
# Desglose por origen probable
|
||||
if origin_counter:
|
||||
print("\n" + "-" * 72)
|
||||
print("ORIGEN PROBABLE (tags + source nativo + submission)")
|
||||
print("-" * 72)
|
||||
for origin, n in origin_counter.most_common():
|
||||
conf = origin_confidence(origin)
|
||||
breakdown = origin_x_bucket[origin]
|
||||
parts = [f"{b}={c}" for b, c in breakdown.most_common()]
|
||||
print(f" {origin:24s} ({conf:5s} confianza): {n:4d} [{', '.join(parts)}]")
|
||||
|
||||
if by_marca_sucursal:
|
||||
print("\n" + "-" * 72)
|
||||
print("DESGLOSE POR SUCURSAL EN MARCA (solo contactos con submission)")
|
||||
print("-" * 72)
|
||||
for marca_suc, cnt in sorted(by_marca_sucursal.items(), key=lambda x: -sum(x[1].values())):
|
||||
total_suc = sum(cnt.values())
|
||||
print(f" {marca_suc!r}: {total_suc} "
|
||||
f"(OK={cnt.get('OK',0)}, VERIF={cnt.get('VERIFICAR',0)}, DISCREP={cnt.get('DISCREPANCIA',0)})")
|
||||
|
||||
# 6. detalle
|
||||
sections = []
|
||||
if args.show == "all":
|
||||
sections = ["DISCREPANCIA", "VERIFICAR", "OK", "CONTACTO_SIN_SUCURSAL",
|
||||
"SIN_SUBMISSION", "CONTACTO_NO_EN_DB"]
|
||||
elif args.show == "discrepancia":
|
||||
sections = ["DISCREPANCIA"]
|
||||
elif args.show == "verificar":
|
||||
sections = ["DISCREPANCIA", "VERIFICAR"]
|
||||
|
||||
origin_filter = set(o.strip().upper() for o in (args.origin or "").split(",") if o.strip())
|
||||
|
||||
for sec in sections:
|
||||
rows = buckets[sec]
|
||||
if origin_filter:
|
||||
rows = [r for r in rows if (r.get("origin") or "").upper() in origin_filter]
|
||||
if not rows:
|
||||
continue
|
||||
print("\n" + "-" * 72)
|
||||
print(f"{sec} ({len(rows)})")
|
||||
print("-" * 72)
|
||||
for r in rows:
|
||||
name = (r.get("name") or "").strip() or "(sin nombre)"
|
||||
sim_str = f"sim={r['similarity']:.2f}" if r.get("similarity") is not None else "sim=N/A"
|
||||
origin = r.get("origin", "?")
|
||||
conf = r.get("origin_confidence", "?")
|
||||
print(f" {name} [{sim_str}] origen={origin} ({conf})")
|
||||
print(f" contact_id: {r['contact_id']} phone={r.get('phone')!r} email={r.get('email')!r}")
|
||||
print(f" tags={r.get('tags')!r} source={r.get('source')!r}")
|
||||
print(f" Marca dice: {r.get('marca_sucursal')!r}")
|
||||
if r.get("form_sucursal"):
|
||||
print(f" Form dijo: {r['form_sucursal']!r} (submission_at={r.get('submission_at')})")
|
||||
else:
|
||||
print(f" Form: (sin submission registrado)")
|
||||
|
||||
# 7. xlsx opcional
|
||||
if args.xlsx_path is not None:
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
print("\nWARN: openpyxl no esta instalado; salteo --xlsx")
|
||||
else:
|
||||
os.makedirs(EXPORTS_DIR, exist_ok=True)
|
||||
if args.xlsx_path:
|
||||
path = args.xlsx_path
|
||||
if not os.path.isabs(path) and not os.sep in path:
|
||||
base, ext = os.path.splitext(path)
|
||||
if not ext:
|
||||
ext = ".xlsx"
|
||||
path = os.path.join(EXPORTS_DIR, base + ext)
|
||||
else:
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = os.path.join(EXPORTS_DIR, f"sucursal_vs_form_{ts}.xlsx")
|
||||
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
header = ["bucket", "similarity", "origin", "origin_confidence",
|
||||
"contact_id", "name", "email", "phone",
|
||||
"tags", "source", "marca_sucursal", "form_sucursal",
|
||||
"submission_at", "submission_id"]
|
||||
for sec in ["DISCREPANCIA", "VERIFICAR", "OK", "CONTACTO_SIN_SUCURSAL",
|
||||
"SIN_SUBMISSION", "CONTACTO_NO_EN_DB"]:
|
||||
ws = wb.create_sheet(sec[:31])
|
||||
ws.append(header)
|
||||
for r in buckets[sec]:
|
||||
ws.append([
|
||||
r.get("bucket"), r.get("similarity"),
|
||||
r.get("origin"), r.get("origin_confidence"),
|
||||
r.get("contact_id"), r.get("name"),
|
||||
r.get("email"), r.get("phone"),
|
||||
",".join(r.get("tags") or []) if isinstance(r.get("tags"), list) else r.get("tags"),
|
||||
r.get("source"),
|
||||
r.get("marca_sucursal"), r.get("form_sucursal"),
|
||||
r.get("submission_at"), r.get("submission_id"),
|
||||
])
|
||||
wb.save(path)
|
||||
print(f"\nExcel exportado: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user