# backend/services.py """Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads.""" from data_manager_v2 import DataManager import leads_logic as L from cache_manager import cache_get_or_set, cache_invalidate _DM = None def get_dm() -> DataManager: global _DM if _DM is None: _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() # ── 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 # los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache. _PREF_CAMPANIAS = ( "campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros", "leads_alertas", "vendedores_dash", "roas_dash", "otros_general", "matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas", "tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo", "resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash", ) def _invalidar_por_campanias(): """Invalida el cache de campañas (Supabase) y de todos los apartados que dependen de las leyendas, para que el cambio se vea de inmediato.""" from data_manager_v2 import invalidar_campanias_cache invalidar_campanias_cache() for p in _PREF_CAMPANIAS: cache_invalidate(p) # ── Datos crudos cacheados (consultas pesadas, una sola vez) ──── def _leads_crudos(): return cache_get_or_set("leads_raw", ("GLOBAL",), lambda: L.procesar_leads(get_dm().traer_leads_chatwoot())) def _cursos_crudos(): return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos()) def _matriculas_crudas(): return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas()) def _pauta_cruda(): return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos()) def _programa_pautas(): """{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos).""" return cache_get_or_set("programa_pautas", ("GLOBAL",), lambda: get_dm().traer_programa_pautas()) def _pautas_de_indice(): """{num_indice: [pautas]} combinando: - la pauta de basebi_programacion (1 por num_indice), y - las pautas extra de programa_pautas (varias). Sin duplicados. Un num_indice puede terminar con varias pautas que se SUMAN.""" def _load(): pm = _pauta_cruda() # {num_indice: {pauta,...}} extra = _programa_pautas() # {num_indice: [pautas]} out = {} for ni, info in pm.items(): p = info.get("pauta") if p: out.setdefault(str(ni).strip(), []).append(str(p).strip()) for ni, pautas in extra.items(): lst = out.setdefault(str(ni).strip(), []) for p in pautas: if str(p).strip() not in lst: lst.append(str(p).strip()) return out return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load) def _campanias_map(): return cache_get_or_set("campanias_map", ("GLOBAL",), lambda: get_dm().traer_campanias_map()) def _clasif_meta(): """JSON de clasificacion (sede/programa) de GitHub, MISMO que Ventas.""" return cache_get_or_set("clasif_meta", ("GLOBAL",), lambda: get_dm().traer_clasificacion_meta()) def clasificar_sede_meta(dsc_programa): """Sede segun el JSON (clasificacion_sedes). Default LIMA. Igual que Ventas.""" up = str(dsc_programa or "").upper() data = _clasif_meta().get("clasificacion_sedes", {}) for sede, cfg in data.items(): if sede == "DEFAULT": continue for pat in cfg.get("patrones", []): if str(pat).upper() in up: return sede return data.get("DEFAULT", "LIMA") def clasificar_prog_meta(dsc_programa): """Programa (TEAC/TERC/SEMINARIOS/OTROS) segun clasificacion_filtro_programa del JSON. Respeta el ORDEN del JSON (primero que coincide gana). Igual que Ventas.""" up = str(dsc_programa or "").upper() data = _clasif_meta().get("clasificacion_filtro_programa", {}) for cat, cfg in data.items(): if cat == "DEFAULT": continue for pat in cfg.get("patrones", []): if str(pat).upper() in up: return cat return data.get("DEFAULT", "OTROS") def meta_de_curso(dsc_programa): """Meta de inscritos segun sede+programa (JSON), como acordado: LIMA TEAC/TERC=22, provincia TEAC/TERC=18, SEMINARIOS/OTROS=15.""" sede = clasificar_sede_meta(dsc_programa) prog = clasificar_prog_meta(dsc_programa) if prog in ("TEAC", "TERC"): return 22 if sede == "LIMA" else 18 return 15 # Conecta los clasificadores del JSON a leads_logic (metas + tipo de matricula). L.set_clasificadores(clasificar_sede_meta, clasificar_prog_meta) def _conjunto_pauta(): return cache_get_or_set("conjunto_pauta", ("GLOBAL",), lambda: get_dm().traer_conjunto_pauta()) def _meta_filas(): """Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...].""" return cache_get_or_set("meta_filas", ("GLOBAL",), lambda: get_dm().traer_meta_importe()) def _importe_por_pauta(): """{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha.""" def _load(): conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]} imp_norm = {} for f in _meta_filas(): k = " ".join(str(f.get("conjunto")).split()).upper() imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0) out = {} for pauta, conjuntos in conj_pauta.items(): total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) out[str(pauta).strip()] = round(total, 2) return out return cache_get_or_set("importe_pauta", ("GLOBAL",), _load) # Canales que cuentan como Copito (ademas de origen_base=POSTGRE). _CANALES_COPITO = {"PAUTA_WSP", "PAUTA_WSP_FACE", "COPITO"} def _tel_es_copito(): """{telefono: True/False} = si el telefono es COPITO segun su FILA MAS ANTIGUA en la cartera. Copito = (origen_base=POSTGRE) OR (canal en _CANALES_COPITO). Se calcula una vez sobre toda la cartera (cacheado).""" def _load(): mejor = {} # tel -> (fecha_mas_antigua, es_copito) for r in _cartera_rows_cache(): tel = str(r.get("telefono") or "").strip() if not tel: continue f = L._to_date(r.get("fecha_creada")) if f is None: continue if tel not in mejor or f < mejor[tel][0]: canal = " ".join(str(r.get("canal") or "").upper().split()) ob = " ".join(str(r.get("origen_base") or "").upper().split()) es_cop = (ob == "POSTGRE") or (canal in _CANALES_COPITO) mejor[tel] = (f, es_cop) return {t: v[1] for t, v in mejor.items()} return cache_get_or_set("tel_es_copito", ("GLOBAL",), _load) def _leads_por_codigo_periodo(ano, mes, dia): """{codigo(pauta): {"rec": set(tels recibidos del periodo), "proc": set(tels procesados del periodo)}}. Recibido = lead unico con ese codigo cuya fecha cae en el periodo. Procesado = de esos, con asesor. Mismo criterio que 'Detalle por Curso'.""" def _load(): from collections import defaultdict out = defaultdict(lambda: {"rec": set(), "proc": set(), "cont": set()}) for l in _leads_crudos(): if not l.get("es_unico"): continue if not L._en_periodo(l.get("fecha"), ano, mes, dia): continue cod = str(l.get("codigo", "-")).strip() tel = l.get("telefono") if not tel: continue out[cod]["rec"].add(tel) if l.get("asesor"): out[cod]["proc"].add(tel) ult = l.get("ultima") or "NO TRABAJADO" if ult != "NO TRABAJADO": out[cod].setdefault("cont", set()).add(tel) return dict(out) return cache_get_or_set("leads_x_codigo", (ano, mes, dia), _load) def _resultados_por_pauta(): """{pauta: resultados_totales} = suma de Resultados de TODAS las filas de los conjuntos ligados a esa pauta. Sin filtro de fecha (analogo a _importe_por_pauta).""" def _load(): conj_pauta = _conjunto_pauta() res_norm = {} for f in _meta_filas(): k = " ".join(str(f.get("conjunto")).split()).upper() res_norm[k] = res_norm.get(k, 0.0) + float(f.get("resultados") or 0.0) out = {} for pauta, conjuntos in conj_pauta.items(): total = sum(res_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) out[str(pauta).strip()] = int(total) return out return cache_get_or_set("resultados_pauta", ("GLOBAL",), _load) def _importe_por_pauta_periodo(ano, mes, dia): """{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe' cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba.""" def _load(): conj_pauta = _conjunto_pauta() imp_norm = {} sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) for f in _meta_filas(): fx = L._to_date(f.get("inicio")) if sin_filtro: pass # sin filtro: cuenta todo (incluye filas sin fecha) = total elif not L._en_periodo(fx, ano, mes, dia): continue k = " ".join(str(f.get("conjunto")).split()).upper() imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0) out = {} for pauta, conjuntos in conj_pauta.items(): total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) out[str(pauta).strip()] = round(total, 2) return out return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load) def _resultados_por_pauta_periodo(ano, mes, dia): """{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe' cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados.""" def _load(): conj_pauta = _conjunto_pauta() res_norm = {} sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) for f in _meta_filas(): if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia): continue k = " ".join(str(f.get("conjunto")).split()).upper() res_norm[k] = res_norm.get(k, 0.0) + float(f.get("resultados") or 0.0) out = {} for pauta, conjuntos in conj_pauta.items(): total = sum(res_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos) out[str(pauta).strip()] = int(total) return out return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load) def _campanias_sede_cargo(): return cache_get_or_set("campanias_sc", ("GLOBAL",), lambda: get_dm().traer_campanias_sede_cargo()) def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"): """Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice). Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados, Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor). Sede/programa salen de la tabla campanias. Pautas sin campanias NO se cuentan.""" def _load(): conj_pauta = _conjunto_pauta() # {pauta: [conjuntos]} camp = _campanias_sede_cargo() # {pauta: {sede,cargo}} pauta_map = _pauta_cruda() # {num_indice: {pauta,...}} con_indice = {str(v.get("pauta")).strip() for v in pauta_map.values() if v.get("pauta")} # Leads nuevos (unicos) por pauta = codigo, cuya fecha_creada cae en el mes filtrado leads = _leads_crudos() leads_nuevos = {} # codigo -> nº leads unicos del mes leads_nuevos_asig = {} # codigo -> nº leads unicos del mes con asesor tel_lead_mes = {} # telefono -> (codigo, fecha_creada) del lead unico del mes for l in leads: if not l.get("es_unico"): continue if not L._en_periodo(l.get("fecha"), ano, mes, dia): continue cod = str(l.get("codigo") or "").strip() if not cod: continue leads_nuevos[cod] = leads_nuevos.get(cod, 0) + 1 if str(l.get("asesor") or "").strip(): leads_nuevos_asig[cod] = leads_nuevos_asig.get(cod, 0) + 1 tel = str(l.get("telefono") or "").strip() if tel: tel_lead_mes[tel] = (cod, l.get("fecha")) # MATRICULAS por pauta: matriculas cuyo telefono = un lead nuevo del mes, y con # fch_matricula >= fecha_creada del lead. Cuenta CADA matricula (una vez c/u), # atribuida a la pauta del lead. Cruce por telefono (tel_1, o tel_2 si vacio). matriculas_pauta = {} # codigo -> nº matriculas venta_pauta = {} # codigo -> suma INV_NETA en soles (DOL ×3.34) _vistas = set() # (num_matricula) para no contar 2 veces for m in _matriculas_crudas(): t1 = str(m.get("dsc_telefono_1") or "").strip() t2 = str(m.get("dsc_telefono_2") or "").strip() ph = t1 if t1 else t2 if ph not in tel_lead_mes: continue cod, f_lead = tel_lead_mes[ph] fm = L._to_date(m.get("fch_matricula")) if not fm or not f_lead or fm < f_lead: continue # matricula anterior al ingreso del lead -> no cuenta nm = str(m.get("num_matricula")) if nm in _vistas: continue _vistas.add(nm) matriculas_pauta[cod] = matriculas_pauta.get(cod, 0) + 1 # INV_NETA en soles: si moneda DOL, ×3.34 (igual que tabla por Sede) try: inv = float(m.get("INV_NETA", 0) or 0) except (TypeError, ValueError): inv = 0.0 if str(m.get("cod_moneda", "")).strip().upper() == "DOL": inv *= 3.34 venta_pauta[cod] = venta_pauta.get(cod, 0.0) + inv # Importe y Resultados por conjunto, filtrando por el MES de arriba imp = {}; res = {} sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) for f in _meta_filas(): if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia): continue k = " ".join(str(f.get("conjunto")).split()).upper() imp[k] = imp.get(k, 0.0) + float(f.get("importe") or 0.0) res[k] = res.get(k, 0.0) + float(f.get("resultados") or 0.0) # Conjuntos BRANDING/COPEAIRE: su importe SÍ suma, pero sus resultados NO. conj_brand = _conjuntos_branding() # Acumular por Sede -> Programa -> Pauta (solo pautas ALWAYS que esten en campanias) from collections import defaultdict grupos = defaultdict(lambda: defaultdict(lambda: {"importe": 0.0, "resultados": 0.0, "nuevos": 0, "nuevos_asig": 0, "matriculas": 0, "venta": 0.0, "pautas": []})) for pauta, conjuntos in conj_pauta.items(): p = str(pauta).strip() if p in con_indice: # tiene num_indice -> NO es always continue info = camp.get(p) if not info: # no esta en campanias -> no se cuenta continue se = str(info.get("sede") or "").upper() gr = L.grupo_programa_lead(info.get("cargo")) # TEAC/TERC/SEMINARIOS ti = sum(imp.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos) # Resultados: NO contar los conjuntos BRANDING/COPEAIRE. tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos if " ".join(str(c).split()).upper() not in conj_brand) if ti == 0 and tr == 0: continue # sin gasto en el mes -> no aparece # filtros de arriba if sede not in ("TODOS", None) and se != str(sede).upper(): continue if programa not in ("TODOS", None) and gr != str(programa).upper(): continue nv = leads_nuevos.get(p, 0) na = leads_nuevos_asig.get(p, 0) mt = matriculas_pauta.get(p, 0) vv = venta_pauta.get(p, 0.0) g = grupos[se][gr] g["importe"] += ti g["resultados"] += tr g["nuevos"] += nv g["nuevos_asig"] += na g["matriculas"] += mt g["venta"] += vv g["pautas"].append({"pauta": p, "importe": round(ti, 2), "resultados": int(tr), "nuevos": nv, "nuevos_asig": na, "matriculas": mt, "venta": round(vv, 0)}) # Armar filas: Sede -> subfilas por programa -> pautas (solo con gasto) filas = [] for se in sorted(grupos.keys()): subs = [] s_imp = s_res = 0.0; s_nv = s_na = s_mt = 0; s_vv = 0.0 for gr in ("TEAC", "TERC", "SEMINARIOS"): if gr in grupos[se]: d = grupos[se][gr] pautas = sorted(d["pautas"], key=lambda x: -x["importe"]) subs.append({"programa": gr, "importe": round(d["importe"], 2), "resultados": int(d["resultados"]), "nuevos": d["nuevos"], "nuevos_asig": d["nuevos_asig"], "matriculas": d["matriculas"], "venta": round(d["venta"], 0), "pautas": pautas}) s_imp += d["importe"]; s_res += d["resultados"] s_nv += d["nuevos"]; s_na += d["nuevos_asig"]; s_mt += d["matriculas"]; s_vv += d["venta"] filas.append({"sede": se, "importe": round(s_imp, 2), "resultados": int(s_res), "nuevos": s_nv, "nuevos_asig": s_na, "matriculas": s_mt, "venta": round(s_vv, 0), "subfilas": subs}) tot_i = round(sum(f["importe"] for f in filas), 2) tot_r = int(sum(f["resultados"] for f in filas)) tot_nv = sum(f["nuevos"] for f in filas) tot_na = sum(f["nuevos_asig"] for f in filas) tot_mt = sum(f["matriculas"] for f in filas) tot_vv = round(sum(f["venta"] for f in filas), 0) return {"filas": filas, "total": {"importe": tot_i, "resultados": tot_r, "nuevos": tot_nv, "nuevos_asig": tot_na, "matriculas": tot_mt, "venta": tot_vv}} return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load) def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"): """Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa. Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead).""" def _load(): rows = _cartera_rows_cache() # 1) leads WEB_FORMULARIO cuya fila es el ORIGEN del telefono en TODA la cartera # (es_origen=SI) y con fecha_creada en el periodo. Asi NO cuenta si el telefono # ya vino antes por otro canal (igual criterio que las demas tablas). vistos = {} # telefono -> {fecha, sede, prog, asesor} for r in rows: if str(r.get("canal") or "").strip().upper() != "WEB_FORMULARIO": continue if str(r.get("es_origen") or "").strip().upper() != "SI": continue # solo el origen real del telefono tel = str(r.get("telefono") or "").strip() if not tel: continue f = L._to_date(r.get("fecha_creada")) if not L._en_periodo(f, ano, mes, dia): continue # sede y programa YA vienen normalizados en cartera_junta. Reducir a 4 grupos. se = " ".join(str(r.get("sede") or "").upper().split()) or "SIN SEDE" pr = " ".join(str(r.get("programa") or "").upper().split()) if pr == "TEAC": gr = "TEAC" elif pr == "TERC": gr = "TERC" elif pr in ("SEMINARIOS", "VRF", "CO2", "DIPLOMADO", "VENTILACIÓN", "VENTILACION"): gr = "SEMINARIOS" else: gr = "OTROS" vistos[tel] = {"fecha": f, "sede": se, "prog": gr, "asesor": str(r.get("asesor") or "").strip()} # 2) matriculas por telefono (num_matricula, fecha, inv en soles) from collections import defaultdict mat_por_tel = defaultdict(list) for m in _matriculas_crudas(): t1 = str(m.get("dsc_telefono_1") or "").strip() t2 = str(m.get("dsc_telefono_2") or "").strip() ph = t1 if t1 else t2 if not ph: continue fm = L._to_date(m.get("fch_matricula")) try: inv = float(m.get("INV_NETA", 0) or 0) except (TypeError, ValueError): inv = 0.0 if str(m.get("cod_moneda", "")).strip().upper() == "DOL": inv *= 3.34 mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv)) # 3) agrupar Sede -> Programa grupos = defaultdict(lambda: defaultdict(lambda: {"recibidos": 0, "procesados": 0, "matriculas": 0, "venta": 0.0})) for tel, info in vistos.items(): se = info["sede"]; gr = info["prog"]; f_lead = info["fecha"] if sede not in ("TODOS", None) and se != str(sede).upper(): continue if programa not in ("TODOS", None) and gr != str(programa).upper(): continue g = grupos[se][gr] g["recibidos"] += 1 if info["asesor"]: g["procesados"] += 1 for (nm, fm, inv) in mat_por_tel.get(tel, []): if fm and f_lead and fm >= f_lead: g["matriculas"] += 1 g["venta"] += inv # 4) filas filas = [] for se in sorted(grupos.keys()): subs = [] s_rec = s_proc = s_mt = 0; s_vv = 0.0 for gr in ("TEAC", "TERC", "SEMINARIOS", "OTROS"): if gr in grupos[se]: d = grupos[se][gr] subs.append({"programa": gr, "recibidos": d["recibidos"], "procesados": d["procesados"], "matriculas": d["matriculas"], "venta": round(d["venta"], 0)}) s_rec += d["recibidos"]; s_proc += d["procesados"] s_mt += d["matriculas"]; s_vv += d["venta"] filas.append({"sede": se, "recibidos": s_rec, "procesados": s_proc, "matriculas": s_mt, "venta": round(s_vv, 0), "subfilas": subs}) tot = {"recibidos": sum(f["recibidos"] for f in filas), "procesados": sum(f["procesados"] for f in filas), "matriculas": sum(f["matriculas"] for f in filas), "venta": round(sum(f["venta"] for f in filas), 0)} return {"filas": filas, "total": tot} return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load) def _asignacion_respuesta_crudos(): return cache_get_or_set("asig_resp", ("GLOBAL",), lambda: get_dm().traer_asignacion_respuesta()) def _leads_asignados_crudos(): # UNIFICADO: usa la MISMA consulta/cache que el grafico (asignacion+respuesta+etiquetas), # asi la tarjeta, la matriz de asignados y el grafico nunca se desfasan. return _asignacion_respuesta_crudos() def _actividad_asesores_crudos(): return cache_get_or_set("act_asesores", ("GLOBAL",), lambda: get_dm().traer_actividad_asesores()) def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"): """Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del mes; valor = cantidad de telefonos asignados a ese asesor ese dia. Fecha = created_at del mensaje de asignacion (ya con -5h).""" def _load(): import calendar from collections import defaultdict rows = _leads_asignados_crudos() # dias del mes filtrado (columnas) if mes not in ("TODOS", None) and ano not in ("TODOS", None): ndias = calendar.monthrange(int(ano), int(mes))[1] dias_cols = list(range(1, ndias + 1)) else: dias_cols = list(range(1, 32)) # asesores excluidos de esta matriz EXCLUIR = {"SIN ASESOR", "COPITO RIVERA"} # asesor -> dia -> [telefonos] por = defaultdict(lambda: defaultdict(list)) for r in rows: f = L._to_date(r.get("created_at")) if not L._en_periodo(f, ano, mes, dia): continue ase = str(r.get("user_name") or "Sin Asesor").strip() or "Sin Asesor" if ase.upper() in EXCLUIR: continue tel = str(r.get("telefono") or "").strip() por[ase][f.day].append(tel) filas = [] tot_por_dia = defaultdict(int) for ase in sorted(por.keys()): pordia = {d: len(por[ase].get(d, [])) for d in dias_cols} total = sum(pordia.values()) # telefonos por dia (para expandir con +) tels_dia = {d: sorted(por[ase].get(d, [])) for d in dias_cols if por[ase].get(d)} for d, n in pordia.items(): tot_por_dia[d] += n filas.append({"asesor": ase, "por_dia": pordia, "total": total, "tels_dia": tels_dia}) filas.sort(key=lambda x: -x["total"]) total_gral = sum(f["total"] for f in filas) return {"dias": dias_cols, "filas": filas, "total_por_dia": {d: tot_por_dia.get(d, 0) for d in dias_cols}, "total": total_gral} return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load) def _plantillas_crudas(): return cache_get_or_set("plantillas_raw", ("GLOBAL",), lambda: get_dm().traer_plantillas()) def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"): """Matriz de plantillas COBRADAS por plantilla. Columnas: ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1) ASIGNADAS = de esas, con asesor Y respondida por el contacto MATRICULAS = matriculas de esos telefonos (fch_matricula >= envio mas antiguo). Sin filtro de mes. VENTA = INV_NETA (DOL x3.34) de esas matriculas.""" from datetime import timedelta, datetime from collections import defaultdict def _load(): rows = _plantillas_crudas() UMBRAL = timedelta(hours=24) def _lleno(s): return s is not None and str(s).strip() != "" enviadas = defaultdict(int) respondidas = defaultdict(int) asignadas = defaultdict(int) prim_por_tel = {} # telefono -> (fecha_envio, plantilla) la cobrada mas antigua DEL PERIODO for r in rows: plantilla = r.get("plantilla") envio = r.get("created_at_peru") f = L._to_date(envio) # ENVIADAS/RESP/ASIG respetan el filtro de mes (por created_at_peru) if not L._en_periodo(f, ano, mes, dia): continue anterior = r.get("hora_anterior_contacto") sig = r.get("siguiente_mensaje_1") es_si = False if plantilla == "descuento_egresados_seminarios": es_si = True elif anterior is not None and (envio - anterior) > UMBRAL: es_si = True if not es_si: continue enviadas[plantilla] += 1 if _lleno(sig): respondidas[plantilla] += 1 if _lleno(r.get("user_name")) and _lleno(sig): asignadas[plantilla] += 1 tel = str(r.get("telefono") or "").strip() if tel and envio is not None: if tel not in prim_por_tel or envio < prim_por_tel[tel][0]: prim_por_tel[tel] = (envio, plantilla) # MATRICULAS + VENTA de los telefonos filtrados (sin filtro de mes en la matricula) mat_por_tel = defaultdict(list) for m in _matriculas_crudas(): t1 = str(m.get("dsc_telefono_1") or "").strip() t2 = str(m.get("dsc_telefono_2") or "").strip() ph = t1 if t1 else t2 if not ph: continue fm = m.get("fch_matricula") if isinstance(fm, datetime): fm = fm.date() try: inv = float(m.get("INV_NETA", 0) or 0) except (TypeError, ValueError): inv = 0.0 if str(m.get("cod_moneda", "")).strip().upper() == "DOL": inv *= 3.34 mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv)) matriculas = defaultdict(int) venta = defaultdict(float) _vistas = set() for tel, (fenvio, plantilla) in prim_por_tel.items(): fenvio_d = fenvio.date() if hasattr(fenvio, "date") else fenvio for (nm, fm, inv) in mat_por_tel.get(tel, []): if not fm or nm in _vistas: continue if fm >= fenvio_d: _vistas.add(nm) matriculas[plantilla] += 1 venta[plantilla] += inv filas = [] for p in sorted(enviadas.keys(), key=lambda x: -enviadas[x]): filas.append({"plantilla": p, "enviadas": enviadas[p], "respondidas": respondidas.get(p, 0), "asignadas": asignadas.get(p, 0), "matriculas": matriculas.get(p, 0), "venta": round(venta.get(p, 0.0), 0)}) tot = {"enviadas": sum(enviadas.values()), "respondidas": sum(respondidas.values()), "asignadas": sum(asignadas.values()), "matriculas": sum(matriculas.values()), "venta": round(sum(venta.values()), 0)} return {"filas": filas, "total": tot} return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load) def _norm_ni(x): """Normaliza num_indice a texto (quita el '.0' si viniera como float).""" s = str(x or "").strip() return s[:-2] if s.endswith(".0") else s def _mat_por_indice(): """{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas. Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2.""" def _load(): from collections import defaultdict out = defaultdict(list) for m in _matriculas_crudas(): ni = _norm_ni(m.get("num_indice")) t1 = str(m.get("dsc_telefono_1") or "").strip() t2 = str(m.get("dsc_telefono_2") or "").strip() fm = L._to_date(m.get("fch_matricula")) out[ni].append((t1 if t1 else t2, fm)) return out return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load) def _cartera_rows_cache(): """Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez). Evita re-descargar las 86k filas en cada llamada/cambio de mes.""" return cache_get_or_set("cartera_rows", ("GLOBAL",), lambda: get_dm().traer_cartera_rows()) def _cartera_idx(): """Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}. 'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío). Se baja la cartera UNA vez y queda cacheado.""" def _load(): from collections import defaultdict CANAL_PAUTA = {"COPITO", "PAUTA_WSP", "PAUTA_WSP_FACE"} CANAL_WEB = {"WEB_FORMULARIO", "WEB_WHATSAPP", "WHATSAPP WEB"} def _clase(canal): if canal in CANAL_PAUTA: return "PAUTA" if canal in CANAL_WEB: return "WEB" return "OTROS" # PASO 1: DEDUP por (sede, prog, telefono) -> quedarse con la fila mas antigua. # Se guarda la fecha, si tiene asesor, y el CANAL de esa fila ganadora. # (Asi el canal de PAUTA/OTROS se decide DESPUES de deduplicar, como pide el usuario.) ganador = {} # (sede, prog, tel) -> {"f":fecha, "asesor":bool, "canal":canal} tels = set() tels_canal = {} # telefono -> canal de su fila ganadora GLOBAL (para matriculas) gan_global = {} # telefono -> fecha ganadora global (para decidir canal de matriculas) origen = {} for r in _cartera_rows_cache(): tel = str(r.get("telefono") or "").strip() if not tel: continue tels.add(tel) sede = " ".join(str(r.get("sede") or "").upper().split()) prog = " ".join(str(r.get("programa") or "").upper().split()) canal = " ".join(str(r.get("canal") or "").upper().split()) f = L._to_date(r.get("fecha_creada")) if str(r.get("es_origen") or "").upper() == "SI" and f is not None: origen[tel] = f if f is None: continue con_asesor = bool(str(r.get("asesor") or "").strip()) # ganador por sede+prog+tel (para la tabla) k = (sede, prog, tel) g = ganador.get(k) if g is None or f < g["f"]: ganador[k] = {"f": f, "asesor": con_asesor, "canal": canal} # ganador global por tel (para clasificar matriculas por canal) if tel not in gan_global or f < gan_global[tel]: gan_global[tel] = f tels_canal[tel] = canal # PASO 2: armar indices con la fila ganadora ya deduplicada idx_all = defaultdict(dict); idx_asig = defaultdict(dict) # por clase (PAUTA/WEB/OTROS): {clase: {"all":..,"asig":..}} CLASES = ("PAUTA", "WEB", "OTROS") idx_cl = {cl: {"all": defaultdict(dict), "asig": defaultdict(dict)} for cl in CLASES} tels_cl = {cl: set() for cl in CLASES} for (sede, prog, tel), g in ganador.items(): f = g["f"]; cl = _clase(g["canal"]) idx_all[(sede, prog)][tel] = f if g["asesor"]: idx_asig[(sede, prog)][tel] = f idx_cl[cl]["all"][(sede, prog)][tel] = f if g["asesor"]: idx_cl[cl]["asig"][(sede, prog)][tel] = f # telefonos por clase (segun ganador GLOBAL) para matriculas for tel, canal in tels_canal.items(): tels_cl[_clase(canal)].add(tel) out = {"all": idx_all, "asig": idx_asig, "tels": tels, "origen": origen} for cl in CLASES: out[f"all_{cl.lower()}"] = idx_cl[cl]["all"] out[f"asig_{cl.lower()}"] = idx_cl[cl]["asig"] out[f"tels_{cl.lower()}"] = tels_cl[cl] return out return cache_get_or_set("cartera_idx", ("GLOBAL",), _load) # ── Respuesta del dashboard de Leads (con filtros) ────────────── def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"): def _load(): leads = _leads_crudos() cursos = _cursos_crudos() mats = _matriculas_crudas() # Filtro por PROGRAMA (4 grupos: TEAC / TERC / SEMINARIOS / OTROS). # Recalcula toda la pantalla filtrando las listas base ANTES de calcular. # - leads: grupo por 'cargo' del código de campaña # - cursos y matrículas: grupo por dsc_programa if programa not in ("TODOS", None): g = str(programa).upper() leads = [l for l in leads if L.grupo_programa_lead(l.get("cargo")) == g] cursos = [c for c in cursos if L.grupo_programa_curso(c.get("dsc_programa")) == g] mats = [m for m in mats if L.grupo_programa_curso(m.get("dsc_programa")) == g] # Filtro por SEDE (recalcula toda la pantalla). Se filtran las listas base # ANTES de calcular, para no tocar las funciones de cálculo existentes. # - leads: sede POR CÓDIGO de campaña (NO sede_act; coincide con conteo real) # - cursos y matrículas: sede por clasificar_sede(dsc_programa) if sede not in ("TODOS", None): s = str(sede).upper() sede_cod = L.sede_por_codigo_map() leads = [l for l in leads if sede_cod.get(str(l.get("codigo") or "").strip()) == s] cursos = [c for c in cursos if L.clasificar_sede(c.get("dsc_programa")) == s] mats = [m for m in mats if L.clasificar_sede(m.get("dsc_programa")) == s] kpis = L.kpis_leads(leads, cursos, mats, ano, mes, dia) tabla = L.tabla_estado_objecion(leads, ano, mes, dia) por_dia = L.matriculas_por_dia(mats, cursos, ano, mes, dia) leads_dia = L.leads_por_dia(leads, ano, mes, dia) pauta = L.tabla_pauta(leads, mats, ano, mes, dia, _importe_por_pauta_periodo(ano, mes, dia), _resultados_por_pauta_periodo(ano, mes, dia), _campanias_sede_cargo(), codigos_branding=_codigos_branding()) ci = _cartera_idx() cartera_canal = { "PAUTA": {"all": ci.get("all_pauta"), "asig": ci.get("asig_pauta"), "tels": ci.get("tels_pauta")}, "WEB": {"all": ci.get("all_web"), "asig": ci.get("asig_web"), "tels": ci.get("tels_web")}, "OTROS": {"all": ci.get("all_otros"), "asig": ci.get("asig_otros"), "tels": ci.get("tels_otros")}, } matriz = L.matriz_cursos(cursos, leads, ano, mes, dia, _pauta_cruda(), _campanias_map(), ci.get("all"), ci.get("asig"), _mat_por_indice(), ci.get("tels"), ci.get("origen"), _importe_por_pauta(), _importe_por_pauta_periodo(ano, mes, dia), cartera_canal, _conjunto_pauta(), _pautas_de_indice(), _resultados_por_pauta()) return {"kpis": kpis, "estado_objecion": tabla, "matriculas_por_dia": por_dia, "leads_por_dia": leads_dia, "tabla_pauta": pauta, "matriz_cursos": matriz} return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load) def _asesores(): """{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}.""" return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores()) def _norm_asesor(nombre, mapa): """Normaliza un nombre de asesor a su 'correcto' via el diccionario.""" k = " ".join(str(nombre or "").upper().split()) return mapa.get(k) # None si no es uno de los vendedores def _norm_tel_pe(v): """Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta.""" s = str(v or "") for x in ("+51", "+", " ", "-", "(", ")"): s = s.replace(x, "") s = s.strip() if s.isdigit() and 51900000000 <= int(s) <= 51999999999: s = s[2:] return s def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): """Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion. - Turno detectado por dia/asesor segun donde hay MAS actividad: tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido). noche (18-22) -> Turno 2 (09-13 + 18-22, sin refrigerio). sabado: manana(09-13) o tarde(14-18), 4h, donde haya mas actividad. sin actividad ese dia -> no trabajo (se salta). - El tiempo cuenta SOLO minutos dentro del turno; si la asignacion cae fuera, el reloj arranca al inicio del siguiente bloque laboral (saltando dias sin actividad). Leads asignados en refrigerio (13-14) de turno1 se excluyen. - Serie dia a dia por FECHA DE ASIGNACION: promedio de minutos de los leads asignados ese dia (solo respondidos). Filtro ano/mes/dia por fecha asignacion. - No respondidos -> se marcan aparte, NO entran en el promedio.""" from datetime import datetime, timedelta, time as _time def _load(): from collections import defaultdict ase = _asesores(); amap = ase["map"]; lista = ase["lista"] # ── 1) Detectar turno por (asesor_norm, fecha) segun actividad ── # Mapa user_id -> nombre normalizado (de asignacion_respuesta). uid2vend = {} for r in _asignacion_respuesta_crudos(): uid = r.get("user_id") v = _norm_asesor(r.get("user_name"), amap) if uid is not None and v: uid2vend[uid] = v # Contar actividad por (vend, fecha, franja): tarde=13-18, noche=18-22, # sab_manana=9-13, sab_tarde=14-18. act = defaultdict(lambda: defaultdict(int)) # (vend,fecha) -> franja -> conteo for a in _actividad_asesores_crudos(): uid = a.get("user_id"); ts = a.get("created_at") if uid not in uid2vend or not isinstance(ts, datetime): continue v = uid2vend[uid]; d = ts.date(); h = ts.hour + ts.minute / 60.0 key = (v, d) if d.weekday() == 6: # domingo: no trabaja continue if d.weekday() == 5: # sabado if 9 <= h < 13: act[key]["sab_manana"] += 1 elif 14 <= h < 18: act[key]["sab_tarde"] += 1 else: # lun-vie: 3 franjas if 9 <= h < 13: act[key]["manana"] += 1 elif 14 <= h < 18: act[key]["tarde"] += 1 # tarde = 14-18 (T1) elif 18 <= h < 22: act[key]["noche"] += 1 # noche = 18-22 (T2) def _turno_dia(v, d): """Devuelve lista de bloques [(h_ini,h_fin), ...] del turno de ese dia, o None si no trabajo. Reglas lun-vie: Turno 1 = actividad en manana (9-13) Y en tarde (14-18). Turno 2 = actividad en manana (9-13) Y en noche (18-22). Si cumple ambos, gana el que tenga mas actividad (tarde vs noche). Si no cumple ninguno completo -> None (no trabajo).""" if d.weekday() == 6: return None fr = act.get((v, d)) if not fr: return None # sin actividad -> no trabajo if d.weekday() == 5: # sabado (4h) -> regla SIN cambios m = fr.get("sab_manana", 0); t = fr.get("sab_tarde", 0) if m == 0 and t == 0: return None return [(9, 13)] if m >= t else [(14, 18)] # lun-vie: se exige la manana + (tarde o noche) man = fr.get("manana", 0); t = fr.get("tarde", 0); n = fr.get("noche", 0) cumple_t1 = man > 0 and t > 0 cumple_t2 = man > 0 and n > 0 if cumple_t1 and cumple_t2: return [(9, 13), (18, 22)] if n > t else [(9, 13), (14, 18)] if cumple_t2: return [(9, 13), (18, 22)] # Turno 2 if cumple_t1: return [(9, 13), (14, 18)] # Turno 1 return None # no cumple ningun turno completo def _dt(d, hh): return datetime(d.year, d.month, d.day) + timedelta(hours=hh) def _en_refrigerio_t1(v, ts): """True si ts cae 13-15 y ese dia el asesor es turno 1 (lun-vie). Estos leads NO se excluyen: cuentan como respondidos, pero su tiempo (diferencia directa T2-T1) se reduce 45 min si supera 45.""" d = ts.date() if d.weekday() >= 5: return False bloques = _turno_dia(v, d) # Turno 1 = bloques [(9,13),(14,18)] (contiene la tarde 14-18) es_t1 = bloques == [(9, 13), (14, 18)] h = ts.hour + ts.minute / 60.0 return es_t1 and (13 <= h < 15) def _minutos_laborales(v, t1, t2): """Minutos de turno entre t1 (asignacion) y t2 (respuesta), saltando huecos y dias sin actividad. Devuelve None si no se puede (raro).""" if not isinstance(t1, datetime) or not isinstance(t2, datetime) or t2 <= t1: return 0 total = 0.0 cur = t1 saltos = 0 while cur < t2 and saltos < 60: # tope 60 dias por seguridad d = cur.date() bloques = _turno_dia(v, d) if not bloques: # dia sin trabajo -> saltar al dia siguiente 00:00 cur = _dt(d, 24); saltos += 1; continue avanzo = False for (hi, hf) in bloques: ini = _dt(d, hi); fin = _dt(d, hf) if t2 <= ini: # la respuesta es antes de este bloque break if cur >= fin: # ya paso este bloque continue a = max(cur, ini) b = min(t2, fin) if b > a: total += (b - a).total_seconds() / 60.0 cur = fin # avanzar al fin del bloque avanzo = True if cur >= t2: break # pasar al dia siguiente if cur.date() == d: cur = _dt(d, 24); saltos += 1 return round(total) # ── 2) Por lead: minutos laborales, agrupado por FECHA DE ASIGNACION ── por_dia_sum = defaultdict(float) # (vend,dia) -> suma minutos por_dia_cnt = defaultdict(int) # (vend,dia) -> nº respondidos no_resp = defaultdict(int) # (vend,dia) -> nº sin responder for r in _asignacion_respuesta_crudos(): v = _norm_asesor(r.get("user_name"), amap) if not v: continue t1 = r.get("created_at") if not isinstance(t1, datetime): continue if not L._en_periodo(t1.date(), ano, mes, dia): continue # filtro por fecha de asignacion t2 = r.get("respuesta_fecha") if not isinstance(t2, datetime): no_resp[(v, t1.day)] += 1 # no respondio continue if _en_refrigerio_t1(v, t1): # asignado 13-15 en turno1: tiempo = diferencia DIRECTA (T2-T1), # y si supera 45 min se le restan 45 (nunca negativo). dif = (t2 - t1).total_seconds() / 60.0 mins = round(dif - 45) if dif > 45 else round(dif) if mins < 0: mins = 0 else: mins = _minutos_laborales(v, t1, t2) por_dia_sum[(v, t1.day)] += mins por_dia_cnt[(v, t1.day)] += 1 # ── 2b) Igual pero agrupado por FECHA DE RESPUESTA (dia en que respondio). # Solo leads respondidos. Filtro por fecha de respuesta (t2). # Si el tiempo laboral supera 960 min (2 dias laborales) -> NO entra en # el promedio; se cuenta aparte como "respuesta tardia". LIMITE_TARDIA = 960 # 2 dias laborales (8h/dia) pr_sum = defaultdict(float) # (vend,dia_respuesta) -> suma minutos (validos) pr_cnt = defaultdict(int) # (vend,dia_respuesta) -> nº validos (<=960) pr_tardia = defaultdict(int) # (vend,dia_respuesta) -> nº tardios (>960) for r in _asignacion_respuesta_crudos(): v = _norm_asesor(r.get("user_name"), amap) if not v: continue t1 = r.get("created_at"); t2 = r.get("respuesta_fecha") if not isinstance(t1, datetime) or not isinstance(t2, datetime): continue # sin respuesta -> no entra en este grafico if not L._en_periodo(t2.date(), ano, mes, dia): continue # filtro por FECHA DE RESPUESTA if _en_refrigerio_t1(v, t1): dif = (t2 - t1).total_seconds() / 60.0 mins = round(dif - 45) if dif > 45 else round(dif) if mins < 0: mins = 0 else: mins = _minutos_laborales(v, t1, t2) if mins > LIMITE_TARDIA: pr_tardia[(v, t2.day)] += 1 # respuesta tardia -> fuera del promedio continue pr_sum[(v, t2.day)] += mins pr_cnt[(v, t2.day)] += 1 # ── 3) Serie dia a dia (promedio min) por vendedor y total ── def _serie(vends): out = [] for dnum in range(1, 32): s = sum(por_dia_sum.get((v, dnum), 0) for v in vends) c = sum(por_dia_cnt.get((v, dnum), 0) for v in vends) nr = sum(no_resp.get((v, dnum), 0) for v in vends) out.append({"dia": dnum, "promedio_min": round(s / c) if c else 0, "respondidos": c, "no_respondidos": nr}) return out def _serie_resp(vends): out = [] for dnum in range(1, 32): s = sum(pr_sum.get((v, dnum), 0) for v in vends) c = sum(pr_cnt.get((v, dnum), 0) for v in vends) td = sum(pr_tardia.get((v, dnum), 0) for v in vends) out.append({"dia": dnum, "promedio_min": round(s / c) if c else 0, "respondidos": c, "tardios": td}) return out serie_por_vend = {v: _serie([v]) for v in lista} serie_total = _serie(lista) serie_resp_por_vend = {v: _serie_resp([v]) for v in lista} serie_resp_total = _serie_resp(lista) return {"serie_por_vend": serie_por_vend, "serie_total": serie_total, "serie_resp_por_vend": serie_resp_por_vend, "serie_resp_total": serie_resp_total, "vendedores": lista} return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load) def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS", ano="TODOS", mes="TODOS"): """Detalle de los leads que forman el promedio de UN día del gráfico 'Tiempo de Respuesta por Día'. Reproduce la MISMA lógica de turnos/minutos. modo: 'asignacion' (agrupa por día de created_at) o 'respuesta' (por respuesta_fecha). Devuelve, por asesor, la lista de leads con T1, T2 y minutos, y el promedio.""" from datetime import datetime, timedelta def _load(): from collections import defaultdict ase = _asesores(); amap = ase["map"]; lista = ase["lista"] uid2vend = {} for r in _asignacion_respuesta_crudos(): uid = r.get("user_id"); v = _norm_asesor(r.get("user_name"), amap) if uid is not None and v: uid2vend[uid] = v act = defaultdict(lambda: defaultdict(int)) for a in _actividad_asesores_crudos(): uid = a.get("user_id"); ts = a.get("created_at") if uid not in uid2vend or not isinstance(ts, datetime): continue v = uid2vend[uid]; d = ts.date(); h = ts.hour + ts.minute / 60.0 key = (v, d) if d.weekday() == 6: continue if d.weekday() == 5: if 9 <= h < 13: act[key]["sab_manana"] += 1 elif 14 <= h < 18: act[key]["sab_tarde"] += 1 else: # lun-vie: 3 franjas if 9 <= h < 13: act[key]["manana"] += 1 elif 14 <= h < 18: act[key]["tarde"] += 1 elif 18 <= h < 22: act[key]["noche"] += 1 def _turno_dia(v, d): # Turno 1 = manana(9-13) Y tarde(14-18). Turno 2 = manana(9-13) Y noche(18-22). if d.weekday() == 6: return None fr = act.get((v, d)) if not fr: return None if d.weekday() == 5: m = fr.get("sab_manana", 0); t = fr.get("sab_tarde", 0) if m == 0 and t == 0: return None return [(9, 13)] if m >= t else [(14, 18)] man = fr.get("manana", 0); t = fr.get("tarde", 0); n = fr.get("noche", 0) cumple_t1 = man > 0 and t > 0 cumple_t2 = man > 0 and n > 0 if cumple_t1 and cumple_t2: return [(9, 13), (18, 22)] if n > t else [(9, 13), (14, 18)] if cumple_t2: return [(9, 13), (18, 22)] if cumple_t1: return [(9, 13), (14, 18)] return None def _dt(d, hh): return datetime(d.year, d.month, d.day) + timedelta(hours=hh) def _en_refrigerio_t1(v, ts): d = ts.date() if d.weekday() >= 5: return False es_t1 = _turno_dia(v, d) == [(9, 13), (14, 18)] h = ts.hour + ts.minute / 60.0 return es_t1 and (13 <= h < 15) def _minutos_laborales(v, t1, t2): if not isinstance(t1, datetime) or not isinstance(t2, datetime) or t2 <= t1: return 0 total = 0.0; cur = t1; saltos = 0 while cur < t2 and saltos < 60: d = cur.date(); bloques = _turno_dia(v, d) if not bloques: cur = _dt(d, 24); saltos += 1; continue for (hi, hf) in bloques: ini = _dt(d, hi); fin = _dt(d, hf) if t2 <= ini: break if cur >= fin: continue a = max(cur, ini); b = min(t2, fin) if b > a: total += (b - a).total_seconds() / 60.0 cur = fin if cur >= t2: break if cur.date() == d: cur = _dt(d, 24); saltos += 1 return round(total) LIMITE_TARDIA = 960 d_click = int(dia_click) 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(): v = _norm_asesor(r.get("user_name"), amap) if not v: continue if vendedor not in ("TODOS", None, "") and v != vendedor: continue 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 if not L._en_periodo(t2.date(), ano, mes, "TODOS"): continue if t2.day != d_click: continue if _en_refrigerio_t1(v, t1): dif = (t2 - t1).total_seconds() / 60.0 mins = round(dif - 45) if dif > 45 else round(dif) if mins < 0: mins = 0 else: mins = _minutos_laborales(v, t1, t2) if mins > LIMITE_TARDIA: continue # tardío -> fuera del promedio else: # agrupar por fecha de asignación; solo respondidos entran al promedio if not L._en_periodo(t1.date(), ano, mes, "TODOS"): continue if t1.day != d_click: continue if not isinstance(t2, datetime): continue # sin responder: no promedia if _en_refrigerio_t1(v, t1): dif = (t2 - t1).total_seconds() / 60.0 mins = round(dif - 45) if dif > 45 else round(dif) if mins < 0: mins = 0 else: mins = _minutos_laborales(v, t1, t2) por_asesor[v].append({ "telefono": str(r.get("telefono") or "").strip(), "t1": t1.strftime("%d/%m/%Y %H:%M"), "t2": t2.strftime("%d/%m/%Y %H:%M"), "minutos": int(mins), }) # Armar salida por asesor con su promedio; y total del día asesores = [] tot_sum = 0; tot_cnt = 0 for v in sorted(por_asesor.keys()): leads = sorted(por_asesor[v], key=lambda x: x["t1"]) 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": 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": 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) def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): """Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día: - Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta. - Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI, fecha_creada en el periodo); la matricula cuenta en CUALQUIER fecha. - Leads Procesados/Contactados: filtran por fecha del lead (mensaje campaña).""" def _load(): from collections import defaultdict ase = _asesores() amap = ase["map"]; lista = ase["lista"] # 1) CARTERA por vendedor. Fila ORIGEN de cada telefono (es_origen=SI) cuya # fecha_creada cae en el periodo filtrado. Cada telefono cuenta para UN solo asesor. # Ademas guardamos {vendedor: set(telefonos del periodo)} para cruzar matriculas. # COPITO/OTROS: se clasifica el telefono con SU FILA ORIGEN # (Copito = origen_base=POSTGRE O canal en PAUTA_WSP/PAUTA_WSP_FACE/COPITO). cart_total = defaultdict(int); cart_copito = defaultdict(int); cart_otros = defaultdict(int) tels_por_vend = defaultdict(set) # vendedor -> telefonos (es_origen=SI del periodo) cont_tels = defaultdict(set) # vendedor -> telefonos ya contados (sin repetir) for r in _cartera_rows_cache(): if str(r.get("es_origen") or "").strip().upper() != "SI": continue # solo la fila origen de cada telefono f = L._to_date(r.get("fecha_creada")) if not L._en_periodo(f, ano, mes, dia): continue # filtro por fecha_creada de la cartera v = _norm_asesor(r.get("asesor"), amap) if not v: # el asesor del origen no es uno de los 8 continue tel = str(r.get("telefono") or "").strip() if tel and tel in cont_tels[v]: continue # ese telefono ya se conto para este vendedor canal = " ".join(str(r.get("canal") or "").upper().split()) ob = " ".join(str(r.get("origen_base") or "").upper().split()) cart_total[v] += 1 if (ob == "POSTGRE") or (canal in _CANALES_COPITO): cart_copito[v] += 1 else: cart_otros[v] += 1 if tel: cont_tels[v].add(tel) tels_por_vend[v].add(tel) # 2) MATRICULAS por vendedor. Se CRUZAN por TELEFONO con los leads del periodo # (tels_por_vend). La matricula cuenta en CUALQUIER fecha (no se filtra por # fch_matricula). Telefono de matricula: dsc_telefono_1, o _2 si el 1 esta vacio. # Un telefono puede tener varias matriculas -> se cuentan todas (no unico). # MATRICULAS: por dsc_vendedor + fch_matricula en el periodo (NO se cruza # con la cartera por telefono). Filtro Año/Mes/Día sobre fch_matricula. mat_total = defaultdict(int); mat_curso = defaultdict(int); mat_retir = defaultdict(int) for m in _matriculas_crudas(): vend = _norm_asesor(m.get("dsc_vendedor"), amap) if not vend: continue estado = str(m.get("estado_matricula") or "").strip().upper() if estado == "ANU": continue # anuladas no cuentan fm = L._to_date(m.get("fch_matricula")) if not L._en_periodo(fm, ano, mes, dia): continue # filtro por fch_matricula mat_total[vend] += 1 if estado in ("ALU", "PRE"): mat_curso[vend] += 1 elif estado == "RET": mat_retir[vend] += 1 # 3) LEADS Procesados/Contactados por vendedor (leads de Chatwoot). Filtro por # fecha del lead (mensaje de campaña). Procesados = lead unico con asesor; # Contactados = de esos, ultima_etiqueta != "NO TRABAJADO". lead_proc = defaultdict(int); lead_cont = defaultdict(int) etiq = defaultdict(int) # clave: (vendedor, ultima_etiqueta) -> nº leads procesados for l in _leads_crudos(): if not l.get("es_unico"): continue if not L._en_periodo(l.get("fecha"), ano, mes, dia): continue # filtro por fecha del lead v = _norm_asesor(l.get("asesor"), amap) if not v: continue lead_proc[v] += 1 # tiene asesor de los 8 => procesado ult = l.get("ultima") or "NO TRABAJADO" etiq[(v, ult)] += 1 # para la matriz Estado/Objeción # NOTA: "Contactados" ya NO se cuenta aqui; ahora sale de los ASIGNADOS # (bloque 3b), usando su ultima etiqueta. # 3b) LEADS ASIGNADOS por vendedor (mensajes "Asignado a..." de Chatwoot). # Se cuenta por telefono (1 asignacion por telefono, la mas reciente). # Filtro por fecha de asignacion (created_at). Asesor via user_name normalizado. # CONTACTADOS = de los asignados, cuya ULTIMA ETIQUETA != "NO TRABAJADO". lead_asig = defaultdict(int) etiq_asig = defaultdict(int) # (vendedor, ultima_etiqueta) -> nº asignados (para matriz) for a in _leads_asignados_crudos(): f = L._to_date(a.get("created_at")) if not L._en_periodo(f, ano, mes, dia): continue # filtro por fecha de asignacion v = _norm_asesor(a.get("user_name"), amap) if not v: continue # el asesor asignado no es uno de los 8 lead_asig[v] += 1 ult = L.ultima_etiqueta(a.get("etiquetas")) or "NO TRABAJADO" etiq_asig[(v, ult)] += 1 # para la matriz Estado/Objeción (asignados) if ult != "NO TRABAJADO": lead_cont[v] += 1 # contactado = asignado con ultima etiqueta trabajada # SERIE DIARIA: Leads Asignados (por fecha de asignacion) y Matriculas (por fch_matricula), # por vendedor y por dia del mes. clave: (vendedor, dia) -> conteo. serie_proc = defaultdict(int) # (vendedor, dia) -> leads asignados serie_mat = defaultdict(int) # (vendedor, dia) -> matriculas for a in _leads_asignados_crudos(): f = L._to_date(a.get("created_at")) if not L._en_periodo(f, ano, mes, dia): continue v = _norm_asesor(a.get("user_name"), amap) if v and f: serie_proc[(v, f.day)] += 1 for m in _matriculas_crudas(): vend = _norm_asesor(m.get("dsc_vendedor"), amap) if not vend: continue estado = str(m.get("estado_matricula") or "").strip().upper() if estado == "ANU": continue fm = L._to_date(m.get("fch_matricula")) if fm and L._en_periodo(fm, ano, mes, dia): serie_mat[(vend, fm.day)] += 1 def _serie(vendedores): """Lista 1..31 con asignados y matriculas del conjunto de vendedores.""" out = [] for d in range(1, 32): p = sum(serie_proc.get((v, d), 0) for v in vendedores) mm = sum(serie_mat.get((v, d), 0) for v in vendedores) out.append({"dia": d, "asignados": p, "matriculas": mm}) return out serie_por_vend = {v: _serie([v]) for v in lista} serie_total = _serie(lista) # Matriz Estado/Objeción (Ultima_Etiqueta) por vendedor y total. # Ahora se basa en los LEADS ASIGNADOS (etiq_asig), no en los de campaña. etiquetas_all = sorted({e for (_, e) in etiq_asig.keys()}, key=lambda x: x.lower()) def _matriz_etiq(vendedores): out = [] for e in etiquetas_all: c = sum(etiq_asig.get((v, e), 0) for v in vendedores) if c > 0: out.append({"estado": e, "cantidad": c}) out.sort(key=lambda x: -x["cantidad"]) # mayor a menor por cantidad return out matriz_etiq_por_vend = {v: _matriz_etiq([v]) for v in lista} matriz_etiq_total = _matriz_etiq(lista) # 4) MATRIZ por TIPO DE PROGRAMA (TEAC/TERC/SEMINARIOS/OTROS). # Filtro por fch_matricula (periodo) y dsc_vendedor. Total y Retiradas (RET). # Se guarda por (vendedor, grupo) para poder filtrar por vendedor en el front. GRUPOS_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS"] prog_tot = defaultdict(int); prog_ret = defaultdict(int) # clave: (vendedor, grupo) for m in _matriculas_crudas(): vend = _norm_asesor(m.get("dsc_vendedor"), amap) if not vend: continue estado = str(m.get("estado_matricula") or "").strip().upper() if estado == "ANU": continue fm = L._to_date(m.get("fch_matricula")) if not L._en_periodo(fm, ano, mes, dia): continue # filtro por fch_matricula grupo = L.grupo_programa_curso(m.get("dsc_programa")) prog_tot[(vend, grupo)] += 1 if estado == "RET": prog_ret[(vend, grupo)] += 1 def _matriz_prog(vendedores): """Suma total/retiradas por grupo para el conjunto de vendedores dado.""" out = [] for g in GRUPOS_PROG: t = sum(prog_tot.get((v, g), 0) for v in vendedores) r = sum(prog_ret.get((v, g), 0) for v in vendedores) out.append({"programa": g, "total": t, "retiradas": r, "pct_retiradas": round(r / t * 100, 1) if t else 0}) return out # matriz por programa para CADA vendedor y para el TOTAL (todos) matriz_prog_por_vend = {v: _matriz_prog([v]) for v in lista} matriz_prog_total = _matriz_prog(lista) filas = [] for v in lista: proc = lead_proc.get(v, 0); cont = lead_cont.get(v, 0) asig = lead_asig.get(v, 0) filas.append({ "vendedor": v, "cartera_total": cart_total.get(v, 0), "cartera_copito": cart_copito.get(v, 0), "cartera_otros": cart_otros.get(v, 0), "mat_total": mat_total.get(v, 0), "mat_curso": mat_curso.get(v, 0), "mat_retiradas": mat_retir.get(v, 0), "pct_retiradas": round(mat_retir.get(v, 0) / mat_total.get(v, 0) * 100, 1) if mat_total.get(v, 0) else 0, "leads_procesados": proc, "leads_contactados": cont, # % Contactados = contactados / ASIGNADOS (ambos de la misma fuente) "pct_contactados": round(cont / asig * 100, 1) if asig else 0, "leads_asignados": asig, }) tot = {"cartera_total": sum(f["cartera_total"] for f in filas), "cartera_copito": sum(f["cartera_copito"] for f in filas), "cartera_otros": sum(f["cartera_otros"] for f in filas), "mat_total": sum(f["mat_total"] for f in filas), "mat_curso": sum(f["mat_curso"] for f in filas), "mat_retiradas": sum(f["mat_retiradas"] for f in filas), "leads_procesados": sum(f["leads_procesados"] for f in filas), "leads_contactados": sum(f["leads_contactados"] for f in filas), "leads_asignados": sum(f["leads_asignados"] for f in filas)} tot["pct_retiradas"] = (round(tot["mat_retiradas"] / tot["mat_total"] * 100, 1) if tot["mat_total"] else 0) tot["pct_contactados"] = (round(tot["leads_contactados"] / tot["leads_asignados"] * 100, 1) if tot["leads_asignados"] else 0) # Tiempo de respuesta laboral (min) dia a dia por asesor (por fecha asignacion) tr = tiempo_respuesta_dashboard(ano, mes, dia) return {"filas": filas, "total": tot, "vendedores": lista, "matriz_prog": matriz_prog_por_vend, "matriz_prog_total": matriz_prog_total, "matriz_etiq": matriz_etiq_por_vend, "matriz_etiq_total": matriz_etiq_total, "serie_dia": serie_por_vend, "serie_dia_total": serie_total, "tiempo_resp": tr.get("serie_por_vend", {}), "tiempo_resp_total": tr.get("serie_total", []), "tiempo_resp_r": tr.get("serie_resp_por_vend", {}), "tiempo_resp_r_total": tr.get("serie_resp_total", [])} return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load) def _conjunto_sede_prog(): """{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase).""" return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",), lambda: get_dm().traer_conjunto_sede_programa()) # Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca). _PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} def _conjuntos_branding(): """Set de CONJUNTOS (UPPER) asignados manualmente como BRANDING/COPEAIRE. Sus resultados NO deben contarse (su importe sí).""" def _load(): asign = _conjunto_sede_prog() # {conjunto_UPPER: {sede, programa}} return {k for k, v in asign.items() if str(v.get("programa") or "").strip().upper() in _PROG_SIN_RESULTADO} return cache_get_or_set("conjuntos_branding", ("GLOBAL",), _load) def _codigos_branding(): """Set de codigos(pauta) cuyos conjuntos estan asignados manualmente como BRANDING/COPEAIRE. Sirve para NO contar sus resultados en las tablas.""" def _load(): conj_pauta = _conjunto_pauta() # {pauta: [conjuntos]} asign = _conjunto_sede_prog() # {conjunto_UPPER: {sede, programa}} # conjuntos_branding (UPPER) conj_brand = {k for k, v in asign.items() if str(v.get("programa") or "").strip().upper() in _PROG_SIN_RESULTADO} cods = set() for pauta, conjuntos in conj_pauta.items(): for co in conjuntos: if " ".join(str(co).split()).upper() in conj_brand: cods.add(str(pauta).strip()) break return cods return cache_get_or_set("codigos_branding", ("GLOBAL",), _load) def conjuntos_sin_sede(): """Lista los conjuntos del Meta CSV que NO estan ligados a ninguna sede (ni por pauta->campania, ni por asignacion manual). Para el apartado de asignacion. Sin filtro de mes. Incluye la sede/programa YA asignados (si tiene). Devuelve [{conjunto, importe, resultados, sede, programa}].""" def _load(): SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} conj_pauta = _conjunto_pauta() camp = _campanias_sede_cargo() manual = _conjunto_sede_prog() # conjuntos que SÍ tienen sede via pauta -> campania con_sede = set() for pauta, conjuntos in conj_pauta.items(): sp = camp.get(str(pauta).strip()) sede = " ".join(str((sp or {}).get("sede") or "").upper().split()) if sp and sede in SEDES: for co in conjuntos: con_sede.add(" ".join(str(co).split()).upper()) agg = {} for f in _meta_filas(): co = " ".join(str(f.get("conjunto")).split()) if not co: continue k = co.upper() if k in con_sede: continue # ya tiene sede por campania -> no aparece aqui d = agg.setdefault(k, {"conjunto": co, "importe": 0.0, "resultados": 0, "sede": manual.get(k, {}).get("sede", ""), "programa": manual.get(k, {}).get("programa", "")}) d["importe"] += float(f.get("importe") or 0.0) d["resultados"] += int(float(f.get("resultados") or 0.0)) out = sorted(agg.values(), key=lambda x: -x["importe"]) for d in out: d["importe"] = round(d["importe"], 2) return out return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load) def guardar_conjunto_sede(cambios): """Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes.""" r = get_dm().guardar_conjunto_sede_programa(cambios) for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): cache_invalidate(prefix=pref) return r def borrar_conjunto_sede(conjunto): """Borra la asignacion de un conjunto e invalida cachés dependientes.""" r = get_dm().borrar_conjunto_sede_programa(conjunto) for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): cache_invalidate(prefix=pref) return r def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): """Matriz ROAS por SEDE (LIMA, PIURA, AREQUIPA, TRUJILLO): Importe Gastado y Resultados del Google Sheet (Meta Ads), filtrado por Año/Mes (Inicio del informe). Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin pauta/sede se omiten por ahora.""" def _load(): SEDES = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO", "OTROS"] # OTROS = 5ta fila (manual) CAMP_SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} # sedes validas por campania conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]} camp = _campanias_sede_cargo() # {pauta: {sede, cargo}} PROGS_OK = {"TEAC", "TERC", "SEMINARIOS", "OTROS"} # Programas cuyos RESULTADOS NO deben sumar (son de otro tipo: alcance/marca). # Su importe SÍ suma; sus resultados se muestran solo en el detalle, y el # subtotal del programa aparece en 0 para no inflar los totales. PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} # conjunto (normalizado) -> (sede, programa) via su pauta -> campania conj_sede = {} # conjunto -> sede conj_prog = {} # conjunto -> programa (grupo) for pauta, conjuntos in conj_pauta.items(): sp = camp.get(str(pauta).strip()) if not sp: continue sede = " ".join(str(sp.get("sede") or "").upper().split()) if sede not in CAMP_SEDES: continue prog = L.grupo_programa_lead(sp.get("cargo")) # TEAC/TERC/SEMINARIOS for co in conjuntos: k = " ".join(str(co).split()).upper() conj_sede[k] = sede conj_prog[k] = prog # + asignaciones MANUALES (sede + programa). Prioridad sobre la campania. for k, v in _conjunto_sede_prog().items(): sede = v.get("sede") if sede in SEDES: conj_sede[k] = sede prog = str(v.get("programa") or "").strip().upper() if prog: # acepta CUALQUIER programa asignado manualmente (no solo los fijos) conj_prog[k] = prog # conjunto(UPPER) -> [pautas] (para cruzar con leads por codigo) pautas_de_conj = {} for pauta, conjuntos in conj_pauta.items(): for co in conjuntos: k = " ".join(str(co).split()).upper() pautas_de_conj.setdefault(k, []).append(str(pauta).strip()) # leads (recibidos/procesados del periodo) por codigo de pauta lxc = _leads_por_codigo_periodo(ano, mes, dia) # sumar importe y resultados por sede y por (sede, programa), filtrando por Año/Mes imp = {s: 0.0 for s in SEDES} res = {s: 0 for s in SEDES} imp_pg = {} # (sede, prog) -> importe res_pg = {} # (sede, prog) -> resultados # tels de leads por nivel (sets para no duplicar telefonos) rec_s = {s: set() for s in SEDES}; proc_s = {s: set() for s in SEDES} # por sede rec_pg = {}; proc_pg = {} # (sede,prog) # conjuntos por (sede, prog): nombre_conjunto -> {importe, resultados} conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados, rec, proc} } sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None)) for f in _meta_filas(): if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia): continue co = " ".join(str(f.get("conjunto")).split()) k = co.upper() sede = conj_sede.get(k) if not sede: continue # conjunto sin pauta/sede -> se omite i = float(f.get("importe") or 0.0) rr = int(float(f.get("resultados") or 0.0)) prog = conj_prog.get(k, "SIN PROGRAMA") # BRANDING/COPEAIRE: su importe suma, pero sus resultados NO cuentan en # los totales (sede y general). Solo se ven en el detalle del conjunto. rr_sede = 0 if prog in PROG_SIN_RESULTADO else rr imp[sede] += i res[sede] += rr_sede imp_pg[(sede, prog)] = imp_pg.get((sede, prog), 0.0) + i res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr_sede dd = conj_det.setdefault((sede, prog), {}) d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0, "rec": 0, "proc": 0}) d["importe"] += i d["resultados"] += rr # ── Leads Recibidos/Procesados del mes por conjunto (via su pauta -> codigo). # Se hace UNA vez por conjunto (no por fila del CSV). Los sets evitan # duplicar telefonos al agregar por programa y por sede. for (sede, prog), dd in conj_det.items(): for co, d in dd.items(): k = co.upper() rec = set(); proc = set() for pa in pautas_de_conj.get(k, []): lp = lxc.get(pa) if lp: rec |= lp["rec"]; proc |= lp["proc"] d["rec"] = len(rec); d["proc"] = len(proc) rec_pg.setdefault((sede, prog), set()).update(rec) proc_pg.setdefault((sede, prog), set()).update(proc) rec_s[sede].update(rec); proc_s[sede].update(proc) ORDEN_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS", "SIN PROGRAMA"] def _conjuntos(sede, prog): """Lista de conjuntos de anuncio de un (sede, programa), ordenada por importe.""" det = list(conj_det.get((sede, prog), {}).values()) det.sort(key=lambda x: -x["importe"]) for d in det: d["importe"] = round(d["importe"], 2) return det def _sub(sede): """Desglose por programa de una sede (solo los que tienen algo). Primero los fijos en su orden, luego CUALQUIER otro programa asignado (ej. BRANDING), y al final SIN PROGRAMA.""" progs_de_sede = [p for (s, p) in imp_pg.keys() if s == sede] extras = sorted(set(progs_de_sede) - set(ORDEN_PROG)) orden = [p for p in ORDEN_PROG if p != "SIN PROGRAMA"] + extras + ["SIN PROGRAMA"] out = [] for p in orden: i = imp_pg.get((sede, p), 0.0); r = res_pg.get((sede, p), 0) if i or r: out.append({"programa": p, "importe": round(i, 2), "resultados": r, "rec": len(rec_pg.get((sede, p), set())), "proc": len(proc_pg.get((sede, p), set())), "conjuntos": _conjuntos(sede, p)}) return out # Las 4 sedes reales siempre se muestran. OTROS solo si tiene algo (>0). filas = [] for s in SEDES: if s == "OTROS" and imp[s] == 0 and res[s] == 0: continue filas.append({"sede": s, "importe": round(imp[s], 2), "resultados": res[s], "rec": len(rec_s[s]), "proc": len(proc_s[s]), "programas": _sub(s)}) tot_rec = set(); tot_proc = set() for s in SEDES: tot_rec |= rec_s[s]; tot_proc |= proc_s[s] tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values()), "rec": len(tot_rec), "proc": len(tot_proc)} return {"filas": filas, "total": tot} return cache_get_or_set("roas_dash", (ano, mes, dia), _load) _CURSOS_EXCLUIDOS = {"1121", "1154"} def _curso_excluido(c, pmap): """Mismo criterio que 'Detalle por Curso': excluye cursos en la lista fija, o con contar=NO / estado=SUSPENDIDO en basebi.""" ni = str(c.get("num_indice", "")).strip() if ni in _CURSOS_EXCLUIDOS: return True info = pmap.get(ni) if not info: return False return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO" def programa_opciones(ano="TODOS", mes="TODOS"): """Lista de programas (dsc_det_programa) cuyos cursos tienen fch_inicio en el periodo. Para el selector del apartado PROGRAMA. Ordenada por fch_inicio ASC.""" def _load(): from datetime import date pmap = _pauta_cruda() prog_fecha = {} # nombre -> fecha_inicio mas temprana for c in _cursos_crudos(): f = L._to_date(c.get("fch_inicio")) if not L._en_periodo(f, ano, mes, "TODOS"): continue if _curso_excluido(c, pmap): # mismo filtro que 'Detalle por Curso' continue nom = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() if not nom: continue if nom not in prog_fecha or (f and f < prog_fecha[nom]): prog_fecha[nom] = f or date.max # ordenar por fecha de inicio ascendente return [n for n, _ in sorted(prog_fecha.items(), key=lambda x: (x[1] or date.max, x[0]))] return cache_get_or_set("programa_opciones", (ano, mes), _load) def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"): """Datos agregados de UN programa (dsc_det_programa) en el periodo: Cartera Total/Copito/Otros, Leads Recibidos/Procesados/Contactados y %. - Filtro por fch_inicio del curso para elegir los cursos del programa. - Cartera: teléfonos origen (es_origen=SI) cuya sede+programa (via pauta-> campania) coincide con los cursos del programa, en el periodo. - Leads: por codigo de pauta de esos cursos (recibidos/procesados/contactados del periodo), sin duplicar telefonos.""" def _load(): prog_sel = str(programa or "").strip().upper() pmap = _pauta_cruda() # 1) cursos del programa (dsc_det_programa) en el periodo -> sus pautas. # Aplica el MISMO filtro de exclusion que 'Detalle por Curso'. cods = set() cursos_prog = [] # [(fch_inicio, [pautas del curso])] para la cartera for c in _cursos_crudos(): nom = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip().upper() if prog_sel != "TODOS" and nom != prog_sel: continue f = L._to_date(c.get("fch_inicio")) if not L._en_periodo(f, ano, mes, "TODOS"): continue if _curso_excluido(c, pmap): continue ni = _norm_ni(c.get("num_indice")) cds_curso = set() for p in _pautas_de_indice().get(ni, []): cds_curso.add(str(p).strip()) info = _pauta_cruda().get(ni) if info and info.get("pauta"): cds_curso.add(str(info.get("pauta")).strip()) cods |= cds_curso cursos_prog.append((f, list(cds_curso))) # 2) LEADS por esas pautas (recibidos/procesados/contactados TOTAL/historico, # sin filtro de mes -> igual que la columna "L. Recibidos" de Detalle por Curso). lxc = _leads_por_codigo_periodo("TODOS", "TODOS", "TODOS") rec = set(); proc = set(); cont = set() for cd in cods: lp = lxc.get(cd) if lp: rec |= lp["rec"]; proc |= lp["proc"]; cont |= lp.get("cont", set()) # 2b) MATRIZ ESTADO/OBJECION: por ultima etiqueta de los leads (procesados) # de las pautas del programa. Telefonos unicos por etiqueta. from collections import defaultdict as _dd etiq_tels = _dd(set) etiq_ase_tels = _dd(lambda: _dd(set)) # estado -> asesor -> {telefonos} cods_set0 = set(cods) for l in _leads_crudos(): if not l.get("es_unico") or not l.get("asesor"): continue if str(l.get("codigo", "-")).strip() not in cods_set0: continue tel = l.get("telefono") if not tel: continue ult = l.get("ultima") or "NO TRABAJADO" ase = l.get("asesor") or "SIN ASESOR" etiq_tels[ult].add(tel) etiq_ase_tels[ult][ase].add(tel) matriz_etiq = sorted( [{"estado": k, "cantidad": len(v), "asesores": sorted( [{"asesor": a, "cantidad": len(t), "telefonos": sorted(t)} for a, t in etiq_ase_tels[k].items()], key=lambda x: -x["cantidad"])} for k, v in etiq_tels.items()], key=lambda x: -x["cantidad"]) # 3) CARTERA TOTAL: MISMA logica que 'Detalle por Curso'. Por cada curso, # telefonos con la sede+programa de sus pautas y fecha_creada <= fch_inicio. # Union entre cursos del programa (sin duplicar telefono). # COPITO/OTROS: se clasifica cada telefono por su FILA MAS ANTIGUA DENTRO # de esta seleccion (Copito = origen_base=POSTGRE O canal en # PAUTA_WSP/PAUTA_WSP_FACE/COPITO). cmap = _campanias_map() # {codigo: (sede, programa)} # {(sede,prog): fch_corte maximo} de las pautas de los cursos del programa. sp_corte = {} for (fch, cds_curso) in cursos_prog: if not fch: continue for cd in cds_curso: sp = cmap.get(str(cd).upper()) if not sp: continue if sp not in sp_corte or fch > sp_corte[sp]: sp_corte[sp] = fch # recorrer cartera cruda; fila mas antigua por telefono dentro de la # seleccion (sede+prog de una pauta + fecha_creada <= fch_inicio del curso). mejor = {} # tel -> (fecha, es_copito) for r in _cartera_rows_cache(): sede = " ".join(str(r.get("sede") or "").upper().split()) prog = " ".join(str(r.get("programa") or "").upper().split()) corte = sp_corte.get((sede, prog)) if corte is None: continue f = L._to_date(r.get("fecha_creada")) if f is None or f > corte: continue tel = str(r.get("telefono") or "").strip() if not tel: continue if tel not in mejor or f < mejor[tel][0]: canal = " ".join(str(r.get("canal") or "").upper().split()) ob = " ".join(str(r.get("origen_base") or "").upper().split()) es_cop = (ob == "POSTGRE") or (canal in _CANALES_COPITO) mejor[tel] = (f, es_cop) cart_total = len(mejor) cart_cop = sum(1 for (f, ec) in mejor.values() if ec) cart_otros = cart_total - cart_cop # 4) IMPORTE (gasto Meta) y RESULTADOS totales de las pautas del programa. imp_map = _importe_por_pauta(); res_map = _resultados_por_pauta() importe = round(sum(float(imp_map.get(cd, 0.0)) for cd in cods), 2) resultados = int(sum(int(res_map.get(cd, 0)) for cd in cods)) tiene_pauta = len(cods) > 0 # 4b) V. VENTA INICIAL = suma de INV_NETA (en soles) de las matriculas del # programa (dsc_promocion == programa). Igual que Rentabilidad: DOL -> soles # con el imp_tc del comprobante (fallback 3.45 si viene 0/invalido). # ROAS = venta_inicial (soles) / (importe pauta en soles = importe * 3.5). # cartera para clasificar matriculas (nuevo/antiguo/no iden) _ci = _cartera_idx() cart_origen = _ci.get("origen", {}) # tel -> fecha origen del lead cart_tels = _ci.get("tels", set()) # telefonos que estan en cartera venta_inicial = 0.0 mat_por_fecha = {} # date -> cantidad de matriculas (no ANU) mat_no_iden = 0; mat_nuevos = 0; mat_antiguos = 0 dias_lista = [] # dias lead->matricula (promedio general) dias_nuevos = [] # dias de los <= 45 dias_antiguos = [] # dias de los > 45 for m in _matriculas_crudas(): nomp = str(m.get("dsc_promocion") or m.get("dsc_programa") or "").strip().upper() if nomp != prog_sel: continue if str(m.get("estado_matricula") or "").strip().upper() == "ANU": continue try: inv = float(m.get("INV_NETA", 0.0) or 0.0) except Exception: inv = 0.0 if str(m.get("cod_moneda") or "").strip().upper() == "DOL": try: tc = float(m.get("imp_tc", 0) or 0) except Exception: tc = 0 if tc < 2: tc = 3.45 # TC por defecto, igual que Rentabilidad inv *= tc venta_inicial += inv # serie de matriculas por dia (fch_matricula) fm = L._to_date(m.get("fch_matricula")) if fm: mat_por_fecha[fm] = mat_por_fecha.get(fm, 0) + 1 # telefono de la matricula (tel_1 o tel_2) ph = str(m.get("dsc_telefono_1") or "").strip() or str(m.get("dsc_telefono_2") or "").strip() if not ph or ph not in cart_tels: mat_no_iden += 1 continue forig = cart_origen.get(ph) if fm and forig: d = (fm - forig).days if 0 <= d <= 45: mat_nuevos += 1 dias_nuevos.append(d) else: mat_antiguos += 1 if d > 45: dias_antiguos.append(d) if d >= 0: dias_lista.append(d) # promedio general: TODOS los que tienen origen else: mat_antiguos += 1 venta_inicial = round(venta_inicial, 2) # se muestra al 100% tiempo_mat_prom = round(sum(dias_lista) / len(dias_lista)) if dias_lista else 0 prom_nuevos = round(sum(dias_nuevos) / len(dias_nuevos)) if dias_nuevos else 0 prom_antiguos = round(sum(dias_antiguos) / len(dias_antiguos)) if dias_antiguos else 0 importe_soles = round(importe * 3.5, 2) # importe Meta (dolares) -> soles con 3.5 # ROAS usa el 85% de la venta inicial (descuento estimado de retiros/mora) roas = round((venta_inicial * 0.85) / importe_soles, 2) if importe_soles else 0.0 # 5) SERIE DIARIA (por fecha del lead) para el grafico: recibidos/procesados/ # contactados por FECHA COMPLETA (dd/mm/yyyy). NO filtra por mes: muestra # TODO el historial del programa. Solo aparecen dias con al menos 1 lead. cods_set = set(cods) por_fecha = {} # date -> {"rec":set,"proc":set,"cont":set} for l in _leads_crudos(): if not l.get("es_unico"): continue if str(l.get("codigo", "-")).strip() not in cods_set: continue fx = l.get("fecha") if not fx: continue tel = l.get("telefono") if not tel: continue d = por_fecha.setdefault(fx, {"rec": set(), "proc": set(), "cont": set()}) d["rec"].add(tel) if l.get("asesor"): d["proc"].add(tel) if (l.get("ultima") or "NO TRABAJADO") != "NO TRABAJADO": d["cont"].add(tel) _MES_ES = ["", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Setiembre", "Octubre", "Noviembre", "Diciembre"] serie = [{"fecha": f.strftime("%d/%m/%Y"), "dia": f.day, "mes": f"{_MES_ES[f.month]} {f.year}", "recibidos": len(v["rec"]), "procesados": len(v["proc"]), "contactados": len(v["cont"])} for f, v in sorted(por_fecha.items())] # 5b) LEADS SIN RESPONDER por dia: de los telefonos del programa (rec), # los que estan asignados pero NO tienen respuesta del asesor. # Se agrupa por dia de la FECHA DE ASIGNACION (created_at). from datetime import datetime as _dt sin_resp_dia = {} # date -> set(tel) for a in _asignacion_respuesta_crudos(): tel = str(a.get("telefono") or "").strip() if tel not in rec: # solo leads de este programa continue t2 = a.get("respuesta_fecha") if isinstance(t2, _dt): continue # tiene respuesta -> no cuenta t1 = a.get("created_at") if not isinstance(t1, _dt): continue sin_resp_dia.setdefault(t1.date(), set()).add(tel) serie_sin_resp = [{"fecha": f.strftime("%d/%m/%Y"), "dia": f.day, "mes": f"{_MES_ES[f.month]} {f.year}", "cantidad": len(v)} for f, v in sorted(sin_resp_dia.items()) if len(v) >= 1] # 5c) SERIE de MATRICULAS por dia (fch_matricula) del programa. serie_mat = [{"fecha": f.strftime("%d/%m/%Y"), "dia": f.day, "mes": f"{_MES_ES[f.month]} {f.year}", "cantidad": n} for f, n in sorted(mat_por_fecha.items()) if n >= 1] n_rec = len(rec); n_proc = len(proc); n_cont = len(cont) return { "programa": programa, "tiene_pauta": tiene_pauta, "serie": serie, "serie_sin_resp": serie_sin_resp, "serie_mat": serie_mat, "matriz_etiq": matriz_etiq, "importe": importe, "resultados": resultados, "cpl": round(importe / resultados, 2) if resultados else 0.0, # CPL = importe / resultados "venta_inicial": venta_inicial, # V. Venta Inicial (soles) "roas": roas, # Venta inicial / (importe * 3.5) "mat_no_iden": mat_no_iden, "mat_nuevos": mat_nuevos, "mat_antiguos": mat_antiguos, "tiempo_mat_prom": tiempo_mat_prom, # dias promedio lead->matricula "prom_nuevos": prom_nuevos, # dias prom de los <=45 "prom_antiguos": prom_antiguos, # dias prom de los >45 "cartera_total": cart_total, "cartera_copito": cart_cop, "cartera_otros": cart_otros, "leads_recibidos": n_rec, "leads_procesados": n_proc, "pct_procesados": round(n_proc / n_rec * 100, 1) if n_rec else 0.0, "leads_contactados": n_cont, "pct_contactados": round(n_cont / n_proc * 100, 1) if n_proc else 0.0, } return cache_get_or_set("programa_dash", (ano, mes, programa), _load) def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"): """Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta). Mas liviano que leads_dashboard -> filtrar por mes es mas rapido.""" def _load(): return { "matriz_always": matriz_always(ano, mes, dia, "TODOS", "TODOS"), "matriz_webform": matriz_web_formulario(ano, mes, dia, "TODOS", "TODOS"), "matriz_asignados": matriz_asignados(ano, mes, dia), "matriz_plantillas": matriz_plantillas(ano, mes, dia), } return cache_get_or_set("otros_general", (ano, mes, dia), _load) def conjuntos_sin_pauta(): """Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up).""" return get_dm().traer_conjuntos_sin_pauta() def _pautas_de_curso_ni(ni): """Pautas (codigos) de un num_indice = pauta de basebi + programa_pautas.""" cds = set() for p in _pautas_de_indice().get(ni, []): cds.add(str(p).strip()) info = _pauta_cruda().get(ni) if info and info.get("pauta"): cds.add(str(info.get("pauta")).strip()) return cds def alertas_dashboard(): """ALERTAS: cursos que inician en los proximos 30 dias (con pauta, importe>0). Estado (CRITICO/ADVERTENCIA/BUEN RUMBO) segun MATRICULAS vs meta ESPERADA por dias que faltan. CPL alto / pocos leads son SENALES (no cambian el estado). Pares = cursos mismo sede+programa con importe>0 (info real desde ~diciembre).""" def _load(): import datetime as _dt from collections import defaultdict as _dd HOY = _dt.date.today() LIM = HOY + _dt.timedelta(days=30) imp_map = _importe_por_pauta() res_map = _resultados_por_pauta() lxc = _leads_por_codigo_periodo("TODOS", "TODOS", "TODOS") mat_idx = _mat_por_indice() pmap = _pauta_cruda() # telefono -> set(num_indice donde se matriculo) para el analisis "este vs otros" tel_mats = _dd(set) for ni_m, filas in mat_idx.items(): for (tel, _f) in filas: if tel: tel_mats[str(tel).strip()].add(ni_m) def datos(c): ni = _norm_ni(c.get("num_indice")) cds = _pautas_de_curso_ni(ni) importe = round(sum(float(imp_map.get(cd, 0.0)) for cd in cds), 2) resultados = int(sum(int(res_map.get(cd, 0)) for cd in cds)) rec = set(); proc = set() for cd in cds: lp = lxc.get(cd) if lp: rec |= lp["rec"]; proc |= lp["proc"] nom = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() info = pmap.get(ni) or {} susp = (str(c.get("cod_estado", "")).strip().upper() == "SUS" or info.get("estado") == "SUSPENDIDO") return {"ni": ni, "nom": nom, "sede": clasificar_sede_meta(nom), "prog": clasificar_prog_meta(nom), "fch": L._to_date(c.get("fch_inicio")), "importe": importe, "resultados": resultados, "rec": rec, "proc": proc, "suspendido": susp, "mats": len(mat_idx.get(ni, [])), "meta": meta_de_curso(nom)} cursos = [] susp_ini = [] # cursos SUSPENDIDOS que ya iniciaron (se muestran marcados) for c in _cursos_crudos(): if _curso_excluido(c, pmap): # MISMO filtro que 'Detalle por Curso' # Los suspendidos SÍ se listan en "Ya iniciados" (con etiqueta), # pero NO entran en cursos/pares/próximos (no afectan promedios). d = datos(c) if d["fch"] and d["suspendido"]: susp_ini.append(d) continue d = datos(c) if d["fch"]: cursos.append(d) # pares por (sede,prog) SOLO con importe>0 pares = _dd(list) for d in cursos: if d["importe"] > 0: pares[(d["sede"], d["prog"])].append(d) proximos = [d for d in cursos if HOY <= d["fch"] <= LIM and d["importe"] > 0] proximos.sort(key=lambda x: x["fch"]) # Programas que YA INICIARON: desde ayer hasta hace 30 días (con importe>0). INI_MIN = HOY - _dt.timedelta(days=30) INI_MAX = HOY - _dt.timedelta(days=1) iniciados_src = [d for d in cursos if INI_MIN <= d["fch"] <= INI_MAX and d["importe"] > 0] # Suspendidos que ya iniciaron en el rango (se muestran marcados, sin exigir importe). iniciados_src += [d for d in susp_ini if INI_MIN <= d["fch"] <= INI_MAX] iniciados_src.sort(key=lambda x: x["fch"], reverse=True) # más recientes primero def _armar_item(d, ya_inicio=False): grp = [p for p in pares.get((d["sede"], d["prog"]), []) if p["ni"] != d["ni"]] sum_imp = sum(p["importe"] for p in grp) sum_res = sum(p["resultados"] for p in grp) cpl_prom = round(sum_imp / sum_res, 2) if sum_res > 0 else 0.0 imp_prom = round(sum_imp / len(grp), 2) if grp else 0.0 # importe prom por curso par res_prom = round(sum_res / len(grp), 0) if grp else 0.0 # resultados prom por curso par leads_prom = round(sum(len(p["rec"]) for p in grp) / len(grp), 1) if grp else 0.0 proc_prom = round(sum(len(p["proc"]) for p in grp) / len(grp), 0) if grp else 0.0 leads = len(d["rec"]); proc = len(d["proc"]) cpl = round(d["importe"] / d["resultados"], 2) if d["resultados"] > 0 else 0.0 dias = (d["fch"] - HOY).days dias_desde = (HOY - d["fch"]).days # días transcurridos desde el inicio (>=1) # meta esperada por dias que faltan (si ya inició, se espera el 100%) if ya_inicio: pct_esp = 1.0 else: pct_esp = 0.30 if dias > 20 else (0.60 if dias >= 8 else 0.85) esperado = round(d["meta"] * pct_esp) avance = (d["mats"] / esperado) if esperado > 0 else 1.0 if d["meta"] > 0 and d["mats"] >= d["meta"]: nivel = "ok" elif avance >= 1.0: nivel = "ok" elif avance >= 0.60: nivel = "advertencia" else: nivel = "critico" # senales CPL/leads (rojo) cpl_rojo = cpl_prom > 0 and cpl > 0 and (cpl - cpl_prom) / cpl_prom > 0.75 leads_rojo = leads_prom > 0 and (leads - leads_prom) / leads_prom < -0.50 # conversion Meta -> base y atencion conv = round(leads / d["resultados"] * 100, 1) if d["resultados"] > 0 else 0.0 aten = round(proc / leads * 100, 0) if leads > 0 else 0.0 # matriculados de ESTE curso vs OTROS (cruce por telefono de los recibidos) este = otros = 0 for tel in d["rec"]: ms = tel_mats.get(tel) if not ms: continue if d["ni"] in ms: este += 1 else: otros += 1 def est_cpl(): if cpl_prom <= 0 or cpl <= 0: return "verde" dif = (cpl - cpl_prom) / cpl_prom return "rojo" if dif > 0.75 else ("amarillo" if dif > 0.25 else "verde") def est_leads(): if leads_prom <= 0: return "verde" dif = (leads - leads_prom) / leads_prom return "rojo" if dif < -0.50 else ("amarillo" if dif < -0.20 else "verde") est_mat = {"ok": "verde", "advertencia": "amarillo", "critico": "rojo"}[nivel] def est_vs(actual, prom): # Mas es mejor: verde >=prom, naranja 50-99% del prom, rojo <50%. if prom <= 0: return "verde" r = actual / prom return "verde" if r >= 1.0 else ("amarillo" if r >= 0.50 else "rojo") def est_gasto(actual, prom): # Importe: menos es mejor. verde <=100%, naranja 101-150%, rojo >150%. if prom <= 0: return "verde" r = actual / prom return "verde" if r <= 1.0 else ("amarillo" if r <= 1.5 else "rojo") metricas = [ {"l": "Importe", "v": f"$ {d['importe']:.2f}", "sub": f"prom $ {imp_prom:.2f}", "est": est_gasto(d['importe'], imp_prom)}, {"l": "CPL", "v": f"$ {cpl:.2f}", "sub": f"prom $ {cpl_prom:.2f}", "est": est_cpl()}, {"l": "Resultados", "v": f"{d['resultados']}", "sub": f"prom {res_prom:.0f}", "est": est_vs(d['resultados'], res_prom)}, {"l": "Leads recibidos", "v": f"{leads}", "sub": f"prom {leads_prom:.0f}", "est": est_leads()}, {"l": "Leads procesados", "v": f"{proc}", "sub": f"prom {proc_prom:.0f}", "est": est_vs(proc, proc_prom)}, {"l": "Matrículas", "v": f"{d['mats']} / {d['meta']}", "sub": (f"cerró con {d['mats']} de {d['meta']}" if ya_inicio else f"esperado ~{esperado} a {dias}d"), "est": est_mat}, ] # analisis narrado: cada punto lleva su estado (verde/amarillo/rojo) # para agruparlo luego en Óptimo / En observación / Crítico. puntos = [] # lista de {"txt":..., "est":...} # Matriculas if ya_inicio: puntos.append({"est": est_mat, "txt": f"El curso inició hace {dias_desde} días y cerró con {d['mats']} de {d['meta']} matrículas."}) else: puntos.append({"est": est_mat, "txt": f"Faltan {dias} días para el inicio y lleva {d['mats']} de {d['meta']} matrículas " f"(se esperaría ~{esperado} a esta altura)."}) # CPL if cpl_prom > 0: if cpl > cpl_prom: puntos.append({"est": est_cpl(), "txt": f"El CPL es $ {cpl:.2f} siendo el promedio $ {cpl_prom:.2f}: está costando más " f"conseguir cada resultado que en cursos similares — la pauta se encareció."}) else: puntos.append({"est": est_cpl(), "txt": f"El CPL es $ {cpl:.2f}, por debajo del promedio $ {cpl_prom:.2f} — la pauta rinde bien."}) # Importe (punto propio) if imp_prom > 0: pct_imp = d["importe"] / imp_prom * 100 puntos.append({"est": est_gasto(d["importe"], imp_prom), "txt": f"Llevas invertido $ {d['importe']:.2f} de un promedio de $ {imp_prom:.2f} por curso similar " f"({pct_imp:.0f}% del gasto habitual)."}) # Resultados (punto propio) if res_prom > 0: pct_res = d["resultados"] / res_prom * 100 puntos.append({"est": est_vs(d["resultados"], res_prom), "txt": f"Tienes {d['resultados']} resultados de un promedio de {res_prom:.0f} ({pct_res:.0f}%)." + (" Aún no alcanzas el promedio de resultados, tienes margen para invertir más." if pct_res < 100 else " Ya superaste el promedio de resultados.")}) # Recibidos vs promedio if leads_prom > 0: dif_pct = (leads - leads_prom) / leads_prom * 100 p = f"Tenemos {leads} leads recibidos; antes recibíamos en promedio {leads_prom:.0f}." if leads < leads_prom: p += (f" Es {abs(dif_pct):.0f}% menos de lo habitual: está entrando poca gente a la base, " "hay que reforzar la captación (pauta/anuncio).") else: p += " Estamos por encima del promedio de captación." puntos.append({"est": est_leads(), "txt": p}) # Procesados vs promedio if proc_prom > 0: p = f"De esos, {proc} pasaron a un asesor (procesados); antes en promedio {proc_prom:.0f}." if proc < proc_prom: dp = (proc - proc_prom) / proc_prom * 100 p += (f" Es {abs(dp):.0f}% menos: llegan menos interesados a la etapa de asesor, " "consecuencia de los pocos recibidos.") else: p += " Buen paso a asesor frente al promedio." puntos.append({"est": est_vs(proc, proc_prom), "txt": p}) # (el cruce por telefono se muestra aparte, ver "cruce" abajo) # Aviso por baja conversion: muchos resultados de Meta pero pocos llegan # al WhatsApp/base (conv < 30%). Indica fuga entre el anuncio y el chat. conv_baja = "" if d["resultados"] >= 20 and conv < 30: conv_baja = (f"Atención: de {d['resultados']} resultados solo {leads} llegaron a nuestra base " f"({conv:.0f}%). Se está perdiendo mucha conversión entre resultados y recibidos: " f"de muchos resultados, pocos llegan a nuestra base de datos. Si bien vamos bien en " f"CPL e importe, tenemos pocas adiciones a nuestra base. ") # recomendacion if ya_inicio: # Curso ya iniciado: la lectura es de cierre/aprendizaje, no de acción urgente. if nivel == "critico": reco = (f"El curso cerró con {d['mats']} de {d['meta']} matrículas, por debajo de la meta. " + conv_baja + ("El CPL quedó alto: revisar el anuncio para próximos cursos similares. " if cpl_rojo else "") + ("Entró poca gente a la base: reforzar captación en el siguiente. " if leads_rojo else "") + "Usar este resultado para ajustar la pauta del próximo inicio.") elif nivel == "advertencia": reco = (conv_baja + "Cerró cerca de la meta; hay margen de mejora en captación para el próximo curso.") else: reco = (conv_baja + "Cerró en buen nivel de matrículas.") if conv_baja else "Cerró en buen nivel de matrículas." elif nivel == "critico": reco = (f"Con {d['mats']} matrículas y {dias} días para el inicio va por debajo del ritmo esperado. " + conv_baja + ("Revisar la segmentación/mensaje del anuncio o aumentar inversión. " if leads_rojo else "") + ("El CPL está alto, revisar el anuncio. " if cpl_rojo else "") + "Priorizar la captación y el seguimiento de los leads pendientes.") elif nivel == "advertencia": reco = (conv_baja + "Va encaminado pero necesita un empujón: reforzar seguimiento de leads y vigilar el CPL.") else: reco = (conv_baja + "Sin acciones urgentes, va a buen ritmo.") if conv_baja else "Sin acciones urgentes, va a buen ritmo." señal = "" if cpl_rojo: señal += "CPL alto" if leads_rojo: señal = (señal + " · " if señal else "") + "pocos leads" # Agrupa los PUNTOS de analisis segun su estado (verde/amarillo/rojo). g_opt = [x["txt"] for x in puntos if x["est"] == "verde"] g_med = [x["txt"] for x in puntos if x["est"] == "amarillo"] g_mal = [x["txt"] for x in puntos if x["est"] == "rojo"] grupos = [ {"titulo": "Óptimo", "color": "verde", "items": g_opt}, {"titulo": "En observación", "color": "amarillo", "items": g_med}, {"titulo": "Crítico", "color": "rojo", "items": g_mal}, ] # Cruce por telefono, separado del resto. cruce = f"{este} se matricularon a ESTE curso" + (f" y {otros} en OTROS programas." if otros else ".") return { "id": d["ni"], "curso": d["nom"], "dias_inicio": dias, "dias_desde": dias_desde, "ya_inicio": ya_inicio, "suspendido": bool(d.get("suspendido")), "nivel": nivel, "sede": d["sede"], "programa": d["prog"], "resumen": señal or ("Cerca de la meta" if nivel != "critico" else "Requiere atención"), "metricas": metricas, "puntos": puntos, "reco": reco, "grupos": grupos, "cruce": cruce, } items = [_armar_item(d, ya_inicio=False) for d in proximos] iniciados = [_armar_item(d, ya_inicio=True) for d in iniciados_src] return {"items": items, "iniciados": iniciados} # NO cachear si sale vacio por data aun no lista (evita quedar pegado en []). # Si hay cursos cargados y aun asi no hay proximos, se cachea normal. res = cache_get_or_set("alertas_dash", ("GLOBAL",), _load) if not res.get("items") and _cursos_crudos(): from cache_manager import cache_invalidate cache_invalidate("alertas_dash") res = _load() return res def programas_ocultos(): """Programas con contar=NO en basebi_programacion (ocultos de la matriz). Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}.""" pm = _pauta_cruda() ocultos_ni = {ni for ni, info in pm.items() if str(info.get("contar") or "").upper() == "NO"} prog_de_ni = {} fch_de_ni = {} for c in _cursos_crudos(): ni = _norm_ni(c.get("num_indice")) prog_de_ni[ni] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() f = L._to_date(c.get("fch_inicio")) fch_de_ni[ni] = f.strftime("%d/%m/%Y") if f else "" filas = [] for ni in ocultos_ni: filas.append({"num_indice": ni, "dsc_det_programa": prog_de_ni.get(ni, "(sin nombre)"), "fch_inicio": fch_de_ni.get(ni, ""), "pauta": (pm.get(ni) or {}).get("pauta") or ""}) filas.sort(key=lambda x: x["dsc_det_programa"]) return {"filas": filas} def encender_programas(num_indices): """Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva).""" from cache_manager import cache_invalidate dm = get_dm() for ni in num_indices: dm.guardar_pauta_basebi(ni, "", "SI") # pauta vacia -> no toca pauta, solo contar for pref in ("pauta_raw", "leads_dash", "otros_general", "leads_filtros"): cache_invalidate(pref) return {"encendidos": len(num_indices)} def programas_disponibles(): """Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}.""" from datetime import date corte = date(2026, 1, 1) vistos = {} for c in _cursos_crudos(): f = L._to_date(c.get("fch_inicio")) if not f or f < corte: continue ni = _norm_ni(c.get("num_indice")) nombre = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() if not nombre: continue finicio = f.strftime("%d/%m/%Y") label = f"{nombre} · {finicio}" vistos[ni] = {"num_indice": ni, "dsc_det_programa": nombre, "fch_inicio": finicio, "label": label} filas = sorted(vistos.values(), key=lambda x: x["label"]) return {"programas": filas} # ── LEYENDA (campanias de Supabase): listar / agregar / borrar ── def campanias_listar(): return get_dm().listar_campanias() def campania_agregar(datos): res = get_dm().agregar_campania(datos) _invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard return res def campania_editar(cid, datos): res = get_dm().editar_campania(cid, datos) _invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard return res def campania_borrar(cid): res = get_dm().borrar_campania(cid) _invalidar_por_campanias() return res def leyenda_anuncios(): """Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa (dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya en memoria) para que abrir la Leyenda sea rapido.""" def _load(): conj_pauta = _conjunto_pauta() # cacheado pauta_de_conj = {} for pa, cjs in conj_pauta.items(): for c in cjs: pauta_de_conj[" ".join(str(c).split())] = str(pa).strip() # pauta -> [num_indices] usando TODAS las vinculaciones (basebi + programa_pautas) ni_de_pauta = {} for ni, pautas in _pautas_de_indice().items(): for p in pautas: ni_de_pauta.setdefault(str(p).strip(), []).append(str(ni).strip()) prog_de_ni = {} for c in _cursos_crudos(): # cacheado prog_de_ni[_norm_ni(c.get("num_indice"))] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() def _programa(pauta): if not pauta: return "" nis = ni_de_pauta.get(str(pauta).strip(), []) nombres = [prog_de_ni.get(n, "") for n in nis if prog_de_ni.get(n)] return " | ".join(sorted(set(nombres))) filas = [] vistos = set() for f in _meta_filas(): # cacheado (CSV de Meta) co = " ".join(str(f.get("conjunto")).split()) if not co or co in vistos: continue vistos.add(co) pa = pauta_de_conj.get(co, "") filas.append({"conjunto": co, "pauta": pa, "programa": _programa(pa)}) filas.sort(key=lambda x: x["conjunto"]) return {"filas": filas} return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load) def guardar_leyenda(cambios): """cambios = [{conjunto, pauta, num_indice?}]. - conjunto -> pauta: upsert en conjunto_pauta. - si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion. Invalida solo cachés dependientes de pauta/conjunto (no la cartera).""" from cache_manager import cache_invalidate dm = get_dm() for c in cambios: conjunto = c.get("conjunto") pauta = (c.get("pauta") or "").strip() ni = c.get("num_indice") if conjunto: dm.upsert_conjunto_pauta(conjunto, pauta) # si eligió un programa (num_indice), AGREGAR el vínculo num_indice<->pauta # en programa_pautas (muchos-a-muchos, se SUMA, no reemplaza). if ni and pauta: dm.agregar_programa_pauta(str(ni).strip(), pauta) # Invalida TODO lo que depende de pauta/conjunto (incluye ROAS, Programa, etc.) for pref in ("conjunto_pauta", "pauta_raw", "programa_pautas", "pautas_de_indice", "meta_filas", "importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo", "resultados_pauta", "leads_dash", "otros_general", "matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas", "leyenda_anuncios", "roas_dash", "programa_dash", "programa_opciones", "leads_x_codigo", "leads_filtros", "vendedores_dash"): cache_invalidate(pref) return {"guardados": len(cambios)} def uso_de_pauta(pauta, excluir_num_indice=None): """Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa) y conjuntos que la usan (para la advertencia antes de guardar).""" dm = get_dm() nis = dm.num_indices_de_pauta(pauta) excl = str(excluir_num_indice or "").strip() nis = [n for n in nis if n != excl] # dsc_det_programa de esos num_indice (desde cursos de SQL, ya cacheados) cursos = _cursos_crudos() detalle = [] for c in cursos: ni = _norm_ni(c.get("num_indice")) if ni in nis: detalle.append({"num_indice": ni, "dsc_det_programa": str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()}) conjuntos = _conjunto_pauta().get(str(pauta).strip(), []) return {"en_uso": bool(nis) or bool(conjuntos), "cursos": detalle, "num_indices": nis, "conjuntos": conjuntos} def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None): """Guarda pauta y contar (SI/NO) en basebi_programacion (upsert) y, si se eligió, vincula el conjunto a esa pauta en conjunto_pauta. Invalida SOLO los cachés que dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida).""" from cache_manager import cache_invalidate dm = get_dm() res = dm.guardar_pauta_basebi(num_indice, pauta, contar) if conjunto: dm.guardar_conjunto_pauta(conjunto, pauta) # invalidar solo lo afectado por pauta/conjunto (la cartera NO se re-baja) for pref in ("pauta_raw", "pautas_de_indice", "conjunto_pauta", "meta_filas", "importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo", "leads_dash", "otros_general", "matriz_always", "matriz_plantillas", "leads_filtros", "roas_dash"): cache_invalidate(pref) return res def _tipo_de_curso(c): """Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS).""" dscp = c.get("dsc_programa", "") otros = L.clasificar_tipo_programa(dscp) if otros == "OTROS": return "OTROS" up = str(dscp).upper() cat = "TEAC" if ("AIRE ACONDICIONADO" in up or "TEAC" in up) else ( "TERC" if ("REFRIGERACION COMERCIAL" in up or "REFRIGERACIÓN COMERCIAL" in up or "TERC" in up) else "SEM") sede = L.clasificar_sede(dscp) return L.tipo_programa_cohorte(sede, cat) # ── Listas para los filtros (años, tipos de programa) ─────────── def opciones_filtros(): def _load(): cursos = _cursos_crudos() anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos if L._to_date(c.get("fch_inicio"))}, reverse=True) # Grupos de programa fijos para el filtro PROGRAMA tipos = ["TEAC", "TERC", "SEMINARIOS", "OTROS"] return {"anos": anos or [2026], "tipos": tipos} return cache_get_or_set("leads_filtros", ("GLOBAL",), _load) # ── Alertas: valores de programa/sede no identificados en cartera_junta ── def alertas(): return cache_get_or_set("leads_alertas", ("GLOBAL",), lambda: get_dm().traer_no_identificados()) # ── Diagnóstico de cursos (para cuadrar ocupabilidad) ─────────── def debug_cursos(ano, mes): cursos = _cursos_crudos() periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")] detalle = [] tot_insc = 0 tot_meta = 0 for c in periodo: insc = int(c.get("Inscritos_Totales", 0) or 0) meta = L.meta_curso(c.get("dsc_programa")) tp = L.tipo_programa_curso(c.get("dsc_programa")) tot_insc += insc tot_meta += meta detalle.append({ "num_indice": c.get("num_indice"), "dsc_programa": c.get("dsc_programa"), "fch_inicio": str(c.get("fch_inicio"))[:10], "cod_estado": c.get("cod_estado"), "inscritos": insc, "tipo": tp, "meta": meta, }) return { "num_cursos": len(periodo), "suma_inscritos": tot_insc, "suma_meta": tot_meta, "ocupabilidad": round((tot_insc / tot_meta * 100), 2) if tot_meta else 0, "cursos": detalle, } # ── Precarga / refresco ───────────────────────────────────────── def _periodos_actual_anterior(): """[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy).""" from datetime import date hoy = date.today() ano, mes = hoy.year, hoy.month if mes == 1: prev = (ano - 1, 12) else: prev = (ano, mes - 1) return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))] import os as _os _LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log") def _log_carga(linea, reset=False): """Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos).""" try: with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f: f.write(linea + "\n") except Exception: pass import threading as _thr _TIEMPOS = {} # etiqueta -> segundos (para el resumen final) _TIEMPOS_LOCK = _thr.Lock() _HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen def _cron(etiqueta, base, fn, apartado=None): """Ejecuta fn() midiendo el tiempo. Imprime y ademas guarda en el log: paso, base de datos y segundos. Se ve en la ventana del BACKEND y en la ventana de TIEMPOS al iniciar, para saber que carga cada cosa. Si 'apartado' se indica, acumula el tiempo para el resumen final.""" import time as _t t0 = _t.perf_counter() r = fn() dt = _t.perf_counter() - t0 n = "" try: if isinstance(r, (list, dict)): n = f"({len(r)} filas)" except Exception: pass linea = f" [carga] {etiqueta:32} {base:14} {dt:6.2f}s {n}" print(linea, flush=True) _log_carga(linea) with _TIEMPOS_LOCK: _TIEMPOS[apartado or etiqueta] = _TIEMPOS.get(apartado or etiqueta, 0.0) + dt return r def _hilo_termino(nombre): """Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan, imprime el resumen final por apartado en el log.""" with _TIEMPOS_LOCK: _HILOS_LISTOS.add(nombre) listos = {"leads", "otros"}.issubset(_HILOS_LISTOS) tiempos = dict(_TIEMPOS) if not listos: return _log_carga("") _log_carga("=" * 60) _log_carga(" RESUMEN — tiempo listo para abrir cada apartado") _log_carga("=" * 60) orden = ["Leads", "Vendedores", "Otros General", "ROAS"] for ap in orden: seg = tiempos.get(ap) if seg is not None: estado = "instantaneo al abrir" if seg < 1 else f"listo tras {seg:.1f}s" _log_carga(f" {ap:16} {seg:6.2f}s -> {estado}") _log_carga("-" * 60) _log_carga(" Ya precargados: al hacer clic se muestran AL INSTANTE (cache).") _log_carga("=" * 60) def precargar_todo(): """Precarga en segundo plano: insumos crudos + el dashboard del mes actual y anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar.""" try: import time as _t _log_carga("=" * 60, reset=True) _log_carga(" CARGA DEL DASHBOARD LEADS — " + _t.strftime("%d/%m/%Y %H:%M:%S")) _log_carga("=" * 60) print(">>> Precargando LEADS (hilo 1)...", flush=True) _log_carga(">>> Hilo 1: LEADS (PostgreSQL + SQL Server)") _cron("Leads (mensajes)", "PostgreSQL", _leads_crudos) _cron("Cursos", "SQL Server", _cursos_crudos) _cron("Matriculas", "SQL Server", _matriculas_crudas) _cron("Leads asignados/respuesta", "PostgreSQL", _leads_asignados_crudos) # Dashboard (Leads) del mes actual y anterior (lo mas usado). Otros General # NO se precarga aqui (sus consultas son pesadas); se calcula al entrar. p = _periodos_actual_anterior() a0, m0 = p[0] for (a, m) in p: _cron(f"Apartado Leads {a}-{m}", "cache", lambda a=a, m=m: leads_dashboard(a, m, "TODOS", "TODOS", "TODOS"), apartado="Leads") # Vendedores y ROAS del periodo actual: se precargan para abrir al instante. _cron("Apartado Vendedores", "cache", lambda: vendedores_dashboard(a0, m0, "TODOS"), apartado="Vendedores") _cron("Apartado ROAS", "cache", lambda: roas_dashboard(a0, m0, "TODOS"), apartado="ROAS") _cron("Apartado Alertas", "cache", alertas_dashboard, apartado="Alertas") _marcar_actualizacion() print(">>> Hilo 1 (Leads) LISTO.", flush=True) _log_carga(">>> Hilo 1 (Leads) LISTO.") _hilo_termino("leads") except Exception as e: print(f"[precarga] {e}", flush=True) _log_carga(f"[precarga] ERROR: {e}") _hilo_termino("leads") def precargar_otros_general(): """Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual y anterior. Se llama en su propio hilo para no bloquear.""" try: print(">>> Precargando CARTERA + OTROS GENERAL (hilo 2)...", flush=True) _log_carga(">>> Hilo 2: CARTERA (Supabase) + OTROS GENERAL") _cron("Plantillas", "Google Sheet", _plantillas_crudas) _cron("Cartera", "Supabase", _cartera_rows_cache) # la mas pesada for (a, m) in _periodos_actual_anterior(): _cron(f"Otros General {a}-{m}", "cache", lambda a=a, m=m: otros_general(a, m, "TODOS"), apartado="Otros General") print(">>> Hilo 2 (Cartera/Otros) LISTO.", flush=True) _log_carga(">>> Hilo 2 (Cartera/Otros) LISTO.") _hilo_termino("otros") except Exception as e: print(f"[precarga-otros] {e}", flush=True) _log_carga(f"[precarga-otros] ERROR: {e}") _hilo_termino("otros") # Hora de la última actualización (hora Perú) _ULTIMA_ACTUALIZACION = None def _marcar_actualizacion(): global _ULTIMA_ACTUALIZACION from datetime import datetime, timezone, timedelta peru = timezone(timedelta(hours=-5)) _ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M") def ultima_actualizacion(): return {"hora": _ULTIMA_ACTUALIZACION} def refrescar_todo(): """Refresco de los 30 min SIN dejar la caché vacía (stale-while-revalidate). NO se vacía nada al inicio: se recalcula TODO con datos frescos de las bases en 2º plano (modo 'forzar'), y cada entrada se reemplaza recién cuando su valor nuevo está listo. Mientras dura la recarga, el usuario sigue viendo los datos viejos; al terminar, el cambio es un solo parpadeo. NO pantalla de carga.""" from cache_manager import set_forzar from data_manager_v2 import invalidar_campanias_cache # Las campañas (cache aparte de data_manager) sí se invalidan: son livianas y # las relee la primera consulta forzada de abajo, sin costo perceptible. invalidar_campanias_cache() set_forzar(True) # este hilo recalcula y sobrescribe (no lee cache viejo) try: precargar_todo() # leads (mes actual/anterior) -> reemplaza al terminar precargar_otros_general() # cartera + Otros General -> reemplaza al terminar finally: set_forzar(False) # vuelve a modo normal para no afectar otras llamadas _marcar_actualizacion()