# 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 _DM = None def get_dm() -> DataManager: global _DM if _DM is None: _DM = DataManager() return _DM # ── 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 _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) 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) # 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) tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos) 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 _leads_asignados_crudos(): return cache_get_or_set("leads_asignados", ("GLOBAL",), lambda: get_dm().traer_leads_asignados()) 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()) 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()) 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 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 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} 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) for pref in ("conjunto_pauta", "pauta_raw", "programa_pautas", "pautas_de_indice", "meta_filas", "importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo", "leads_dash", "otros_general", "matriz_always", "matriz_plantillas", "leyenda_anuncios"): 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"): 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]))] 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: _leads_crudos(); _cursos_crudos(); _matriculas_crudas() _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. for (a, m) in _periodos_actual_anterior(): leads_dashboard(a, m, "TODOS", "TODOS", "TODOS") print(f"[precarga] listo {a}-{m}") _marcar_actualizacion() except Exception as e: print(f"[precarga] {e}") 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: _plantillas_crudas() _cartera_rows_cache() # baja la cartera 1 vez (queda cacheada para todas las matrices) for (a, m) in _periodos_actual_anterior(): otros_general(a, m, "TODOS") print(f"[precarga-otros] listo {a}-{m}") except Exception as e: print(f"[precarga-otros] {e}") # 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(): from cache_manager import cache_invalidate cache_invalidate() # vaciar y recargar TODO precargar_todo() # leads (mes actual/anterior) precargar_otros_general() # cartera + Otros General (para que no quede vacia) _marcar_actualizacion()