Files
MP-Manager/main.py
T
2026-05-30 14:31:19 -06:00

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)