From 63ee6c7f24d268dda118ef0d4f1df5ee4910a7fb Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 9 Oct 2026 13:45:50 -0500 Subject: [PATCH] Actualizar backend/services.py --- backend/services.py | 152 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/backend/services.py b/backend/services.py index 885c5f6..ab05856 100644 --- a/backend/services.py +++ b/backend/services.py @@ -187,6 +187,158 @@ def plantillas_estados(): conn.close() +# ── EXPORTAR BASE: base de leads por asesor (mismo cruce del PBI) ─────────── +# Mapa Asesor -> SEDE_ACT (SWITCH del Power BI). Si el asesor no está aquí, se +# usa la sede de la pauta tal cual (fallback). Comparación normalizada (sin +# tildes/mayúsculas) para no fallar por como escriba Chatwoot el nombre. +_SEDE_ACT_MAP = { + "ALMENDRA PERALTA": "Arequipa", + "JUAN CARLOS AGUILAR": "Piura", + "DIEGO LAZARO": "Trujillo", + "VERONICA LA ROSA": "Lima", + "DAYANA BALABARCA": "Lima", + "MILAGROS VARGAS": "Lima", + "CARMEN MONTOYA": "Lima", + "DIANA CHAVEZ": "Lima", + "COPITO RIVERA": "Lima", +} + + +def _norm_nombre(s): + import unicodedata + s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode() + s = s.replace("_", " ") # etiquetas de Chatwoot usan "_" (contacto_iniciado) + return " ".join(s.upper().split()) + + +def exportar_base_asesores(): + """Lista los asesores (agents) de Chatwoot con id y nombre (en vivo).""" + import os, requests + URL = os.getenv("CHATWOOT_URL", "https://gestor.escueladerefrigeracion.edu.pe") + TOKEN = os.getenv("CHATWOOT_TOKEN", "4anazHvZnvKLtup8biu5Zuoh") + ACC = os.getenv("CHATWOOT_ACCOUNT_ID", "1") + h = {"api_access_token": TOKEN, "Content-Type": "application/json"} + r = requests.get(f"{URL}/api/v1/accounts/{ACC}/agents", headers=h, timeout=15) + r.raise_for_status() + ags = [{"id": a.get("id"), "nombre": a.get("name") or a.get("email")} + for a in (r.json() or []) if a.get("id")] + ags.sort(key=lambda x: str(x["nombre"]).upper()) + return {"asesores": ags} + + +def exportar_base_excel(asesor, etiquetas=None): + """Base de leads de UN asesor (mismo cruce del Power BI: mensajes de Contact x + pautas de Supabase, LIKE frase dentro del rango). Dedup por teléfono (más + antiguo), SEDE_ACT por asesor y última etiqueta. Si 'etiquetas' trae valores, + filtra por última etiqueta (si está vacío = todas). Devuelve bytes del .xlsx.""" + from data_manager_v2 import _campanias_supabase + asesor = str(asesor or "").strip() + if not asesor: + raise ValueError("Falta el asesor.") + # Set de etiquetas a filtrar (normalizadas). Vacío = todas. + filtro_etq = set(_norm_nombre(e) for e in (etiquetas or []) if str(e).strip()) + + # 1) VALUES con las pautas de Supabase (frase, sede, cargo, codigo, fi, ff) + vals, params = [], [] + for (frase, cargo, codigo, sede, dia, origen, fi, ff) in _campanias_supabase(): + fr = str(frase or "").strip() + if not fr: + continue + vals.append("(%s,%s,%s,%s,%s,%s)") + params += [fr, str(cargo or ""), str(sede or ""), + str(fi or "2000-01-01")[:10], str(ff or "2100-01-01")[:10], + str(codigo or "")] + if not vals: + raise RuntimeError("No hay campañas en Supabase.") + values_sql = ",".join(vals) + + sql = f""" + SELECT c.phone_number, m.created_at, map.sede, map.cargo, cv.cached_label_list + 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 + JOIN (VALUES {values_sql}) + AS map(frase_busqueda, cargo, sede, fecha_inicio, fecha_fin, codigo) + ON m.content LIKE '%%' || map.frase_busqueda || '%%' + AND m.created_at >= CAST(map.fecha_inicio AS TIMESTAMP) + AND m.created_at <= CAST(map.fecha_fin AS TIMESTAMP) + INTERVAL '1 day' + WHERE m.sender_type = 'Contact' + AND u.name = %s + ORDER BY m.created_at ASC + """ + sede_act = _SEDE_ACT_MAP.get(_norm_nombre(asesor)) # None = usar sede pauta + + dm = get_dm() + conn = dm.pg_conn() + try: + cur = conn.cursor() + cur.execute(sql, params + [asesor]) + por_tel = {} + for tel, created, sede_p, cargo, etiquetas in cur.fetchall(): + # Quitar +51 / + y limpiar (igual que el PBI) + t = str(tel or "").strip().replace("+51", "").replace("+", "").strip() + if not t or t in ("51924374783", "924374783"): + continue + if t in por_tel: + continue # ASC -> ya guardamos el más antiguo + etq = L.ultima_etiqueta(etiquetas) + if filtro_etq and _norm_nombre(etq) not in filtro_etq: + continue # no coincide con las etiquetas elegidas + por_tel[t] = { + "telefono": t, + "fecha": created, + "sede": sede_act or (sede_p or ""), + "programa": cargo or "", + "etiqueta": etq, + } + finally: + conn.close() + + filas = sorted(por_tel.values(), key=lambda x: (x["fecha"] or __import__("datetime").datetime.max)) + + # 2) Armar Excel bonito + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from io import BytesIO + wb = Workbook() + ws = wb.active + ws.title = "Base" + encabezados = ["TELEFONO", "FECHA CREADA", "SEDE", "PROGRAMA", "ULTIMA ETIQUETA"] + ws.append(encabezados) + + azul = PatternFill("solid", fgColor="1E3A5F") + blanco = Font(color="FFFFFF", bold=True, size=11) + centro = Alignment(horizontal="center", vertical="center") + borde = Border(bottom=Side(style="thin", color="D9D9D9")) + for c in ws[1]: + c.fill = azul; c.font = blanco; c.alignment = centro + + for d in filas: + fecha_val = d["fecha"].date() if d["fecha"] else None + ws.append([int(d["telefono"]) if str(d["telefono"]).isdigit() else d["telefono"], + fecha_val, d["sede"], d["programa"], d["etiqueta"]]) + + # Formato de la columna fecha (B) como fecha real + alineación + for row in ws.iter_rows(min_row=2, min_col=1, max_col=5): + for c in row: + c.border = borde + if c.column == 2: # FECHA CREADA + c.number_format = "DD/MM/YYYY" + c.alignment = centro + elif c.column in (3, 5): # SEDE, ULTIMA ETIQUETA + c.alignment = centro + + anchos = {"A": 16, "B": 15, "C": 14, "D": 16, "E": 26} + for col, w in anchos.items(): + ws.column_dimensions[col].width = w + ws.freeze_panes = "A2" + + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + # ── 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