3065 lines
118 KiB
Python
3065 lines
118 KiB
Python
import os
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
from fastapi import FastAPI, Request, BackgroundTasks, HTTPException
|
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font, PatternFill
|
|
import uvicorn
|
|
|
|
import db
|
|
import error_logging
|
|
import sync_engine
|
|
import script_runner
|
|
import script_audit
|
|
import contact_classifier
|
|
|
|
|
|
# ============================================================================
|
|
# APP MODE — Dry-Run global
|
|
# ============================================================================
|
|
# Cada request mutante lleva el header X-Dry-Run (1/0) desde el frontend
|
|
# (window.AppMode). Los endpoints que escriben en Bucéfalo deben llamar a
|
|
# is_dry_run_request(request) y, si True, NO llamar a GHL: devuelven una
|
|
# respuesta de simulación con shape compatible al live (mismo nombre de
|
|
# campos, totales razonables). El audit registra la acción como simulada.
|
|
|
|
def is_dry_run_request(request: Request) -> bool:
|
|
"""Devuelve True si el cliente pidió simulación. Lee X-Dry-Run; acepta
|
|
también X-App-Mode=dryrun por compatibilidad. Default: False (live)."""
|
|
if request is None:
|
|
return False
|
|
raw = (request.headers.get("X-Dry-Run") or "").strip().lower()
|
|
if raw in ("1", "true", "yes", "on"):
|
|
return True
|
|
if raw in ("0", "false", "no", "off"):
|
|
return False
|
|
mode = (request.headers.get("X-App-Mode") or "").strip().lower()
|
|
return mode == "dryrun"
|
|
|
|
|
|
def dry_run_response(action: str, details: dict | None = None) -> dict:
|
|
"""Shape estándar de respuesta dry-run para endpoints mutantes."""
|
|
return {
|
|
"ok": True,
|
|
"dry_run": True,
|
|
"simulated_action": action,
|
|
"details": details or {},
|
|
"message": "Modo simulación: ninguna acción se aplicó en Bucéfalo.",
|
|
}
|
|
|
|
app = FastAPI(title="MP Manager - Go High Level Control Panel", version="1.0.0")
|
|
|
|
# Habilitar CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
async def build_request_context(request: Request, include_body=False):
|
|
context = {
|
|
"request_id": getattr(request.state, "request_id", None),
|
|
"method": request.method,
|
|
"path": request.url.path,
|
|
"query_params": dict(request.query_params),
|
|
"client": request.client.host if request.client else None,
|
|
}
|
|
if include_body:
|
|
try:
|
|
body = await request.body()
|
|
context["body"] = body.decode("utf-8", errors="replace") if body else ""
|
|
except Exception as exc:
|
|
context["body_error"] = str(exc)
|
|
return context
|
|
|
|
|
|
@app.middleware("http")
|
|
async def request_id_middleware(request: Request, call_next):
|
|
request.state.request_id = error_logging.new_error_id()
|
|
response = await call_next(request)
|
|
response.headers["X-Request-ID"] = request.state.request_id
|
|
return response
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
context = await build_request_context(request, include_body=True)
|
|
context.update({"status_code": exc.status_code, "detail": exc.detail})
|
|
error_id = error_logging.log_error("fastapi_http_exception", exc, context, error_id=context["request_id"])
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail, "error_id": error_id},
|
|
headers={"X-Request-ID": error_id},
|
|
)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
|
context = await build_request_context(request, include_body=True)
|
|
context["status_code"] = 500
|
|
error_id = error_logging.log_error("fastapi_unhandled_exception", exc, context, error_id=context["request_id"])
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Error interno del servidor", "error_id": error_id},
|
|
headers={"X-Request-ID": error_id},
|
|
)
|
|
|
|
# Asegurar directorios
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
static_dir = os.path.join(BASE_DIR, "static")
|
|
templates_dir = os.path.join(BASE_DIR, "templates")
|
|
os.makedirs(static_dir, exist_ok=True)
|
|
os.makedirs(templates_dir, exist_ok=True)
|
|
|
|
# Montar archivos estáticos
|
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
templates = Jinja2Templates(directory=templates_dir)
|
|
|
|
# Caché de tokens en memoria al arrancar
|
|
TOKENS_CACHE = {}
|
|
|
|
@app.on_event("startup")
|
|
def startup_event():
|
|
print("=== INICIANDO SERVIDOR MP MANAGER ===")
|
|
print("Inicializando base de datos SQLite...")
|
|
db.init_db()
|
|
|
|
print("Cargando y parseando catálogo de cuentas desde CSV...")
|
|
try:
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
db.save_accounts(accounts)
|
|
|
|
# Almacenar tokens en memoria (nunca van a la DB por seguridad)
|
|
global TOKENS_CACHE
|
|
TOKENS_CACHE = {acc['location_id']: acc['token'] for acc in accounts}
|
|
print(f"Catálogo cargado con éxito: {len(accounts)} cuentas registradas en memoria.")
|
|
except Exception as e:
|
|
error_id = error_logging.log_error("startup_accounts_csv_failed", e)
|
|
print(f"ADVERTENCIA CRÍTICA: No se pudo cargar el CSV de cuentas al iniciar: {e} | error_id={error_id}")
|
|
|
|
# --- RUTAS DE NAVEGACIÓN ---
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def read_root(request: Request):
|
|
return templates.TemplateResponse(request, "index.html")
|
|
|
|
# --- ENDPOINTS DE LA API ---
|
|
|
|
@app.get("/api/branches")
|
|
def get_branches():
|
|
"""
|
|
Retorna la lista de todas las sucursales con sus métricas locales cacheadas.
|
|
"""
|
|
accounts = db.get_accounts()
|
|
result = []
|
|
for acc in accounts:
|
|
metrics = db.get_account_metrics(acc['location_id'])
|
|
result.append({
|
|
**acc,
|
|
"metrics": metrics
|
|
})
|
|
return result
|
|
|
|
@app.get("/api/metrics")
|
|
def get_global_metrics():
|
|
"""
|
|
Retorna estadísticas globales consolidadas.
|
|
"""
|
|
return db.get_global_metrics()
|
|
|
|
# --- PROXY DE DATOS CACHEADOS (GHL CACHE) ---
|
|
|
|
def _attach_vehicle_fields(records, location_id, object_key, alias_to_out_key):
|
|
"""Adjunta a cada registro los valores de sus custom fields de vehículo.
|
|
|
|
Los IDs de custom fields varían por sucursal, así que resolvemos los nombres
|
|
(Marca/Versión/Año/Vehículo) desde la tabla `object_schemas` cacheada en
|
|
SQLite — sin pegarle a la API de GHL. El matching es tolerante a acentos,
|
|
mayúsculas y puntuación vía `normalize_text` + `FIELD_ALIASES`.
|
|
|
|
`alias_to_out_key`: dict {alias_FIELD_ALIASES: clave_de_salida}. Cada registro
|
|
queda con esas claves pobladas (valor o None si la sucursal no tiene el campo
|
|
o el contacto no lo trae).
|
|
"""
|
|
import json as _json
|
|
from scripts.common import FIELD_ALIASES, normalize_text
|
|
|
|
schema_rows = db.get_object_schemas(location_id, object_key)
|
|
alias_to_ids = {alias: set() for alias in alias_to_out_key}
|
|
for alias in alias_to_out_key:
|
|
targets = {normalize_text(name) for name in FIELD_ALIASES.get(alias, [alias])}
|
|
for row in schema_rows:
|
|
if normalize_text(row.get("field_name") or "") in targets:
|
|
fid = row.get("field_id")
|
|
if fid:
|
|
alias_to_ids[alias].add(fid)
|
|
|
|
for rec in records:
|
|
cfs = rec.get("custom_fields")
|
|
if cfs is None:
|
|
cfs = rec.get("custom_fields_json")
|
|
if isinstance(cfs, str):
|
|
try:
|
|
cfs = _json.loads(cfs) if cfs else []
|
|
except Exception:
|
|
cfs = []
|
|
values = {}
|
|
for field in cfs or []:
|
|
fid = field.get("id") or field.get("fieldId")
|
|
if not fid:
|
|
continue
|
|
val = field.get("value")
|
|
if val is None:
|
|
val = field.get("fieldValue")
|
|
if val is None:
|
|
val = field.get("fieldValueString")
|
|
values[fid] = val
|
|
for alias, out_key in alias_to_out_key.items():
|
|
resolved = None
|
|
for fid in alias_to_ids.get(alias, ()):
|
|
v = values.get(fid)
|
|
if v is not None and str(v).strip() != "":
|
|
resolved = v
|
|
break
|
|
rec[out_key] = resolved
|
|
|
|
|
|
@app.get("/api/contacts/{location_id}")
|
|
def get_contacts(location_id: str, q: str = None, page: int = 1, limit: int = 100, without_opp: bool = False):
|
|
"""
|
|
Devuelve contactos cacheados de una sucursal con marca `is_test` para
|
|
cada uno (usa contact_classifier). Los contactos detectados como test/
|
|
prueba/E3 quedan ordenados al inicio, despues el resto por date_added
|
|
DESC. La paginacion se aplica sobre el set ya ordenado.
|
|
|
|
Nota: para garantizar que TODOS los test queden arriba (no solo los de la
|
|
pagina actual), traemos todos los contactos filtrados de la sucursal y
|
|
paginamos en Python. Esto es razonable para los volumenes actuales (cientos
|
|
por sucursal). Si alguna sucursal crece a miles, optimizar con un flag en
|
|
SQL o un cache.
|
|
"""
|
|
if page < 1:
|
|
page = 1
|
|
if limit < 1:
|
|
limit = 100
|
|
|
|
# Traemos todos los contactos que matchean el filtro (sin limit en SQL).
|
|
# Usamos un limit alto en lugar de None para no romper la firma actual.
|
|
all_contacts = db.get_contacts(
|
|
location_id,
|
|
search_query=q,
|
|
limit=10_000_000,
|
|
offset=0,
|
|
without_opp=without_opp,
|
|
)
|
|
|
|
# Anotar is_test / test_reasons
|
|
for c in all_contacts:
|
|
contact_classifier.annotate_contact(c)
|
|
|
|
# Adjuntar info de vehículo (custom fields del contacto) para mostrarla en la UI.
|
|
_attach_vehicle_fields(all_contacts, location_id, "contact", {
|
|
"marca_vehiculo": "marca_vehiculo",
|
|
"version_vehiculo": "version_vehiculo",
|
|
"ano_vehiculo": "ano_vehiculo",
|
|
})
|
|
|
|
# Orden: is_test primero, luego por date_added DESC (None al final)
|
|
def _sort_key(c):
|
|
date_added = c.get("date_added") or ""
|
|
return (0 if c.get("is_test") else 1, _negate_date(date_added))
|
|
|
|
all_contacts.sort(key=_sort_key)
|
|
|
|
total = len(all_contacts)
|
|
test_count = sum(1 for c in all_contacts if c.get("is_test"))
|
|
|
|
start = (page - 1) * limit
|
|
end = start + limit
|
|
page_contacts = all_contacts[start:end]
|
|
|
|
return {
|
|
"location_id": location_id,
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"test_count": test_count,
|
|
"without_opp": without_opp,
|
|
"contacts": page_contacts,
|
|
}
|
|
|
|
|
|
def _negate_date(date_str):
|
|
"""
|
|
Helper para invertir el ordenamiento de fechas ISO en un sort ascendente.
|
|
Como las fechas son strings comparables, restamos al maximo posible.
|
|
"""
|
|
# Truco simple: invertir con un sentinel grande para que sort asc => DESC efectivo
|
|
return tuple(-ord(c) for c in date_str[:24])
|
|
|
|
|
|
@app.post("/api/contacts/{location_id}/{contact_id}/opportunity")
|
|
def create_opportunity_for_contact(location_id: str, contact_id: str, request: Request):
|
|
"""
|
|
Crea una oportunidad para un contacto sin oportunidad usando el script
|
|
`create_opportunities_for_contacts_without_any.create_for_single_contact`.
|
|
|
|
Comportamiento fijo (modo estricto, sin parámetros configurables):
|
|
- status: open
|
|
- mapea sucursal, tienda, fuente_prospecto, canal_origen (requeridos) y
|
|
vehiculo/marca/version/año (opcionales) del contacto a la oportunidad
|
|
- aborta si falta cualquiera de los 4 requeridos
|
|
- registra cambio en script_audit para rollback desde el dashboard
|
|
|
|
Respeta el toggle global vía header X-Dry-Run: en simulación devuelve un
|
|
preview sin invocar GHL.
|
|
"""
|
|
import uuid
|
|
from scripts import create_opportunities_for_contacts_without_any as creator
|
|
|
|
# Modo simulación: chequear pre-condiciones razonables sin escribir.
|
|
if is_dry_run_request(request):
|
|
contact = db.get_contact_by_id(location_id, contact_id)
|
|
if not contact:
|
|
return dry_run_response("create_opportunity_for_contact", {
|
|
"ok": False,
|
|
"warning": "Contacto no está en caché local; en live se intentaría descargar de Bucéfalo.",
|
|
"contact_id": contact_id,
|
|
"location_id": location_id,
|
|
})
|
|
has_opp = db.contact_has_opportunity(location_id, contact_id) if hasattr(db, "contact_has_opportunity") else False
|
|
return dry_run_response("create_opportunity_for_contact", {
|
|
"contact_id": contact_id,
|
|
"location_id": location_id,
|
|
"contact_name": " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")])).strip(),
|
|
"already_has_opportunity": has_opp,
|
|
"would_create": not has_opp,
|
|
})
|
|
|
|
run_id = f"co1-{uuid.uuid4().hex[:12]}"
|
|
try:
|
|
script_audit.init_audit_db()
|
|
script_audit.create_run(
|
|
run_id,
|
|
"create_opportunities_for_contacts_without_any.py",
|
|
arguments=f"--contact-id {contact_id} --map-custom-fields --strict-fields --apply",
|
|
locations=[location_id],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as exc:
|
|
error_logging.log_error("create_opp_for_contact_run_init_failed", exc, {
|
|
"location_id": location_id,
|
|
"contact_id": contact_id,
|
|
})
|
|
run_id = None
|
|
|
|
try:
|
|
result = creator.create_for_single_contact(
|
|
location_id,
|
|
contact_id,
|
|
map_custom_fields=True,
|
|
strict_fields=True,
|
|
status="open",
|
|
run_id=run_id,
|
|
)
|
|
except Exception as exc:
|
|
error_id = error_logging.log_error("create_opp_for_contact_failed", exc, {
|
|
"location_id": location_id,
|
|
"contact_id": contact_id,
|
|
"run_id": run_id,
|
|
})
|
|
if run_id:
|
|
try:
|
|
script_audit.update_run_status(run_id, "failed", str(exc))
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail=f"Error inesperado: {exc} (error_id={error_id})")
|
|
|
|
if run_id:
|
|
try:
|
|
script_audit.update_run_status(
|
|
run_id,
|
|
"success" if result.get("ok") else "failed",
|
|
None if result.get("ok") else result.get("error"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
if not result.get("ok"):
|
|
# 409 para errores de negocio (faltan campos, pipeline inválido, contacto ya tiene opp);
|
|
# el frontend muestra el mensaje al usuario.
|
|
raise HTTPException(status_code=409, detail=result)
|
|
|
|
return result
|
|
|
|
@app.post("/api/contacts/{location_id}/bulk-create-opportunities")
|
|
def bulk_create_opportunities(location_id: str, request: Request):
|
|
"""
|
|
Crea oportunidades para TODOS los contactos sin oportunidad de la sucursal.
|
|
|
|
Respeta el toggle global vía header X-Dry-Run: en simulación devuelve el
|
|
listado de candidatos y cuántos pasarían las validaciones, sin tocar GHL.
|
|
Itera cada contacto y delega en `create_for_single_contact`. Todos los
|
|
cambios quedan registrados bajo un único `run_id` en script_audit para
|
|
auditoría/rollback desde el dashboard.
|
|
|
|
Sincrono: para sucursales con cientos de contactos puede tardar varios
|
|
minutos (rate limit GHL ~110ms por request por token). El frontend muestra
|
|
spinner durante la operación y al final un resumen con OK/saltados/fallidos.
|
|
|
|
Retorna:
|
|
{
|
|
"ok": bool,
|
|
"run_id": str,
|
|
"totals": {"processed": N, "created": N, "skipped": N, "failed": N},
|
|
"details": [ {contact_id, name, ok, error_code?, error?, opportunity_id?} ... ],
|
|
}
|
|
"""
|
|
import uuid
|
|
from scripts import create_opportunities_for_contacts_without_any as creator
|
|
|
|
# 1. Traer contactos sin oportunidad de la sucursal (todos, sin paginar).
|
|
candidates = db.get_contacts(
|
|
location_id,
|
|
search_query=None,
|
|
limit=10_000_000,
|
|
offset=0,
|
|
without_opp=True,
|
|
)
|
|
if not candidates:
|
|
return {
|
|
"ok": True,
|
|
"run_id": None,
|
|
"totals": {"processed": 0, "created": 0, "skipped": 0, "failed": 0},
|
|
"details": [],
|
|
"message": "No hay contactos sin oportunidad en esta sucursal.",
|
|
}
|
|
|
|
# Modo simulación: enumerar candidatos sin escribir.
|
|
if is_dry_run_request(request):
|
|
preview = []
|
|
for c in candidates[:50]:
|
|
preview.append({
|
|
"contact_id": c.get("id"),
|
|
"name": " ".join(filter(None, [c.get("first_name"), c.get("last_name")])).strip()
|
|
or c.get("email") or c.get("id"),
|
|
})
|
|
return {
|
|
"ok": True,
|
|
"dry_run": True,
|
|
"run_id": None,
|
|
"totals": {
|
|
"processed": len(candidates),
|
|
"created": 0,
|
|
"skipped": 0,
|
|
"failed": 0,
|
|
"would_attempt": len(candidates),
|
|
},
|
|
"details": preview,
|
|
"message": f"Modo simulación: se intentarían crear {len(candidates)} oportunidades en Bucéfalo (live cambia a este endpoint con X-Dry-Run: 0).",
|
|
}
|
|
|
|
# 2. Crear run_id de auditoría.
|
|
run_id = f"cobulk-{uuid.uuid4().hex[:12]}"
|
|
try:
|
|
script_audit.init_audit_db()
|
|
script_audit.create_run(
|
|
run_id,
|
|
"create_opportunities_for_contacts_without_any.py",
|
|
arguments=f"--bulk-from-dashboard --location {location_id} --map-custom-fields --strict-fields --apply",
|
|
locations=[location_id],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as exc:
|
|
error_logging.log_error("bulk_create_opps_run_init_failed", exc, {
|
|
"location_id": location_id,
|
|
})
|
|
run_id = None
|
|
|
|
# 3. Iterar y crear.
|
|
created = skipped = failed = 0
|
|
details = []
|
|
for c in candidates:
|
|
contact_id = c.get("id")
|
|
name = " ".join(filter(None, [c.get("first_name"), c.get("last_name")])).strip() or c.get("email") or contact_id
|
|
try:
|
|
result = creator.create_for_single_contact(
|
|
location_id,
|
|
contact_id,
|
|
map_custom_fields=True,
|
|
strict_fields=True,
|
|
status="open",
|
|
run_id=run_id,
|
|
)
|
|
except Exception as exc:
|
|
failed += 1
|
|
error_id = error_logging.log_error("bulk_create_opps_item_failed", exc, {
|
|
"location_id": location_id,
|
|
"contact_id": contact_id,
|
|
"run_id": run_id,
|
|
})
|
|
details.append({
|
|
"contact_id": contact_id,
|
|
"name": name,
|
|
"ok": False,
|
|
"error": f"Excepción inesperada: {exc}",
|
|
"error_code": "unexpected_exception",
|
|
"error_id": error_id,
|
|
})
|
|
continue
|
|
|
|
if result.get("ok"):
|
|
created += 1
|
|
details.append({
|
|
"contact_id": contact_id,
|
|
"name": name,
|
|
"ok": True,
|
|
"opportunity_id": result.get("opportunity_id"),
|
|
})
|
|
else:
|
|
# Distinguimos "saltado por regla de negocio" vs "fallido inesperado".
|
|
# Estos códigos vienen del propio create_for_single_contact.
|
|
code = result.get("error_code") or ""
|
|
if code in ("contact_has_opportunity", "missing_custom_fields", "pipeline_invalid"):
|
|
skipped += 1
|
|
else:
|
|
failed += 1
|
|
details.append({
|
|
"contact_id": contact_id,
|
|
"name": name,
|
|
"ok": False,
|
|
"error": result.get("error"),
|
|
"error_code": code,
|
|
})
|
|
|
|
# 4. Cerrar run de auditoría.
|
|
if run_id:
|
|
status = "success" if failed == 0 else "failed"
|
|
try:
|
|
script_audit.update_run_status(
|
|
run_id,
|
|
status,
|
|
None if status == "success" else f"{failed} fallos de {len(candidates)}",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"ok": failed == 0,
|
|
"run_id": run_id,
|
|
"totals": {
|
|
"processed": len(candidates),
|
|
"created": created,
|
|
"skipped": skipped,
|
|
"failed": failed,
|
|
},
|
|
"details": details,
|
|
}
|
|
|
|
|
|
@app.post("/api/pipelines/{location_id}/dedupe")
|
|
async def dedupe_pipelines_for_branch(location_id: str, request: Request):
|
|
"""
|
|
Migra oportunidades del pipeline más antiguo al más reciente (por dateUpdated)
|
|
para una sucursal. Reutiliza la lógica probada de scripts/dedupe_branch_pipelines.py.
|
|
|
|
Modos:
|
|
- dry-run (header X-Dry-Run: 1): devuelve el plan con origen/destino, mapeo
|
|
de etapas y cantidad de oportunidades a mover; no escribe en Bucéfalo.
|
|
- live (header X-Dry-Run: 0): aplica las migraciones y registra cada cambio
|
|
en script_audit bajo un único run_id reversible (PUT /opportunities/{id}
|
|
cambiando pipelineId + pipelineStageId).
|
|
|
|
Body opcional (JSON):
|
|
- from_pipeline, to_pipeline: IDs explícitos cuando los nombres normalizados
|
|
no coinciden (uso "caso excepcional", como en Atizapán "Standar"/"Standar1").
|
|
|
|
Restricción de seguridad: este endpoint NO actúa sobre la cuenta principal de
|
|
Marca (location_id `GbKkBpCmKu2QmloKFHy3`).
|
|
"""
|
|
import sys as _sys
|
|
import uuid
|
|
scripts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
|
|
if scripts_dir not in _sys.path:
|
|
_sys.path.insert(0, scripts_dir)
|
|
import dedupe_branch_pipelines as dedupe_mod # noqa: E402
|
|
|
|
# Validaciones tempranas.
|
|
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
|
if location_id == BRAND_LOCATION_ID:
|
|
raise HTTPException(status_code=400, detail="Por seguridad, el dedupe no se ejecuta sobre la cuenta de Marca principal.")
|
|
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
account = next((a for a in accounts if a["location_id"] == location_id), None)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail=f"Sucursal {location_id} no existe en el CSV de tokens.")
|
|
|
|
body = {}
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
from_pipeline = (body or {}).get("from_pipeline")
|
|
to_pipeline = (body or {}).get("to_pipeline")
|
|
explicit_pair = bool(from_pipeline and to_pipeline)
|
|
|
|
# 1. Cargar pipelines live de Bucéfalo.
|
|
try:
|
|
raw_pipelines = sync_engine.ghl_client.get_pipelines(account["token"], location_id)
|
|
except Exception as exc:
|
|
error_id = error_logging.log_error("dedupe_pipelines_fetch_failed", exc, {"location_id": location_id})
|
|
raise HTTPException(status_code=502, detail=f"Bucéfalo rechazó la lectura de pipelines: {exc} (error_id={error_id})")
|
|
|
|
if not raw_pipelines or len(raw_pipelines) < 2:
|
|
return {
|
|
"ok": True,
|
|
"dry_run": is_dry_run_request(request),
|
|
"totals": {"plans": 0, "opportunities": 0},
|
|
"plans": [],
|
|
"message": "No hay pipelines duplicados para deduplicar (la sucursal tiene 0 o 1 pipeline).",
|
|
}
|
|
|
|
pipelines = [dedupe_mod.normalize_pipeline(p) for p in raw_pipelines]
|
|
|
|
# 2. Construir plan (auto-detect por nombre, o explícito por IDs).
|
|
try:
|
|
if explicit_pair:
|
|
plans = dedupe_mod.build_plan_explicit(account, pipelines, from_pipeline, to_pipeline, True, sync_engine.ghl_client)
|
|
else:
|
|
plans = dedupe_mod.build_plan(account, pipelines, True, sync_engine.ghl_client)
|
|
except SystemExit as exc:
|
|
# build_plan_explicit lanza SystemExit con mensaje cuando los IDs no existen.
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
# 3. Serializar plan para la respuesta (útil en dry-run y como confirmación pre-apply).
|
|
plans_payload = []
|
|
total_opps = 0
|
|
total_merge_obsolete = 0
|
|
total_merge_official = 0
|
|
total_plain_move = 0
|
|
for plan in plans:
|
|
opps = plan.get("opportunities") or []
|
|
total_opps += len(opps)
|
|
# Contar tipos de operación a partir del merge_analysis precomputado.
|
|
analysis = plan.get("merge_analysis") or []
|
|
plan_merge_obsolete = sum(1 for a in analysis if a.get("is_duplicate") and a.get("winner") == "obsolete")
|
|
plan_merge_official = sum(1 for a in analysis if a.get("is_duplicate") and a.get("winner") == "official")
|
|
plan_plain_move = sum(1 for a in analysis if not a.get("is_duplicate"))
|
|
total_merge_obsolete += plan_merge_obsolete
|
|
total_merge_official += plan_merge_official
|
|
total_plain_move += plan_plain_move
|
|
plans_payload.append({
|
|
"obsolete": {
|
|
"id": plan["obsolete"]["id"],
|
|
"name": plan["obsolete"]["name"],
|
|
"date_added": plan["obsolete"].get("date_added"),
|
|
"date_updated": plan["obsolete"].get("date_updated"),
|
|
"stages_count": len(plan["obsolete"].get("stages") or []),
|
|
},
|
|
"official": {
|
|
"id": plan["official"]["id"],
|
|
"name": plan["official"]["name"],
|
|
"date_added": plan["official"].get("date_added"),
|
|
"date_updated": plan["official"].get("date_updated"),
|
|
"stages_count": len(plan["official"].get("stages") or []),
|
|
},
|
|
"mapping_kind": plan.get("mapping_kind"),
|
|
"mapping": [
|
|
{"from_stage": old["name"], "to_stage": new["name"]}
|
|
for old, new in (plan.get("mapping") or [])
|
|
] if plan.get("mapping") else None,
|
|
"opportunities_count": len(opps),
|
|
# Breakdown del merge: cuántas pasan por cada flujo.
|
|
"plain_move_count": plan_plain_move,
|
|
"merge_obsolete_won_count": plan_merge_obsolete,
|
|
"merge_official_won_count": plan_merge_official,
|
|
})
|
|
|
|
# 4. Dry-run: devolver el plan sin escribir.
|
|
if is_dry_run_request(request):
|
|
merge_total = total_merge_obsolete + total_merge_official
|
|
merge_summary = (
|
|
f" De ellas, {total_plain_move} se moverían directamente, "
|
|
f"{merge_total} resolverían un duplicado vía merge "
|
|
f"({total_merge_obsolete} con la obsoleta ganando + {total_merge_official} con la oficial ganando)."
|
|
) if merge_total > 0 else ""
|
|
return {
|
|
"ok": True,
|
|
"dry_run": True,
|
|
"totals": {
|
|
"plans": len(plans_payload),
|
|
"opportunities": total_opps,
|
|
"plain_move": total_plain_move,
|
|
"merge_obsolete_won": total_merge_obsolete,
|
|
"merge_official_won": total_merge_official,
|
|
},
|
|
"plans": plans_payload,
|
|
"message": ("Modo simulación: no se migró nada. "
|
|
f"En live se procesarían {total_opps} oportunidad(es) entre {len(plans_payload)} plan(es)."
|
|
+ merge_summary),
|
|
}
|
|
|
|
if not plans:
|
|
return {
|
|
"ok": True,
|
|
"totals": {"plans": 0, "opportunities": 0},
|
|
"plans": [],
|
|
"message": "No se detectaron pipelines duplicados con nombres normalizados coincidentes.",
|
|
}
|
|
|
|
# 5. Live apply.
|
|
run_id = f"pipededupe-{uuid.uuid4().hex[:12]}"
|
|
try:
|
|
script_audit.init_audit_db()
|
|
script_audit.create_run(
|
|
run_id,
|
|
"dedupe_branch_pipelines.py",
|
|
arguments=f"--location {location_id} --live --apply --from-dashboard",
|
|
locations=[location_id],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as exc:
|
|
error_logging.log_error("dedupe_pipelines_run_init_failed", exc, {"location_id": location_id})
|
|
run_id = None
|
|
|
|
total_moved = total_skipped = total_duplicates = total_failed = 0
|
|
total_merged_obsolete_won = total_merged_official_won = 0
|
|
apply_results = []
|
|
for plan in plans:
|
|
if plan.get("mapping") is None:
|
|
apply_results.append({
|
|
"obsolete_id": plan["obsolete"]["id"],
|
|
"official_id": plan["official"]["id"],
|
|
"skipped": "mapping_impossible",
|
|
})
|
|
continue
|
|
res = dedupe_mod.apply_plan(account, plan, run_id, None, sync_engine.ghl_client)
|
|
moved = res["moved"]
|
|
skipped = res["skipped"]
|
|
duplicates = res["duplicates"]
|
|
failed = res["failed"]
|
|
merged_o = res["merged_obsolete_won"]
|
|
merged_f = res["merged_official_won"]
|
|
dup_records = res["duplicate_records"]
|
|
total_moved += moved
|
|
total_skipped += skipped
|
|
total_duplicates += duplicates
|
|
total_failed += failed
|
|
total_merged_obsolete_won += merged_o
|
|
total_merged_official_won += merged_f
|
|
apply_results.append({
|
|
"obsolete_id": plan["obsolete"]["id"],
|
|
"official_id": plan["official"]["id"],
|
|
"moved": moved,
|
|
"skipped": skipped,
|
|
"duplicates": duplicates,
|
|
"merged_obsolete_won": merged_o,
|
|
"merged_official_won": merged_f,
|
|
"failed": failed,
|
|
"duplicate_records": dup_records,
|
|
})
|
|
|
|
if run_id:
|
|
try:
|
|
script_audit.update_run_status(
|
|
run_id,
|
|
"success" if total_failed == 0 else "failed",
|
|
None if total_failed == 0 else f"{total_failed} fallos",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"ok": total_failed == 0,
|
|
"dry_run": False,
|
|
"run_id": run_id,
|
|
"totals": {
|
|
"plans": len(plans_payload),
|
|
"opportunities": total_opps,
|
|
"moved": total_moved,
|
|
"skipped": total_skipped,
|
|
"duplicates_in_official": total_duplicates,
|
|
"merged_obsolete_won": total_merged_obsolete_won,
|
|
"merged_official_won": total_merged_official_won,
|
|
"failed": total_failed,
|
|
},
|
|
"plans": plans_payload,
|
|
"results": apply_results,
|
|
"message": (f"Migración completada: {total_moved} movidas, "
|
|
f"{total_merged_obsolete_won} merge(obsoleto gana), "
|
|
f"{total_merged_official_won} merge(oficial gana), "
|
|
f"{total_skipped} saltadas, {total_duplicates} DUP no resueltos, "
|
|
f"{total_failed} fallidas."),
|
|
}
|
|
|
|
|
|
@app.get("/api/pipelines/{location_id}")
|
|
def get_pipelines(location_id: str):
|
|
pipelines = db.get_pipelines(location_id)
|
|
|
|
def is_synthetic_pipeline(pipeline):
|
|
name = pipeline.get("name") or ""
|
|
stages = pipeline.get("stages") or []
|
|
if name.startswith("Pipeline Sint"):
|
|
return True
|
|
return any((stage.get("name") or "").startswith("Etapa ") and (stage.get("name") or "").endswith("...") for stage in stages)
|
|
|
|
if TOKENS_CACHE.get(location_id) and (not pipelines or any(is_synthetic_pipeline(p) for p in pipelines)):
|
|
live_pipelines = sync_engine.ghl_client.get_pipelines(TOKENS_CACHE[location_id], location_id)
|
|
if live_pipelines:
|
|
db.save_pipelines(location_id, live_pipelines)
|
|
pipelines = db.get_pipelines(location_id)
|
|
|
|
return {"location_id": location_id, "pipelines": pipelines}
|
|
|
|
@app.get("/api/location/{location_id}")
|
|
def get_location_details(location_id: str):
|
|
"""
|
|
Obtiene la información detallada de una sucursal,
|
|
intentando obtenerla de GHL o recurriendo a los datos locales.
|
|
"""
|
|
acc = db.get_account(location_id)
|
|
if not acc:
|
|
raise HTTPException(status_code=404, detail="Cuenta no encontrada")
|
|
|
|
token = TOKENS_CACHE.get(location_id)
|
|
ghl_data = None
|
|
if token:
|
|
try:
|
|
ghl_data = sync_engine.ghl_client.get_location(token, location_id)
|
|
except Exception as e:
|
|
error_logging.log_error("api_get_location_ghl_failed", e, {"location_id": location_id})
|
|
|
|
return {
|
|
"local_data": acc,
|
|
"ghl_data": ghl_data
|
|
}
|
|
|
|
@app.get("/api/opportunities/{location_id}")
|
|
def get_opportunities(location_id: str, pipeline_id: str = None):
|
|
opps = db.get_opportunities(location_id, pipeline_id=pipeline_id)
|
|
# En oportunidades solo existe el campo combinado "Vehículo".
|
|
_attach_vehicle_fields(opps, location_id, "opportunity", {"vehiculo": "vehiculo"})
|
|
return {"location_id": location_id, "opportunities": opps}
|
|
|
|
# --- COMPARATIVA MARCA vs SUCURSALES ---
|
|
|
|
@app.get("/api/comparativa/marca-vs-sucursales")
|
|
def get_comparativa_marca_vs_sucursales(limit_missing: int = 500):
|
|
"""
|
|
Devuelve la comparativa de conteos y matching entre la cuenta de Marca y la
|
|
suma de todas las sucursales no-demo. Los listados de ausentes se recortan
|
|
a `limit_missing` items por defecto (la UI puede pedir mas via query param).
|
|
"""
|
|
from scripts import audit_brand_vs_branches_totals as audit
|
|
try:
|
|
# limit_missing<=0 → sin limite
|
|
cap = None if (limit_missing is None or limit_missing <= 0) else limit_missing
|
|
return audit.run_audit(limit_missing=cap)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
|
|
@app.get("/api/comparativa/marca-vs-sucursales/export")
|
|
def export_comparativa(bucket: str):
|
|
"""
|
|
Exporta uno de los listados de la comparativa a CSV.
|
|
|
|
bucket admite:
|
|
- missing_in_brand
|
|
- missing_in_assigned_branch
|
|
- brand_without_tienda
|
|
- brand_unknown_tienda
|
|
- brand_not_in_any_branch
|
|
- missing_opps_in_brand
|
|
- brand_duplicate_link_opps
|
|
- per_branch
|
|
"""
|
|
from scripts import audit_brand_vs_branches_totals as audit
|
|
import csv as _csv
|
|
from io import StringIO
|
|
|
|
bucket_map = {
|
|
"missing_in_brand": ("contacts_in_branch_not_in_brand", "missing"),
|
|
"missing_in_assigned_branch": ("contacts_in_brand_not_in_assigned_branch", "missing"),
|
|
"present_in_other_branch_not_assigned": ("contacts_in_brand_present_in_other_branch_not_assigned", "missing"),
|
|
"probable_duplicate_in_brand": ("contacts_in_brand_probable_duplicate", "missing"),
|
|
"brand_without_tienda": ("contacts_in_brand_without_tienda", "missing"),
|
|
"brand_unknown_tienda": ("contacts_in_brand_with_unknown_tienda", "missing"),
|
|
"brand_not_in_any_branch": ("contacts_in_brand_not_in_any_branch", "missing"),
|
|
"missing_opps_in_brand": ("opportunities_in_branch_not_in_brand", "missing"),
|
|
"opps_missing_id_field": ("opportunities_missing_id_field", "missing"),
|
|
"brand_duplicate_link_opps": ("opportunities_in_brand_duplicate_link", "missing"),
|
|
"contacts_missing_id_field": ("contacts_missing_id_field", "missing"),
|
|
"intra_brand_duplicates": ("intra_brand_duplicates", "missing"),
|
|
"per_branch": ("per_branch", "per_branch"),
|
|
}
|
|
if bucket not in bucket_map:
|
|
raise HTTPException(status_code=400, detail=f"bucket invalido. Validos: {list(bucket_map.keys())}")
|
|
|
|
try:
|
|
data = audit.run_audit(limit_missing=None)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
key, root = bucket_map[bucket]
|
|
if root == "missing":
|
|
rows = data["missing"][key]["items"]
|
|
else:
|
|
rows = data["per_branch"]
|
|
|
|
buf = StringIO()
|
|
if rows:
|
|
# Recolectar union de keys para ser tolerantes con items heterogeneos.
|
|
all_keys = []
|
|
seen = set()
|
|
for r in rows:
|
|
for k in r.keys():
|
|
if k not in seen:
|
|
seen.add(k)
|
|
all_keys.append(k)
|
|
writer = _csv.DictWriter(buf, fieldnames=all_keys)
|
|
writer.writeheader()
|
|
for r in rows:
|
|
writer.writerow({k: r.get(k, "") for k in all_keys})
|
|
else:
|
|
buf.write("(sin filas)\n")
|
|
|
|
filename = f"comparativa_{bucket}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
return StreamingResponse(
|
|
iter([buf.getvalue()]),
|
|
media_type="text/csv; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@app.post("/api/comparativa/sync-missing-opps")
|
|
async def sync_missing_opps(request: Request):
|
|
"""
|
|
Sincroniza al CRM de Marca las oportunidades del bucket
|
|
"opportunities_in_branch_not_in_brand". Default dry-run.
|
|
|
|
Body JSON:
|
|
{
|
|
"dry_run": true|false, # default true
|
|
"opp_ids": ["id1", ...], # opcional, default todas las del bucket
|
|
"yes": true # confirma cuando dry_run=false (igual que --yes CLI)
|
|
}
|
|
"""
|
|
from scripts import sync_missing_opps_to_brand as sync_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
opp_ids = body.get("opp_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
# Crear run_id en script_audit cuando se aplica, para tener rollback.
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"smt-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id,
|
|
sync_script.SCRIPT_NAME,
|
|
arguments=f"--apply opp_ids={opp_ids or 'all'}",
|
|
locations=[sync_script.BRAND_LOCATION_ID],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as e:
|
|
error_logging.log_error("sync_missing_opps_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = sync_script.run_sync(
|
|
opp_ids=opp_ids,
|
|
dry_run=dry_run,
|
|
log=lambda *_a, **_kw: None, # silencioso en API; la UI usa el JSON resultante
|
|
run_id=run_id,
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
status = "failed" if result["summary"]["errors"] > 0 and (result["summary"]["opps_created"] + result["summary"]["opps_updated"]) == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/cleanup-duplicate-opps")
|
|
async def cleanup_duplicate_opps(request: Request):
|
|
"""
|
|
Elimina las replicas duplicadas de opps en Marca del bucket
|
|
"opportunities_in_brand_duplicate_link" (mismo 'ID Oportunidad Sucursal').
|
|
Conserva la canonica (jerarquia: valor -> status -> mas antigua) y borra las
|
|
sobrantes. Default dry-run. Reversible por run_id (snapshot + script_audit).
|
|
|
|
Body JSON:
|
|
{
|
|
"dry_run": true|false, # default true
|
|
"only_link": "<id>", # opcional, limita a un cluster
|
|
"yes": true # confirma cuando dry_run=false
|
|
}
|
|
"""
|
|
from scripts import cleanup_brand_duplicate_replica_opps as cleanup_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
only_link = body.get("only_link") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"cdo-{uuid.uuid4().hex[:12]}"
|
|
except Exception as e:
|
|
error_logging.log_error("cleanup_duplicate_opps_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = cleanup_script.run(
|
|
apply=not dry_run,
|
|
run_id=run_id,
|
|
only_link=only_link,
|
|
log=lambda *_a, **_kw: None, # silencioso en API; la UI usa el JSON
|
|
)
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
# Reshape para el modal generico del dashboard: items[] planos + summary.candidates.
|
|
plans = result.get("plans", [])
|
|
ok_plans = [p for p in plans if p.get("status") == "ok"]
|
|
result["items"] = [
|
|
{
|
|
"status": "deleted" if not dry_run else "plan",
|
|
"link_value": p.get("link_value"),
|
|
"name": p.get("name"),
|
|
"keep": p.get("keep"),
|
|
"delete": p.get("delete", []),
|
|
}
|
|
for p in ok_plans
|
|
]
|
|
summary = result.setdefault("summary", {})
|
|
summary["candidates"] = summary.get("clusters", 0)
|
|
|
|
if run_id:
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/fill-opp-id-sucursal")
|
|
async def fill_opp_id_sucursal(request: Request):
|
|
"""
|
|
Llena el custom field 'ID Oportunidad Sucursal' de cada opp con su propio id
|
|
nativo. Acciona el bucket "opportunities_missing_id_field" SOLO en sucursales
|
|
(Marca queda informativa, no se llena: el filtro de location_ids/opp_ids del
|
|
body se sanea contra el location_id de Marca).
|
|
|
|
Body JSON:
|
|
{
|
|
"dry_run": true|false, # default true
|
|
"opp_ids": ["id1", ...], # opcional, default todas las del bucket
|
|
"location_ids": ["loc1", ...], # opcional, default todas las sucursales
|
|
"yes": true # confirma cuando dry_run=false
|
|
}
|
|
"""
|
|
from scripts import fill_opp_id_oportunidad_sucursal as fill_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
opp_ids = body.get("opp_ids") or None
|
|
location_ids = body.get("location_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
# Defensa: nunca tocar Marca. Si llega como location_id explícito, se quita.
|
|
BRAND = fill_script.BRAND_LOCATION_ID
|
|
if location_ids:
|
|
location_ids = [l for l in location_ids if l != BRAND]
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"fill-oppid-{uuid.uuid4().hex[:12]}"
|
|
except Exception as e:
|
|
error_logging.log_error("fill_opp_id_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = fill_script.run_fill(
|
|
location_ids=location_ids,
|
|
opp_ids=opp_ids,
|
|
dry_run=dry_run,
|
|
run_id=run_id,
|
|
log=lambda *_a, **_kw: None, # silencioso en API
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["set"] == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/match-brand-opp-id-sucursal")
|
|
async def match_brand_opp_id_sucursal(request: Request):
|
|
"""
|
|
Matchea opportunities de Marca contra sus contrapartes en sucursales y
|
|
rellena el custom field "ID Oportunidad Sucursal" en Marca con el id de la
|
|
opp de sucursal. Acciona las filas con badge MARCA del bucket
|
|
"opportunities_missing_id_field" (donde el botón normal de llenado no
|
|
aplica porque el campo no se llena con el id propio sino con el id de la
|
|
opp sucursal de origen).
|
|
|
|
Filtro defensivo INVERSO al de /fill-opp-id-sucursal: solo opera sobre
|
|
BRAND_LOCATION_ID. Si el body trae opp_ids, se sanean para excluir cualquier
|
|
id que no pertenezca a una opp de Marca.
|
|
|
|
Body JSON:
|
|
{
|
|
"dry_run": true|false, # default true
|
|
"opp_ids": ["id1", ...], # opcional, default todas las opps Marca con CF vacio/invalido
|
|
"yes": true # confirma cuando dry_run=false
|
|
}
|
|
"""
|
|
from scripts import backfill_opp_sucursal_link as match_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
opp_ids = body.get("opp_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
# Defensa: filtrar opp_ids para garantizar que sean opps de Marca.
|
|
BRAND = match_script.BRAND_LOCATION_ID
|
|
if opp_ids:
|
|
conn = db.get_db_connection()
|
|
try:
|
|
placeholders = ",".join("?" for _ in opp_ids)
|
|
rows = conn.execute(
|
|
f"SELECT id FROM opportunities "
|
|
f"WHERE id IN ({placeholders}) AND location_id = ?",
|
|
(*opp_ids, BRAND),
|
|
).fetchall()
|
|
opp_ids = [r["id"] for r in rows]
|
|
finally:
|
|
conn.close()
|
|
if not opp_ids:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Ninguno de los opp_ids enviados pertenece a Marca.",
|
|
)
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"match-brand-oppid-{uuid.uuid4().hex[:12]}"
|
|
except Exception as e:
|
|
error_logging.log_error("match_brand_opp_id_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = match_script.run_match(
|
|
opp_ids=opp_ids,
|
|
dry_run=dry_run,
|
|
run_id=run_id,
|
|
log=lambda *_a, **_kw: None, # silencioso en API
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
applied = result["summary"].get("applied", 0)
|
|
errors = result["summary"].get("errors", 0)
|
|
status = "failed" if errors > 0 and applied == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/fill-contact-id-sucursal")
|
|
async def fill_contact_id_sucursal(request: Request):
|
|
"""
|
|
Llena el custom field 'ID Contacto Sucursal' de cada contacto con su propio
|
|
id nativo. Acciona el bucket "contacts_missing_id_field" SOLO en sucursales
|
|
(Marca queda informativa).
|
|
|
|
Body JSON:
|
|
{
|
|
"dry_run": true|false, # default true
|
|
"contact_ids": ["id1", ...], # opcional, default todos los del bucket
|
|
"location_ids": ["loc1", ...], # opcional, default todas las sucursales
|
|
"yes": true # confirma cuando dry_run=false
|
|
}
|
|
"""
|
|
from scripts import fill_contact_id_sucursal as fill_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
contact_ids = body.get("contact_ids") or None
|
|
location_ids = body.get("location_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
BRAND = fill_script.BRAND_LOCATION_ID
|
|
if location_ids:
|
|
location_ids = [l for l in location_ids if l != BRAND]
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"fill-contactid-{uuid.uuid4().hex[:12]}"
|
|
except Exception as e:
|
|
error_logging.log_error("fill_contact_id_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = fill_script.run_fill(
|
|
location_ids=location_ids,
|
|
contact_ids=contact_ids,
|
|
dry_run=dry_run,
|
|
run_id=run_id,
|
|
log=lambda *_a, **_kw: None,
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["set"] == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/verify-in-expected-branch")
|
|
async def verify_in_expected_branch(request: Request):
|
|
"""
|
|
Verifica si el contacto Marca tiene homónimo con nombre EXACTO en la
|
|
sucursal esperada. Lee del cache SQLite local (rápido y suficiente tras una
|
|
sincronización reciente). Read-only — no muta nada.
|
|
|
|
"Exacto" significa: el `full_name` normalizado (lowercase + trim + NFKD sin
|
|
acentos + espacios colapsados) coincide byte por byte. Esto evita falsos
|
|
positivos como "Juan Pérez" vs "Juan Perez" pero exige 100% identidad textual.
|
|
|
|
Body JSON: { brand_contact_id, expected_location_id }
|
|
Devuelve: { found, query, matches: [ {id, first_name, last_name, email, phone} ] }
|
|
"""
|
|
import unicodedata
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
brand_contact_id = body.get("brand_contact_id")
|
|
expected_location_id = body.get("expected_location_id")
|
|
if not brand_contact_id or not expected_location_id:
|
|
raise HTTPException(status_code=400, detail="brand_contact_id y expected_location_id son requeridos.")
|
|
|
|
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
|
brand_contact = db.get_contact_by_id(BRAND_LOCATION_ID, brand_contact_id)
|
|
if not brand_contact:
|
|
raise HTTPException(status_code=404, detail="Contacto Marca no está en SQLite local. Sincroniza Marca primero.")
|
|
|
|
full_name_raw = " ".join(filter(None, [
|
|
brand_contact.get("first_name"), brand_contact.get("last_name")
|
|
])).strip()
|
|
if not full_name_raw:
|
|
return {"ok": True, "found": False, "query": "", "matches": [],
|
|
"reason": "El contacto Marca no tiene nombre; no se puede buscar por nombre exacto."}
|
|
|
|
def _normalize(s):
|
|
if not s:
|
|
return ""
|
|
nfkd = unicodedata.normalize("NFD", str(s))
|
|
clean = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
|
|
return " ".join(clean.lower().split())
|
|
|
|
target = _normalize(full_name_raw)
|
|
|
|
conn = db.get_db_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, first_name, last_name, email, phone FROM contacts WHERE location_id=?",
|
|
(expected_location_id,),
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
matches = []
|
|
for r in rows:
|
|
candidate = " ".join(filter(None, [r["first_name"], r["last_name"]])).strip()
|
|
if _normalize(candidate) == target:
|
|
matches.append({
|
|
"id": r["id"],
|
|
"first_name": r["first_name"],
|
|
"last_name": r["last_name"],
|
|
"email": r["email"],
|
|
"phone": r["phone"],
|
|
})
|
|
|
|
return {
|
|
"ok": True,
|
|
"found": len(matches) > 0,
|
|
"query": full_name_raw,
|
|
"normalized_query": target,
|
|
"matches": matches,
|
|
"match_count": len(matches),
|
|
}
|
|
|
|
|
|
@app.post("/api/comparativa/create-in-expected-branch")
|
|
async def create_in_expected_branch(request: Request):
|
|
"""
|
|
Crea el contacto Marca en la sucursal esperada (mapeando custom fields por
|
|
nombre Marca → Sucursal). Caso de uso: bucket brand_not_in_any_branch.
|
|
|
|
Si ya existe homónimo exacto en la sucursal, NO crea — devuelve 409 con la
|
|
sugerencia de usar el botón "Verificar" o eliminar manualmente.
|
|
|
|
Respeta el toggle global X-Dry-Run.
|
|
|
|
Body JSON: { brand_contact_id, expected_location_id }
|
|
"""
|
|
import sqlite3
|
|
from scripts.sync_missing_opps_to_brand import (
|
|
DB_PATH, build_contact_payload, load_schemas_id_to_name, load_schemas_name_to_id,
|
|
upsert_contact_in_db,
|
|
)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
brand_contact_id = body.get("brand_contact_id")
|
|
expected_location_id = body.get("expected_location_id")
|
|
if not brand_contact_id or not expected_location_id:
|
|
raise HTTPException(status_code=400, detail="brand_contact_id y expected_location_id son requeridos.")
|
|
|
|
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
|
if expected_location_id == BRAND_LOCATION_ID:
|
|
raise HTTPException(status_code=400, detail="La sucursal esperada no puede ser la cuenta Marca.")
|
|
|
|
branch_token = TOKENS_CACHE.get(expected_location_id)
|
|
if not branch_token:
|
|
raise HTTPException(status_code=404, detail=f"No hay token para la sucursal {expected_location_id}.")
|
|
|
|
# Modo simulación: previsualizar lo que se haría.
|
|
if is_dry_run_request(request):
|
|
brand_contact = db.get_contact_by_id(BRAND_LOCATION_ID, brand_contact_id)
|
|
if not brand_contact:
|
|
return dry_run_response("create_in_expected_branch", {
|
|
"ok": False,
|
|
"warning": "Contacto Marca no está en SQLite local; en live se intentaría descargarlo.",
|
|
"brand_contact_id": brand_contact_id,
|
|
})
|
|
full_name = " ".join(filter(None, [brand_contact.get("first_name"), brand_contact.get("last_name")])).strip()
|
|
return dry_run_response("create_in_expected_branch", {
|
|
"brand_contact_id": brand_contact_id,
|
|
"expected_location_id": expected_location_id,
|
|
"contact_name": full_name,
|
|
"email": brand_contact.get("email"),
|
|
"phone": brand_contact.get("phone"),
|
|
"custom_fields_count": len(brand_contact.get("custom_fields") or []),
|
|
})
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
brand_row = conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
|
(BRAND_LOCATION_ID, brand_contact_id),
|
|
).fetchone()
|
|
if not brand_row:
|
|
raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no está en SQLite.")
|
|
brand_contact = dict(brand_row)
|
|
# Mapear customFields del schema Marca al schema sucursal por nombre.
|
|
brand_schema_id_to_name = load_schemas_id_to_name(conn, BRAND_LOCATION_ID, "contact")
|
|
branch_schema_name_to_id = load_schemas_name_to_id(conn, expected_location_id, "contact")
|
|
finally:
|
|
conn.close()
|
|
|
|
payload = build_contact_payload(brand_contact, brand_schema_id_to_name, branch_schema_name_to_id, expected_location_id)
|
|
if not payload:
|
|
raise HTTPException(status_code=400, detail="No se pudo construir el payload del contacto (falta first_name/last_name o phone/email).")
|
|
|
|
# Crear en GHL.
|
|
import uuid
|
|
run_id = f"cieb-{uuid.uuid4().hex[:12]}"
|
|
try:
|
|
script_audit.init_audit_db()
|
|
script_audit.create_run(
|
|
run_id, "create_in_expected_branch",
|
|
arguments=f"--brand-contact-id {brand_contact_id} --target-location {expected_location_id}",
|
|
locations=[expected_location_id], execution_mode="sequential",
|
|
)
|
|
except Exception:
|
|
run_id = None
|
|
|
|
try:
|
|
res = sync_engine.ghl_client.create_contact(branch_token, payload)
|
|
except Exception as exc:
|
|
error_id = error_logging.log_error("create_in_expected_branch_ghl_failed", exc, {
|
|
"brand_contact_id": brand_contact_id,
|
|
"expected_location_id": expected_location_id,
|
|
"run_id": run_id,
|
|
})
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(exc))
|
|
except Exception: pass
|
|
# GHL devuelve 400 con detalle "Contact with this Email/Phone already exists" cuando hay colisión.
|
|
raise HTTPException(status_code=502, detail=f"Bucéfalo rechazó la creación: {exc} (error_id={error_id})")
|
|
|
|
contact_obj = (res or {}).get("contact") or res or {}
|
|
new_contact_id = contact_obj.get("id")
|
|
|
|
# Actualizar cache local.
|
|
if new_contact_id:
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
tags = brand_contact.get("tags") or []
|
|
if isinstance(tags, str):
|
|
import json as _json
|
|
try: tags = _json.loads(tags) or []
|
|
except Exception: tags = []
|
|
upsert_contact_in_db(conn, new_contact_id, payload, expected_location_id, tags=tags)
|
|
conn.commit()
|
|
except Exception as exc:
|
|
error_logging.log_error("create_in_expected_branch_cache_failed", exc, {"new_contact_id": new_contact_id})
|
|
finally:
|
|
conn.close()
|
|
# Refresh autoritativo del nuevo contacto en sucursal.
|
|
ref = sync_engine.refresh_contact_in_db(branch_token, new_contact_id, expected_location_id)
|
|
if not ref.get("ok"):
|
|
error_logging.log_error("create_in_expected_branch_refresh_failed",
|
|
Exception(ref.get("error") or "unknown"),
|
|
{"new_contact_id": new_contact_id})
|
|
|
|
if run_id:
|
|
try:
|
|
script_audit.record_change(
|
|
run_id, expected_location_id, "contact_created_in_branch",
|
|
new_contact_id or "", "", "created",
|
|
None,
|
|
{"brand_contact_id": brand_contact_id, "branch_contact_id": new_contact_id,
|
|
"name": payload.get("firstName", "") + " " + payload.get("lastName", "")},
|
|
)
|
|
script_audit.update_run_status(run_id, "success")
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"ok": True,
|
|
"created": True,
|
|
"branch_contact_id": new_contact_id,
|
|
"expected_location_id": expected_location_id,
|
|
"brand_contact_id": brand_contact_id,
|
|
"run_id": run_id,
|
|
"payload": {
|
|
"firstName": payload.get("firstName"),
|
|
"lastName": payload.get("lastName"),
|
|
"email": payload.get("email"),
|
|
"phone": payload.get("phone"),
|
|
"custom_fields_count": len(payload.get("customFields") or []),
|
|
},
|
|
}
|
|
|
|
|
|
@app.post("/api/comparativa/sync-missing-contacts")
|
|
async def sync_missing_contacts(request: Request):
|
|
"""
|
|
Sincroniza al CRM de Marca los contactos del bucket
|
|
"contacts_in_branch_not_in_brand". Default dry-run.
|
|
|
|
Hace doble-check por telefono -> email -> nombre antes de crear (evita
|
|
falsos positivos del audit que solo busca por phone/email).
|
|
|
|
Body JSON:
|
|
{
|
|
"dry_run": true|false,
|
|
"contact_ids": ["id1", ...], # opcional
|
|
"yes": true # confirma cuando dry_run=false
|
|
}
|
|
"""
|
|
from scripts import sync_missing_contacts_to_brand as sync_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
contact_ids = body.get("contact_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"smc-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id,
|
|
sync_script.SCRIPT_NAME,
|
|
arguments=f"--apply contact_ids={contact_ids or 'all'}",
|
|
locations=[sync_script.BRAND_LOCATION_ID],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as e:
|
|
error_logging.log_error("sync_missing_contacts_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = sync_script.run_sync(
|
|
contact_ids=contact_ids,
|
|
dry_run=dry_run,
|
|
log=lambda *_a, **_kw: None,
|
|
run_id=run_id,
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["contacts_created"] == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/sync-brand-to-branch-contacts")
|
|
async def sync_brand_to_branch_contacts(request: Request):
|
|
"""
|
|
Sincroniza los contactos del bucket "contacts_in_brand_not_in_any_branch"
|
|
desde Marca hacia su sucursal correspondiente (resuelta por el campo TIENDA
|
|
+ verificador). Default dry-run.
|
|
|
|
Reglas estrictas:
|
|
- Requiere TIENDA poblada en el contacto Y mapeable via verificador.
|
|
- Double-check en la sucursal destino por phone -> email -> nombre.
|
|
|
|
Body JSON:
|
|
{ "dry_run": true|false, "contact_ids": [...], "yes": true }
|
|
"""
|
|
from scripts import sync_brand_to_branch_contacts as sync_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
contact_ids = body.get("contact_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"sb2b-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id,
|
|
sync_script.SCRIPT_NAME,
|
|
arguments=f"--apply contact_ids={contact_ids or 'all'}",
|
|
locations=[sync_script.BRAND_LOCATION_ID],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as e:
|
|
error_logging.log_error("sync_brand_to_branch_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = sync_script.run_sync(
|
|
contact_ids=contact_ids,
|
|
dry_run=dry_run,
|
|
log=lambda *_a, **_kw: None,
|
|
run_id=run_id,
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["contacts_created"] == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/fix-tienda-from-sucursal")
|
|
async def fix_tienda_from_sucursal(request: Request):
|
|
"""
|
|
Llena el campo TIENDA de los contactos de Marca a partir del valor del
|
|
campo 'Sucursal' (custom field), resuelto via verificador. Aplica solo
|
|
a contactos del bucket "contacts_in_brand_without_tienda" que tienen
|
|
Sucursal poblada y resoluble.
|
|
|
|
Body JSON:
|
|
{ "dry_run": true|false, "contact_ids": [...], "yes": true }
|
|
"""
|
|
from scripts import fix_brand_tienda_from_sucursal as fix_script
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
dry_run = bool(body.get("dry_run", True))
|
|
contact_ids = body.get("contact_ids") or None
|
|
confirmed = bool(body.get("yes", False))
|
|
|
|
if not dry_run and not confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Para apply (dry_run=false) se requiere 'yes: true' en el body como confirmacion explicita.",
|
|
)
|
|
|
|
run_id = None
|
|
if not dry_run:
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"fbts-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id,
|
|
fix_script.SCRIPT_NAME,
|
|
arguments=f"--apply contact_ids={contact_ids or 'all'}",
|
|
locations=[fix_script.BRAND_LOCATION_ID],
|
|
execution_mode="sequential",
|
|
)
|
|
except Exception as e:
|
|
error_logging.log_error("fix_tienda_from_sucursal_start_run_failed", e)
|
|
run_id = None
|
|
|
|
try:
|
|
result = fix_script.run_fix(
|
|
contact_ids=contact_ids,
|
|
dry_run=dry_run,
|
|
log=lambda *_a, **_kw: None,
|
|
run_id=run_id,
|
|
)
|
|
except FileNotFoundError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
except RuntimeError as e:
|
|
if run_id:
|
|
try: script_audit.update_run_status(run_id, "failed", str(e))
|
|
except Exception: pass
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
if run_id:
|
|
try:
|
|
status = "failed" if result["summary"]["errors"] > 0 and result["summary"]["contacts_updated"] == 0 else "completed"
|
|
script_audit.update_run_status(run_id, status)
|
|
except Exception:
|
|
pass
|
|
result["run_id"] = run_id
|
|
|
|
return result
|
|
|
|
|
|
@app.post("/api/comparativa/sync-from-branch-contact")
|
|
async def sync_from_branch_contact(request: Request):
|
|
"""
|
|
Actualiza un contacto de Marca usando los datos + custom fields de un
|
|
contacto de sucursal. Sirve para el caso "ultimo restante de duplicados
|
|
intra-Marca" donde el contacto Marca quedo sin phone/email y existe un
|
|
contacto sucursal con el mismo nombre tambien sin phone/email.
|
|
|
|
PUT /contacts/{brand_contact_id} con:
|
|
- firstName, lastName (del sucursal)
|
|
- customFields (mapeados por nombre del schema sucursal -> schema marca)
|
|
|
|
GHL no acepta locationId/contactId/status en PUT — los omitimos.
|
|
|
|
Body JSON: { brand_contact_id, branch_contact_id, branch_location_id }
|
|
"""
|
|
import sqlite3
|
|
from scripts.sync_missing_opps_to_brand import (
|
|
BRAND_LOCATION_ID, DB_PATH,
|
|
build_contact_payload, load_schemas_id_to_name, load_schemas_name_to_id,
|
|
upsert_contact_in_db,
|
|
)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
brand_contact_id = body.get("brand_contact_id")
|
|
branch_contact_id = body.get("branch_contact_id")
|
|
branch_location_id = body.get("branch_location_id")
|
|
if not brand_contact_id or not branch_contact_id or not branch_location_id:
|
|
raise HTTPException(status_code=400, detail="brand_contact_id, branch_contact_id y branch_location_id son requeridos.")
|
|
|
|
if is_dry_run_request(request):
|
|
return dry_run_response("sync_brand_from_branch_contact", {
|
|
"brand_contact_id": brand_contact_id,
|
|
"branch_contact_id": branch_contact_id,
|
|
"branch_location_id": branch_location_id,
|
|
})
|
|
|
|
brand_token = TOKENS_CACHE.get(BRAND_LOCATION_ID)
|
|
if not brand_token:
|
|
raise HTTPException(status_code=404, detail="No hay token para Marca.")
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
branch_row = conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
|
(branch_location_id, branch_contact_id),
|
|
).fetchone()
|
|
if not branch_row:
|
|
raise HTTPException(status_code=404, detail=f"Contacto sucursal {branch_contact_id} no esta en SQLite.")
|
|
brand_row = conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
|
(BRAND_LOCATION_ID, brand_contact_id),
|
|
).fetchone()
|
|
if not brand_row:
|
|
raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no esta en SQLite.")
|
|
|
|
branch_contact = dict(branch_row)
|
|
# Schemas para mapear custom fields nombre→id
|
|
branch_schema_id_to_name = load_schemas_id_to_name(conn, branch_location_id, "contact")
|
|
brand_schema_name_to_id = load_schemas_name_to_id(conn, BRAND_LOCATION_ID, "contact")
|
|
|
|
# build_contact_payload arma payload COMPLETO con locationId; para PUT
|
|
# hay que quitar campos que GHL rechaza.
|
|
payload = build_contact_payload(
|
|
branch_contact,
|
|
branch_schema_id_to_name,
|
|
brand_schema_name_to_id,
|
|
target_location_id=BRAND_LOCATION_ID,
|
|
)
|
|
# PUT /contacts/{id} no acepta locationId — lo quitamos.
|
|
payload_for_put = {k: v for k, v in payload.items() if k != "locationId"}
|
|
|
|
try:
|
|
sync_engine.ghl_client.update_contact(brand_token, brand_contact_id, payload_for_put)
|
|
except Exception as e:
|
|
err_id = error_logging.log_error(
|
|
"sync_from_branch_contact_failed", e,
|
|
{"brand_contact_id": brand_contact_id, "branch_contact_id": branch_contact_id, "branch_location_id": branch_location_id},
|
|
)
|
|
raise HTTPException(status_code=502, detail=f"GHL rechazo la actualizacion: {e} | error_id={err_id}")
|
|
|
|
# Upsert SQLite local. build_contact_payload incluye locationId; lo
|
|
# pasamos al upsert. Reusamos el helper sobreescribiendo el id.
|
|
upsert_contact_in_db(conn, brand_contact_id, payload, BRAND_LOCATION_ID)
|
|
# Refresh autoritativo del contacto Marca tras update.
|
|
ref = sync_engine.refresh_contact_in_db(brand_token, brand_contact_id, BRAND_LOCATION_ID)
|
|
if not ref.get("ok"):
|
|
error_logging.log_error("sync_from_branch_contact_refresh_failed",
|
|
Exception(ref.get("error") or "unknown"),
|
|
{"brand_contact_id": brand_contact_id})
|
|
|
|
# Audit log
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"sfbc-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id, "sync_from_branch_contact (inline)",
|
|
arguments=f"brand={brand_contact_id} <- branch={branch_contact_id}@{branch_location_id}",
|
|
locations=[BRAND_LOCATION_ID], execution_mode="sequential",
|
|
)
|
|
cid = script_audit.record_change(
|
|
run_id, BRAND_LOCATION_ID, "contact", brand_contact_id,
|
|
"", "updated", dict(brand_row),
|
|
{"source_branch": branch_location_id, "source_branch_contact_id": branch_contact_id, "payload": payload_for_put},
|
|
)
|
|
if cid:
|
|
script_audit.mark_change(cid, "applied")
|
|
script_audit.update_run_status(run_id, "completed")
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"updated": True,
|
|
"brand_contact_id": brand_contact_id,
|
|
"branch_contact_id": branch_contact_id,
|
|
"branch_location_id": branch_location_id,
|
|
"fields_updated": list(payload_for_put.keys()),
|
|
"custom_fields_count": len(payload_for_put.get("customFields", [])),
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/comparativa/update-branch-from-brand")
|
|
async def update_branch_from_brand(request: Request):
|
|
"""
|
|
Actualiza un contacto de sucursal con los datos + custom fields del contacto
|
|
Marca. Caso de uso: bucket missing_in_assigned_branch, donde el contacto
|
|
Marca tiene TIENDA poblada pero NO matchea por phone/email con la sucursal
|
|
asignada; sin embargo, EN la sucursal asignada existe un contacto con el
|
|
mismo nombre exacto. Esto consolida ambos lados pisando el sucursal con la
|
|
info del Marca.
|
|
|
|
Body JSON: { brand_contact_id, branch_contact_id, branch_location_id }
|
|
"""
|
|
import sqlite3
|
|
from scripts.sync_missing_opps_to_brand import (
|
|
BRAND_LOCATION_ID, DB_PATH,
|
|
build_contact_payload, load_schemas_id_to_name, load_schemas_name_to_id,
|
|
upsert_contact_in_db,
|
|
)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
brand_contact_id = body.get("brand_contact_id")
|
|
branch_contact_id = body.get("branch_contact_id")
|
|
branch_location_id = body.get("branch_location_id")
|
|
if not brand_contact_id or not branch_contact_id or not branch_location_id:
|
|
raise HTTPException(status_code=400, detail="brand_contact_id, branch_contact_id y branch_location_id son requeridos.")
|
|
|
|
if is_dry_run_request(request):
|
|
return dry_run_response("update_branch_from_brand", {
|
|
"brand_contact_id": brand_contact_id,
|
|
"branch_contact_id": branch_contact_id,
|
|
"branch_location_id": branch_location_id,
|
|
})
|
|
|
|
branch_token = TOKENS_CACHE.get(branch_location_id)
|
|
if not branch_token:
|
|
raise HTTPException(status_code=404, detail=f"No hay token para {branch_location_id}")
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
brand_row = conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
|
(BRAND_LOCATION_ID, brand_contact_id),
|
|
).fetchone()
|
|
if not brand_row:
|
|
raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no esta en SQLite.")
|
|
branch_row = conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
|
(branch_location_id, branch_contact_id),
|
|
).fetchone()
|
|
if not branch_row:
|
|
raise HTTPException(status_code=404, detail=f"Contacto sucursal {branch_contact_id} no esta en SQLite.")
|
|
|
|
brand_contact = dict(brand_row)
|
|
brand_schema_id_to_name = load_schemas_id_to_name(conn, BRAND_LOCATION_ID, "contact")
|
|
branch_schema_name_to_id = load_schemas_name_to_id(conn, branch_location_id, "contact")
|
|
|
|
payload = build_contact_payload(
|
|
brand_contact,
|
|
brand_schema_id_to_name,
|
|
branch_schema_name_to_id,
|
|
target_location_id=branch_location_id,
|
|
)
|
|
# PUT /contacts/{id} no acepta locationId.
|
|
payload_for_put = {k: v for k, v in payload.items() if k != "locationId"}
|
|
|
|
try:
|
|
sync_engine.ghl_client.update_contact(branch_token, branch_contact_id, payload_for_put)
|
|
except Exception as e:
|
|
err_id = error_logging.log_error(
|
|
"update_branch_from_brand_failed", e,
|
|
{"brand_contact_id": brand_contact_id, "branch_contact_id": branch_contact_id, "branch_location_id": branch_location_id},
|
|
)
|
|
raise HTTPException(status_code=502, detail=f"GHL rechazo la actualizacion: {e} | error_id={err_id}")
|
|
|
|
upsert_contact_in_db(conn, branch_contact_id, payload, branch_location_id)
|
|
# Refresh autoritativo: GET /contacts/{id} y save_single garantiza
|
|
# consistencia con Bucéfalo. El usuario espera "todo se mapee al 100%".
|
|
ref = sync_engine.refresh_contact_in_db(branch_token, branch_contact_id, branch_location_id)
|
|
if not ref.get("ok"):
|
|
error_logging.log_error("update_branch_from_brand_refresh_failed",
|
|
Exception(ref.get("error") or "unknown"),
|
|
{"branch_contact_id": branch_contact_id, "branch_location_id": branch_location_id})
|
|
|
|
# script_audit
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"ubfb-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id, "update_branch_from_brand (inline)",
|
|
arguments=f"branch={branch_contact_id}@{branch_location_id} <- brand={brand_contact_id}",
|
|
locations=[branch_location_id], execution_mode="sequential",
|
|
)
|
|
cid = script_audit.record_change(
|
|
run_id, branch_location_id, "contact", branch_contact_id,
|
|
"", "updated", dict(branch_row),
|
|
{"source": "brand", "source_brand_contact_id": brand_contact_id, "payload": payload_for_put},
|
|
)
|
|
if cid:
|
|
script_audit.mark_change(cid, "applied")
|
|
script_audit.update_run_status(run_id, "completed")
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"updated": True,
|
|
"branch_contact_id": branch_contact_id,
|
|
"branch_location_id": branch_location_id,
|
|
"brand_contact_id": brand_contact_id,
|
|
"custom_fields_count": len(payload_for_put.get("customFields", [])),
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/comparativa/update-brand-tienda")
|
|
async def update_brand_tienda(request: Request):
|
|
"""
|
|
Actualiza el campo TIENDA del contacto Marca al valor indicado. Caso de uso:
|
|
bucket present_in_other_branch_not_assigned, donde el contacto Marca esta
|
|
realmente en una sucursal distinta a la que indica su TIENDA actual; se
|
|
corrige la TIENDA para que apunte a la sucursal donde el contacto vive.
|
|
|
|
Body JSON: { brand_contact_id, new_tienda_value }
|
|
"""
|
|
import sqlite3, json as _json
|
|
from scripts.sync_missing_opps_to_brand import BRAND_LOCATION_ID, DB_PATH, upsert_contact_in_db
|
|
from scripts.audit_brand_vs_branches_totals import resolve_tienda_field_id
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
brand_contact_id = body.get("brand_contact_id")
|
|
new_tienda_value = body.get("new_tienda_value")
|
|
if not brand_contact_id or not new_tienda_value:
|
|
raise HTTPException(status_code=400, detail="brand_contact_id y new_tienda_value son requeridos.")
|
|
|
|
if is_dry_run_request(request):
|
|
return dry_run_response("update_brand_tienda", {
|
|
"brand_contact_id": brand_contact_id,
|
|
"new_tienda_value": new_tienda_value,
|
|
})
|
|
|
|
brand_token = TOKENS_CACHE.get(BRAND_LOCATION_ID)
|
|
if not brand_token:
|
|
raise HTTPException(status_code=404, detail="No hay token para Marca.")
|
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
tienda_field_id = resolve_tienda_field_id(conn, BRAND_LOCATION_ID)
|
|
if not tienda_field_id:
|
|
raise HTTPException(status_code=409, detail="No se pudo resolver el field_id de TIENDA en Marca. Corre la sincronizacion de metadata.")
|
|
|
|
brand_row = conn.execute(
|
|
"SELECT * FROM contacts WHERE location_id=? AND id=?",
|
|
(BRAND_LOCATION_ID, brand_contact_id),
|
|
).fetchone()
|
|
if not brand_row:
|
|
raise HTTPException(status_code=404, detail=f"Contacto Marca {brand_contact_id} no esta en SQLite.")
|
|
|
|
payload = {"customFields": [{"id": tienda_field_id, "value": new_tienda_value}]}
|
|
|
|
try:
|
|
sync_engine.ghl_client.update_contact(brand_token, brand_contact_id, payload)
|
|
except Exception as e:
|
|
err_id = error_logging.log_error(
|
|
"update_brand_tienda_failed", e,
|
|
{"brand_contact_id": brand_contact_id, "new_tienda_value": new_tienda_value},
|
|
)
|
|
raise HTTPException(status_code=502, detail=f"GHL rechazo el update: {e} | error_id={err_id}")
|
|
|
|
# Upsert SQLite: mergear el custom_fields_json existente con el nuevo valor.
|
|
existing = dict(brand_row)
|
|
existing_cfs = []
|
|
try:
|
|
existing_cfs = _json.loads(existing.get("custom_fields_json") or "[]") or []
|
|
except Exception:
|
|
existing_cfs = []
|
|
# Reemplazar o agregar el field TIENDA
|
|
replaced = False
|
|
for cf in existing_cfs:
|
|
if cf.get("id") == tienda_field_id or cf.get("fieldId") == tienda_field_id:
|
|
cf["value"] = new_tienda_value
|
|
replaced = True
|
|
break
|
|
if not replaced:
|
|
existing_cfs.append({"id": tienda_field_id, "value": new_tienda_value})
|
|
# Construir payload sintetico para upsert_contact_in_db (espera estructura camelCase)
|
|
local_payload = {
|
|
"firstName": existing.get("first_name"),
|
|
"lastName": existing.get("last_name"),
|
|
"email": existing.get("email"),
|
|
"phone": existing.get("phone"),
|
|
"customFields": existing_cfs,
|
|
}
|
|
upsert_contact_in_db(conn, brand_contact_id, local_payload, BRAND_LOCATION_ID)
|
|
# Refresh autoritativo Marca tras update.
|
|
ref = sync_engine.refresh_contact_in_db(brand_token, brand_contact_id, BRAND_LOCATION_ID)
|
|
if not ref.get("ok"):
|
|
error_logging.log_error("update_brand_tienda_refresh_failed",
|
|
Exception(ref.get("error") or "unknown"),
|
|
{"brand_contact_id": brand_contact_id})
|
|
|
|
# script_audit
|
|
try:
|
|
import uuid
|
|
script_audit.init_audit_db()
|
|
run_id = f"ubt-{uuid.uuid4().hex[:12]}"
|
|
script_audit.create_run(
|
|
run_id, "update_brand_tienda (inline)",
|
|
arguments=f"brand={brand_contact_id} TIENDA->{new_tienda_value}",
|
|
locations=[BRAND_LOCATION_ID], execution_mode="sequential",
|
|
)
|
|
cid = script_audit.record_change(
|
|
run_id, BRAND_LOCATION_ID, "contact", brand_contact_id,
|
|
tienda_field_id, "TIENDA",
|
|
None, # valor previo: lo dejamos null por simplicidad (esta en script_runs.context)
|
|
{"TIENDA": new_tienda_value},
|
|
)
|
|
if cid:
|
|
script_audit.mark_change(cid, "applied")
|
|
script_audit.update_run_status(run_id, "completed")
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"updated": True,
|
|
"brand_contact_id": brand_contact_id,
|
|
"new_tienda_value": new_tienda_value,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.delete("/api/comparativa/contact")
|
|
def delete_comparativa_contact(contact_id: str, location_id: str, request: Request):
|
|
"""
|
|
Elimina un contacto en GHL y limpia el snapshot SQLite local.
|
|
|
|
Tambien borra las oportunidades cacheadas del contacto en esa location
|
|
porque GHL hace cascade al borrar contactos. Sin rollback: GHL no permite
|
|
undelete.
|
|
|
|
Respeta el toggle global vía header X-Dry-Run: si está en simulación,
|
|
devuelve un preview de cuántas opps habría borrado en cascade sin tocar
|
|
GHL ni el cache local.
|
|
|
|
Query params:
|
|
- contact_id: id de GHL del contacto.
|
|
- location_id: location (Marca o sucursal) donde vive el contacto.
|
|
"""
|
|
if not contact_id or not location_id:
|
|
raise HTTPException(status_code=400, detail="contact_id y location_id son requeridos.")
|
|
|
|
token = TOKENS_CACHE.get(location_id)
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail=f"No hay token para la location {location_id}")
|
|
|
|
# Modo simulación: previsualizar el impacto sin tocar GHL.
|
|
if is_dry_run_request(request):
|
|
conn = db.get_db_connection()
|
|
try:
|
|
opps_count_row = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM opportunities WHERE contact_id=? AND location_id=?",
|
|
(contact_id, location_id),
|
|
).fetchone()
|
|
opps_count = opps_count_row["n"] if opps_count_row else 0
|
|
contact_row = conn.execute(
|
|
"SELECT first_name, last_name, email FROM contacts WHERE id=? AND location_id=?",
|
|
(contact_id, location_id),
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
return dry_run_response("delete_contact", {
|
|
"contact_id": contact_id,
|
|
"location_id": location_id,
|
|
"contact": dict(contact_row) if contact_row else None,
|
|
"opportunities_in_cascade": opps_count,
|
|
})
|
|
|
|
try:
|
|
sync_engine.ghl_client.delete_contact(token, contact_id, location_id)
|
|
except Exception as e:
|
|
error_id = error_logging.log_error(
|
|
"delete_comparativa_contact_failed", e,
|
|
{"contact_id": contact_id, "location_id": location_id},
|
|
)
|
|
raise HTTPException(status_code=502, detail=f"GHL rechazo la eliminacion: {e} | error_id={error_id}")
|
|
|
|
conn = db.get_db_connection()
|
|
deleted_opps = 0
|
|
try:
|
|
cur = conn.execute(
|
|
"DELETE FROM opportunities WHERE contact_id=? AND location_id=?",
|
|
(contact_id, location_id),
|
|
)
|
|
deleted_opps = cur.rowcount
|
|
conn.execute(
|
|
"DELETE FROM contacts WHERE id=? AND location_id=?",
|
|
(contact_id, location_id),
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
conn.rollback()
|
|
error_logging.log_error(
|
|
"delete_comparativa_contact_local_cleanup_failed", e,
|
|
{"contact_id": contact_id, "location_id": location_id},
|
|
)
|
|
# No re-raise: ya borramos en GHL, el cleanup local es secundario.
|
|
finally:
|
|
conn.close()
|
|
|
|
return {
|
|
"deleted": True,
|
|
"contact_id": contact_id,
|
|
"location_id": location_id,
|
|
"local_opps_removed": deleted_opps,
|
|
}
|
|
|
|
|
|
# --- ENDPOINTS DE WORKFLOWS ---
|
|
|
|
from paths import EXPORTS_DIR, REPORTS_DIR # carpetas servidas vía /api/exports/{filename}
|
|
|
|
|
|
def _find_downloadable(filename: str):
|
|
"""Busca `filename` (sin subcarpeta) en EXPORTS_DIR y luego en cada
|
|
subcarpeta de REPORTS_DIR (audit_custom_fields/, duplicados/, drift/,
|
|
coverage/...). Devuelve el path realpath si existe y es seguro, o None.
|
|
|
|
Mantenemos la URL /api/exports/<file> plana (sin subcarpetas) para que el
|
|
marcador `[DOWNLOAD]` que imprimen los scripts siga siendo simple. La
|
|
busqueda en reports/ permite que los scripts guarden el archivo en su
|
|
subcarpeta organizada sin tener que duplicarlo en exports/.
|
|
"""
|
|
exports_real = os.path.realpath(EXPORTS_DIR)
|
|
candidate = os.path.realpath(os.path.join(EXPORTS_DIR, filename))
|
|
if candidate.startswith(exports_real + os.sep) and os.path.isfile(candidate):
|
|
return candidate
|
|
|
|
reports_real = os.path.realpath(REPORTS_DIR)
|
|
if os.path.isdir(reports_real):
|
|
for subdir in os.listdir(reports_real):
|
|
subdir_path = os.path.realpath(os.path.join(reports_real, subdir))
|
|
if not subdir_path.startswith(reports_real + os.sep):
|
|
continue
|
|
if not os.path.isdir(subdir_path):
|
|
continue
|
|
candidate = os.path.realpath(os.path.join(subdir_path, filename))
|
|
if candidate.startswith(subdir_path + os.sep) and os.path.isfile(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
@app.get("/api/exports/{filename}")
|
|
def download_export(filename: str):
|
|
"""Sirve archivos generados por scripts (xlsx/csv/json).
|
|
|
|
Busca en `generated/exports/` y, como fallback, en cada subcarpeta de
|
|
`generated/reports/`. Los scripts pueden imprimir
|
|
`[DOWNLOAD] /api/exports/<file>` aunque el archivo viva en
|
|
`generated/reports/<categoria>/<file>`.
|
|
"""
|
|
if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
|
|
raise HTTPException(status_code=400, detail="Nombre de archivo invalido.")
|
|
path = _find_downloadable(filename)
|
|
if path is None:
|
|
raise HTTPException(status_code=404, detail="Archivo no encontrado.")
|
|
return FileResponse(path, filename=filename)
|
|
|
|
|
|
@app.get("/api/workflows")
|
|
def get_workflows(location_id: str = None):
|
|
"""
|
|
Retorna los workflows almacenados en SQLite, opcionalmente filtrados por location_id.
|
|
"""
|
|
return db.get_workflows(location_id=location_id)
|
|
|
|
@app.get("/api/workflows/export")
|
|
def export_workflows(location_id: str = None, status: str = "all", q: str = None):
|
|
"""
|
|
Exporta los workflows almacenados en SQLite a un archivo Excel, respetando filtros de la UI.
|
|
"""
|
|
workflows = db.get_workflows(location_id=location_id)
|
|
search_query = (q or "").strip().lower()
|
|
|
|
def matches_filters(workflow):
|
|
workflow_status = workflow.get("status") or ""
|
|
if status != "all":
|
|
if status == "active" and workflow_status not in ("active", "published"):
|
|
return False
|
|
if status == "inactive" and workflow_status != "inactive":
|
|
return False
|
|
if status == "draft" and workflow_status != "draft":
|
|
return False
|
|
|
|
if search_query:
|
|
searchable_values = (
|
|
workflow.get("name") or "",
|
|
workflow.get("id") or "",
|
|
workflow.get("trigger") or "",
|
|
)
|
|
if not any(search_query in value.lower() for value in searchable_values):
|
|
return False
|
|
return True
|
|
|
|
filtered_workflows = [workflow for workflow in workflows if matches_filters(workflow)]
|
|
|
|
workbook = Workbook()
|
|
sheet = workbook.active
|
|
sheet.title = "Workflows GHL"
|
|
|
|
headers = [
|
|
"Cuenta/Sucursal",
|
|
"Location ID",
|
|
"ID Workflow",
|
|
"Nombre del Workflow",
|
|
"Estado",
|
|
"Trigger",
|
|
"Fecha de Creación",
|
|
"Fecha de Modificación",
|
|
"Sincronizado en Local",
|
|
]
|
|
sheet.append(headers)
|
|
|
|
header_fill = PatternFill(fill_type="solid", fgColor="1F2937")
|
|
header_font = Font(color="FFFFFF", bold=True)
|
|
for cell in sheet[1]:
|
|
cell.fill = header_fill
|
|
cell.font = header_font
|
|
|
|
status_labels = {
|
|
"active": "Activo",
|
|
"published": "Activo",
|
|
"inactive": "Pausado",
|
|
"draft": "Borrador",
|
|
}
|
|
|
|
for workflow in filtered_workflows:
|
|
workflow_status = workflow.get("status") or "draft"
|
|
sheet.append([
|
|
workflow.get("account_name") or workflow.get("location_id") or "",
|
|
workflow.get("location_id") or "",
|
|
workflow.get("id") or "",
|
|
workflow.get("name") or "",
|
|
status_labels.get(workflow_status, workflow_status),
|
|
workflow.get("trigger") or "",
|
|
workflow.get("created_at") or "",
|
|
workflow.get("updated_at") or "",
|
|
workflow.get("synced_at") or "",
|
|
])
|
|
|
|
for column_cells in sheet.columns:
|
|
max_length = max(len(str(cell.value or "")) for cell in column_cells)
|
|
sheet.column_dimensions[column_cells[0].column_letter].width = min(max(max_length + 2, 14), 60)
|
|
|
|
output = BytesIO()
|
|
workbook.save(output)
|
|
output.seek(0)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"workflows_ghl_{timestamp}.xlsx"
|
|
return StreamingResponse(
|
|
output,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
def sync_workflows_task(location_id: str = None):
|
|
"""
|
|
Tarea de fondo para sincronizar todos los workflows (o de una cuenta específica).
|
|
"""
|
|
try:
|
|
accounts = sync_engine.parse_accounts_csv()
|
|
except Exception as e:
|
|
error_logging.log_error("sync_workflows_bg_task_load_accounts_failed", e)
|
|
return
|
|
|
|
if location_id:
|
|
accounts = [acc for acc in accounts if acc["location_id"] == location_id]
|
|
|
|
for acc in accounts:
|
|
loc_id = acc["location_id"]
|
|
nombre = acc["nombre"]
|
|
token = acc["token"]
|
|
try:
|
|
workflows = sync_engine.ghl_client.get_workflows(token, loc_id)
|
|
db.save_workflows(loc_id, workflows)
|
|
print(f"Sincronizados {len(workflows)} workflows para {nombre} de forma asíncrona.")
|
|
except Exception as e:
|
|
error_logging.log_error("sync_workflows_bg_task_failed", e, {"location_id": loc_id})
|
|
print(f"Error sincronizando workflows para {nombre}: {e}")
|
|
|
|
@app.post("/api/workflows/sync")
|
|
def trigger_workflows_sync(background_tasks: BackgroundTasks, location_id: str = None):
|
|
"""
|
|
Sincroniza en segundo plano todos los workflows de todas las cuentas o de una en particular.
|
|
"""
|
|
background_tasks.add_task(sync_workflows_task, location_id)
|
|
return {"success": True, "message": "Sincronización de workflows iniciada en segundo plano."}
|
|
|
|
|
|
# --- RUTAS DE AUTOMATIZACIÓN DE WORKFLOWS CON PLAYWRIGHT ---
|
|
|
|
@app.post("/api/workflows/{workflow_id}/toggle-status")
|
|
def toggle_workflow_status(workflow_id: str, location_id: str, current_status: str, request: Request):
|
|
"""
|
|
Dispara Playwright en segundo plano para alternar el estado del workflow.
|
|
"""
|
|
if is_dry_run_request(request):
|
|
return {"success": True, "dry_run": True, "task_id": None,
|
|
"message": "Modo simulación: el toggle de workflow no se ejecutó (cambia a Live para aplicar)."}
|
|
try:
|
|
task_id = script_runner.start_script(
|
|
"ghl_browser_workflow_manager.py",
|
|
arguments=f"--action toggle-status --location {location_id} --workflow-id {workflow_id} --current-status {current_status}"
|
|
)
|
|
return {"success": True, "task_id": task_id, "message": "Proceso de Playwright iniciado para cambiar estado."}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.post("/api/workflows/{workflow_id}/rename")
|
|
def rename_workflow(workflow_id: str, location_id: str, new_name: str, request: Request):
|
|
"""
|
|
Dispara Playwright en segundo plano para renombrar el workflow.
|
|
"""
|
|
import shlex
|
|
if is_dry_run_request(request):
|
|
return {"success": True, "dry_run": True, "task_id": None,
|
|
"message": f"Modo simulación: el rename a '{new_name}' no se ejecutó (cambia a Live para aplicar)."}
|
|
try:
|
|
escaped_name = shlex.quote(new_name)
|
|
task_id = script_runner.start_script(
|
|
"ghl_browser_workflow_manager.py",
|
|
arguments=f"--action rename --location {location_id} --workflow-id {workflow_id} --new-name {escaped_name}"
|
|
)
|
|
return {"success": True, "task_id": task_id, "message": "Proceso de Playwright iniciado para renombrar."}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.delete("/api/workflows/{workflow_id}")
|
|
def delete_workflow(workflow_id: str, location_id: str, request: Request):
|
|
"""
|
|
Dispara Playwright en segundo plano para eliminar el workflow.
|
|
"""
|
|
if is_dry_run_request(request):
|
|
return {"success": True, "dry_run": True, "task_id": None,
|
|
"message": "Modo simulación: la eliminación del workflow no se ejecutó (cambia a Live para aplicar)."}
|
|
try:
|
|
task_id = script_runner.start_script(
|
|
"ghl_browser_workflow_manager.py",
|
|
arguments=f"--action delete --location {location_id} --workflow-id {workflow_id}"
|
|
)
|
|
return {"success": True, "task_id": task_id, "message": "Proceso de Playwright iniciado para eliminar."}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
async def _dispatch_bulk_toggle(request: Request, target: str):
|
|
"""Helper compartido por bulk-draft, bulk-publish y bulk-delete. `target` ∈ {'draft','publish','delete'}."""
|
|
import json as _json
|
|
if target not in ("draft", "publish", "delete"):
|
|
raise HTTPException(status_code=400, detail=f"target inválido: {target}")
|
|
try:
|
|
payload = await request.json()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Body inválido (JSON esperado).")
|
|
|
|
items = payload.get("items") if isinstance(payload, dict) else None
|
|
if not isinstance(items, list) or not items:
|
|
raise HTTPException(status_code=400, detail="Se esperaba 'items' como lista no vacía.")
|
|
|
|
for i, it in enumerate(items):
|
|
if not isinstance(it, dict) or not it.get("workflow_id") or not it.get("location_id"):
|
|
raise HTTPException(status_code=400, detail=f"Item {i} inválido: requiere workflow_id y location_id.")
|
|
|
|
if is_dry_run_request(request):
|
|
return {
|
|
"success": True,
|
|
"dry_run": True,
|
|
"target": target,
|
|
"items_count": len(items),
|
|
"task_id": None,
|
|
"message": f"Modo simulación: bulk {target} no se ejecutó. {len(items)} workflows habrían sido afectados.",
|
|
}
|
|
|
|
from paths import BATCH_DIR
|
|
batch_path = os.path.join(
|
|
BATCH_DIR,
|
|
f"_bulk_batch_{target}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.json",
|
|
)
|
|
try:
|
|
with open(batch_path, "w", encoding="utf-8") as f:
|
|
_json.dump(items, f, ensure_ascii=False)
|
|
except Exception as we:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo escribir batch file: {we}")
|
|
|
|
cli_action = {"draft": "bulk-draft", "publish": "bulk-publish", "delete": "bulk-delete"}[target]
|
|
try:
|
|
task_id = script_runner.start_script(
|
|
"ghl_browser_workflow_manager.py",
|
|
arguments=f'--action {cli_action} --batch-file "{batch_path}"',
|
|
)
|
|
return {
|
|
"success": True,
|
|
"task_id": task_id,
|
|
"count": len(items),
|
|
"target": target,
|
|
"message": f"Bulk-{target} iniciado para {len(items)} workflow(s).",
|
|
}
|
|
except Exception as e:
|
|
try:
|
|
os.remove(batch_path)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/workflows/bulk-draft")
|
|
async def bulk_draft_workflows(request: Request):
|
|
"""Pone una lista de workflows en Borrador con un solo subprocess de Playwright."""
|
|
return await _dispatch_bulk_toggle(request, "draft")
|
|
|
|
|
|
@app.post("/api/workflows/bulk-publish")
|
|
async def bulk_publish_workflows(request: Request):
|
|
"""Pone una lista de workflows en Publicado con un solo subprocess de Playwright."""
|
|
return await _dispatch_bulk_toggle(request, "publish")
|
|
|
|
|
|
@app.post("/api/workflows/bulk-delete")
|
|
async def bulk_delete_workflows(request: Request):
|
|
"""Elimina una lista de workflows con un solo subprocess de Playwright."""
|
|
return await _dispatch_bulk_toggle(request, "delete")
|
|
|
|
|
|
@app.post("/api/workflows/{workflow_id}/scan-anomalies")
|
|
def scan_workflow_anomalies(workflow_id: str, location_id: str):
|
|
"""Lanza el detector de anomalías Playwright para un workflow individual."""
|
|
try:
|
|
task_id = script_runner.start_script(
|
|
"ghl_browser_workflow_anomaly_scanner.py",
|
|
arguments=f"--location {location_id} --workflow-id {workflow_id}",
|
|
)
|
|
return {"success": True, "task_id": task_id,
|
|
"message": "Escaneo de anomalías iniciado."}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/scripts/stop/{task_id}")
|
|
def stop_script_task(task_id: str):
|
|
"""Cancela un script en curso terminando su subproceso. Usado por el botón
|
|
'Cancelar' del panel de progreso del bulk."""
|
|
ok, msg = script_runner.stop_task(task_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=msg)
|
|
return {"success": True, "message": msg}
|
|
|
|
|
|
@app.get("/api/workflows/anomaly-report-xlsx/{filename}")
|
|
def download_anomaly_report_xlsx(filename: str):
|
|
"""Genera al vuelo un XLSX a partir del CSV agregado de anomalías (una fila por
|
|
workflow). Primera columna = hyperlink al editor en Bucéfalo. Filas coloreadas según
|
|
el tipo de anomalía más severo presente."""
|
|
import re as _re
|
|
from fastapi.responses import FileResponse
|
|
if not _re.match(r"^workflow_anomalies_\d{8}_\d{6}\.csv$", filename):
|
|
raise HTTPException(status_code=400, detail="Nombre de archivo inválido.")
|
|
from paths import REPORT_WORKFLOW_ANOMALIES
|
|
csv_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, filename)
|
|
if not os.path.isfile(csv_path):
|
|
raise HTTPException(status_code=404, detail="Reporte no encontrado.")
|
|
xlsx_filename = filename.replace(".csv", ".xlsx")
|
|
xlsx_path = os.path.join(REPORT_WORKFLOW_ANOMALIES, xlsx_filename)
|
|
# Regenerar si el XLSX es más viejo que el CSV, o si no existe.
|
|
needs_build = (not os.path.isfile(xlsx_path)) or os.path.getmtime(xlsx_path) < os.path.getmtime(csv_path)
|
|
if needs_build:
|
|
try:
|
|
import csv as _csv
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Anomalías"
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|
header_fill = PatternFill("solid", fgColor="7C3AED")
|
|
link_font = Font(color="1F6FEB", underline="single")
|
|
wrap_alignment = Alignment(wrap_text=True, vertical="top")
|
|
top_alignment = Alignment(vertical="top")
|
|
# Colores ordenados por severidad (más severo primero).
|
|
severity_order = ["ghl_native_error", "unresolved_field_id", "alert_icon", "n_button_alert"]
|
|
type_colors = {
|
|
"ghl_native_error": "FCA5A5",
|
|
"unresolved_field_id": "FDBA74",
|
|
"alert_icon": "FCD34D",
|
|
"n_button_alert": "F9A8D4",
|
|
}
|
|
|
|
def _most_severe_color(types_csv):
|
|
types = [t.strip() for t in (types_csv or "").split(",") if t.strip()]
|
|
for t in severity_order:
|
|
if t in types:
|
|
return type_colors[t]
|
|
return None
|
|
|
|
widths = {
|
|
"workflow_url": 60, "account_name": 28, "workflow_name": 34,
|
|
"workflow_id": 38, "workflow_status": 14, "anomalies_count": 12,
|
|
"anomaly_types": 24, "affected_nodes": 50, "details": 60,
|
|
"screenshot_path": 60,
|
|
}
|
|
multiline_cols = {"affected_nodes", "details"}
|
|
|
|
with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
|
|
reader = _csv.reader(f)
|
|
headers = next(reader, [])
|
|
ws.append(headers)
|
|
for c in range(1, len(headers) + 1):
|
|
cell = ws.cell(row=1, column=c)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
# Localizar columnas clave
|
|
col_idx = {h: i for i, h in enumerate(headers)}
|
|
url_col = col_idx.get("workflow_url")
|
|
types_col = col_idx.get("anomaly_types")
|
|
|
|
row_idx = 2
|
|
for row in reader:
|
|
ws.append(row)
|
|
# Hyperlink en la primera columna
|
|
if url_col is not None and url_col < len(row) and row[url_col]:
|
|
cell = ws.cell(row=row_idx, column=url_col + 1)
|
|
cell.hyperlink = row[url_col]
|
|
cell.font = link_font
|
|
cell.alignment = top_alignment
|
|
# Wrap text en columnas multilínea
|
|
for h in multiline_cols:
|
|
if h in col_idx:
|
|
ws.cell(row=row_idx, column=col_idx[h] + 1).alignment = wrap_alignment
|
|
# Color de fila según tipo más severo
|
|
if types_col is not None and types_col < len(row):
|
|
color = _most_severe_color(row[types_col])
|
|
if color:
|
|
fill = PatternFill("solid", fgColor=color)
|
|
for c in range(1, len(headers) + 1):
|
|
# No pisar la fuente azul del hyperlink, solo el fill.
|
|
ws.cell(row=row_idx, column=c).fill = fill
|
|
row_idx += 1
|
|
|
|
for i, h in enumerate(headers, 1):
|
|
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = widths.get(h, 18)
|
|
ws.freeze_panes = "A2"
|
|
wb.save(xlsx_path)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo generar XLSX: {e}")
|
|
return FileResponse(xlsx_path, filename=xlsx_filename,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
|
|
|
|
@app.post("/api/workflows/bulk-scan-anomalies")
|
|
async def bulk_scan_workflow_anomalies(request: Request):
|
|
"""Escanea anomalías para una lista de workflows con un solo subprocess de Playwright."""
|
|
import json as _json
|
|
try:
|
|
payload = await request.json()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Body inválido (JSON esperado).")
|
|
|
|
items = payload.get("items") if isinstance(payload, dict) else None
|
|
if not isinstance(items, list) or not items:
|
|
raise HTTPException(status_code=400, detail="Se esperaba 'items' como lista no vacía.")
|
|
for i, it in enumerate(items):
|
|
if not isinstance(it, dict) or not it.get("workflow_id") or not it.get("location_id"):
|
|
raise HTTPException(status_code=400, detail=f"Item {i} inválido: requiere workflow_id y location_id.")
|
|
|
|
from paths import BATCH_DIR
|
|
batch_path = os.path.join(
|
|
BATCH_DIR,
|
|
f"_bulk_batch_scan_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.json",
|
|
)
|
|
try:
|
|
with open(batch_path, "w", encoding="utf-8") as f:
|
|
_json.dump(items, f, ensure_ascii=False)
|
|
except Exception as we:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo escribir batch file: {we}")
|
|
|
|
try:
|
|
task_id = script_runner.start_script(
|
|
"ghl_browser_workflow_anomaly_scanner.py",
|
|
arguments=f'--batch-file "{batch_path}"',
|
|
)
|
|
return {"success": True, "task_id": task_id, "count": len(items),
|
|
"target": "scan-anomalies",
|
|
"message": f"Escaneo de anomalías iniciado para {len(items)} workflow(s)."}
|
|
except Exception as e:
|
|
try:
|
|
os.remove(batch_path)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
# --- SESIÓN DEL NAVEGADOR (Playwright) ---
|
|
|
|
from paths import SESSION_FILE as SESSION_FILE_PATH
|
|
|
|
|
|
@app.get("/api/browser-session/status")
|
|
def browser_session_status():
|
|
"""Devuelve el estado de generated/browser/session.json (existe, antigüedad en horas)."""
|
|
import time
|
|
exists = os.path.exists(SESSION_FILE_PATH)
|
|
age_hours = None
|
|
if exists:
|
|
age_hours = round((time.time() - os.path.getmtime(SESSION_FILE_PATH)) / 3600.0, 1)
|
|
return {"exists": exists, "age_hours": age_hours, "stale": (age_hours or 0) > 24}
|
|
|
|
|
|
@app.post("/api/browser-session/refresh")
|
|
def browser_session_refresh():
|
|
"""Lanza el generador de sesión vía script_runner para que sus logs sean streameables
|
|
por SSE. Devuelve task_id; el frontend se conecta a /api/scripts/stream/{task_id}
|
|
para ver el progreso paso a paso (con o sin auto-login según haya .env configurado).
|
|
"""
|
|
try:
|
|
task_id = script_runner.start_script("ghl_browser_session_generator.py")
|
|
return {
|
|
"success": True,
|
|
"task_id": task_id,
|
|
"message": "Generador de sesión iniciado. Sigue el progreso en vivo.",
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# --- CONTROLES DE SINCRONIZACIÓN ---
|
|
|
|
@app.post("/api/sync")
|
|
def trigger_global_sync():
|
|
"""
|
|
Dispara la sincronización asíncrona de todas las cuentas.
|
|
"""
|
|
return sync_engine.sync_all_accounts()
|
|
|
|
@app.post("/api/sync/metadata")
|
|
def trigger_metadata_sync():
|
|
"""
|
|
Dispara la sincronización asíncrona solo de schemas/metadata de todas las cuentas.
|
|
"""
|
|
return sync_engine.sync_all_metadata()
|
|
|
|
@app.get("/api/sync/progress")
|
|
def get_sync_progress():
|
|
"""
|
|
Retorna el estado y avance de la sincronización en vivo.
|
|
"""
|
|
return sync_engine.get_sync_progress()
|
|
|
|
@app.post("/api/sync/{location_id}")
|
|
def trigger_single_sync(location_id: str, background_tasks: BackgroundTasks):
|
|
"""
|
|
Dispara la sincronización asíncrona de una cuenta específica.
|
|
"""
|
|
token = TOKENS_CACHE.get(location_id)
|
|
if not token:
|
|
raise HTTPException(status_code=404, detail="Token no encontrado para la ubicación seleccionada.")
|
|
|
|
def run_sync():
|
|
sync_engine.sync_account(location_id, token)
|
|
|
|
background_tasks.add_task(run_sync)
|
|
return {"message": f"Sincronización iniciada para la cuenta {location_id} en segundo plano."}
|
|
|
|
# --- CONTROLADOR DE SCRIPTS PYTHON ---
|
|
|
|
@app.get("/api/scripts")
|
|
def get_scripts():
|
|
"""
|
|
Retorna la lista de todos los scripts de Python categorizados y si existen en el sistema.
|
|
"""
|
|
return script_runner.get_available_scripts()
|
|
|
|
|
|
@app.get("/api/scripts/{script_name}/metadata")
|
|
def get_script_metadata(script_name: str):
|
|
try:
|
|
return script_runner.get_script_metadata(script_name)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@app.put("/api/scripts/{script_name}/metadata")
|
|
async def update_script_metadata(script_name: str, request: Request):
|
|
try:
|
|
body = await request.json()
|
|
metadata = body.get("metadata", body)
|
|
return {"success": True, "metadata": script_runner.update_script_metadata(script_name, metadata)}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo guardar la metadata: {str(e)}")
|
|
|
|
|
|
@app.delete("/api/scripts/{script_name}")
|
|
def delete_script(script_name: str):
|
|
try:
|
|
return {"success": True, **script_runner.delete_script(script_name)}
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo borrar el script: {str(e)}")
|
|
|
|
@app.post("/api/scripts/{script_name}/run")
|
|
async def run_script(script_name: str, request: Request):
|
|
"""
|
|
Ejecuta un script de Python con argumentos opcionales.
|
|
"""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
body = {}
|
|
|
|
arguments = body.get("arguments", "")
|
|
locations = body.get("locations") or []
|
|
all_locations = bool(body.get("all_locations", False))
|
|
execution_mode = body.get("execution_mode", "sequential")
|
|
if execution_mode not in ("sequential", "parallel"):
|
|
execution_mode = "sequential"
|
|
if all_locations and execution_mode == "parallel":
|
|
locations = [acc["location_id"] for acc in db.get_accounts() if acc.get("type") == "branch"]
|
|
all_locations = False
|
|
|
|
try:
|
|
task_id = script_runner.start_script(
|
|
script_name,
|
|
arguments=arguments,
|
|
locations=locations,
|
|
all_locations=all_locations,
|
|
execution_mode=execution_mode
|
|
)
|
|
return {
|
|
"success": True,
|
|
"task_id": task_id,
|
|
"message": f"Ejecución del script {script_name} iniciada."
|
|
}
|
|
except FileNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo iniciar el script: {str(e)}")
|
|
|
|
@app.get("/api/scripts/stream/{task_id}")
|
|
def stream_script_logs(task_id: str):
|
|
"""
|
|
Endpoint SSE (Server-Sent Events) para transmitir la terminal en vivo de un script.
|
|
"""
|
|
return StreamingResponse(
|
|
script_runner.get_script_log_stream(task_id),
|
|
media_type="text/event-stream"
|
|
)
|
|
|
|
@app.get("/api/scripts/status/{task_id}")
|
|
def get_script_status(task_id: str):
|
|
status = script_runner.get_task_status(task_id)
|
|
if not status:
|
|
raise HTTPException(status_code=404, detail="Tarea no encontrada")
|
|
return status
|
|
|
|
|
|
@app.get("/api/scripts/logs/{task_id}")
|
|
def download_script_log(task_id: str):
|
|
"""
|
|
Descarga el log completo persistido del run (archivo plano por task_id).
|
|
Soporta runs ya finalizados y runs en vivo: el archivo es line-buffered,
|
|
asi que el contenido al momento de la peticion incluye todo lo emitido.
|
|
"""
|
|
path = script_runner.get_task_log_path(task_id)
|
|
if not path or not os.path.exists(path):
|
|
raise HTTPException(status_code=404, detail="Log no encontrado para ese task_id.")
|
|
return FileResponse(
|
|
path,
|
|
media_type="text/plain; charset=utf-8",
|
|
filename=f"{task_id}.log",
|
|
)
|
|
|
|
|
|
@app.get("/api/scripts/logs/{task_id}/tail")
|
|
def tail_script_log(task_id: str, request: Request, from_byte: int = 0):
|
|
"""
|
|
Devuelve el contenido del log desde el byte indicado (clamped al final).
|
|
Sirve para que el cliente rehidrate el buffer tras una desconexion del
|
|
SSE sin tener que re-pedir todo el log. Respuesta JSON con:
|
|
{ "from_byte": int, "next_byte": int, "size": int, "content": str }
|
|
"""
|
|
# `from_byte` se acepta tanto como query param `from_byte` como `from`
|
|
# (FastAPI no permite usar la palabra reservada `from` como nombre de param).
|
|
qp = request.query_params
|
|
if "from" in qp:
|
|
try:
|
|
from_byte = int(qp["from"])
|
|
except (TypeError, ValueError):
|
|
from_byte = 0
|
|
if from_byte < 0:
|
|
from_byte = 0
|
|
|
|
path = script_runner.get_task_log_path(task_id)
|
|
if not path or not os.path.exists(path):
|
|
raise HTTPException(status_code=404, detail="Log no encontrado para ese task_id.")
|
|
|
|
try:
|
|
size = os.path.getsize(path)
|
|
if from_byte > size:
|
|
from_byte = size
|
|
with open(path, "rb") as fh:
|
|
fh.seek(from_byte)
|
|
chunk = fh.read()
|
|
return {
|
|
"from_byte": from_byte,
|
|
"next_byte": size,
|
|
"size": size,
|
|
"content": chunk.decode("utf-8", errors="replace"),
|
|
}
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=500, detail=f"No se pudo leer el log: {exc}")
|
|
|
|
@app.get("/api/scripts/runs")
|
|
def get_script_runs(limit: int = 20):
|
|
return script_audit.list_runs(limit=limit)
|
|
|
|
@app.get("/api/scripts/runs/{run_id}/changes")
|
|
def get_script_run_changes(run_id: str):
|
|
return script_audit.list_changes(run_id)
|
|
|
|
|
|
@app.get("/api/errors")
|
|
def get_error_logs(limit: int = 50):
|
|
return db.get_error_logs(limit=limit)
|
|
|
|
@app.post("/api/scripts/runs/{run_id}/pause")
|
|
def pause_script_run(run_id: str):
|
|
script_audit.set_control(run_id, pause=True)
|
|
script_audit.update_run_status(run_id, "paused")
|
|
return {"success": True, "message": "Pausa solicitada."}
|
|
|
|
@app.post("/api/scripts/runs/{run_id}/resume")
|
|
def resume_script_run(run_id: str):
|
|
script_audit.set_control(run_id, pause=False)
|
|
script_audit.update_run_status(run_id, "running")
|
|
return {"success": True, "message": "Reanudación solicitada."}
|
|
|
|
@app.post("/api/scripts/runs/{run_id}/stop")
|
|
def stop_script_run(run_id: str):
|
|
script_audit.set_control(run_id, stop=True, pause=False)
|
|
script_audit.update_run_status(run_id, "stop_requested")
|
|
return {"success": True, "message": "Detención segura solicitada."}
|
|
|
|
@app.post("/api/scripts/runs/{run_id}/rollback")
|
|
def rollback_script_run(run_id: str):
|
|
try:
|
|
return {"success": True, **script_audit.rollback_run(run_id)}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
if __name__ == "__main__":
|
|
import os
|
|
import sys
|
|
import socket
|
|
import json
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
|
|
# 1. Determinar puerto base (8000 por defecto o desde variable de entorno/argumento)
|
|
port = 8000
|
|
if os.environ.get("PORT"):
|
|
try:
|
|
port = int(os.environ.get("PORT"))
|
|
except ValueError:
|
|
pass
|
|
for arg in sys.argv:
|
|
if arg.startswith("--port="):
|
|
try:
|
|
port = int(arg.split("=")[1])
|
|
except ValueError:
|
|
pass
|
|
|
|
# 2. Buscar primer puerto libre disponible
|
|
def is_port_in_use(p):
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
try:
|
|
s.bind(('127.0.0.1', p))
|
|
return False
|
|
except OSError:
|
|
return True
|
|
|
|
original_port = port
|
|
while is_port_in_use(port):
|
|
port += 1
|
|
|
|
if port != original_port:
|
|
print(f"[SISTEMA] Puerto {original_port} en uso. Redireccionando al puerto libre: {port}")
|
|
|
|
# 3. Guardar información del servidor para el stop.bat
|
|
server_info = {
|
|
"port": port,
|
|
"pid": os.getpid()
|
|
}
|
|
try:
|
|
import paths as _paths
|
|
with open(_paths.SERVER_INFO, "w", encoding="utf-8") as f:
|
|
json.dump(server_info, f, indent=4)
|
|
except Exception as e:
|
|
print(f"[ADVERTENCIA] No se pudo guardar el archivo de información del servidor: {e}")
|
|
|
|
# 4. Iniciar hilo secundario para abrir el navegador de forma dinámica.
|
|
# El query param `?_=<timestamp>` fuerza al browser a tratar la URL como
|
|
# nueva y no reutilizar pestañas viejas que pudieran tener cargado otro
|
|
# proyecto en 127.0.0.1:8000 (p.ej. el Transcriptor). Sin esto, Edge/Chrome
|
|
# cambian a la pestaña existente sin recargar.
|
|
def open_browser_delayed():
|
|
time.sleep(1.5)
|
|
url = f"http://127.0.0.1:{port}/?_={int(time.time())}"
|
|
print(f"[SISTEMA] Abriendo navegador en la dirección: {url}")
|
|
webbrowser.open(url, new=2) # new=2 = "new tab if possible"
|
|
|
|
threading.Thread(target=open_browser_delayed, daemon=True).start()
|
|
|
|
# 5. Iniciar servidor FastAPI con uvicorn y recarga automática
|
|
uvicorn.run("main:app", host="127.0.0.1", port=port, reload=True)
|