Primer commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user