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
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Super script de origen "Sucursal" para contactos creados por un usuario.
Unifica la lógica que estaba dispersa (fix_web_user_branch_contacts.py,
tag_canal_origen_workflow.py, sync_contact_sucursal_to_opportunity.py,
fill_sucursal_tienda_from_location.py) en una sola herramienta auditada que SOLO
opera sobre sucursales (excluye Marca y demos).
Criterio de identificación: ``contact.createdBy.source in {"WEB_USER", "MOBILE_USER"}``
— los contactos replicados desde Marca llegan por integración (source = INTEGRATION);
los creados manualmente en la sucursal por un empleado quedan como WEB_USER (UI web)
o MOBILE_USER (app móvil). ``createdBy`` SOLO viene en el GET individual del
contacto: el listado paginado ``GET /contacts/`` lo omite.
Fase 1 (dry-run, modo por defecto):
- Muestra por sucursal la distribución real de ``createdBy.source``.
- Imprime el plan de cambios por contacto/oportunidad sin escribir nada.
Fase 2 (--apply --run-id), orden estricto contacto -> oportunidad:
1. Contacto: tag único ``sucursal`` (quita formulario / facebook-ads),
Canal de Origen = SUCURSAL. Si le falta Sucursal / TIENDA, se completan
desde el Verificador CSV (valor determinístico por location_id).
2. TODAS las oportunidades del contacto: Canal de Origen de la Oportunidad =
Sucursal, y se propaga Sucursal / TIENDA del contacto.
NO toca "Fuente de Prospecto" (contiene valores de negocio como ALIANZA /
PROSPECCIÓN que no deben sobrescribirse). Todo cambio queda en script_audit
(reversible por run_id desde el dashboard).
"""
import argparse
import os
import sys
from collections import Counter
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 script_audit # noqa: E402
import sync_engine # noqa: E402 # get_object_schema_fields para opps
import common # noqa: E402 # FIELD_ALIASES + normalize_name
from tag_canal_origen_workflow import ( # noqa: E402
MAIN_LOCATION_ID,
contact_display_name,
get_all_contacts,
get_all_opportunities_for_contact,
get_custom_field_value,
get_opportunity,
get_schemas,
ghl_request,
load_locations,
resolve_opp_field_ids,
safe_add_contact_tag,
safe_remove_contact_tag,
safe_update_contact_field,
safe_update_opportunity_field,
)
from fill_sucursal_tienda_from_location import load_verifier_map # noqa: E402
SCRIPT_NAME = "fix_branch_user_origin.py"
# --- Constantes de negocio (alineadas a los scripts existentes, no inventar) ---
# Fuentes de creación "por un usuario" dentro de la sucursal (no integración):
# WEB_USER -> CRM web UI
# MOBILE_USER -> app móvil del CRM
USER_SOURCES = {"WEB_USER", "MOBILE_USER"}
SUCURSAL_TAG = "sucursal"
ORIGIN_TAGS_TO_REMOVE = ["formulario", "facebook-ads"]
CONTACT_CANAL_VALUE = "SUCURSAL"
OPP_CANAL_FIELD = "Canal de Origen de la Oportunidad"
OPP_CANAL_VALUE = "Sucursal"
# Demos: convención del repo (IDs hardcodeados + nombre que contiene "demo").
DEMO_LOCATION_IDS = {"Vf7qQl3L9vakJ8hDtQ8e", "Z64WQKORPVwXb5mn68Ef"}
# --------------------------------------------------------------------------- #
# Selección de cuentas (solo sucursales productivas)
# --------------------------------------------------------------------------- #
def is_demo_account(account):
name = (account.get("nombre") or "").lower()
return account["location_id"] in DEMO_LOCATION_IDS or "demo" in name
def select_branch_locations(args):
branches = [
acc for acc in load_locations(include_main=False)
if acc["location_id"] != MAIN_LOCATION_ID and not is_demo_account(acc)
]
if args.location:
if args.location == MAIN_LOCATION_ID:
raise SystemExit("Este script NO aplica a la cuenta de Marca.")
if args.location in DEMO_LOCATION_IDS:
raise SystemExit("Este script NO aplica a cuentas demo.")
matches = [acc for acc in branches if acc["location_id"] == args.location]
if not matches:
raise SystemExit(f"Location {args.location} no es una sucursal productiva del CSV.")
return matches
if args.all:
return branches
raise SystemExit("Especifica --location <id> o --all. Sin --apply corre en dry-run.")
# --------------------------------------------------------------------------- #
# Resolución de campos por alias (tolerante a mayúsculas/variantes)
# --------------------------------------------------------------------------- #
def resolve_schema_field_id(schema_dict, alias_key, fallback_name):
"""schema_dict es {name: id} (de get_schemas). Resuelve por FIELD_ALIASES."""
candidates = common.FIELD_ALIASES.get(alias_key, [fallback_name])
norm_to_id = {common.normalize_name(name): fid for name, fid in (schema_dict or {}).items()}
for cand in candidates:
fid = norm_to_id.get(common.normalize_name(cand))
if fid:
return fid
return None
def resolve_opp_field_ids_by_alias(opp_fields_list, alias_key, fallback_name):
"""Devuelve TODOS los IDs de opp que matcheen el alias (maneja duplicados)."""
candidates = common.FIELD_ALIASES.get(alias_key, [fallback_name])
target_norm = {common.normalize_name(name) for name in candidates}
return [
field["id"]
for field in (opp_fields_list or [])
if field.get("name") and field.get("id")
and common.normalize_name(field["name"]) in target_norm
]
def get_contact_full(contact_id, token):
"""Trae el contacto individual. ``createdBy`` SOLO viene aquí: el listado
paginado ``GET /contacts/`` lo omite (siempre llega None)."""
data = ghl_request("GET", f"/contacts/{contact_id}", token)
inner = data.get("contact")
return inner if isinstance(inner, dict) else data
def is_user_created_contact(contact):
"""True si lo creó un empleado en la sucursal (web o app móvil)."""
created_by = contact.get("createdBy") or {}
return created_by.get("source") in USER_SOURCES
# --------------------------------------------------------------------------- #
# Procesamiento por sucursal
# --------------------------------------------------------------------------- #
def process_location(account, *, dry_run, run_id, verifier_map):
location_id = account["location_id"]
token = account["token"]
name = account["nombre"]
local = {
"user_detected": 0,
"sin_sucursal": 0,
"contacts_corrected": 0,
"opps_corrected": 0,
"skipped_locations": 0,
"errors": 0,
}
print(f"\n{'=' * 70}\n{name} ({location_id})\n{'=' * 70}")
# Valores autoritativos de Sucursal / TIENDA para esta location (Verificador).
entry = (verifier_map or {}).get(location_id) or {}
verif_sucursal = (entry.get("sucursal") or "").strip()
verif_tienda = (entry.get("tienda") or "").strip()
if entry:
print(f" Verificador -> Sucursal: '{verif_sucursal}' | TIENDA: '{verif_tienda}'")
else:
print(" AVISO: location no está en el Verificador; no se podrán completar Sucursal/TIENDA faltantes.")
schemas = get_schemas(location_id, token, "contact", "opportunity")
contact_schema = schemas["contact"]
opp_fields_list = sync_engine.ghl_client.get_object_schema_fields(token, location_id, "opportunity")
contact_canal_id = resolve_schema_field_id(contact_schema, "canal_origen", "Canal de Origen")
contact_sucursal_id = resolve_schema_field_id(contact_schema, "sucursal", "Sucursal")
contact_tienda_id = resolve_schema_field_id(contact_schema, "tienda", "TIENDA")
if not contact_canal_id:
print(" SKIP: falta el campo de contacto 'Canal de Origen'")
local["skipped_locations"] = 1
return local
opp_sucursal_ids = resolve_opp_field_ids_by_alias(opp_fields_list, "sucursal", "Sucursal")
opp_tienda_ids = resolve_opp_field_ids_by_alias(opp_fields_list, "tienda", "TIENDA")
opp_canal_ids = resolve_opp_field_ids(opp_fields_list, OPP_CANAL_FIELD)
contacts = get_all_contacts(location_id, token)
print(f" Contactos totales: {len(contacts)}")
# --- Fase 1: createdBy.source SOLO viene en el GET individual; el listado
# paginado lo omite. Hacemos un GET por contacto para clasificar fielmente. ---
dist = Counter()
user_contacts = []
for summary in contacts:
if not script_audit.wait_if_paused_or_stopped(run_id):
print("\n Detención segura solicitada durante el escaneo.")
break
full = get_contact_full(summary["id"], token)
src = (full.get("createdBy") or {}).get("source")
dist[src or "(vacío)"] += 1
if is_user_created_contact(full):
user_contacts.append(full)
print(" Distribución createdBy.source (GET individual):")
for src, cnt in dist.most_common():
marker = " <-- objetivo (creado por usuario)" if src in USER_SOURCES else ""
print(f" {src}: {cnt}{marker}")
local["user_detected"] = len(user_contacts)
print(f" Creados por usuario (WEB_USER + MOBILE_USER) a corregir: {local['user_detected']}")
# --- Fase 2: corrección contacto -> oportunidades (sobre los full ya leídos) ---
for contact in user_contacts:
if not script_audit.wait_if_paused_or_stopped(run_id):
print("\n Detención segura solicitada. Saliendo antes del siguiente contacto.")
break
cid = contact["id"]
display = contact_display_name(contact)
contact_touched = False
# Tag de origen único: dejar 'sucursal', quitar formulario / facebook-ads.
if safe_add_contact_tag(run_id, location_id, contact, SUCURSAL_TAG, token, dry_run):
contact_touched = True
print(f" [contacto] {display} TAG+ {SUCURSAL_TAG}")
for tag in ORIGIN_TAGS_TO_REMOVE:
if safe_remove_contact_tag(run_id, location_id, contact, tag, token, dry_run):
contact_touched = True
print(f" [contacto] {display} TAG- {tag}")
# Canal de Origen del contacto = SUCURSAL (NO se toca Fuente de Prospecto).
if safe_update_contact_field(run_id, location_id, contact, contact_canal_id,
"Canal de Origen", CONTACT_CANAL_VALUE, token, dry_run):
contact_touched = True
print(f" [contacto] {display} Canal de Origen -> {CONTACT_CANAL_VALUE}")
# Sucursal / TIENDA del contacto: usar el valor existente; si falta,
# completarlo desde el Verificador (la sucursal es determinística).
target_sucursal = (get_custom_field_value(contact, contact_sucursal_id) or "").strip() if contact_sucursal_id else ""
target_tienda = (get_custom_field_value(contact, contact_tienda_id) or "").strip() if contact_tienda_id else ""
if not target_sucursal and verif_sucursal and contact_sucursal_id:
if safe_update_contact_field(run_id, location_id, contact, contact_sucursal_id,
"Sucursal", verif_sucursal, token, dry_run):
contact_touched = True
print(f" [contacto] {display} Sucursal -> {verif_sucursal} (Verificador)")
target_sucursal = verif_sucursal
if not target_tienda and verif_tienda and contact_tienda_id:
if safe_update_contact_field(run_id, location_id, contact, contact_tienda_id,
"TIENDA", verif_tienda, token, dry_run):
contact_touched = True
print(f" [contacto] {display} TIENDA -> {verif_tienda} (Verificador)")
target_tienda = verif_tienda
if not target_sucursal:
local["sin_sucursal"] += 1
print(f" [contacto] {display} sin Sucursal y sin dato en Verificador: no se propaga a opps")
if contact_touched:
local["contacts_corrected"] += 1
# ---------- OPORTUNIDADES DESPUÉS (todas) ----------
for opp_summary in get_all_opportunities_for_contact(location_id, cid, token):
opp_id = opp_summary.get("id")
if not opp_id:
continue
opportunity = get_opportunity(location_id, opp_id, token) or opp_summary
opp_touched = False
# Canal de Origen de la Oportunidad = Sucursal.
for field_id in opp_canal_ids:
if safe_update_opportunity_field(run_id, location_id, opp_id, opportunity,
field_id, OPP_CANAL_FIELD, OPP_CANAL_VALUE, token, dry_run):
opp_touched = True
# Propaga Sucursal / TIENDA del contacto a la oportunidad.
if target_sucursal:
for field_id in opp_sucursal_ids:
if safe_update_opportunity_field(run_id, location_id, opp_id, opportunity,
field_id, "Sucursal", target_sucursal, token, dry_run):
opp_touched = True
if target_tienda:
for field_id in opp_tienda_ids:
if safe_update_opportunity_field(run_id, location_id, opp_id, opportunity,
field_id, "TIENDA", target_tienda, token, dry_run):
opp_touched = True
if opp_touched:
local["opps_corrected"] += 1
print(f" [opp] {opp_id} | {opportunity.get('name') or display} -> Sucursal")
print(f" -> {name}: usuario={local['user_detected']} (sin Sucursal={local['sin_sucursal']}), "
f"contactos corregidos={local['contacts_corrected']}, "
f"opps corregidas={local['opps_corrected']}")
return local
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(
description="Corrige Canal de Origen/Sucursal/tags de contactos creados por usuario "
"(WEB_USER + MOBILE_USER) en sucursales. Dry-run por defecto."
)
parser.add_argument("--location", help="Location ID de una sucursal específica")
parser.add_argument("--all", action="store_true",
help="Procesa todas las sucursales productivas (excluye Marca y demos)")
parser.add_argument("--apply", action="store_true",
help="Aplica los cambios en el CRM. Sin este flag corre en dry-run.")
parser.add_argument("--run-id", help="ID de auditoría (lo suministra el dashboard; "
"en CLI se crea uno si se aplica)")
args = parser.parse_args()
dry_run = not args.apply
run_id = args.run_id
accounts = select_branch_locations(args)
verifier_map = load_verifier_map()
print("=" * 70)
print("SUPER SCRIPT - ORIGEN SUCURSAL (contactos creados por usuario)")
print("=" * 70)
print(f"Modo: {'DRY-RUN (sin cambios)' if dry_run else 'APPLY (escribe en el CRM)'}")
print(f"Criterio: createdBy.source in {sorted(USER_SOURCES)}")
print(f"Sucursales objetivo: {len(accounts)} (Marca y demos excluidas)")
# Run de auditoría idempotente (no-op si el dashboard ya lo creó). Solo al aplicar.
if not dry_run and run_id:
script_audit.create_run(run_id, SCRIPT_NAME,
arguments=" ".join(sys.argv[1:]),
locations=[acc["location_id"] for acc in accounts])
grand = {"user_detected": 0, "sin_sucursal": 0, "contacts_corrected": 0,
"opps_corrected": 0, "skipped_locations": 0, "errors": 0}
for account in accounts:
try:
local = process_location(account, dry_run=dry_run, run_id=run_id, verifier_map=verifier_map)
for key in grand:
grand[key] += local.get(key, 0)
except Exception as exc: # noqa: BLE001 - continuar con las demás sucursales
grand["errors"] += 1
print(f"\nERROR en {account['nombre']}: {exc}")
print("\n" + "=" * 70)
print(f"RESUMEN: creados por usuario={grand['user_detected']} "
f"(sin Sucursal={grand['sin_sucursal']}), "
f"contactos corregidos={grand['contacts_corrected']}, "
f"opps corregidas={grand['opps_corrected']}, "
f"locations omitidas={grand['skipped_locations']}, errores={grand['errors']}")
if dry_run:
print("Dry-run terminado. Revisa el plan y vuelve a correr con --apply --run-id <uuid> para aplicar.")
if not dry_run and run_id:
script_audit.update_run_status(
run_id,
"failed" if grand["errors"] else "success",
f"{grand['errors']} errores" if grand["errors"] else None,
)
if grand["errors"]:
raise SystemExit(1)
if __name__ == "__main__":
main()