# 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 # ── 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", ) 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 _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 _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()) 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 _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 if 13 <= h < 18: act[key]["tarde"] += 1 elif 18 <= h < 22: act[key]["noche"] += 1 def _turno_dia(v, d): """Devuelve lista de bloques [(h_ini,h_fin), ...] del turno de ese dia, o None si no trabajo. Refrigerio de turno1 (13-14) NO se incluye.""" 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) 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: comparar tarde vs noche t = fr.get("tarde", 0); n = fr.get("noche", 0) if t == 0 and n == 0: return None if n > t: return [(9, 13), (18, 22)] # Turno 2 return [(9, 13), (14, 18)] # Turno 1 (refrigerio 13-14 fuera) 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 fr = act.get((v, d)) if not fr: return False t = fr.get("tarde", 0); n = fr.get("noche", 0) es_t1 = not (n > t) and (t > 0 or n > 0) 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 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"] CANAL_COPITO = {"COPITO"} # 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. cart_total = defaultdict(int); cart_copito = defaultdict(int); cart_otros = defaultdict(int) tels_por_vend = defaultdict(set) # vendedor -> telefonos (es_origen=SI del periodo) cop_tels = defaultdict(set) # vendedor -> telefonos ya contados como Copito (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() canal = " ".join(str(r.get("canal") or "").upper().split()) ob = " ".join(str(r.get("origen_base") or "").upper().split()) cart_total[v] += 1 # Copito = union (origen_base=POSTGRE) OR (canal=COPITO), sin repetir telefono es_copito = (ob == "POSTGRE") or (canal in CANAL_COPITO) if es_copito: if tel and tel in cop_tels[v]: pass # ese telefono ya se conto como Copito -> no repetir else: cart_copito[v] += 1 if tel: cop_tels[v].add(tel) else: cart_otros[v] += 1 if 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()) 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"} # 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 # 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 # conjuntos por (sede, prog): nombre_conjunto -> {importe, resultados} conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados} } 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)) imp[sede] += i res[sede] += rr prog = conj_prog.get(k, "SIN PROGRAMA") imp_pg[(sede, prog)] = imp_pg.get((sede, prog), 0.0) + i res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr dd = conj_det.setdefault((sede, prog), {}) d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0}) d["importe"] += i d["resultados"] += rr 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, "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], "programas": _sub(s)}) tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values())} return {"filas": filas, "total": tot} return cache_get_or_set("roas_dash", (ano, mes, dia), _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} # ── 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) 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", "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") _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(): from cache_manager import cache_invalidate from data_manager_v2 import invalidar_campanias_cache cache_invalidate() # vaciar y recargar TODO invalidar_campanias_cache() # tambien las campañas (cache aparte) precargar_todo() # leads (mes actual/anterior) precargar_otros_general() # cartera + Otros General (para que no quede vacia) _marcar_actualizacion()