985 lines
45 KiB
Python
985 lines
45 KiB
Python
# 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()
|