diff --git a/backend/services.py b/backend/services.py index f5f13f6..885c5f6 100644 --- a/backend/services.py +++ b/backend/services.py @@ -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)