feat: reestructuracion para easypanel y variables de entorno

This commit is contained in:
desoladorxx
2026-07-20 17:22:50 -05:00
commit 7f38210017
45 changed files with 8219 additions and 0 deletions

984
backend/services.py Normal file
View File

@@ -0,0 +1,984 @@
# 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()