Files
dashboard-leads/backend/services.py

988 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
try:
ndias = calendar.monthrange(int(ano), int(mes))[1]
dias_cols = list(range(1, ndias + 1))
except (ValueError, TypeError):
dias_cols = list(range(1, 32))
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()