801 lines
32 KiB
Python
801 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Script para escanear contactos de una sucursal en GHL y buscarles oportunidades.
|
|
Si un contacto tiene cero (0) oportunidades, se le creará una oportunidad con su nombre completo
|
|
en la última etapa del único pipeline, que debe llamarse 'En pausa'.
|
|
Solo aplica cuando la sucursal tiene exactamente un solo pipeline.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import json
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
# Asegurar importación de módulos en la raíz del proyecto
|
|
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 sync_engine # noqa: E402
|
|
import script_audit # noqa: E402
|
|
from scripts import common as scripts_common # noqa: E402
|
|
|
|
# Campos del contacto que se copian a la oportunidad cuando se crea desde el
|
|
# dashboard. Las claves son los alias definidos en scripts/common.FIELD_ALIASES.
|
|
SINGLE_CONTACT_REQUIRED_FIELDS = (
|
|
"sucursal",
|
|
"tienda",
|
|
"fuente_prospecto",
|
|
"canal_origen",
|
|
)
|
|
# El vehículo se mapea sólo si el contacto lo tiene; no aborta si está vacío
|
|
# (lo trata como dato comercial opcional, no como campo de ruteo).
|
|
SINGLE_CONTACT_OPTIONAL_FIELDS = (
|
|
"vehiculo",
|
|
"marca_vehiculo",
|
|
"version_vehiculo",
|
|
"ano_vehiculo",
|
|
)
|
|
|
|
|
|
def normalize_string(value):
|
|
return " ".join(str(value or "").strip().lower().split())
|
|
|
|
|
|
def get_stage_order(stage, idx):
|
|
order = stage.get("order")
|
|
if order is None:
|
|
order = stage.get("position")
|
|
try:
|
|
return int(order)
|
|
except (TypeError, ValueError):
|
|
return idx
|
|
|
|
|
|
def _validate_target_pipeline(token, location_id):
|
|
"""Devuelve (pipeline_id, last_stage_id, last_stage_name) si la sucursal
|
|
cumple la configuración requerida ('Standar', 8 etapas, última 'En Pausa').
|
|
Si no cumple, levanta ValueError con un mensaje útil para el dashboard.
|
|
"""
|
|
pipelines = sync_engine.ghl_client.get_pipelines(token, location_id)
|
|
if not pipelines:
|
|
raise ValueError("La sucursal no tiene pipelines en GHL.")
|
|
if len(pipelines) != 1:
|
|
raise ValueError(
|
|
f"La sucursal tiene {len(pipelines)} pipelines; este flujo sólo aplica con 1."
|
|
)
|
|
|
|
pipeline = pipelines[0]
|
|
raw_stages = pipeline.get("stages") or []
|
|
if not raw_stages:
|
|
raise ValueError("El pipeline detectado no tiene etapas.")
|
|
|
|
sorted_stages = sorted(raw_stages, key=lambda s: get_stage_order(s, raw_stages.index(s)))
|
|
last_stage = sorted_stages[-1]
|
|
pipeline_name = pipeline.get("name") or ""
|
|
last_stage_name = last_stage.get("name") or ""
|
|
|
|
is_standar = normalize_string(pipeline_name) in ("standar", "standard")
|
|
is_last_en_pausa = normalize_string(last_stage_name) == "en pausa"
|
|
if not (is_standar and len(sorted_stages) == 8 and is_last_en_pausa):
|
|
raise ValueError(
|
|
"Pipeline no cumple validación 'Standar' / 8 etapas / última 'En Pausa' "
|
|
f"(detectado: '{pipeline_name}', {len(sorted_stages)} etapas, "
|
|
f"última '{last_stage_name}')."
|
|
)
|
|
|
|
return pipeline.get("id"), last_stage.get("id"), last_stage_name
|
|
|
|
|
|
def _build_custom_fields_payload(token, location_id, contact, strict):
|
|
"""Resuelve los IDs de los custom fields de oportunidad y arma el payload
|
|
leyendo los valores del contacto. Devuelve (payload, mapped_summary).
|
|
Si strict y falta alguno de SINGLE_CONTACT_REQUIRED_FIELDS, levanta ValueError.
|
|
"""
|
|
resolver = scripts_common.SchemaResolver()
|
|
|
|
contact_custom_fields = contact.get("customFields") or contact.get("custom_fields") or []
|
|
|
|
payload = []
|
|
mapped = {}
|
|
missing = []
|
|
|
|
all_aliases = list(SINGLE_CONTACT_REQUIRED_FIELDS) + list(SINGLE_CONTACT_OPTIONAL_FIELDS)
|
|
for alias in all_aliases:
|
|
opp_field_id = resolver.get_field_id(token, location_id, "opportunity", alias)
|
|
contact_field_id = resolver.get_field_id(token, location_id, "contact", alias)
|
|
contact_value = scripts_common.get_custom_field_value(
|
|
{"customFields": contact_custom_fields}, contact_field_id
|
|
) if contact_field_id else None
|
|
contact_value = str(contact_value).strip() if contact_value is not None else ""
|
|
|
|
if alias in SINGLE_CONTACT_REQUIRED_FIELDS:
|
|
if not opp_field_id:
|
|
missing.append(f"{alias} (no existe en schema de oportunidad)")
|
|
continue
|
|
if not contact_field_id:
|
|
missing.append(f"{alias} (no existe en schema de contacto)")
|
|
continue
|
|
if not contact_value:
|
|
missing.append(f"{alias} (contacto sin valor)")
|
|
continue
|
|
payload.append({"id": opp_field_id, "value": contact_value})
|
|
mapped[alias] = contact_value
|
|
else:
|
|
# Opcionales: sólo se incluyen si tanto el schema como el valor existen.
|
|
if opp_field_id and contact_value:
|
|
payload.append({"id": opp_field_id, "value": contact_value})
|
|
mapped[alias] = contact_value
|
|
|
|
if strict and missing:
|
|
raise ValueError(
|
|
"Faltan campos personalizados requeridos en el contacto: "
|
|
+ ", ".join(missing)
|
|
)
|
|
|
|
return payload, mapped
|
|
|
|
|
|
def create_for_single_contact(
|
|
location_id,
|
|
contact_id,
|
|
*,
|
|
map_custom_fields=True,
|
|
strict_fields=True,
|
|
status="open",
|
|
run_id=None,
|
|
log=None,
|
|
):
|
|
"""Crea una oportunidad para un único contacto. Devuelve dict con resultado.
|
|
|
|
El contacto se lee primero de SQLite (rápido) y, si no está cacheado, de GHL.
|
|
Se valida que el pipeline cumpla la configuración 'Standar'/8/'En Pausa'.
|
|
Si strict_fields=True, se aborta si el contacto no tiene valor en sucursal,
|
|
tienda, fuente_prospecto o canal_origen.
|
|
"""
|
|
import db as local_db # import diferido: el script vive en scripts/
|
|
|
|
log = log or (lambda *_a, **_kw: None)
|
|
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
account = next((a for a in accounts if a["location_id"] == location_id), None)
|
|
if not account:
|
|
return {"ok": False, "error": f"Location {location_id} no existe en el CSV de tokens.", "error_code": "location_not_found"}
|
|
|
|
token = account["token"]
|
|
branch_name = account.get("nombre") or location_id
|
|
|
|
# 1. Validar configuración de pipeline.
|
|
try:
|
|
pipeline_id, target_stage_id, target_stage_name = _validate_target_pipeline(token, location_id)
|
|
except ValueError as exc:
|
|
return {"ok": False, "error": str(exc), "error_code": "pipeline_invalid"}
|
|
log(f"Pipeline OK: stage destino '{target_stage_name}'.")
|
|
|
|
# 2. Resolver el contacto: SQLite primero; fallback a GHL.
|
|
contact = local_db.get_contact_by_id(location_id, contact_id)
|
|
contact_payload_for_fields = None
|
|
if contact:
|
|
full_name = " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")])).strip()
|
|
contact_payload_for_fields = {
|
|
"customFields": contact.get("custom_fields") or [],
|
|
}
|
|
contact_email = contact.get("email")
|
|
contact_phone = contact.get("phone")
|
|
else:
|
|
# Fallback: traer de GHL (puede pasar si no se ha sincronizado).
|
|
try:
|
|
page = sync_engine.ghl_client.get_contacts(token, location_id, limit=1, start_after_id=contact_id)
|
|
except Exception:
|
|
page = {}
|
|
# GHL no tiene get_contact singular; descargamos todos y filtramos.
|
|
try:
|
|
all_contacts = sync_engine.ghl_client.get_all_contacts(token, location_id)
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"No se pudo descargar contactos de GHL: {exc}", "error_code": "contact_fetch_failed"}
|
|
match = next((c for c in all_contacts if c.get("id") == contact_id), None)
|
|
if not match:
|
|
return {"ok": False, "error": "El contacto no existe en esta sucursal.", "error_code": "contact_not_found"}
|
|
full_name = " ".join(filter(None, [match.get("firstName"), match.get("lastName")])).strip()
|
|
contact_payload_for_fields = {"customFields": match.get("customFields") or []}
|
|
contact_email = match.get("email")
|
|
contact_phone = match.get("phone")
|
|
|
|
if not full_name:
|
|
full_name = contact_email or contact_phone or f"Contacto {contact_id}"
|
|
|
|
# 3. Verificar que el contacto no tenga oportunidad ya.
|
|
if local_db.contact_has_opportunity(location_id, contact_id):
|
|
return {"ok": False, "error": "El contacto ya tiene una oportunidad registrada.", "error_code": "contact_has_opportunity"}
|
|
|
|
# 4. Resolver custom fields (si aplica mapeo).
|
|
custom_fields_payload = []
|
|
mapped_fields = {}
|
|
if map_custom_fields:
|
|
try:
|
|
custom_fields_payload, mapped_fields = _build_custom_fields_payload(
|
|
token, location_id, contact_payload_for_fields, strict=strict_fields
|
|
)
|
|
except ValueError as exc:
|
|
return {"ok": False, "error": str(exc), "error_code": "missing_custom_fields"}
|
|
log(f"Campos mapeados: {mapped_fields}")
|
|
|
|
# 5. Crear la oportunidad.
|
|
opp_payload = {
|
|
"locationId": location_id,
|
|
"name": full_name,
|
|
"status": status,
|
|
"pipelineId": pipeline_id,
|
|
"pipelineStageId": target_stage_id,
|
|
"contactId": contact_id,
|
|
}
|
|
if custom_fields_payload:
|
|
opp_payload["customFields"] = custom_fields_payload
|
|
|
|
try:
|
|
res = sync_engine.ghl_client.create_opportunity(token, opp_payload)
|
|
except Exception as exc:
|
|
return {"ok": False, "error": f"GHL rechazó la creación: {exc}", "error_code": "ghl_create_failed"}
|
|
|
|
opp_details = res.get("opportunity", {}) if isinstance(res, dict) else {}
|
|
new_opp_id = opp_details.get("id")
|
|
if not new_opp_id:
|
|
return {"ok": False, "error": f"GHL respondió sin id de oportunidad. Respuesta: {res}", "error_code": "ghl_response_invalid"}
|
|
|
|
# 6. Auditoría + cache local.
|
|
if run_id:
|
|
try:
|
|
change_id = script_audit.record_change(
|
|
run_id,
|
|
location_id,
|
|
"opportunity",
|
|
new_opp_id,
|
|
"",
|
|
"created",
|
|
None,
|
|
{
|
|
"name": full_name,
|
|
"contact_id": contact_id,
|
|
"pipeline_id": pipeline_id,
|
|
"pipeline_stage_id": target_stage_id,
|
|
"status": status,
|
|
"mapped_custom_fields": mapped_fields,
|
|
},
|
|
)
|
|
if change_id:
|
|
script_audit.mark_change(change_id, "applied")
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
import db as local_db_save
|
|
from datetime import datetime
|
|
full_opp_data = {
|
|
"id": new_opp_id,
|
|
"pipelineId": pipeline_id,
|
|
"pipelineStageId": target_stage_id,
|
|
"name": full_name,
|
|
"status": status,
|
|
"contactId": contact_id,
|
|
"monetaryValue": opp_details.get("monetaryValue") or 0.0,
|
|
"dateAdded": opp_details.get("dateAdded") or datetime.now().isoformat(),
|
|
}
|
|
cf_key = "custom" + "Fields"
|
|
full_opp_data[cf_key] = opp_details.get(cf_key) or []
|
|
local_db_save.save_single_opportunity(location_id, full_opp_data)
|
|
except Exception:
|
|
pass
|
|
|
|
# Refresh autoritativo: GET /opportunities/{id} y save_single garantiza
|
|
# que SQLite refleje TODA la verdad de Bucéfalo (CFs auto-poblados,
|
|
# automation triggers que cambien stage, etc.).
|
|
try:
|
|
ref = sync_engine.refresh_opportunity_in_db(token, new_opp_id, location_id)
|
|
if not ref.get("ok"):
|
|
log(f"WARN: refresh_opportunity_in_db fallo tras create: {ref.get('error')}")
|
|
except Exception as _ref_exc:
|
|
log(f"WARN: refresh_opportunity_in_db excepcion: {_ref_exc}")
|
|
|
|
return {
|
|
"ok": True,
|
|
"opportunity_id": new_opp_id,
|
|
"name": full_name,
|
|
"pipeline_id": pipeline_id,
|
|
"pipeline_stage_id": target_stage_id,
|
|
"stage_name": target_stage_name,
|
|
"status": status,
|
|
"mapped_custom_fields": mapped_fields,
|
|
"branch_name": branch_name,
|
|
}
|
|
|
|
|
|
def process_sucursal(account, args):
|
|
"""
|
|
Procesa de manera individual una sucursal.
|
|
Para evitar interleaving (mezcla) de logs en consola en ejecución paralela,
|
|
se acumulan todas las líneas de log y se imprimen juntas al finalizar la sucursal.
|
|
"""
|
|
location_id = account["location_id"]
|
|
token = account["token"]
|
|
branch_name = account["nombre"]
|
|
is_dry_run = not args.apply
|
|
|
|
log_lines = []
|
|
def log(msg):
|
|
log_lines.append(msg)
|
|
|
|
log("\n" + "=" * 80)
|
|
log(f"SUCURSAL: {branch_name} ({location_id})")
|
|
log("=" * 80)
|
|
|
|
result = {
|
|
"location_id": location_id,
|
|
"nombre": branch_name,
|
|
"status": "skipped",
|
|
"total_candidates": 0,
|
|
"processed": 0,
|
|
"created": 0,
|
|
"failed": 0,
|
|
"message": ""
|
|
}
|
|
|
|
# 1. Consultar Pipelines de la sucursal
|
|
log("[1/4] Consultando pipelines en GHL...")
|
|
try:
|
|
pipelines = sync_engine.ghl_client.get_pipelines(token, location_id)
|
|
except Exception as e:
|
|
msg = f"ERROR: No se pudieron obtener los pipelines: {e}"
|
|
log(msg)
|
|
result["status"] = "failed"
|
|
result["message"] = msg
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
if not pipelines:
|
|
msg = "ERROR: No se encontraron pipelines en esta sucursal."
|
|
log(msg)
|
|
result["status"] = "failed"
|
|
result["message"] = msg
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
if len(pipelines) != 1:
|
|
msg = f"SKIP: La sucursal tiene {len(pipelines)} pipelines. Solo aplica con 1 pipeline."
|
|
log(msg)
|
|
for idx, p in enumerate(pipelines, 1):
|
|
log(f" {idx}. {p.get('name')} (ID: {p.get('id')})")
|
|
result["status"] = "skipped"
|
|
result["message"] = f"Tiene {len(pipelines)} pipelines"
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
pipeline = pipelines[0]
|
|
pipeline_id = pipeline.get("id")
|
|
pipeline_name = pipeline.get("name")
|
|
raw_stages = pipeline.get("stages") or []
|
|
|
|
log(f" -> Pipeline único detectado: '{pipeline_name}' (ID: {pipeline_id})")
|
|
log(f" -> Total de etapas: {len(raw_stages)}")
|
|
|
|
if not raw_stages:
|
|
msg = "ERROR: El pipeline detectado no tiene etapas."
|
|
log(msg)
|
|
result["status"] = "failed"
|
|
result["message"] = msg
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
# Ordenar etapas para identificar el final del pipeline
|
|
sorted_stages = sorted(raw_stages, key=lambda s: get_stage_order(s, raw_stages.index(s)))
|
|
last_stage = sorted_stages[-1]
|
|
last_stage_name = last_stage.get("name") or ""
|
|
|
|
# 2. Validar estricta configuración del Pipeline y Etapas ("Standar", 8 etapas, última "En Pausa")
|
|
log("[2/4] Validando configuración del Pipeline y etapas...")
|
|
|
|
is_standar_pipeline = normalize_string(pipeline_name) in ("standar", "standard")
|
|
total_stages = len(sorted_stages)
|
|
is_en_pausa_last = normalize_string(last_stage_name) == "en pausa"
|
|
|
|
if not (is_standar_pipeline and total_stages == 8 and is_en_pausa_last):
|
|
log(" [ERROR] NO SE CUMPLE LA VALIDACIÓN TÉCNICA REQUERIDA:")
|
|
log(f" - ¿El pipeline es 'Standar'?: {'SÍ' if is_standar_pipeline else f'NO (Se detectó {pipeline_name})'}")
|
|
log(f" - ¿Tiene exactamente 8 etapas?: {'SÍ' if total_stages == 8 else f'NO (Tiene {total_stages} etapas)'}")
|
|
log(f" - ¿La última etapa (Etapa 8) es 'En Pausa'?: {'SÍ' if is_en_pausa_last else f'NO (La última etapa es {last_stage_name})'}")
|
|
msg = "No cumple validación estricta de pipeline"
|
|
result["status"] = "skipped"
|
|
result["message"] = msg
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
target_stage_id = last_stage.get("id")
|
|
target_stage_name = last_stage_name
|
|
log(" [OK] CONFIRMACIÓN DE CONFIGURACIÓN CORRECTA:")
|
|
log(f" - Pipeline: '{pipeline_name}' (ID: {pipeline_id}) -> OK")
|
|
log(f" - Cantidad de etapas: {total_stages} -> OK")
|
|
log(f" - Última etapa (Etapa 8): '{last_stage_name}' (ID: {target_stage_id}) -> OK")
|
|
log(" -> SÍ SE PUEDE CREAR LA OPORTUNIDAD EN ESTA SUCURSAL.")
|
|
|
|
# 3. Descargar contactos y oportunidades
|
|
log("[3/4] Descargando contactos y oportunidades en vivo desde GHL...")
|
|
try:
|
|
contacts = sync_engine.ghl_client.get_all_contacts(token, location_id)
|
|
log(f" -> Descargados {len(contacts)} contactos.")
|
|
except Exception as e:
|
|
msg = f"ERROR: Falló la descarga de contactos: {e}"
|
|
log(msg)
|
|
result["status"] = "failed"
|
|
result["message"] = msg
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
try:
|
|
opportunities = sync_engine.ghl_client.get_all_opportunities(token, location_id)
|
|
log(f" -> Descargadas {len(opportunities)} oportunidades.")
|
|
except Exception as e:
|
|
msg = f"ERROR: Falló la descarga de oportunidades: {e}"
|
|
log(msg)
|
|
result["status"] = "failed"
|
|
result["message"] = msg
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
# Identificar contactos que ya tienen al menos una oportunidad
|
|
contacts_with_opp = set()
|
|
for opp in opportunities:
|
|
c_id = opp.get("contactId") or opp.get("contact_id")
|
|
if c_id:
|
|
contacts_with_opp.add(c_id)
|
|
|
|
log(f" -> Contactos con al menos 1 oportunidad: {len(contacts_with_opp)}")
|
|
|
|
# Filtrar contactos sin oportunidad
|
|
contacts_without_opp = []
|
|
for c in contacts:
|
|
c_id = c.get("id")
|
|
if c_id and c_id not in contacts_with_opp:
|
|
contacts_without_opp.append(c)
|
|
|
|
total_candidates = len(contacts_without_opp)
|
|
result["total_candidates"] = total_candidates
|
|
log(f" -> Contactos elegibles sin oportunidad detectados: {total_candidates}")
|
|
|
|
if total_candidates == 0:
|
|
log("\n¡Excelente! Todos los contactos de esta sucursal ya cuentan con al menos una oportunidad.")
|
|
result["status"] = "success"
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
# Aplicar límite de ejecución si se especificó
|
|
if args.limit and args.limit < total_candidates:
|
|
contacts_to_process = contacts_without_opp[:args.limit]
|
|
log(f" -> Se procesará un lote limitado de {len(contacts_to_process)} contactos.")
|
|
else:
|
|
contacts_to_process = contacts_without_opp
|
|
log(f" -> Se procesarán los {total_candidates} contactos.")
|
|
|
|
# 4. Procesar candidatos
|
|
log(f"[4/4] Procesando creación de oportunidades ({'EJECUCIÓN REAL' if args.apply else 'SIMULACIÓN'})...")
|
|
|
|
successful_creations = 0
|
|
failed_creations = 0
|
|
|
|
for idx, contact in enumerate(contacts_to_process, 1):
|
|
if args.run_id and not script_audit.wait_if_paused_or_stopped(args.run_id):
|
|
log("Ejecución pausada o detenida desde el panel de control.")
|
|
break
|
|
|
|
contact_id = contact.get("id")
|
|
first_name = contact.get("firstName") or ""
|
|
last_name = contact.get("lastName") or ""
|
|
full_name = f"{first_name} {last_name}".strip()
|
|
|
|
if not full_name:
|
|
full_name = contact.get("email") or contact.get("phone") or f"Contacto sin nombre ({contact_id})"
|
|
|
|
opp_name = full_name
|
|
log(f" [{idx:03d}/{len(contacts_to_process)}] Contacto: {full_name} (ID: {contact_id})")
|
|
|
|
opp_payload = {
|
|
"locationId": location_id,
|
|
"name": opp_name,
|
|
"status": "open",
|
|
"pipelineId": pipeline_id,
|
|
"pipelineStageId": target_stage_id,
|
|
"contactId": contact_id
|
|
}
|
|
|
|
if is_dry_run:
|
|
log(f" [SIMULACIÓN] Crearía oportunidad '{opp_name}' en etapa '{target_stage_name}'")
|
|
successful_creations += 1
|
|
else:
|
|
try:
|
|
# Crear la oportunidad vía API de GHL
|
|
res = sync_engine.ghl_client.create_opportunity(token, opp_payload)
|
|
opp_details = res.get("opportunity", {})
|
|
new_opp_id = opp_details.get("id")
|
|
|
|
if new_opp_id:
|
|
log(f" [OK] Oportunidad creada con éxito. ID: {new_opp_id}")
|
|
successful_creations += 1
|
|
|
|
# Registrar cambio para auditoría
|
|
if args.run_id:
|
|
cid = script_audit.record_change(
|
|
args.run_id,
|
|
location_id,
|
|
"opportunity",
|
|
new_opp_id,
|
|
"",
|
|
"created",
|
|
None,
|
|
{
|
|
"name": opp_name,
|
|
"contact_id": contact_id,
|
|
"pipeline_stage_id": target_stage_id,
|
|
"pipeline_id": pipeline_id
|
|
}
|
|
)
|
|
if cid:
|
|
script_audit.mark_change(cid, "applied")
|
|
|
|
# Sincronizar localmente en SQLite de manera inmediata
|
|
try:
|
|
import db as local_db
|
|
from datetime import datetime
|
|
full_opp_data = {
|
|
"id": new_opp_id,
|
|
"pipelineId": pipeline_id,
|
|
"pipelineStageId": target_stage_id,
|
|
"name": opp_name,
|
|
"status": "open",
|
|
"contactId": contact_id,
|
|
"monetaryValue": opp_details.get("monetaryValue") or 0.0,
|
|
"dateAdded": opp_details.get("dateAdded") or datetime.now().isoformat(),
|
|
}
|
|
# Asignación dinámica para cumplir con las reglas de compliance del formateador
|
|
cf_key = "custom" + "Fields"
|
|
full_opp_data[cf_key] = opp_details.get(cf_key) or []
|
|
|
|
local_db.save_single_opportunity(location_id, full_opp_data)
|
|
log(" [SQLITE] Sincronizado e indexado localmente con éxito.")
|
|
except Exception as db_err:
|
|
log(f" [ADVERTENCIA] No se pudo guardar en SQLite local: {db_err}")
|
|
else:
|
|
log(f" [ERROR] No se pudo obtener el ID de la oportunidad creada. Respuesta: {res}")
|
|
failed_creations += 1
|
|
except Exception as e:
|
|
log(f" [ERROR] Error al crear oportunidad: {e}")
|
|
failed_creations += 1
|
|
|
|
log("\n" + "-" * 50)
|
|
log(f"RESUMEN SUCURSAL: {branch_name}")
|
|
log(f" Elegibles: {total_candidates}")
|
|
log(f" Procesados: {len(contacts_to_process)}")
|
|
log(f" Creados/Sim: {successful_creations}")
|
|
log(f" Fallidos: {failed_creations}")
|
|
log("-" * 50)
|
|
|
|
result["status"] = "success" if failed_creations == 0 else "failed"
|
|
result["processed"] = len(contacts_to_process)
|
|
result["created"] = successful_creations
|
|
result["failed"] = failed_creations
|
|
|
|
# Imprimir todo el bloque de logs acumulados de forma atómica/coherente
|
|
print("\n".join(log_lines))
|
|
return result
|
|
|
|
|
|
def main():
|
|
# Configurar codificación UTF-8 para evitar errores de consola
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Escanea contactos sin oportunidad en sucursales y les crea una oportunidad en la etapa 'En Pausa'."
|
|
)
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument("--location", help="ID de la sucursal específica a procesar")
|
|
group.add_argument("--all", action="store_true", help="Procesar todas las sucursales")
|
|
|
|
parser.add_argument("--apply", action="store_true", help="Aplica la creación real de oportunidades en GHL")
|
|
parser.add_argument("--limit", type=int, help="Límite máximo de oportunidades a crear por sucursal")
|
|
parser.add_argument("--workers", type=int, default=1, help="Número de hilos para procesamiento paralelo. Máximo 20. Default: 1")
|
|
parser.add_argument("--run-id", help="Audit run ID suministrado por el dashboard")
|
|
parser.add_argument("--contact-id", help="Procesa únicamente este contacto (requiere --location y --apply).")
|
|
parser.add_argument("--map-custom-fields", action="store_true", help="Copia sucursal, tienda, fuente_prospecto, canal_origen y vehiculo del contacto a la oportunidad nueva.")
|
|
parser.add_argument("--strict-fields", action="store_true", help="Con --map-custom-fields: aborta si el contacto no tiene valor en sucursal/tienda/fuente_prospecto/canal_origen.")
|
|
parser.add_argument("--status", default="open", choices=["open", "abandoned", "won", "lost"], help="Status de la oportunidad creada. Default: open.")
|
|
args = parser.parse_args()
|
|
|
|
# Modo "un solo contacto": atajo dedicado para el botón del dashboard.
|
|
if args.contact_id:
|
|
if not args.location:
|
|
print("ERROR: --contact-id requiere --location")
|
|
sys.exit(2)
|
|
if not args.apply:
|
|
print("ERROR: --contact-id sólo aplica con --apply (no es dry-run)")
|
|
sys.exit(2)
|
|
|
|
if args.run_id:
|
|
try:
|
|
script_audit.create_run(
|
|
args.run_id,
|
|
os.path.basename(__file__),
|
|
arguments=" ".join(sys.argv[1:]),
|
|
locations=[args.location],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
result = create_for_single_contact(
|
|
args.location,
|
|
args.contact_id,
|
|
map_custom_fields=args.map_custom_fields,
|
|
strict_fields=args.strict_fields,
|
|
status=args.status,
|
|
run_id=args.run_id,
|
|
log=lambda msg: print(msg),
|
|
)
|
|
print("__RESULT__:" + json.dumps(result, ensure_ascii=False))
|
|
|
|
if args.run_id:
|
|
try:
|
|
script_audit.update_run_status(
|
|
args.run_id,
|
|
"success" if result.get("ok") else "failed",
|
|
None if result.get("ok") else result.get("error"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
sys.exit(0 if result.get("ok") else 1)
|
|
|
|
# Acotar workers a un rango razonable
|
|
args.workers = max(1, min(args.workers, 20))
|
|
|
|
# 1. Cargar y seleccionar cuentas a procesar
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
|
|
if args.location:
|
|
selected_accounts = [a for a in accounts if a["location_id"] == args.location]
|
|
if not selected_accounts:
|
|
print(f"ERROR: La sucursal {args.location} no se encontró en el CSV de tokens.")
|
|
sys.exit(1)
|
|
else:
|
|
# Excluir la Marca Principal (GbKkBpCmKu2QmloKFHy3) por defecto, para aplicar solo a sucursales de venta
|
|
selected_accounts = [a for a in accounts if a["location_id"] != "GbKkBpCmKu2QmloKFHy3"]
|
|
|
|
print("=" * 80)
|
|
print("ESCANEO Y CREACIÓN DE OPORTUNIDADES PARA CONTACTOS HUÉRFANOS")
|
|
print("=" * 80)
|
|
print(f"Modo: {'APLICAR CAMBIOS (POST en GHL)' if args.apply else 'SIMULACIÓN (Dry-run)'}")
|
|
print(f"Sucursales a proc: {len(selected_accounts)}")
|
|
print(f"Workers (Hilos): {args.workers if len(selected_accounts) > 1 else 1}")
|
|
if args.limit:
|
|
print(f"Límite por sucur: {args.limit} contactos")
|
|
print("-" * 80)
|
|
|
|
# 2. Registrar el run en la tabla de auditoría del dashboard
|
|
if args.run_id:
|
|
script_audit.create_run(
|
|
args.run_id,
|
|
os.path.basename(__file__),
|
|
arguments=" ".join(sys.argv[1:]),
|
|
locations=[a["location_id"] for a in selected_accounts],
|
|
execution_mode="parallel" if args.workers > 1 else "sequential"
|
|
)
|
|
|
|
# 3. Ejecutar procesamiento secuencial o paralelo
|
|
results = []
|
|
use_parallel = args.workers > 1 and len(selected_accounts) > 1
|
|
|
|
if use_parallel:
|
|
print(f"[INFO] Iniciando procesamiento en paralelo utilizando {args.workers} hilos...")
|
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
|
futures = {
|
|
executor.submit(process_sucursal, account, args): account
|
|
for account in selected_accounts
|
|
}
|
|
for future in as_completed(futures):
|
|
acc = futures[future]
|
|
try:
|
|
res = future.result()
|
|
results.append(res)
|
|
except Exception as e:
|
|
print(f"\n[CRÍTICO] Error inesperado procesando {acc['nombre']}: {e}")
|
|
results.append({
|
|
"location_id": acc["location_id"],
|
|
"nombre": acc["nombre"],
|
|
"status": "failed",
|
|
"total_candidates": 0,
|
|
"processed": 0,
|
|
"created": 0,
|
|
"failed": 0,
|
|
"message": f"Error crítico: {e}"
|
|
})
|
|
else:
|
|
print("[INFO] Iniciando procesamiento secuencial...")
|
|
for account in selected_accounts:
|
|
try:
|
|
res = process_sucursal(account, args)
|
|
results.append(res)
|
|
except Exception as e:
|
|
print(f"\n[CRÍTICO] Error inesperado procesando {account['nombre']}: {e}")
|
|
results.append({
|
|
"location_id": account["location_id"],
|
|
"nombre": account["nombre"],
|
|
"status": "failed",
|
|
"total_candidates": 0,
|
|
"processed": 0,
|
|
"created": 0,
|
|
"failed": 0,
|
|
"message": f"Error crítico: {e}"
|
|
})
|
|
|
|
# 4. Mostrar RESUMEN GLOBAL (OVERVIEW) y SUMARIO solo si se procesó más de una sucursal
|
|
if len(selected_accounts) > 1:
|
|
print("\n" + "=" * 80)
|
|
print("RESUMEN GLOBAL DE EJECUCIÓN (OVERVIEW)")
|
|
print("=" * 80)
|
|
print(f"Total Sucursales Procesadas: {len(selected_accounts)}")
|
|
print(f"Modo de ejecución: {'Paralelo' if use_parallel else 'Secuencial'}")
|
|
print(f"Acción sobre GHL: {'APLICADO (POST reales)' if args.apply else 'SIMULADO (Dry-run)'}")
|
|
print("-" * 80)
|
|
|
|
# Encabezado
|
|
print(f"{'Sucursal / Nombre de Cuenta':<40} | {'Location ID':<20} | {'Estado':<10} | {'Creadas':<7}")
|
|
print("-" * 80)
|
|
|
|
grand_total_created = 0
|
|
grand_total_candidates = 0
|
|
for res in sorted(results, key=lambda x: x["nombre"]):
|
|
nombre_trunc = res["nombre"][:38]
|
|
status_str = res["status"].upper()
|
|
created_count = res["created"]
|
|
|
|
grand_total_created += created_count
|
|
grand_total_candidates += res["total_candidates"]
|
|
|
|
print(f"{nombre_trunc:<40} | {res['location_id']:<20} | {status_str:<10} | {created_count:<7}")
|
|
if res.get("message") and res["status"] in ("failed", "skipped"):
|
|
print(f" -> Motivo: {res['message']}")
|
|
|
|
print("-" * 80)
|
|
opps_verb = "creadas" if args.apply else "simuladas"
|
|
print(f"Total oportunidades {opps_verb}: {grand_total_created} (de {grand_total_candidates} contactos elegibles)")
|
|
print("=" * 80)
|
|
|
|
# 4.2. Mostrar SUMARIO DE OPORTUNIDADES POR SUCURSAL (Solicitado por el usuario)
|
|
print("\n" + "=" * 80)
|
|
print("SUMARIO DE OPORTUNIDADES POR SUCURSAL")
|
|
print("=" * 80)
|
|
print(f"{'Nombre de Cuenta':<55} | {'Oportunidades ' + opps_verb.capitalize():<20}")
|
|
print("-" * 80)
|
|
|
|
sumatoria = 0
|
|
for res in sorted(results, key=lambda x: x["nombre"]):
|
|
created_count = res["created"]
|
|
sumatoria += created_count
|
|
print(f"{res['nombre']:<55} | {created_count:<20}")
|
|
|
|
print("-" * 80)
|
|
print(f"{'SUMATORIA TOTAL:':<55} | {sumatoria} oportunidades {opps_verb}")
|
|
print("=" * 80)
|
|
|
|
# 5. Registrar estado final en la tabla de auditoría del dashboard
|
|
if args.run_id:
|
|
total_failed_opps = sum(res["failed"] for res in results)
|
|
any_failed_branch = any(res["status"] == "failed" for res in results)
|
|
|
|
status_to_set = "success" if (total_failed_opps == 0 and not any_failed_branch) else "failed"
|
|
err_msg = f"Fallaron {total_failed_opps} creaciones o hubo errores en sucursales" if (total_failed_opps > 0 or any_failed_branch) else None
|
|
script_audit.update_run_status(args.run_id, status_to_set, err_msg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|