1769 lines
82 KiB
Python
1769 lines
82 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, cache_invalidate
|
||
|
||
_DM = None
|
||
|
||
|
||
def get_dm() -> DataManager:
|
||
global _DM
|
||
if _DM is None:
|
||
_DM = DataManager()
|
||
return _DM
|
||
|
||
|
||
# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar
|
||
# una leyenda se invalidan estos para que TODOS los apartados se reconstruyan
|
||
# en su proxima peticion (perezoso: solo lo que se pida, ~2-5s), sin esperar
|
||
# los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache.
|
||
_PREF_CAMPANIAS = (
|
||
"campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros",
|
||
"leads_alertas", "vendedores_dash", "roas_dash", "otros_general",
|
||
"matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas",
|
||
"tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo",
|
||
)
|
||
|
||
|
||
def _invalidar_por_campanias():
|
||
"""Invalida el cache de campañas (Supabase) y de todos los apartados que
|
||
dependen de las leyendas, para que el cambio se vea de inmediato."""
|
||
from data_manager_v2 import invalidar_campanias_cache
|
||
invalidar_campanias_cache()
|
||
for p in _PREF_CAMPANIAS:
|
||
cache_invalidate(p)
|
||
|
||
|
||
# ── Datos crudos cacheados (consultas pesadas, una sola vez) ────
|
||
def _leads_crudos():
|
||
return cache_get_or_set("leads_raw", ("GLOBAL",),
|
||
lambda: L.procesar_leads(get_dm().traer_leads_chatwoot()))
|
||
|
||
|
||
def _cursos_crudos():
|
||
return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos())
|
||
|
||
|
||
def _matriculas_crudas():
|
||
return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas())
|
||
|
||
|
||
def _pauta_cruda():
|
||
return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos())
|
||
|
||
|
||
def _programa_pautas():
|
||
"""{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos)."""
|
||
return cache_get_or_set("programa_pautas", ("GLOBAL",),
|
||
lambda: get_dm().traer_programa_pautas())
|
||
|
||
|
||
def _pautas_de_indice():
|
||
"""{num_indice: [pautas]} combinando:
|
||
- la pauta de basebi_programacion (1 por num_indice), y
|
||
- las pautas extra de programa_pautas (varias).
|
||
Sin duplicados. Un num_indice puede terminar con varias pautas que se SUMAN."""
|
||
def _load():
|
||
pm = _pauta_cruda() # {num_indice: {pauta,...}}
|
||
extra = _programa_pautas() # {num_indice: [pautas]}
|
||
out = {}
|
||
for ni, info in pm.items():
|
||
p = info.get("pauta")
|
||
if p:
|
||
out.setdefault(str(ni).strip(), []).append(str(p).strip())
|
||
for ni, pautas in extra.items():
|
||
lst = out.setdefault(str(ni).strip(), [])
|
||
for p in pautas:
|
||
if str(p).strip() not in lst:
|
||
lst.append(str(p).strip())
|
||
return out
|
||
return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load)
|
||
|
||
|
||
def _campanias_map():
|
||
return cache_get_or_set("campanias_map", ("GLOBAL",),
|
||
lambda: get_dm().traer_campanias_map())
|
||
|
||
|
||
def _conjunto_pauta():
|
||
return cache_get_or_set("conjunto_pauta", ("GLOBAL",),
|
||
lambda: get_dm().traer_conjunto_pauta())
|
||
|
||
|
||
def _meta_filas():
|
||
"""Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...]."""
|
||
return cache_get_or_set("meta_filas", ("GLOBAL",),
|
||
lambda: get_dm().traer_meta_importe())
|
||
|
||
|
||
def _importe_por_pauta():
|
||
"""{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los
|
||
conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha."""
|
||
def _load():
|
||
conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]}
|
||
imp_norm = {}
|
||
for f in _meta_filas():
|
||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||
imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0)
|
||
out = {}
|
||
for pauta, conjuntos in conj_pauta.items():
|
||
total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
|
||
out[str(pauta).strip()] = round(total, 2)
|
||
return out
|
||
return cache_get_or_set("importe_pauta", ("GLOBAL",), _load)
|
||
|
||
|
||
def _importe_por_pauta_periodo(ano, mes, dia):
|
||
"""{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe'
|
||
cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba."""
|
||
def _load():
|
||
conj_pauta = _conjunto_pauta()
|
||
imp_norm = {}
|
||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||
for f in _meta_filas():
|
||
fx = L._to_date(f.get("inicio"))
|
||
if sin_filtro:
|
||
pass # sin filtro: cuenta todo (incluye filas sin fecha) = total
|
||
elif not L._en_periodo(fx, ano, mes, dia):
|
||
continue
|
||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||
imp_norm[k] = imp_norm.get(k, 0.0) + float(f.get("importe") or 0.0)
|
||
out = {}
|
||
for pauta, conjuntos in conj_pauta.items():
|
||
total = sum(imp_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
|
||
out[str(pauta).strip()] = round(total, 2)
|
||
return out
|
||
return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load)
|
||
|
||
|
||
def _resultados_por_pauta_periodo(ano, mes, dia):
|
||
"""{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe'
|
||
cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados."""
|
||
def _load():
|
||
conj_pauta = _conjunto_pauta()
|
||
res_norm = {}
|
||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||
for f in _meta_filas():
|
||
if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
|
||
continue
|
||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||
res_norm[k] = res_norm.get(k, 0.0) + float(f.get("resultados") or 0.0)
|
||
out = {}
|
||
for pauta, conjuntos in conj_pauta.items():
|
||
total = sum(res_norm.get(" ".join(str(co).split()).upper(), 0.0) for co in conjuntos)
|
||
out[str(pauta).strip()] = int(total)
|
||
return out
|
||
return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load)
|
||
|
||
|
||
def _campanias_sede_cargo():
|
||
return cache_get_or_set("campanias_sc", ("GLOBAL",),
|
||
lambda: get_dm().traer_campanias_sede_cargo())
|
||
|
||
|
||
def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
|
||
"""Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice).
|
||
Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados,
|
||
Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor).
|
||
Sede/programa salen de la tabla campanias. Pautas sin campanias NO se cuentan."""
|
||
def _load():
|
||
conj_pauta = _conjunto_pauta() # {pauta: [conjuntos]}
|
||
camp = _campanias_sede_cargo() # {pauta: {sede,cargo}}
|
||
pauta_map = _pauta_cruda() # {num_indice: {pauta,...}}
|
||
con_indice = {str(v.get("pauta")).strip() for v in pauta_map.values() if v.get("pauta")}
|
||
|
||
# Leads nuevos (unicos) por pauta = codigo, cuya fecha_creada cae en el mes filtrado
|
||
leads = _leads_crudos()
|
||
leads_nuevos = {} # codigo -> nº leads unicos del mes
|
||
leads_nuevos_asig = {} # codigo -> nº leads unicos del mes con asesor
|
||
tel_lead_mes = {} # telefono -> (codigo, fecha_creada) del lead unico del mes
|
||
for l in leads:
|
||
if not l.get("es_unico"):
|
||
continue
|
||
if not L._en_periodo(l.get("fecha"), ano, mes, dia):
|
||
continue
|
||
cod = str(l.get("codigo") or "").strip()
|
||
if not cod:
|
||
continue
|
||
leads_nuevos[cod] = leads_nuevos.get(cod, 0) + 1
|
||
if str(l.get("asesor") or "").strip():
|
||
leads_nuevos_asig[cod] = leads_nuevos_asig.get(cod, 0) + 1
|
||
tel = str(l.get("telefono") or "").strip()
|
||
if tel:
|
||
tel_lead_mes[tel] = (cod, l.get("fecha"))
|
||
|
||
# MATRICULAS por pauta: matriculas cuyo telefono = un lead nuevo del mes, y con
|
||
# fch_matricula >= fecha_creada del lead. Cuenta CADA matricula (una vez c/u),
|
||
# atribuida a la pauta del lead. Cruce por telefono (tel_1, o tel_2 si vacio).
|
||
matriculas_pauta = {} # codigo -> nº matriculas
|
||
venta_pauta = {} # codigo -> suma INV_NETA en soles (DOL ×3.34)
|
||
_vistas = set() # (num_matricula) para no contar 2 veces
|
||
for m in _matriculas_crudas():
|
||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||
ph = t1 if t1 else t2
|
||
if ph not in tel_lead_mes:
|
||
continue
|
||
cod, f_lead = tel_lead_mes[ph]
|
||
fm = L._to_date(m.get("fch_matricula"))
|
||
if not fm or not f_lead or fm < f_lead:
|
||
continue # matricula anterior al ingreso del lead -> no cuenta
|
||
nm = str(m.get("num_matricula"))
|
||
if nm in _vistas:
|
||
continue
|
||
_vistas.add(nm)
|
||
matriculas_pauta[cod] = matriculas_pauta.get(cod, 0) + 1
|
||
# INV_NETA en soles: si moneda DOL, ×3.34 (igual que tabla por Sede)
|
||
try:
|
||
inv = float(m.get("INV_NETA", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
inv = 0.0
|
||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||
inv *= 3.34
|
||
venta_pauta[cod] = venta_pauta.get(cod, 0.0) + inv
|
||
|
||
# Importe y Resultados por conjunto, filtrando por el MES de arriba
|
||
imp = {}; res = {}
|
||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||
for f in _meta_filas():
|
||
if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
|
||
continue
|
||
k = " ".join(str(f.get("conjunto")).split()).upper()
|
||
imp[k] = imp.get(k, 0.0) + float(f.get("importe") or 0.0)
|
||
res[k] = res.get(k, 0.0) + float(f.get("resultados") or 0.0)
|
||
|
||
# Acumular por Sede -> Programa -> Pauta (solo pautas ALWAYS que esten en campanias)
|
||
from collections import defaultdict
|
||
grupos = defaultdict(lambda: defaultdict(lambda: {"importe": 0.0, "resultados": 0.0,
|
||
"nuevos": 0, "nuevos_asig": 0,
|
||
"matriculas": 0, "venta": 0.0, "pautas": []}))
|
||
for pauta, conjuntos in conj_pauta.items():
|
||
p = str(pauta).strip()
|
||
if p in con_indice: # tiene num_indice -> NO es always
|
||
continue
|
||
info = camp.get(p)
|
||
if not info: # no esta en campanias -> no se cuenta
|
||
continue
|
||
se = str(info.get("sede") or "").upper()
|
||
gr = L.grupo_programa_lead(info.get("cargo")) # TEAC/TERC/SEMINARIOS
|
||
ti = sum(imp.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos)
|
||
tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos)
|
||
if ti == 0 and tr == 0:
|
||
continue # sin gasto en el mes -> no aparece
|
||
# filtros de arriba
|
||
if sede not in ("TODOS", None) and se != str(sede).upper():
|
||
continue
|
||
if programa not in ("TODOS", None) and gr != str(programa).upper():
|
||
continue
|
||
nv = leads_nuevos.get(p, 0)
|
||
na = leads_nuevos_asig.get(p, 0)
|
||
mt = matriculas_pauta.get(p, 0)
|
||
vv = venta_pauta.get(p, 0.0)
|
||
g = grupos[se][gr]
|
||
g["importe"] += ti
|
||
g["resultados"] += tr
|
||
g["nuevos"] += nv
|
||
g["nuevos_asig"] += na
|
||
g["matriculas"] += mt
|
||
g["venta"] += vv
|
||
g["pautas"].append({"pauta": p, "importe": round(ti, 2), "resultados": int(tr),
|
||
"nuevos": nv, "nuevos_asig": na, "matriculas": mt,
|
||
"venta": round(vv, 0)})
|
||
|
||
# Armar filas: Sede -> subfilas por programa -> pautas (solo con gasto)
|
||
filas = []
|
||
for se in sorted(grupos.keys()):
|
||
subs = []
|
||
s_imp = s_res = 0.0; s_nv = s_na = s_mt = 0; s_vv = 0.0
|
||
for gr in ("TEAC", "TERC", "SEMINARIOS"):
|
||
if gr in grupos[se]:
|
||
d = grupos[se][gr]
|
||
pautas = sorted(d["pautas"], key=lambda x: -x["importe"])
|
||
subs.append({"programa": gr, "importe": round(d["importe"], 2),
|
||
"resultados": int(d["resultados"]),
|
||
"nuevos": d["nuevos"], "nuevos_asig": d["nuevos_asig"],
|
||
"matriculas": d["matriculas"], "venta": round(d["venta"], 0),
|
||
"pautas": pautas})
|
||
s_imp += d["importe"]; s_res += d["resultados"]
|
||
s_nv += d["nuevos"]; s_na += d["nuevos_asig"]; s_mt += d["matriculas"]; s_vv += d["venta"]
|
||
filas.append({"sede": se, "importe": round(s_imp, 2), "resultados": int(s_res),
|
||
"nuevos": s_nv, "nuevos_asig": s_na, "matriculas": s_mt,
|
||
"venta": round(s_vv, 0), "subfilas": subs})
|
||
tot_i = round(sum(f["importe"] for f in filas), 2)
|
||
tot_r = int(sum(f["resultados"] for f in filas))
|
||
tot_nv = sum(f["nuevos"] for f in filas)
|
||
tot_na = sum(f["nuevos_asig"] for f in filas)
|
||
tot_mt = sum(f["matriculas"] for f in filas)
|
||
tot_vv = round(sum(f["venta"] for f in filas), 0)
|
||
return {"filas": filas, "total": {"importe": tot_i, "resultados": tot_r,
|
||
"nuevos": tot_nv, "nuevos_asig": tot_na,
|
||
"matriculas": tot_mt, "venta": tot_vv}}
|
||
|
||
return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load)
|
||
|
||
|
||
def matriz_web_formulario(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"):
|
||
"""Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa.
|
||
Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y
|
||
Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead)."""
|
||
def _load():
|
||
rows = _cartera_rows_cache()
|
||
# 1) leads WEB_FORMULARIO cuya fila es el ORIGEN del telefono en TODA la cartera
|
||
# (es_origen=SI) y con fecha_creada en el periodo. Asi NO cuenta si el telefono
|
||
# ya vino antes por otro canal (igual criterio que las demas tablas).
|
||
vistos = {} # telefono -> {fecha, sede, prog, asesor}
|
||
for r in rows:
|
||
if str(r.get("canal") or "").strip().upper() != "WEB_FORMULARIO":
|
||
continue
|
||
if str(r.get("es_origen") or "").strip().upper() != "SI":
|
||
continue # solo el origen real del telefono
|
||
tel = str(r.get("telefono") or "").strip()
|
||
if not tel:
|
||
continue
|
||
f = L._to_date(r.get("fecha_creada"))
|
||
if not L._en_periodo(f, ano, mes, dia):
|
||
continue
|
||
# sede y programa YA vienen normalizados en cartera_junta. Reducir a 4 grupos.
|
||
se = " ".join(str(r.get("sede") or "").upper().split()) or "SIN SEDE"
|
||
pr = " ".join(str(r.get("programa") or "").upper().split())
|
||
if pr == "TEAC":
|
||
gr = "TEAC"
|
||
elif pr == "TERC":
|
||
gr = "TERC"
|
||
elif pr in ("SEMINARIOS", "VRF", "CO2", "DIPLOMADO", "VENTILACIÓN", "VENTILACION"):
|
||
gr = "SEMINARIOS"
|
||
else:
|
||
gr = "OTROS"
|
||
vistos[tel] = {"fecha": f, "sede": se, "prog": gr,
|
||
"asesor": str(r.get("asesor") or "").strip()}
|
||
|
||
# 2) matriculas por telefono (num_matricula, fecha, inv en soles)
|
||
from collections import defaultdict
|
||
mat_por_tel = defaultdict(list)
|
||
for m in _matriculas_crudas():
|
||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||
ph = t1 if t1 else t2
|
||
if not ph:
|
||
continue
|
||
fm = L._to_date(m.get("fch_matricula"))
|
||
try:
|
||
inv = float(m.get("INV_NETA", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
inv = 0.0
|
||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||
inv *= 3.34
|
||
mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv))
|
||
|
||
# 3) agrupar Sede -> Programa
|
||
grupos = defaultdict(lambda: defaultdict(lambda: {"recibidos": 0, "procesados": 0,
|
||
"matriculas": 0, "venta": 0.0}))
|
||
for tel, info in vistos.items():
|
||
se = info["sede"]; gr = info["prog"]; f_lead = info["fecha"]
|
||
if sede not in ("TODOS", None) and se != str(sede).upper():
|
||
continue
|
||
if programa not in ("TODOS", None) and gr != str(programa).upper():
|
||
continue
|
||
g = grupos[se][gr]
|
||
g["recibidos"] += 1
|
||
if info["asesor"]:
|
||
g["procesados"] += 1
|
||
for (nm, fm, inv) in mat_por_tel.get(tel, []):
|
||
if fm and f_lead and fm >= f_lead:
|
||
g["matriculas"] += 1
|
||
g["venta"] += inv
|
||
|
||
# 4) filas
|
||
filas = []
|
||
for se in sorted(grupos.keys()):
|
||
subs = []
|
||
s_rec = s_proc = s_mt = 0; s_vv = 0.0
|
||
for gr in ("TEAC", "TERC", "SEMINARIOS", "OTROS"):
|
||
if gr in grupos[se]:
|
||
d = grupos[se][gr]
|
||
subs.append({"programa": gr, "recibidos": d["recibidos"],
|
||
"procesados": d["procesados"], "matriculas": d["matriculas"],
|
||
"venta": round(d["venta"], 0)})
|
||
s_rec += d["recibidos"]; s_proc += d["procesados"]
|
||
s_mt += d["matriculas"]; s_vv += d["venta"]
|
||
filas.append({"sede": se, "recibidos": s_rec, "procesados": s_proc,
|
||
"matriculas": s_mt, "venta": round(s_vv, 0), "subfilas": subs})
|
||
tot = {"recibidos": sum(f["recibidos"] for f in filas),
|
||
"procesados": sum(f["procesados"] for f in filas),
|
||
"matriculas": sum(f["matriculas"] for f in filas),
|
||
"venta": round(sum(f["venta"] for f in filas), 0)}
|
||
return {"filas": filas, "total": tot}
|
||
|
||
return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load)
|
||
|
||
|
||
def _asignacion_respuesta_crudos():
|
||
return cache_get_or_set("asig_resp", ("GLOBAL",),
|
||
lambda: get_dm().traer_asignacion_respuesta())
|
||
|
||
|
||
def _leads_asignados_crudos():
|
||
# UNIFICADO: usa la MISMA consulta/cache que el grafico (asignacion+respuesta+etiquetas),
|
||
# asi la tarjeta, la matriz de asignados y el grafico nunca se desfasan.
|
||
return _asignacion_respuesta_crudos()
|
||
|
||
|
||
def _actividad_asesores_crudos():
|
||
return cache_get_or_set("act_asesores", ("GLOBAL",),
|
||
lambda: get_dm().traer_actividad_asesores())
|
||
|
||
|
||
def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||
"""Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del
|
||
mes; valor = cantidad de telefonos asignados a ese asesor ese dia.
|
||
Fecha = created_at del mensaje de asignacion (ya con -5h)."""
|
||
def _load():
|
||
import calendar
|
||
from collections import defaultdict
|
||
rows = _leads_asignados_crudos()
|
||
# dias del mes filtrado (columnas)
|
||
if mes not in ("TODOS", None) and ano not in ("TODOS", None):
|
||
ndias = calendar.monthrange(int(ano), int(mes))[1]
|
||
dias_cols = list(range(1, ndias + 1))
|
||
else:
|
||
dias_cols = list(range(1, 32))
|
||
|
||
# asesores excluidos de esta matriz
|
||
EXCLUIR = {"SIN ASESOR", "COPITO RIVERA"}
|
||
# asesor -> dia -> [telefonos]
|
||
por = defaultdict(lambda: defaultdict(list))
|
||
for r in rows:
|
||
f = L._to_date(r.get("created_at"))
|
||
if not L._en_periodo(f, ano, mes, dia):
|
||
continue
|
||
ase = str(r.get("user_name") or "Sin Asesor").strip() or "Sin Asesor"
|
||
if ase.upper() in EXCLUIR:
|
||
continue
|
||
tel = str(r.get("telefono") or "").strip()
|
||
por[ase][f.day].append(tel)
|
||
|
||
filas = []
|
||
tot_por_dia = defaultdict(int)
|
||
for ase in sorted(por.keys()):
|
||
pordia = {d: len(por[ase].get(d, [])) for d in dias_cols}
|
||
total = sum(pordia.values())
|
||
# telefonos por dia (para expandir con +)
|
||
tels_dia = {d: sorted(por[ase].get(d, [])) for d in dias_cols if por[ase].get(d)}
|
||
for d, n in pordia.items():
|
||
tot_por_dia[d] += n
|
||
filas.append({"asesor": ase, "por_dia": pordia, "total": total, "tels_dia": tels_dia})
|
||
filas.sort(key=lambda x: -x["total"])
|
||
total_gral = sum(f["total"] for f in filas)
|
||
return {"dias": dias_cols, "filas": filas,
|
||
"total_por_dia": {d: tot_por_dia.get(d, 0) for d in dias_cols},
|
||
"total": total_gral}
|
||
return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load)
|
||
|
||
|
||
def _plantillas_crudas():
|
||
return cache_get_or_set("plantillas_raw", ("GLOBAL",),
|
||
lambda: get_dm().traer_plantillas())
|
||
|
||
|
||
def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||
"""Matriz de plantillas COBRADAS por plantilla. Columnas:
|
||
ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado
|
||
RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1)
|
||
ASIGNADAS = de esas, con asesor Y respondida por el contacto
|
||
MATRICULAS = matriculas de esos telefonos (fch_matricula >= envio mas antiguo). Sin filtro de mes.
|
||
VENTA = INV_NETA (DOL x3.34) de esas matriculas."""
|
||
from datetime import timedelta, datetime
|
||
from collections import defaultdict
|
||
|
||
def _load():
|
||
rows = _plantillas_crudas()
|
||
UMBRAL = timedelta(hours=24)
|
||
|
||
def _lleno(s):
|
||
return s is not None and str(s).strip() != ""
|
||
|
||
enviadas = defaultdict(int)
|
||
respondidas = defaultdict(int)
|
||
asignadas = defaultdict(int)
|
||
prim_por_tel = {} # telefono -> (fecha_envio, plantilla) la cobrada mas antigua DEL PERIODO
|
||
|
||
for r in rows:
|
||
plantilla = r.get("plantilla")
|
||
envio = r.get("created_at_peru")
|
||
f = L._to_date(envio)
|
||
# ENVIADAS/RESP/ASIG respetan el filtro de mes (por created_at_peru)
|
||
if not L._en_periodo(f, ano, mes, dia):
|
||
continue
|
||
anterior = r.get("hora_anterior_contacto")
|
||
sig = r.get("siguiente_mensaje_1")
|
||
es_si = False
|
||
if plantilla == "descuento_egresados_seminarios":
|
||
es_si = True
|
||
elif anterior is not None and (envio - anterior) > UMBRAL:
|
||
es_si = True
|
||
if not es_si:
|
||
continue
|
||
enviadas[plantilla] += 1
|
||
if _lleno(sig):
|
||
respondidas[plantilla] += 1
|
||
if _lleno(r.get("user_name")) and _lleno(sig):
|
||
asignadas[plantilla] += 1
|
||
tel = str(r.get("telefono") or "").strip()
|
||
if tel and envio is not None:
|
||
if tel not in prim_por_tel or envio < prim_por_tel[tel][0]:
|
||
prim_por_tel[tel] = (envio, plantilla)
|
||
|
||
# MATRICULAS + VENTA de los telefonos filtrados (sin filtro de mes en la matricula)
|
||
mat_por_tel = defaultdict(list)
|
||
for m in _matriculas_crudas():
|
||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||
ph = t1 if t1 else t2
|
||
if not ph:
|
||
continue
|
||
fm = m.get("fch_matricula")
|
||
if isinstance(fm, datetime): fm = fm.date()
|
||
try:
|
||
inv = float(m.get("INV_NETA", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
inv = 0.0
|
||
if str(m.get("cod_moneda", "")).strip().upper() == "DOL":
|
||
inv *= 3.34
|
||
mat_por_tel[ph].append((str(m.get("num_matricula")), fm, inv))
|
||
|
||
matriculas = defaultdict(int)
|
||
venta = defaultdict(float)
|
||
_vistas = set()
|
||
for tel, (fenvio, plantilla) in prim_por_tel.items():
|
||
fenvio_d = fenvio.date() if hasattr(fenvio, "date") else fenvio
|
||
for (nm, fm, inv) in mat_por_tel.get(tel, []):
|
||
if not fm or nm in _vistas:
|
||
continue
|
||
if fm >= fenvio_d:
|
||
_vistas.add(nm)
|
||
matriculas[plantilla] += 1
|
||
venta[plantilla] += inv
|
||
|
||
filas = []
|
||
for p in sorted(enviadas.keys(), key=lambda x: -enviadas[x]):
|
||
filas.append({"plantilla": p, "enviadas": enviadas[p],
|
||
"respondidas": respondidas.get(p, 0),
|
||
"asignadas": asignadas.get(p, 0),
|
||
"matriculas": matriculas.get(p, 0),
|
||
"venta": round(venta.get(p, 0.0), 0)})
|
||
tot = {"enviadas": sum(enviadas.values()), "respondidas": sum(respondidas.values()),
|
||
"asignadas": sum(asignadas.values()), "matriculas": sum(matriculas.values()),
|
||
"venta": round(sum(venta.values()), 0)}
|
||
return {"filas": filas, "total": tot}
|
||
|
||
return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load)
|
||
|
||
|
||
def _norm_ni(x):
|
||
"""Normaliza num_indice a texto (quita el '.0' si viniera como float)."""
|
||
s = str(x or "").strip()
|
||
return s[:-2] if s.endswith(".0") else s
|
||
|
||
|
||
def _mat_por_indice():
|
||
"""{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas.
|
||
Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2."""
|
||
def _load():
|
||
from collections import defaultdict
|
||
out = defaultdict(list)
|
||
for m in _matriculas_crudas():
|
||
ni = _norm_ni(m.get("num_indice"))
|
||
t1 = str(m.get("dsc_telefono_1") or "").strip()
|
||
t2 = str(m.get("dsc_telefono_2") or "").strip()
|
||
fm = L._to_date(m.get("fch_matricula"))
|
||
out[ni].append((t1 if t1 else t2, fm))
|
||
return out
|
||
return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load)
|
||
|
||
|
||
def _cartera_rows_cache():
|
||
"""Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez).
|
||
Evita re-descargar las 86k filas en cada llamada/cambio de mes."""
|
||
return cache_get_or_set("cartera_rows", ("GLOBAL",),
|
||
lambda: get_dm().traer_cartera_rows())
|
||
|
||
|
||
def _cartera_idx():
|
||
"""Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}.
|
||
'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío).
|
||
Se baja la cartera UNA vez y queda cacheado."""
|
||
def _load():
|
||
from collections import defaultdict
|
||
CANAL_PAUTA = {"COPITO", "PAUTA_WSP", "PAUTA_WSP_FACE"}
|
||
CANAL_WEB = {"WEB_FORMULARIO", "WEB_WHATSAPP", "WHATSAPP WEB"}
|
||
|
||
def _clase(canal):
|
||
if canal in CANAL_PAUTA: return "PAUTA"
|
||
if canal in CANAL_WEB: return "WEB"
|
||
return "OTROS"
|
||
|
||
# PASO 1: DEDUP por (sede, prog, telefono) -> quedarse con la fila mas antigua.
|
||
# Se guarda la fecha, si tiene asesor, y el CANAL de esa fila ganadora.
|
||
# (Asi el canal de PAUTA/OTROS se decide DESPUES de deduplicar, como pide el usuario.)
|
||
ganador = {} # (sede, prog, tel) -> {"f":fecha, "asesor":bool, "canal":canal}
|
||
tels = set()
|
||
tels_canal = {} # telefono -> canal de su fila ganadora GLOBAL (para matriculas)
|
||
gan_global = {} # telefono -> fecha ganadora global (para decidir canal de matriculas)
|
||
origen = {}
|
||
for r in _cartera_rows_cache():
|
||
tel = str(r.get("telefono") or "").strip()
|
||
if not tel:
|
||
continue
|
||
tels.add(tel)
|
||
sede = " ".join(str(r.get("sede") or "").upper().split())
|
||
prog = " ".join(str(r.get("programa") or "").upper().split())
|
||
canal = " ".join(str(r.get("canal") or "").upper().split())
|
||
f = L._to_date(r.get("fecha_creada"))
|
||
if str(r.get("es_origen") or "").upper() == "SI" and f is not None:
|
||
origen[tel] = f
|
||
if f is None:
|
||
continue
|
||
con_asesor = bool(str(r.get("asesor") or "").strip())
|
||
# ganador por sede+prog+tel (para la tabla)
|
||
k = (sede, prog, tel)
|
||
g = ganador.get(k)
|
||
if g is None or f < g["f"]:
|
||
ganador[k] = {"f": f, "asesor": con_asesor, "canal": canal}
|
||
# ganador global por tel (para clasificar matriculas por canal)
|
||
if tel not in gan_global or f < gan_global[tel]:
|
||
gan_global[tel] = f
|
||
tels_canal[tel] = canal
|
||
|
||
# PASO 2: armar indices con la fila ganadora ya deduplicada
|
||
idx_all = defaultdict(dict); idx_asig = defaultdict(dict)
|
||
# por clase (PAUTA/WEB/OTROS): {clase: {"all":..,"asig":..}}
|
||
CLASES = ("PAUTA", "WEB", "OTROS")
|
||
idx_cl = {cl: {"all": defaultdict(dict), "asig": defaultdict(dict)} for cl in CLASES}
|
||
tels_cl = {cl: set() for cl in CLASES}
|
||
for (sede, prog, tel), g in ganador.items():
|
||
f = g["f"]; cl = _clase(g["canal"])
|
||
idx_all[(sede, prog)][tel] = f
|
||
if g["asesor"]: idx_asig[(sede, prog)][tel] = f
|
||
idx_cl[cl]["all"][(sede, prog)][tel] = f
|
||
if g["asesor"]: idx_cl[cl]["asig"][(sede, prog)][tel] = f
|
||
# telefonos por clase (segun ganador GLOBAL) para matriculas
|
||
for tel, canal in tels_canal.items():
|
||
tels_cl[_clase(canal)].add(tel)
|
||
|
||
out = {"all": idx_all, "asig": idx_asig, "tels": tels, "origen": origen}
|
||
for cl in CLASES:
|
||
out[f"all_{cl.lower()}"] = idx_cl[cl]["all"]
|
||
out[f"asig_{cl.lower()}"] = idx_cl[cl]["asig"]
|
||
out[f"tels_{cl.lower()}"] = tels_cl[cl]
|
||
return out
|
||
return cache_get_or_set("cartera_idx", ("GLOBAL",), _load)
|
||
|
||
|
||
# ── Respuesta del dashboard de Leads (con filtros) ──────────────
|
||
def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"):
|
||
def _load():
|
||
leads = _leads_crudos()
|
||
cursos = _cursos_crudos()
|
||
mats = _matriculas_crudas()
|
||
|
||
# Filtro por PROGRAMA (4 grupos: TEAC / TERC / SEMINARIOS / OTROS).
|
||
# Recalcula toda la pantalla filtrando las listas base ANTES de calcular.
|
||
# - leads: grupo por 'cargo' del código de campaña
|
||
# - cursos y matrículas: grupo por dsc_programa
|
||
if programa not in ("TODOS", None):
|
||
g = str(programa).upper()
|
||
leads = [l for l in leads if L.grupo_programa_lead(l.get("cargo")) == g]
|
||
cursos = [c for c in cursos if L.grupo_programa_curso(c.get("dsc_programa")) == g]
|
||
mats = [m for m in mats if L.grupo_programa_curso(m.get("dsc_programa")) == g]
|
||
|
||
# Filtro por SEDE (recalcula toda la pantalla). Se filtran las listas base
|
||
# ANTES de calcular, para no tocar las funciones de cálculo existentes.
|
||
# - leads: sede POR CÓDIGO de campaña (NO sede_act; coincide con conteo real)
|
||
# - cursos y matrículas: sede por clasificar_sede(dsc_programa)
|
||
if sede not in ("TODOS", None):
|
||
s = str(sede).upper()
|
||
sede_cod = L.sede_por_codigo_map()
|
||
leads = [l for l in leads
|
||
if sede_cod.get(str(l.get("codigo") or "").strip()) == s]
|
||
cursos = [c for c in cursos if L.clasificar_sede(c.get("dsc_programa")) == s]
|
||
mats = [m for m in mats if L.clasificar_sede(m.get("dsc_programa")) == s]
|
||
|
||
kpis = L.kpis_leads(leads, cursos, mats, ano, mes, dia)
|
||
tabla = L.tabla_estado_objecion(leads, ano, mes, dia)
|
||
por_dia = L.matriculas_por_dia(mats, cursos, ano, mes, dia)
|
||
leads_dia = L.leads_por_dia(leads, ano, mes, dia)
|
||
pauta = L.tabla_pauta(leads, mats, ano, mes, dia,
|
||
_importe_por_pauta_periodo(ano, mes, dia),
|
||
_resultados_por_pauta_periodo(ano, mes, dia),
|
||
_campanias_sede_cargo())
|
||
ci = _cartera_idx()
|
||
cartera_canal = {
|
||
"PAUTA": {"all": ci.get("all_pauta"), "asig": ci.get("asig_pauta"), "tels": ci.get("tels_pauta")},
|
||
"WEB": {"all": ci.get("all_web"), "asig": ci.get("asig_web"), "tels": ci.get("tels_web")},
|
||
"OTROS": {"all": ci.get("all_otros"), "asig": ci.get("asig_otros"), "tels": ci.get("tels_otros")},
|
||
}
|
||
matriz = L.matriz_cursos(cursos, leads, ano, mes, dia, _pauta_cruda(),
|
||
_campanias_map(), ci.get("all"), ci.get("asig"),
|
||
_mat_por_indice(), ci.get("tels"), ci.get("origen"),
|
||
_importe_por_pauta(),
|
||
_importe_por_pauta_periodo(ano, mes, dia),
|
||
cartera_canal, _conjunto_pauta(), _pautas_de_indice())
|
||
return {"kpis": kpis, "estado_objecion": tabla, "matriculas_por_dia": por_dia,
|
||
"leads_por_dia": leads_dia, "tabla_pauta": pauta, "matriz_cursos": matriz}
|
||
|
||
return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load)
|
||
|
||
|
||
def _asesores():
|
||
"""{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}."""
|
||
return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores())
|
||
|
||
|
||
def _norm_asesor(nombre, mapa):
|
||
"""Normaliza un nombre de asesor a su 'correcto' via el diccionario."""
|
||
k = " ".join(str(nombre or "").upper().split())
|
||
return mapa.get(k) # None si no es uno de los vendedores
|
||
|
||
|
||
def _norm_tel_pe(v):
|
||
"""Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial
|
||
de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta."""
|
||
s = str(v or "")
|
||
for x in ("+51", "+", " ", "-", "(", ")"):
|
||
s = s.replace(x, "")
|
||
s = s.strip()
|
||
if s.isdigit() and 51900000000 <= int(s) <= 51999999999:
|
||
s = s[2:]
|
||
return s
|
||
|
||
|
||
def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||
"""Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion.
|
||
- Turno detectado por dia/asesor segun donde hay MAS actividad:
|
||
tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido).
|
||
noche (18-22) -> Turno 2 (09-13 + 18-22, sin refrigerio).
|
||
sabado: manana(09-13) o tarde(14-18), 4h, donde haya mas actividad.
|
||
sin actividad ese dia -> no trabajo (se salta).
|
||
- El tiempo cuenta SOLO minutos dentro del turno; si la asignacion cae fuera,
|
||
el reloj arranca al inicio del siguiente bloque laboral (saltando dias sin
|
||
actividad). Leads asignados en refrigerio (13-14) de turno1 se excluyen.
|
||
- Serie dia a dia por FECHA DE ASIGNACION: promedio de minutos de los leads
|
||
asignados ese dia (solo respondidos). Filtro ano/mes/dia por fecha asignacion.
|
||
- No respondidos -> se marcan aparte, NO entran en el promedio."""
|
||
from datetime import datetime, timedelta, time as _time
|
||
def _load():
|
||
from collections import defaultdict
|
||
ase = _asesores(); amap = ase["map"]; lista = ase["lista"]
|
||
|
||
# ── 1) Detectar turno por (asesor_norm, fecha) segun actividad ──
|
||
# Mapa user_id -> nombre normalizado (de asignacion_respuesta).
|
||
uid2vend = {}
|
||
for r in _asignacion_respuesta_crudos():
|
||
uid = r.get("user_id")
|
||
v = _norm_asesor(r.get("user_name"), amap)
|
||
if uid is not None and v:
|
||
uid2vend[uid] = v
|
||
|
||
# Contar actividad por (vend, fecha, franja): tarde=13-18, noche=18-22,
|
||
# sab_manana=9-13, sab_tarde=14-18.
|
||
act = defaultdict(lambda: defaultdict(int)) # (vend,fecha) -> franja -> conteo
|
||
for a in _actividad_asesores_crudos():
|
||
uid = a.get("user_id"); ts = a.get("created_at")
|
||
if uid not in uid2vend or not isinstance(ts, datetime):
|
||
continue
|
||
v = uid2vend[uid]; d = ts.date(); h = ts.hour + ts.minute / 60.0
|
||
key = (v, d)
|
||
if d.weekday() == 6: # domingo: no trabaja
|
||
continue
|
||
if d.weekday() == 5: # sabado
|
||
if 9 <= h < 13: act[key]["sab_manana"] += 1
|
||
elif 14 <= h < 18: act[key]["sab_tarde"] += 1
|
||
else: # lun-vie
|
||
if 13 <= h < 18: act[key]["tarde"] += 1
|
||
elif 18 <= h < 22: act[key]["noche"] += 1
|
||
|
||
def _turno_dia(v, d):
|
||
"""Devuelve lista de bloques [(h_ini,h_fin), ...] del turno de ese dia,
|
||
o None si no trabajo. Refrigerio de turno1 (13-14) NO se incluye."""
|
||
if d.weekday() == 6:
|
||
return None
|
||
fr = act.get((v, d))
|
||
if not fr:
|
||
return None # sin actividad -> no trabajo
|
||
if d.weekday() == 5: # sabado (4h)
|
||
m = fr.get("sab_manana", 0); t = fr.get("sab_tarde", 0)
|
||
if m == 0 and t == 0:
|
||
return None
|
||
return [(9, 13)] if m >= t else [(14, 18)]
|
||
# lun-vie: comparar tarde vs noche
|
||
t = fr.get("tarde", 0); n = fr.get("noche", 0)
|
||
if t == 0 and n == 0:
|
||
return None
|
||
if n > t:
|
||
return [(9, 13), (18, 22)] # Turno 2
|
||
return [(9, 13), (14, 18)] # Turno 1 (refrigerio 13-14 fuera)
|
||
|
||
def _dt(d, hh):
|
||
return datetime(d.year, d.month, d.day) + timedelta(hours=hh)
|
||
|
||
def _en_refrigerio_t1(v, ts):
|
||
"""True si ts cae 13-15 y ese dia el asesor es turno 1 (lun-vie).
|
||
Estos leads NO se excluyen: cuentan como respondidos, pero su tiempo
|
||
(diferencia directa T2-T1) se reduce 45 min si supera 45."""
|
||
d = ts.date()
|
||
if d.weekday() >= 5:
|
||
return False
|
||
fr = act.get((v, d))
|
||
if not fr:
|
||
return False
|
||
t = fr.get("tarde", 0); n = fr.get("noche", 0)
|
||
es_t1 = not (n > t) and (t > 0 or n > 0)
|
||
h = ts.hour + ts.minute / 60.0
|
||
return es_t1 and (13 <= h < 15)
|
||
|
||
def _minutos_laborales(v, t1, t2):
|
||
"""Minutos de turno entre t1 (asignacion) y t2 (respuesta), saltando
|
||
huecos y dias sin actividad. Devuelve None si no se puede (raro)."""
|
||
if not isinstance(t1, datetime) or not isinstance(t2, datetime) or t2 <= t1:
|
||
return 0
|
||
total = 0.0
|
||
cur = t1
|
||
saltos = 0
|
||
while cur < t2 and saltos < 60: # tope 60 dias por seguridad
|
||
d = cur.date()
|
||
bloques = _turno_dia(v, d)
|
||
if not bloques:
|
||
# dia sin trabajo -> saltar al dia siguiente 00:00
|
||
cur = _dt(d, 24); saltos += 1; continue
|
||
avanzo = False
|
||
for (hi, hf) in bloques:
|
||
ini = _dt(d, hi); fin = _dt(d, hf)
|
||
if t2 <= ini: # la respuesta es antes de este bloque
|
||
break
|
||
if cur >= fin: # ya paso este bloque
|
||
continue
|
||
a = max(cur, ini)
|
||
b = min(t2, fin)
|
||
if b > a:
|
||
total += (b - a).total_seconds() / 60.0
|
||
cur = fin # avanzar al fin del bloque
|
||
avanzo = True
|
||
if cur >= t2:
|
||
break
|
||
# pasar al dia siguiente
|
||
if cur.date() == d:
|
||
cur = _dt(d, 24); saltos += 1
|
||
return round(total)
|
||
|
||
# ── 2) Por lead: minutos laborales, agrupado por FECHA DE ASIGNACION ──
|
||
por_dia_sum = defaultdict(float) # (vend,dia) -> suma minutos
|
||
por_dia_cnt = defaultdict(int) # (vend,dia) -> nº respondidos
|
||
no_resp = defaultdict(int) # (vend,dia) -> nº sin responder
|
||
for r in _asignacion_respuesta_crudos():
|
||
v = _norm_asesor(r.get("user_name"), amap)
|
||
if not v:
|
||
continue
|
||
t1 = r.get("created_at")
|
||
if not isinstance(t1, datetime):
|
||
continue
|
||
if not L._en_periodo(t1.date(), ano, mes, dia):
|
||
continue # filtro por fecha de asignacion
|
||
t2 = r.get("respuesta_fecha")
|
||
if not isinstance(t2, datetime):
|
||
no_resp[(v, t1.day)] += 1 # no respondio
|
||
continue
|
||
if _en_refrigerio_t1(v, t1):
|
||
# asignado 13-15 en turno1: tiempo = diferencia DIRECTA (T2-T1),
|
||
# y si supera 45 min se le restan 45 (nunca negativo).
|
||
dif = (t2 - t1).total_seconds() / 60.0
|
||
mins = round(dif - 45) if dif > 45 else round(dif)
|
||
if mins < 0:
|
||
mins = 0
|
||
else:
|
||
mins = _minutos_laborales(v, t1, t2)
|
||
por_dia_sum[(v, t1.day)] += mins
|
||
por_dia_cnt[(v, t1.day)] += 1
|
||
|
||
# ── 2b) Igual pero agrupado por FECHA DE RESPUESTA (dia en que respondio).
|
||
# Solo leads respondidos. Filtro por fecha de respuesta (t2).
|
||
# Si el tiempo laboral supera 960 min (2 dias laborales) -> NO entra en
|
||
# el promedio; se cuenta aparte como "respuesta tardia".
|
||
LIMITE_TARDIA = 960 # 2 dias laborales (8h/dia)
|
||
pr_sum = defaultdict(float) # (vend,dia_respuesta) -> suma minutos (validos)
|
||
pr_cnt = defaultdict(int) # (vend,dia_respuesta) -> nº validos (<=960)
|
||
pr_tardia = defaultdict(int) # (vend,dia_respuesta) -> nº tardios (>960)
|
||
for r in _asignacion_respuesta_crudos():
|
||
v = _norm_asesor(r.get("user_name"), amap)
|
||
if not v:
|
||
continue
|
||
t1 = r.get("created_at"); t2 = r.get("respuesta_fecha")
|
||
if not isinstance(t1, datetime) or not isinstance(t2, datetime):
|
||
continue # sin respuesta -> no entra en este grafico
|
||
if not L._en_periodo(t2.date(), ano, mes, dia):
|
||
continue # filtro por FECHA DE RESPUESTA
|
||
if _en_refrigerio_t1(v, t1):
|
||
dif = (t2 - t1).total_seconds() / 60.0
|
||
mins = round(dif - 45) if dif > 45 else round(dif)
|
||
if mins < 0:
|
||
mins = 0
|
||
else:
|
||
mins = _minutos_laborales(v, t1, t2)
|
||
if mins > LIMITE_TARDIA:
|
||
pr_tardia[(v, t2.day)] += 1 # respuesta tardia -> fuera del promedio
|
||
continue
|
||
pr_sum[(v, t2.day)] += mins
|
||
pr_cnt[(v, t2.day)] += 1
|
||
|
||
# ── 3) Serie dia a dia (promedio min) por vendedor y total ──
|
||
def _serie(vends):
|
||
out = []
|
||
for dnum in range(1, 32):
|
||
s = sum(por_dia_sum.get((v, dnum), 0) for v in vends)
|
||
c = sum(por_dia_cnt.get((v, dnum), 0) for v in vends)
|
||
nr = sum(no_resp.get((v, dnum), 0) for v in vends)
|
||
out.append({"dia": dnum,
|
||
"promedio_min": round(s / c) if c else 0,
|
||
"respondidos": c, "no_respondidos": nr})
|
||
return out
|
||
|
||
def _serie_resp(vends):
|
||
out = []
|
||
for dnum in range(1, 32):
|
||
s = sum(pr_sum.get((v, dnum), 0) for v in vends)
|
||
c = sum(pr_cnt.get((v, dnum), 0) for v in vends)
|
||
td = sum(pr_tardia.get((v, dnum), 0) for v in vends)
|
||
out.append({"dia": dnum,
|
||
"promedio_min": round(s / c) if c else 0,
|
||
"respondidos": c, "tardios": td})
|
||
return out
|
||
|
||
serie_por_vend = {v: _serie([v]) for v in lista}
|
||
serie_total = _serie(lista)
|
||
serie_resp_por_vend = {v: _serie_resp([v]) for v in lista}
|
||
serie_resp_total = _serie_resp(lista)
|
||
return {"serie_por_vend": serie_por_vend, "serie_total": serie_total,
|
||
"serie_resp_por_vend": serie_resp_por_vend,
|
||
"serie_resp_total": serie_resp_total,
|
||
"vendedores": lista}
|
||
return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load)
|
||
|
||
|
||
def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||
"""Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día:
|
||
- Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta.
|
||
- Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI,
|
||
fecha_creada en el periodo); la matricula cuenta en CUALQUIER fecha.
|
||
- Leads Procesados/Contactados: filtran por fecha del lead (mensaje campaña)."""
|
||
def _load():
|
||
from collections import defaultdict
|
||
ase = _asesores()
|
||
amap = ase["map"]; lista = ase["lista"]
|
||
CANAL_COPITO = {"COPITO"}
|
||
|
||
# 1) CARTERA por vendedor. Fila ORIGEN de cada telefono (es_origen=SI) cuya
|
||
# fecha_creada cae en el periodo filtrado. Cada telefono cuenta para UN solo asesor.
|
||
# Ademas guardamos {vendedor: set(telefonos del periodo)} para cruzar matriculas.
|
||
cart_total = defaultdict(int); cart_copito = defaultdict(int); cart_otros = defaultdict(int)
|
||
tels_por_vend = defaultdict(set) # vendedor -> telefonos (es_origen=SI del periodo)
|
||
cop_tels = defaultdict(set) # vendedor -> telefonos ya contados como Copito (sin repetir)
|
||
for r in _cartera_rows_cache():
|
||
if str(r.get("es_origen") or "").strip().upper() != "SI":
|
||
continue # solo la fila origen de cada telefono
|
||
f = L._to_date(r.get("fecha_creada"))
|
||
if not L._en_periodo(f, ano, mes, dia):
|
||
continue # filtro por fecha_creada de la cartera
|
||
v = _norm_asesor(r.get("asesor"), amap)
|
||
if not v: # el asesor del origen no es uno de los 8
|
||
continue
|
||
tel = str(r.get("telefono") or "").strip()
|
||
canal = " ".join(str(r.get("canal") or "").upper().split())
|
||
ob = " ".join(str(r.get("origen_base") or "").upper().split())
|
||
cart_total[v] += 1
|
||
# Copito = union (origen_base=POSTGRE) OR (canal=COPITO), sin repetir telefono
|
||
es_copito = (ob == "POSTGRE") or (canal in CANAL_COPITO)
|
||
if es_copito:
|
||
if tel and tel in cop_tels[v]:
|
||
pass # ese telefono ya se conto como Copito -> no repetir
|
||
else:
|
||
cart_copito[v] += 1
|
||
if tel:
|
||
cop_tels[v].add(tel)
|
||
else:
|
||
cart_otros[v] += 1
|
||
if tel:
|
||
tels_por_vend[v].add(tel)
|
||
|
||
# 2) MATRICULAS por vendedor. Se CRUZAN por TELEFONO con los leads del periodo
|
||
# (tels_por_vend). La matricula cuenta en CUALQUIER fecha (no se filtra por
|
||
# fch_matricula). Telefono de matricula: dsc_telefono_1, o _2 si el 1 esta vacio.
|
||
# Un telefono puede tener varias matriculas -> se cuentan todas (no unico).
|
||
# MATRICULAS: por dsc_vendedor + fch_matricula en el periodo (NO se cruza
|
||
# con la cartera por telefono). Filtro Año/Mes/Día sobre fch_matricula.
|
||
mat_total = defaultdict(int); mat_curso = defaultdict(int); mat_retir = defaultdict(int)
|
||
for m in _matriculas_crudas():
|
||
vend = _norm_asesor(m.get("dsc_vendedor"), amap)
|
||
if not vend:
|
||
continue
|
||
estado = str(m.get("estado_matricula") or "").strip().upper()
|
||
if estado == "ANU":
|
||
continue # anuladas no cuentan
|
||
fm = L._to_date(m.get("fch_matricula"))
|
||
if not L._en_periodo(fm, ano, mes, dia):
|
||
continue # filtro por fch_matricula
|
||
mat_total[vend] += 1
|
||
if estado in ("ALU", "PRE"):
|
||
mat_curso[vend] += 1
|
||
elif estado == "RET":
|
||
mat_retir[vend] += 1
|
||
|
||
# 3) LEADS Procesados/Contactados por vendedor (leads de Chatwoot). Filtro por
|
||
# fecha del lead (mensaje de campaña). Procesados = lead unico con asesor;
|
||
# Contactados = de esos, ultima_etiqueta != "NO TRABAJADO".
|
||
lead_proc = defaultdict(int); lead_cont = defaultdict(int)
|
||
etiq = defaultdict(int) # clave: (vendedor, ultima_etiqueta) -> nº leads procesados
|
||
for l in _leads_crudos():
|
||
if not l.get("es_unico"):
|
||
continue
|
||
if not L._en_periodo(l.get("fecha"), ano, mes, dia):
|
||
continue # filtro por fecha del lead
|
||
v = _norm_asesor(l.get("asesor"), amap)
|
||
if not v:
|
||
continue
|
||
lead_proc[v] += 1 # tiene asesor de los 8 => procesado
|
||
ult = l.get("ultima") or "NO TRABAJADO"
|
||
etiq[(v, ult)] += 1 # para la matriz Estado/Objeción
|
||
# NOTA: "Contactados" ya NO se cuenta aqui; ahora sale de los ASIGNADOS
|
||
# (bloque 3b), usando su ultima etiqueta.
|
||
|
||
# 3b) LEADS ASIGNADOS por vendedor (mensajes "Asignado a..." de Chatwoot).
|
||
# Se cuenta por telefono (1 asignacion por telefono, la mas reciente).
|
||
# Filtro por fecha de asignacion (created_at). Asesor via user_name normalizado.
|
||
# CONTACTADOS = de los asignados, cuya ULTIMA ETIQUETA != "NO TRABAJADO".
|
||
lead_asig = defaultdict(int)
|
||
etiq_asig = defaultdict(int) # (vendedor, ultima_etiqueta) -> nº asignados (para matriz)
|
||
for a in _leads_asignados_crudos():
|
||
f = L._to_date(a.get("created_at"))
|
||
if not L._en_periodo(f, ano, mes, dia):
|
||
continue # filtro por fecha de asignacion
|
||
v = _norm_asesor(a.get("user_name"), amap)
|
||
if not v:
|
||
continue # el asesor asignado no es uno de los 8
|
||
lead_asig[v] += 1
|
||
ult = L.ultima_etiqueta(a.get("etiquetas")) or "NO TRABAJADO"
|
||
etiq_asig[(v, ult)] += 1 # para la matriz Estado/Objeción (asignados)
|
||
if ult != "NO TRABAJADO":
|
||
lead_cont[v] += 1 # contactado = asignado con ultima etiqueta trabajada
|
||
|
||
# SERIE DIARIA: Leads Asignados (por fecha de asignacion) y Matriculas (por fch_matricula),
|
||
# por vendedor y por dia del mes. clave: (vendedor, dia) -> conteo.
|
||
serie_proc = defaultdict(int) # (vendedor, dia) -> leads asignados
|
||
serie_mat = defaultdict(int) # (vendedor, dia) -> matriculas
|
||
for a in _leads_asignados_crudos():
|
||
f = L._to_date(a.get("created_at"))
|
||
if not L._en_periodo(f, ano, mes, dia):
|
||
continue
|
||
v = _norm_asesor(a.get("user_name"), amap)
|
||
if v and f:
|
||
serie_proc[(v, f.day)] += 1
|
||
for m in _matriculas_crudas():
|
||
vend = _norm_asesor(m.get("dsc_vendedor"), amap)
|
||
if not vend:
|
||
continue
|
||
estado = str(m.get("estado_matricula") or "").strip().upper()
|
||
if estado == "ANU":
|
||
continue
|
||
fm = L._to_date(m.get("fch_matricula"))
|
||
if fm and L._en_periodo(fm, ano, mes, dia):
|
||
serie_mat[(vend, fm.day)] += 1
|
||
|
||
def _serie(vendedores):
|
||
"""Lista 1..31 con asignados y matriculas del conjunto de vendedores."""
|
||
out = []
|
||
for d in range(1, 32):
|
||
p = sum(serie_proc.get((v, d), 0) for v in vendedores)
|
||
mm = sum(serie_mat.get((v, d), 0) for v in vendedores)
|
||
out.append({"dia": d, "asignados": p, "matriculas": mm})
|
||
return out
|
||
|
||
serie_por_vend = {v: _serie([v]) for v in lista}
|
||
serie_total = _serie(lista)
|
||
|
||
# Matriz Estado/Objeción (Ultima_Etiqueta) por vendedor y total.
|
||
# Ahora se basa en los LEADS ASIGNADOS (etiq_asig), no en los de campaña.
|
||
etiquetas_all = sorted({e for (_, e) in etiq_asig.keys()}, key=lambda x: x.lower())
|
||
|
||
def _matriz_etiq(vendedores):
|
||
out = []
|
||
for e in etiquetas_all:
|
||
c = sum(etiq_asig.get((v, e), 0) for v in vendedores)
|
||
if c > 0:
|
||
out.append({"estado": e, "cantidad": c})
|
||
out.sort(key=lambda x: -x["cantidad"]) # mayor a menor por cantidad
|
||
return out
|
||
|
||
matriz_etiq_por_vend = {v: _matriz_etiq([v]) for v in lista}
|
||
matriz_etiq_total = _matriz_etiq(lista)
|
||
|
||
# 4) MATRIZ por TIPO DE PROGRAMA (TEAC/TERC/SEMINARIOS/OTROS).
|
||
# Filtro por fch_matricula (periodo) y dsc_vendedor. Total y Retiradas (RET).
|
||
# Se guarda por (vendedor, grupo) para poder filtrar por vendedor en el front.
|
||
GRUPOS_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]
|
||
prog_tot = defaultdict(int); prog_ret = defaultdict(int) # clave: (vendedor, grupo)
|
||
for m in _matriculas_crudas():
|
||
vend = _norm_asesor(m.get("dsc_vendedor"), amap)
|
||
if not vend:
|
||
continue
|
||
estado = str(m.get("estado_matricula") or "").strip().upper()
|
||
if estado == "ANU":
|
||
continue
|
||
fm = L._to_date(m.get("fch_matricula"))
|
||
if not L._en_periodo(fm, ano, mes, dia):
|
||
continue # filtro por fch_matricula
|
||
grupo = L.grupo_programa_curso(m.get("dsc_programa"))
|
||
prog_tot[(vend, grupo)] += 1
|
||
if estado == "RET":
|
||
prog_ret[(vend, grupo)] += 1
|
||
|
||
def _matriz_prog(vendedores):
|
||
"""Suma total/retiradas por grupo para el conjunto de vendedores dado."""
|
||
out = []
|
||
for g in GRUPOS_PROG:
|
||
t = sum(prog_tot.get((v, g), 0) for v in vendedores)
|
||
r = sum(prog_ret.get((v, g), 0) for v in vendedores)
|
||
out.append({"programa": g, "total": t, "retiradas": r,
|
||
"pct_retiradas": round(r / t * 100, 1) if t else 0})
|
||
return out
|
||
|
||
# matriz por programa para CADA vendedor y para el TOTAL (todos)
|
||
matriz_prog_por_vend = {v: _matriz_prog([v]) for v in lista}
|
||
matriz_prog_total = _matriz_prog(lista)
|
||
|
||
filas = []
|
||
for v in lista:
|
||
proc = lead_proc.get(v, 0); cont = lead_cont.get(v, 0)
|
||
asig = lead_asig.get(v, 0)
|
||
filas.append({
|
||
"vendedor": v,
|
||
"cartera_total": cart_total.get(v, 0),
|
||
"cartera_copito": cart_copito.get(v, 0),
|
||
"cartera_otros": cart_otros.get(v, 0),
|
||
"mat_total": mat_total.get(v, 0),
|
||
"mat_curso": mat_curso.get(v, 0),
|
||
"mat_retiradas": mat_retir.get(v, 0),
|
||
"pct_retiradas": round(mat_retir.get(v, 0) / mat_total.get(v, 0) * 100, 1)
|
||
if mat_total.get(v, 0) else 0,
|
||
"leads_procesados": proc,
|
||
"leads_contactados": cont,
|
||
# % Contactados = contactados / ASIGNADOS (ambos de la misma fuente)
|
||
"pct_contactados": round(cont / asig * 100, 1) if asig else 0,
|
||
"leads_asignados": asig,
|
||
})
|
||
tot = {"cartera_total": sum(f["cartera_total"] for f in filas),
|
||
"cartera_copito": sum(f["cartera_copito"] for f in filas),
|
||
"cartera_otros": sum(f["cartera_otros"] for f in filas),
|
||
"mat_total": sum(f["mat_total"] for f in filas),
|
||
"mat_curso": sum(f["mat_curso"] for f in filas),
|
||
"mat_retiradas": sum(f["mat_retiradas"] for f in filas),
|
||
"leads_procesados": sum(f["leads_procesados"] for f in filas),
|
||
"leads_contactados": sum(f["leads_contactados"] for f in filas),
|
||
"leads_asignados": sum(f["leads_asignados"] for f in filas)}
|
||
tot["pct_retiradas"] = (round(tot["mat_retiradas"] / tot["mat_total"] * 100, 1)
|
||
if tot["mat_total"] else 0)
|
||
tot["pct_contactados"] = (round(tot["leads_contactados"] / tot["leads_asignados"] * 100, 1)
|
||
if tot["leads_asignados"] else 0)
|
||
# Tiempo de respuesta laboral (min) dia a dia por asesor (por fecha asignacion)
|
||
tr = tiempo_respuesta_dashboard(ano, mes, dia)
|
||
return {"filas": filas, "total": tot, "vendedores": lista,
|
||
"matriz_prog": matriz_prog_por_vend, "matriz_prog_total": matriz_prog_total,
|
||
"matriz_etiq": matriz_etiq_por_vend, "matriz_etiq_total": matriz_etiq_total,
|
||
"serie_dia": serie_por_vend, "serie_dia_total": serie_total,
|
||
"tiempo_resp": tr.get("serie_por_vend", {}),
|
||
"tiempo_resp_total": tr.get("serie_total", []),
|
||
"tiempo_resp_r": tr.get("serie_resp_por_vend", {}),
|
||
"tiempo_resp_r_total": tr.get("serie_resp_total", [])}
|
||
return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load)
|
||
|
||
|
||
def _conjunto_sede_prog():
|
||
"""{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase)."""
|
||
return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",),
|
||
lambda: get_dm().traer_conjunto_sede_programa())
|
||
|
||
|
||
def conjuntos_sin_sede():
|
||
"""Lista los conjuntos del Meta CSV que NO estan ligados a ninguna sede
|
||
(ni por pauta->campania, ni por asignacion manual). Para el apartado de
|
||
asignacion. Sin filtro de mes. Incluye la sede/programa YA asignados (si tiene).
|
||
Devuelve [{conjunto, importe, resultados, sede, programa}]."""
|
||
def _load():
|
||
SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"}
|
||
conj_pauta = _conjunto_pauta()
|
||
camp = _campanias_sede_cargo()
|
||
manual = _conjunto_sede_prog()
|
||
# conjuntos que SÍ tienen sede via pauta -> campania
|
||
con_sede = set()
|
||
for pauta, conjuntos in conj_pauta.items():
|
||
sp = camp.get(str(pauta).strip())
|
||
sede = " ".join(str((sp or {}).get("sede") or "").upper().split())
|
||
if sp and sede in SEDES:
|
||
for co in conjuntos:
|
||
con_sede.add(" ".join(str(co).split()).upper())
|
||
agg = {}
|
||
for f in _meta_filas():
|
||
co = " ".join(str(f.get("conjunto")).split())
|
||
if not co:
|
||
continue
|
||
k = co.upper()
|
||
if k in con_sede:
|
||
continue # ya tiene sede por campania -> no aparece aqui
|
||
d = agg.setdefault(k, {"conjunto": co, "importe": 0.0, "resultados": 0,
|
||
"sede": manual.get(k, {}).get("sede", ""),
|
||
"programa": manual.get(k, {}).get("programa", "")})
|
||
d["importe"] += float(f.get("importe") or 0.0)
|
||
d["resultados"] += int(float(f.get("resultados") or 0.0))
|
||
out = sorted(agg.values(), key=lambda x: -x["importe"])
|
||
for d in out:
|
||
d["importe"] = round(d["importe"], 2)
|
||
return out
|
||
return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load)
|
||
|
||
|
||
def guardar_conjunto_sede(cambios):
|
||
"""Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes."""
|
||
r = get_dm().guardar_conjunto_sede_programa(cambios)
|
||
for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"):
|
||
cache_invalidate(prefix=pref)
|
||
return r
|
||
|
||
|
||
def borrar_conjunto_sede(conjunto):
|
||
"""Borra la asignacion de un conjunto e invalida cachés dependientes."""
|
||
r = get_dm().borrar_conjunto_sede_programa(conjunto)
|
||
for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"):
|
||
cache_invalidate(prefix=pref)
|
||
return r
|
||
|
||
|
||
def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||
"""Matriz ROAS por SEDE (LIMA, PIURA, AREQUIPA, TRUJILLO): Importe Gastado y
|
||
Resultados del Google Sheet (Meta Ads), filtrado por Año/Mes (Inicio del informe).
|
||
Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin
|
||
pauta/sede se omiten por ahora."""
|
||
def _load():
|
||
SEDES = ["LIMA", "PIURA", "AREQUIPA", "TRUJILLO", "OTROS"] # OTROS = 5ta fila (manual)
|
||
CAMP_SEDES = {"LIMA", "PIURA", "AREQUIPA", "TRUJILLO"} # sedes validas por campania
|
||
conj_pauta = _conjunto_pauta() # {pauta: [conjunto,...]}
|
||
camp = _campanias_sede_cargo() # {pauta: {sede, cargo}}
|
||
|
||
PROGS_OK = {"TEAC", "TERC", "SEMINARIOS", "OTROS"}
|
||
|
||
# conjunto (normalizado) -> (sede, programa) via su pauta -> campania
|
||
conj_sede = {} # conjunto -> sede
|
||
conj_prog = {} # conjunto -> programa (grupo)
|
||
for pauta, conjuntos in conj_pauta.items():
|
||
sp = camp.get(str(pauta).strip())
|
||
if not sp:
|
||
continue
|
||
sede = " ".join(str(sp.get("sede") or "").upper().split())
|
||
if sede not in CAMP_SEDES:
|
||
continue
|
||
prog = L.grupo_programa_lead(sp.get("cargo")) # TEAC/TERC/SEMINARIOS
|
||
for co in conjuntos:
|
||
k = " ".join(str(co).split()).upper()
|
||
conj_sede[k] = sede
|
||
conj_prog[k] = prog
|
||
|
||
# + asignaciones MANUALES (sede + programa). Prioridad sobre la campania.
|
||
for k, v in _conjunto_sede_prog().items():
|
||
sede = v.get("sede")
|
||
if sede in SEDES:
|
||
conj_sede[k] = sede
|
||
prog = str(v.get("programa") or "").strip().upper()
|
||
if prog: # acepta CUALQUIER programa asignado manualmente (no solo los fijos)
|
||
conj_prog[k] = prog
|
||
|
||
# sumar importe y resultados por sede y por (sede, programa), filtrando por Año/Mes
|
||
imp = {s: 0.0 for s in SEDES}
|
||
res = {s: 0 for s in SEDES}
|
||
imp_pg = {} # (sede, prog) -> importe
|
||
res_pg = {} # (sede, prog) -> resultados
|
||
# conjuntos por (sede, prog): nombre_conjunto -> {importe, resultados}
|
||
conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados} }
|
||
sin_filtro = (ano in ("TODOS", None) and mes in ("TODOS", None) and dia in ("TODOS", None))
|
||
for f in _meta_filas():
|
||
if not sin_filtro and not L._en_periodo(L._to_date(f.get("inicio")), ano, mes, dia):
|
||
continue
|
||
co = " ".join(str(f.get("conjunto")).split())
|
||
k = co.upper()
|
||
sede = conj_sede.get(k)
|
||
if not sede:
|
||
continue # conjunto sin pauta/sede -> se omite
|
||
i = float(f.get("importe") or 0.0)
|
||
rr = int(float(f.get("resultados") or 0.0))
|
||
imp[sede] += i
|
||
res[sede] += rr
|
||
prog = conj_prog.get(k, "SIN PROGRAMA")
|
||
imp_pg[(sede, prog)] = imp_pg.get((sede, prog), 0.0) + i
|
||
res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr
|
||
dd = conj_det.setdefault((sede, prog), {})
|
||
d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0})
|
||
d["importe"] += i
|
||
d["resultados"] += rr
|
||
|
||
ORDEN_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS", "SIN PROGRAMA"]
|
||
|
||
def _conjuntos(sede, prog):
|
||
"""Lista de conjuntos de anuncio de un (sede, programa), ordenada por importe."""
|
||
det = list(conj_det.get((sede, prog), {}).values())
|
||
det.sort(key=lambda x: -x["importe"])
|
||
for d in det:
|
||
d["importe"] = round(d["importe"], 2)
|
||
return det
|
||
|
||
def _sub(sede):
|
||
"""Desglose por programa de una sede (solo los que tienen algo).
|
||
Primero los fijos en su orden, luego CUALQUIER otro programa asignado
|
||
(ej. BRANDING), y al final SIN PROGRAMA."""
|
||
progs_de_sede = [p for (s, p) in imp_pg.keys() if s == sede]
|
||
extras = sorted(set(progs_de_sede) - set(ORDEN_PROG))
|
||
orden = [p for p in ORDEN_PROG if p != "SIN PROGRAMA"] + extras + ["SIN PROGRAMA"]
|
||
out = []
|
||
for p in orden:
|
||
i = imp_pg.get((sede, p), 0.0); r = res_pg.get((sede, p), 0)
|
||
if i or r:
|
||
out.append({"programa": p, "importe": round(i, 2), "resultados": r,
|
||
"conjuntos": _conjuntos(sede, p)})
|
||
return out
|
||
|
||
# Las 4 sedes reales siempre se muestran. OTROS solo si tiene algo (>0).
|
||
filas = []
|
||
for s in SEDES:
|
||
if s == "OTROS" and imp[s] == 0 and res[s] == 0:
|
||
continue
|
||
filas.append({"sede": s, "importe": round(imp[s], 2), "resultados": res[s],
|
||
"programas": _sub(s)})
|
||
tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values())}
|
||
return {"filas": filas, "total": tot}
|
||
return cache_get_or_set("roas_dash", (ano, mes, dia), _load)
|
||
|
||
|
||
def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
|
||
"""Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta).
|
||
Mas liviano que leads_dashboard -> filtrar por mes es mas rapido."""
|
||
def _load():
|
||
return {
|
||
"matriz_always": matriz_always(ano, mes, dia, "TODOS", "TODOS"),
|
||
"matriz_webform": matriz_web_formulario(ano, mes, dia, "TODOS", "TODOS"),
|
||
"matriz_asignados": matriz_asignados(ano, mes, dia),
|
||
"matriz_plantillas": matriz_plantillas(ano, mes, dia),
|
||
}
|
||
return cache_get_or_set("otros_general", (ano, mes, dia), _load)
|
||
|
||
|
||
def conjuntos_sin_pauta():
|
||
"""Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up)."""
|
||
return get_dm().traer_conjuntos_sin_pauta()
|
||
|
||
|
||
def programas_ocultos():
|
||
"""Programas con contar=NO en basebi_programacion (ocultos de la matriz).
|
||
Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}."""
|
||
pm = _pauta_cruda()
|
||
ocultos_ni = {ni for ni, info in pm.items() if str(info.get("contar") or "").upper() == "NO"}
|
||
prog_de_ni = {}
|
||
fch_de_ni = {}
|
||
for c in _cursos_crudos():
|
||
ni = _norm_ni(c.get("num_indice"))
|
||
prog_de_ni[ni] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
|
||
f = L._to_date(c.get("fch_inicio"))
|
||
fch_de_ni[ni] = f.strftime("%d/%m/%Y") if f else ""
|
||
filas = []
|
||
for ni in ocultos_ni:
|
||
filas.append({"num_indice": ni,
|
||
"dsc_det_programa": prog_de_ni.get(ni, "(sin nombre)"),
|
||
"fch_inicio": fch_de_ni.get(ni, ""),
|
||
"pauta": (pm.get(ni) or {}).get("pauta") or ""})
|
||
filas.sort(key=lambda x: x["dsc_det_programa"])
|
||
return {"filas": filas}
|
||
|
||
|
||
def encender_programas(num_indices):
|
||
"""Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva)."""
|
||
from cache_manager import cache_invalidate
|
||
dm = get_dm()
|
||
for ni in num_indices:
|
||
dm.guardar_pauta_basebi(ni, "", "SI") # pauta vacia -> no toca pauta, solo contar
|
||
for pref in ("pauta_raw", "leads_dash", "otros_general", "leads_filtros"):
|
||
cache_invalidate(pref)
|
||
return {"encendidos": len(num_indices)}
|
||
|
||
|
||
def programas_disponibles():
|
||
"""Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el
|
||
desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}."""
|
||
from datetime import date
|
||
corte = date(2026, 1, 1)
|
||
vistos = {}
|
||
for c in _cursos_crudos():
|
||
f = L._to_date(c.get("fch_inicio"))
|
||
if not f or f < corte:
|
||
continue
|
||
ni = _norm_ni(c.get("num_indice"))
|
||
nombre = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
|
||
if not nombre:
|
||
continue
|
||
finicio = f.strftime("%d/%m/%Y")
|
||
label = f"{nombre} · {finicio}"
|
||
vistos[ni] = {"num_indice": ni, "dsc_det_programa": nombre,
|
||
"fch_inicio": finicio, "label": label}
|
||
filas = sorted(vistos.values(), key=lambda x: x["label"])
|
||
return {"programas": filas}
|
||
|
||
|
||
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
|
||
def campanias_listar():
|
||
return get_dm().listar_campanias()
|
||
|
||
|
||
def campania_agregar(datos):
|
||
res = get_dm().agregar_campania(datos)
|
||
_invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard
|
||
return res
|
||
|
||
|
||
def campania_editar(cid, datos):
|
||
res = get_dm().editar_campania(cid, datos)
|
||
_invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard
|
||
return res
|
||
|
||
|
||
def campania_borrar(cid):
|
||
res = get_dm().borrar_campania(cid)
|
||
_invalidar_por_campanias()
|
||
return res
|
||
|
||
|
||
def leyenda_anuncios():
|
||
"""Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa
|
||
(dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya
|
||
en memoria) para que abrir la Leyenda sea rapido."""
|
||
def _load():
|
||
conj_pauta = _conjunto_pauta() # cacheado
|
||
pauta_de_conj = {}
|
||
for pa, cjs in conj_pauta.items():
|
||
for c in cjs:
|
||
pauta_de_conj[" ".join(str(c).split())] = str(pa).strip()
|
||
# pauta -> [num_indices] usando TODAS las vinculaciones (basebi + programa_pautas)
|
||
ni_de_pauta = {}
|
||
for ni, pautas in _pautas_de_indice().items():
|
||
for p in pautas:
|
||
ni_de_pauta.setdefault(str(p).strip(), []).append(str(ni).strip())
|
||
prog_de_ni = {}
|
||
for c in _cursos_crudos(): # cacheado
|
||
prog_de_ni[_norm_ni(c.get("num_indice"))] = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()
|
||
def _programa(pauta):
|
||
if not pauta:
|
||
return ""
|
||
nis = ni_de_pauta.get(str(pauta).strip(), [])
|
||
nombres = [prog_de_ni.get(n, "") for n in nis if prog_de_ni.get(n)]
|
||
return " | ".join(sorted(set(nombres)))
|
||
filas = []
|
||
vistos = set()
|
||
for f in _meta_filas(): # cacheado (CSV de Meta)
|
||
co = " ".join(str(f.get("conjunto")).split())
|
||
if not co or co in vistos:
|
||
continue
|
||
vistos.add(co)
|
||
pa = pauta_de_conj.get(co, "")
|
||
filas.append({"conjunto": co, "pauta": pa, "programa": _programa(pa)})
|
||
filas.sort(key=lambda x: x["conjunto"])
|
||
return {"filas": filas}
|
||
return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load)
|
||
|
||
|
||
def guardar_leyenda(cambios):
|
||
"""cambios = [{conjunto, pauta, num_indice?}].
|
||
- conjunto -> pauta: upsert en conjunto_pauta.
|
||
- si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion.
|
||
Invalida solo cachés dependientes de pauta/conjunto (no la cartera)."""
|
||
from cache_manager import cache_invalidate
|
||
dm = get_dm()
|
||
for c in cambios:
|
||
conjunto = c.get("conjunto")
|
||
pauta = (c.get("pauta") or "").strip()
|
||
ni = c.get("num_indice")
|
||
if conjunto:
|
||
dm.upsert_conjunto_pauta(conjunto, pauta)
|
||
# si eligió un programa (num_indice), AGREGAR el vínculo num_indice<->pauta
|
||
# en programa_pautas (muchos-a-muchos, se SUMA, no reemplaza).
|
||
if ni and pauta:
|
||
dm.agregar_programa_pauta(str(ni).strip(), pauta)
|
||
for pref in ("conjunto_pauta", "pauta_raw", "programa_pautas", "pautas_de_indice",
|
||
"meta_filas", "importe_pauta", "importe_pauta_periodo",
|
||
"resultados_pauta_periodo", "leads_dash", "otros_general",
|
||
"matriz_always", "matriz_plantillas", "leyenda_anuncios"):
|
||
cache_invalidate(pref)
|
||
return {"guardados": len(cambios)}
|
||
|
||
|
||
def uso_de_pauta(pauta, excluir_num_indice=None):
|
||
"""Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa)
|
||
y conjuntos que la usan (para la advertencia antes de guardar)."""
|
||
dm = get_dm()
|
||
nis = dm.num_indices_de_pauta(pauta)
|
||
excl = str(excluir_num_indice or "").strip()
|
||
nis = [n for n in nis if n != excl]
|
||
# dsc_det_programa de esos num_indice (desde cursos de SQL, ya cacheados)
|
||
cursos = _cursos_crudos()
|
||
detalle = []
|
||
for c in cursos:
|
||
ni = _norm_ni(c.get("num_indice"))
|
||
if ni in nis:
|
||
detalle.append({"num_indice": ni,
|
||
"dsc_det_programa": str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip()})
|
||
conjuntos = _conjunto_pauta().get(str(pauta).strip(), [])
|
||
return {"en_uso": bool(nis) or bool(conjuntos),
|
||
"cursos": detalle, "num_indices": nis, "conjuntos": conjuntos}
|
||
|
||
|
||
def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None):
|
||
"""Guarda pauta y contar (SI/NO) en basebi_programacion (upsert) y, si se eligió,
|
||
vincula el conjunto a esa pauta en conjunto_pauta. Invalida SOLO los cachés que
|
||
dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida)."""
|
||
from cache_manager import cache_invalidate
|
||
dm = get_dm()
|
||
res = dm.guardar_pauta_basebi(num_indice, pauta, contar)
|
||
if conjunto:
|
||
dm.guardar_conjunto_pauta(conjunto, pauta)
|
||
# invalidar solo lo afectado por pauta/conjunto (la cartera NO se re-baja)
|
||
for pref in ("pauta_raw", "pautas_de_indice", "conjunto_pauta", "meta_filas",
|
||
"importe_pauta", "importe_pauta_periodo", "resultados_pauta_periodo",
|
||
"leads_dash", "otros_general", "matriz_always", "matriz_plantillas",
|
||
"leads_filtros", "roas_dash"):
|
||
cache_invalidate(pref)
|
||
return res
|
||
|
||
|
||
def _tipo_de_curso(c):
|
||
"""Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS)."""
|
||
dscp = c.get("dsc_programa", "")
|
||
otros = L.clasificar_tipo_programa(dscp)
|
||
if otros == "OTROS":
|
||
return "OTROS"
|
||
up = str(dscp).upper()
|
||
cat = "TEAC" if ("AIRE ACONDICIONADO" in up or "TEAC" in up) else (
|
||
"TERC" if ("REFRIGERACION COMERCIAL" in up or "REFRIGERACIÓN COMERCIAL" in up or "TERC" in up) else "SEM")
|
||
sede = L.clasificar_sede(dscp)
|
||
return L.tipo_programa_cohorte(sede, cat)
|
||
|
||
|
||
# ── Listas para los filtros (años, tipos de programa) ───────────
|
||
def opciones_filtros():
|
||
def _load():
|
||
cursos = _cursos_crudos()
|
||
anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos
|
||
if L._to_date(c.get("fch_inicio"))}, reverse=True)
|
||
# Grupos de programa fijos para el filtro PROGRAMA
|
||
tipos = ["TEAC", "TERC", "SEMINARIOS", "OTROS"]
|
||
return {"anos": anos or [2026], "tipos": tipos}
|
||
return cache_get_or_set("leads_filtros", ("GLOBAL",), _load)
|
||
|
||
|
||
# ── Alertas: valores de programa/sede no identificados en cartera_junta ──
|
||
def alertas():
|
||
return cache_get_or_set("leads_alertas", ("GLOBAL",),
|
||
lambda: get_dm().traer_no_identificados())
|
||
|
||
|
||
# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ───────────
|
||
def debug_cursos(ano, mes):
|
||
cursos = _cursos_crudos()
|
||
periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")]
|
||
detalle = []
|
||
tot_insc = 0
|
||
tot_meta = 0
|
||
for c in periodo:
|
||
insc = int(c.get("Inscritos_Totales", 0) or 0)
|
||
meta = L.meta_curso(c.get("dsc_programa"))
|
||
tp = L.tipo_programa_curso(c.get("dsc_programa"))
|
||
tot_insc += insc
|
||
tot_meta += meta
|
||
detalle.append({
|
||
"num_indice": c.get("num_indice"),
|
||
"dsc_programa": c.get("dsc_programa"),
|
||
"fch_inicio": str(c.get("fch_inicio"))[:10],
|
||
"cod_estado": c.get("cod_estado"),
|
||
"inscritos": insc,
|
||
"tipo": tp,
|
||
"meta": meta,
|
||
})
|
||
return {
|
||
"num_cursos": len(periodo),
|
||
"suma_inscritos": tot_insc,
|
||
"suma_meta": tot_meta,
|
||
"ocupabilidad": round((tot_insc / tot_meta * 100), 2) if tot_meta else 0,
|
||
"cursos": detalle,
|
||
}
|
||
|
||
|
||
# ── Precarga / refresco ─────────────────────────────────────────
|
||
def _periodos_actual_anterior():
|
||
"""[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy)."""
|
||
from datetime import date
|
||
hoy = date.today()
|
||
ano, mes = hoy.year, hoy.month
|
||
if mes == 1:
|
||
prev = (ano - 1, 12)
|
||
else:
|
||
prev = (ano, mes - 1)
|
||
return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))]
|
||
|
||
|
||
import os as _os
|
||
_LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log")
|
||
|
||
|
||
def _log_carga(linea, reset=False):
|
||
"""Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos)."""
|
||
try:
|
||
with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f:
|
||
f.write(linea + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
import threading as _thr
|
||
_TIEMPOS = {} # etiqueta -> segundos (para el resumen final)
|
||
_TIEMPOS_LOCK = _thr.Lock()
|
||
_HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen
|
||
|
||
|
||
def _cron(etiqueta, base, fn, apartado=None):
|
||
"""Ejecuta fn() midiendo el tiempo. Imprime y ademas guarda en el log:
|
||
paso, base de datos y segundos. Se ve en la ventana del BACKEND y en la
|
||
ventana de TIEMPOS al iniciar, para saber que carga cada cosa.
|
||
Si 'apartado' se indica, acumula el tiempo para el resumen final."""
|
||
import time as _t
|
||
t0 = _t.perf_counter()
|
||
r = fn()
|
||
dt = _t.perf_counter() - t0
|
||
n = ""
|
||
try:
|
||
if isinstance(r, (list, dict)):
|
||
n = f"({len(r)} filas)"
|
||
except Exception:
|
||
pass
|
||
linea = f" [carga] {etiqueta:32} {base:14} {dt:6.2f}s {n}"
|
||
print(linea, flush=True)
|
||
_log_carga(linea)
|
||
with _TIEMPOS_LOCK:
|
||
_TIEMPOS[apartado or etiqueta] = _TIEMPOS.get(apartado or etiqueta, 0.0) + dt
|
||
return r
|
||
|
||
|
||
def _hilo_termino(nombre):
|
||
"""Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan,
|
||
imprime el resumen final por apartado en el log."""
|
||
with _TIEMPOS_LOCK:
|
||
_HILOS_LISTOS.add(nombre)
|
||
listos = {"leads", "otros"}.issubset(_HILOS_LISTOS)
|
||
tiempos = dict(_TIEMPOS)
|
||
if not listos:
|
||
return
|
||
_log_carga("")
|
||
_log_carga("=" * 60)
|
||
_log_carga(" RESUMEN — tiempo listo para abrir cada apartado")
|
||
_log_carga("=" * 60)
|
||
orden = ["Leads", "Vendedores", "Otros General", "ROAS"]
|
||
for ap in orden:
|
||
seg = tiempos.get(ap)
|
||
if seg is not None:
|
||
estado = "instantaneo al abrir" if seg < 1 else f"listo tras {seg:.1f}s"
|
||
_log_carga(f" {ap:16} {seg:6.2f}s -> {estado}")
|
||
_log_carga("-" * 60)
|
||
_log_carga(" Ya precargados: al hacer clic se muestran AL INSTANTE (cache).")
|
||
_log_carga("=" * 60)
|
||
|
||
|
||
def precargar_todo():
|
||
"""Precarga en segundo plano: insumos crudos + el dashboard del mes actual y
|
||
anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar."""
|
||
try:
|
||
import time as _t
|
||
_log_carga("=" * 60, reset=True)
|
||
_log_carga(" CARGA DEL DASHBOARD LEADS — " +
|
||
_t.strftime("%d/%m/%Y %H:%M:%S"))
|
||
_log_carga("=" * 60)
|
||
print(">>> Precargando LEADS (hilo 1)...", flush=True)
|
||
_log_carga(">>> Hilo 1: LEADS (PostgreSQL + SQL Server)")
|
||
_cron("Leads (mensajes)", "PostgreSQL", _leads_crudos)
|
||
_cron("Cursos", "SQL Server", _cursos_crudos)
|
||
_cron("Matriculas", "SQL Server", _matriculas_crudas)
|
||
_cron("Leads asignados/respuesta", "PostgreSQL", _leads_asignados_crudos)
|
||
# Dashboard (Leads) del mes actual y anterior (lo mas usado). Otros General
|
||
# NO se precarga aqui (sus consultas son pesadas); se calcula al entrar.
|
||
p = _periodos_actual_anterior()
|
||
a0, m0 = p[0]
|
||
for (a, m) in p:
|
||
_cron(f"Apartado Leads {a}-{m}", "cache",
|
||
lambda a=a, m=m: leads_dashboard(a, m, "TODOS", "TODOS", "TODOS"), apartado="Leads")
|
||
# Vendedores y ROAS del periodo actual: se precargan para abrir al instante.
|
||
_cron("Apartado Vendedores", "cache",
|
||
lambda: vendedores_dashboard(a0, m0, "TODOS"), apartado="Vendedores")
|
||
_cron("Apartado ROAS", "cache",
|
||
lambda: roas_dashboard(a0, m0, "TODOS"), apartado="ROAS")
|
||
_marcar_actualizacion()
|
||
print(">>> Hilo 1 (Leads) LISTO.", flush=True)
|
||
_log_carga(">>> Hilo 1 (Leads) LISTO.")
|
||
_hilo_termino("leads")
|
||
except Exception as e:
|
||
print(f"[precarga] {e}", flush=True)
|
||
_log_carga(f"[precarga] ERROR: {e}")
|
||
_hilo_termino("leads")
|
||
|
||
|
||
def precargar_otros_general():
|
||
"""Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual
|
||
y anterior. Se llama en su propio hilo para no bloquear."""
|
||
try:
|
||
print(">>> Precargando CARTERA + OTROS GENERAL (hilo 2)...", flush=True)
|
||
_log_carga(">>> Hilo 2: CARTERA (Supabase) + OTROS GENERAL")
|
||
_cron("Plantillas", "Google Sheet", _plantillas_crudas)
|
||
_cron("Cartera", "Supabase", _cartera_rows_cache) # la mas pesada
|
||
for (a, m) in _periodos_actual_anterior():
|
||
_cron(f"Otros General {a}-{m}", "cache",
|
||
lambda a=a, m=m: otros_general(a, m, "TODOS"), apartado="Otros General")
|
||
print(">>> Hilo 2 (Cartera/Otros) LISTO.", flush=True)
|
||
_log_carga(">>> Hilo 2 (Cartera/Otros) LISTO.")
|
||
_hilo_termino("otros")
|
||
except Exception as e:
|
||
print(f"[precarga-otros] {e}", flush=True)
|
||
_log_carga(f"[precarga-otros] ERROR: {e}")
|
||
_hilo_termino("otros")
|
||
|
||
|
||
# Hora de la última actualización (hora Perú)
|
||
_ULTIMA_ACTUALIZACION = None
|
||
|
||
|
||
def _marcar_actualizacion():
|
||
global _ULTIMA_ACTUALIZACION
|
||
from datetime import datetime, timezone, timedelta
|
||
peru = timezone(timedelta(hours=-5))
|
||
_ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M")
|
||
|
||
|
||
def ultima_actualizacion():
|
||
return {"hora": _ULTIMA_ACTUALIZACION}
|
||
|
||
|
||
def refrescar_todo():
|
||
from cache_manager import cache_invalidate
|
||
from data_manager_v2 import invalidar_campanias_cache
|
||
cache_invalidate() # vaciar y recargar TODO
|
||
invalidar_campanias_cache() # tambien las campañas (cache aparte)
|
||
precargar_todo() # leads (mes actual/anterior)
|
||
precargar_otros_general() # cartera + Otros General (para que no quede vacia)
|
||
_marcar_actualizacion()
|