Actualizar backend/services.py
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user