Actualizar backend/services.py

This commit is contained in:
2026-09-19 10:56:55 -05:00
parent de2e2b94d0
commit 8c566220ed

View File

@@ -14,6 +14,179 @@ def get_dm() -> DataManager:
return _DM
# ── PLANTILLAS: campañas (leyendas) y base de números por campaña ──────────
def plantillas_campanias():
"""Lista las campañas AGRUPADAS por código (un código = una opción, aunque
tenga varias frases). Devuelve [{codigo, frases:[...]}]."""
from data_manager_v2 import _campanias_supabase
agrup = {}
for (frase, cargo, codigo, sede, dia, origen, fi, ff) in _campanias_supabase():
cod = str(codigo).strip()
if not cod:
continue
agrup.setdefault(cod, {"codigo": cod, "frases": [], "cargo": cargo, "sede": sede})
if frase and frase not in agrup[cod]["frases"]:
agrup[cod]["frases"].append(frase)
out = sorted(agrup.values(), key=lambda x: str(x["codigo"]))
return {"campanias": out}
def plantillas_base_por_codigos(codigos, asesores=None, estados=None):
"""Dado uno o más códigos de campaña, busca en Chatwoot los contactos cuya
conversación hizo match con la frase (LIKE), sin asesor y con último mensaje
hace >24h. Devuelve [{telefono, fecha_mensaje, fecha_ult_mensaje, asesor, estado}].
'estados' (opcional) filtra por ESTADO/OBJECION (ultima_etiqueta), mismo que el
apartado Leads. Excluye el número 51924374783 (mismo filtro del SQL manual)."""
from data_manager_v2 import _campanias_supabase
# Agrupar por código: juntar TODAS sus frases y usar el RANGO MÁS AMPLIO
# (fecha_inicio más antigua, fecha_fin más lejana de todas sus filas).
cods = set(str(c).strip() for c in (codigos or []))
por_cod = {} # codigo -> {"frases":[...], "fi":min, "ff":max}
for (frase, cargo, codigo, sede, dia, origen, fi, ff) in _campanias_supabase():
cod = str(codigo).strip()
if cod not in cods or not frase:
continue
g = por_cod.setdefault(cod, {"frases": [], "fi": None, "ff": None})
if frase not in g["frases"]:
g["frases"].append(frase)
fi10 = str(fi)[:10] if fi else None
ff10 = str(ff)[:10] if ff else None
if fi10 and (g["fi"] is None or fi10 < g["fi"]):
g["fi"] = fi10
if ff10 and (g["ff"] is None or ff10 > g["ff"]):
g["ff"] = ff10
if not por_cod:
return {"filas": []}
# Por cada código: (frase1 OR frase2 ...) AND created_at en [fi, ff]
cond = []
params = []
for cod, g in por_cod.items():
likes = " OR ".join(["m.content LIKE %s"] * len(g["frases"]))
for fr in g["frases"]:
params.append(f"%{fr}%")
partes = [f"({likes})"]
if g["fi"]:
partes.append("m.created_at >= %s")
params.append(g["fi"])
if g["ff"]:
partes.append("m.created_at < (%s::date + INTERVAL '1 day')")
params.append(g["ff"])
cond.append("(" + " AND ".join(partes) + ")")
where_frases = " OR ".join(cond)
# Filtro por ASESOR (checklist). "__VACIO__" = sin asesor (u.name IS NULL).
# Si NO se marca ningún asesor -> se traen TODOS (con y sin asesor).
ases_cond = []
incluir_vacio = False
nombres_reales = []
for a in (asesores or []):
if str(a).strip().upper() == "__VACIO__":
incluir_vacio = True
elif str(a).strip():
nombres_reales.append(str(a).strip())
if incluir_vacio:
ases_cond.append("u.name IS NULL")
if nombres_reales:
placeholders = ",".join(["%s"] * len(nombres_reales))
ases_cond.append(f"u.name IN ({placeholders})")
# Sin selección -> sin filtro de asesor (TRUE = todos).
where_ases = "(" + " OR ".join(ases_cond) + ")" if ases_cond else "TRUE"
sql = f"""
SELECT c.phone_number, m.created_at,
(SELECT MAX(mu.created_at) FROM messages mu
WHERE mu.conversation_id = m.conversation_id AND mu.sender_type='Contact') AS fecha_ult,
u.name AS asesor, cv.cached_label_list AS etiquetas
FROM messages m
JOIN conversations cv ON m.conversation_id = cv.id
JOIN contacts c ON cv.contact_id = c.id
LEFT JOIN users u ON cv.assignee_id = u.id
WHERE ({where_frases})
AND m.sender_type = 'Contact'
AND {where_ases}
AND c.phone_number IS DISTINCT FROM '51924374783'
AND (SELECT MAX(mu.created_at) FROM messages mu
WHERE mu.conversation_id = m.conversation_id AND mu.sender_type='Contact')
< NOW() - INTERVAL '24 hours'
ORDER BY m.created_at DESC
"""
# los nombres reales van al final de params (en el orden del IN)
params = params + nombres_reales
# Filtro por ESTADO/OBJECION (se aplica en Python usando ultima_etiqueta, igual
# que la matriz del apartado Leads). None = sin filtro (todos los estados).
est_filtro = None
if estados:
est_filtro = set(str(e).strip() for e in estados if str(e).strip())
if not est_filtro:
est_filtro = None
dm = get_dm()
conn = dm.pg_conn()
try:
cur = conn.cursor()
cur.execute(sql, params)
vistos = set()
filas = []
for tel, f_msg, f_ult, asesor, etiquetas in cur.fetchall():
t = str(tel or "").strip()
if not t or t in vistos:
continue
estado = L.ultima_etiqueta(etiquetas)
if est_filtro is not None and estado not in est_filtro:
continue
vistos.add(t)
filas.append({
"telefono": t,
# ISO (YYYY-MM-DD) para que el frontend lo convierta a fecha REAL de Excel
"fecha_mensaje": f_msg.strftime("%Y-%m-%d") if f_msg else "",
"fecha_ult_mensaje": f_ult.strftime("%Y-%m-%d") if f_ult else "",
"asesor": (asesor or "Sin asesor"),
"estado": estado,
})
return {"filas": filas}
finally:
conn.close()
def plantillas_asesores():
"""Lista los nombres de asesores (users.name) que aparecen como asignados en
las conversaciones. Para el checklist de filtro."""
dm = get_dm()
conn = dm.pg_conn()
try:
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT u.name
FROM users u
WHERE u.name IS NOT NULL AND TRIM(u.name) <> ''
ORDER BY u.name
""")
nombres = [r[0] for r in cur.fetchall()]
return {"asesores": nombres}
finally:
conn.close()
def plantillas_estados():
"""Lista los ESTADOS/OBJECION distintos (ultima_etiqueta) presentes en las
conversaciones, para el checklist de filtro. Misma lógica que el apartado Leads."""
dm = get_dm()
conn = dm.pg_conn()
try:
cur = conn.cursor()
cur.execute("SELECT DISTINCT cached_label_list FROM conversations")
vistos = {}
for (etiquetas,) in cur.fetchall():
est = L.ultima_etiqueta(etiquetas)
vistos[est] = vistos.get(est, 0) + 1
estados = sorted(vistos.keys(), key=lambda x: (x == "NO TRABAJADO", x.lower()))
return {"estados": estados}
finally:
conn.close()
# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar
# una leyenda se invalidan estos para que TODOS los apartados se reconstruyan
# en su proxima peticion (perezoso: solo lo que se pida, ~2-5s), sin esperar
@@ -1165,7 +1338,9 @@ def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
LIMITE_TARDIA = 960
d_click = int(dia_click)
es_resp = str(modo).lower().startswith("resp")
modo_l = str(modo).lower()
es_resp = modo_l.startswith("resp")
es_sin = modo_l.startswith("sin") # sin_responder
por_asesor = defaultdict(list) # vend -> [ {t1,t2,mins} ]
for r in _asignacion_respuesta_crudos():
@@ -1176,6 +1351,19 @@ def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
t1 = r.get("created_at"); t2 = r.get("respuesta_fecha")
if not isinstance(t1, datetime): continue
if es_sin:
# SIN RESPONDER: agrupa por día de asignación, solo los que NO tienen T2.
if not L._en_periodo(t1.date(), ano, mes, "TODOS"): continue
if t1.day != d_click: continue
if isinstance(t2, datetime): continue # respondidos: fuera
por_asesor[v].append({
"telefono": str(r.get("telefono") or "").strip(),
"t1": t1.strftime("%d/%m/%Y %H:%M"),
"t2": "— sin responder",
"minutos": "—",
})
continue
if es_resp:
# agrupar por fecha de respuesta; solo respondidos, <=960 min
if not isinstance(t2, datetime): continue
@@ -1212,13 +1400,16 @@ def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
tot_sum = 0; tot_cnt = 0
for v in sorted(por_asesor.keys()):
leads = sorted(por_asesor[v], key=lambda x: x["t1"])
s = sum(x["minutos"] for x in leads); c = len(leads)
c = len(leads)
# En "sin responder" no hay minutos -> promedio 0/oculto en el front.
s = 0 if es_sin else sum(x["minutos"] for x in leads)
tot_sum += s; tot_cnt += c
asesores.append({"asesor": v, "leads": leads,
"cantidad": c, "promedio_min": round(s / c) if c else 0})
return {"dia": d_click, "modo": ("respuesta" if es_resp else "asignacion"),
"cantidad": c, "promedio_min": 0 if es_sin else (round(s / c) if c else 0)})
modo_out = "sin_responder" if es_sin else ("respuesta" if es_resp else "asignacion")
return {"dia": d_click, "modo": modo_out,
"asesores": asesores,
"total": {"cantidad": tot_cnt, "promedio_min": round(tot_sum / tot_cnt) if tot_cnt else 0}}
"total": {"cantidad": tot_cnt, "promedio_min": 0 if es_sin else (round(tot_sum / tot_cnt) if tot_cnt else 0)}}
return cache_get_or_set("tiempo_resp_det", (dia_click, modo, vendedor, ano, mes), _load)