From 48dbd6faa4351f03c3a1dd72d42410117fdc6d90 Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 4 Sep 2026 16:16:01 -0500 Subject: [PATCH] Actualizar backend/services.py --- backend/services.py | 1032 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 993 insertions(+), 39 deletions(-) diff --git a/backend/services.py b/backend/services.py index c12d124..d9d72d2 100644 --- a/backend/services.py +++ b/backend/services.py @@ -23,6 +23,7 @@ _PREF_CAMPANIAS = ( "leads_alertas", "vendedores_dash", "roas_dash", "otros_general", "matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas", "tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo", + "resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash", ) @@ -86,6 +87,53 @@ def _campanias_map(): lambda: get_dm().traer_campanias_map()) +def _clasif_meta(): + """JSON de clasificacion (sede/programa) de GitHub, MISMO que Ventas.""" + return cache_get_or_set("clasif_meta", ("GLOBAL",), + lambda: get_dm().traer_clasificacion_meta()) + + +def clasificar_sede_meta(dsc_programa): + """Sede segun el JSON (clasificacion_sedes). Default LIMA. Igual que Ventas.""" + up = str(dsc_programa or "").upper() + data = _clasif_meta().get("clasificacion_sedes", {}) + for sede, cfg in data.items(): + if sede == "DEFAULT": + continue + for pat in cfg.get("patrones", []): + if str(pat).upper() in up: + return sede + return data.get("DEFAULT", "LIMA") + + +def clasificar_prog_meta(dsc_programa): + """Programa (TEAC/TERC/SEMINARIOS/OTROS) segun clasificacion_filtro_programa + del JSON. Respeta el ORDEN del JSON (primero que coincide gana). Igual que Ventas.""" + up = str(dsc_programa or "").upper() + data = _clasif_meta().get("clasificacion_filtro_programa", {}) + for cat, cfg in data.items(): + if cat == "DEFAULT": + continue + for pat in cfg.get("patrones", []): + if str(pat).upper() in up: + return cat + return data.get("DEFAULT", "OTROS") + + +def meta_de_curso(dsc_programa): + """Meta de inscritos segun sede+programa (JSON), como acordado: + LIMA TEAC/TERC=22, provincia TEAC/TERC=18, SEMINARIOS/OTROS=15.""" + sede = clasificar_sede_meta(dsc_programa) + prog = clasificar_prog_meta(dsc_programa) + if prog in ("TEAC", "TERC"): + return 22 if sede == "LIMA" else 18 + return 15 + + +# Conecta los clasificadores del JSON a leads_logic (metas + tipo de matricula). +L.set_clasificadores(clasificar_sede_meta, clasificar_prog_meta) + + def _conjunto_pauta(): return cache_get_or_set("conjunto_pauta", ("GLOBAL",), lambda: get_dm().traer_conjunto_pauta()) @@ -114,6 +162,76 @@ def _importe_por_pauta(): return cache_get_or_set("importe_pauta", ("GLOBAL",), _load) +# Canales que cuentan como Copito (ademas de origen_base=POSTGRE). +_CANALES_COPITO = {"PAUTA_WSP", "PAUTA_WSP_FACE", "COPITO"} + + +def _tel_es_copito(): + """{telefono: True/False} = si el telefono es COPITO segun su FILA MAS ANTIGUA + en la cartera. Copito = (origen_base=POSTGRE) OR (canal en _CANALES_COPITO). + Se calcula una vez sobre toda la cartera (cacheado).""" + def _load(): + mejor = {} # tel -> (fecha_mas_antigua, es_copito) + for r in _cartera_rows_cache(): + tel = str(r.get("telefono") or "").strip() + if not tel: + continue + f = L._to_date(r.get("fecha_creada")) + if f is None: + continue + if tel not in mejor or f < mejor[tel][0]: + canal = " ".join(str(r.get("canal") or "").upper().split()) + ob = " ".join(str(r.get("origen_base") or "").upper().split()) + es_cop = (ob == "POSTGRE") or (canal in _CANALES_COPITO) + mejor[tel] = (f, es_cop) + return {t: v[1] for t, v in mejor.items()} + return cache_get_or_set("tel_es_copito", ("GLOBAL",), _load) + + +def _leads_por_codigo_periodo(ano, mes, dia): + """{codigo(pauta): {"rec": set(tels recibidos del periodo), + "proc": set(tels procesados del periodo)}}. + Recibido = lead unico con ese codigo cuya fecha cae en el periodo. + Procesado = de esos, con asesor. Mismo criterio que 'Detalle por Curso'.""" + def _load(): + from collections import defaultdict + out = defaultdict(lambda: {"rec": set(), "proc": set(), "cont": set()}) + for l in _leads_crudos(): + if not l.get("es_unico"): + continue + if not L._en_periodo(l.get("fecha"), ano, mes, dia): + continue + cod = str(l.get("codigo", "-")).strip() + tel = l.get("telefono") + if not tel: + continue + out[cod]["rec"].add(tel) + if l.get("asesor"): + out[cod]["proc"].add(tel) + ult = l.get("ultima") or "NO TRABAJADO" + if ult != "NO TRABAJADO": + out[cod].setdefault("cont", set()).add(tel) + return dict(out) + return cache_get_or_set("leads_x_codigo", (ano, mes, dia), _load) + + +def _resultados_por_pauta(): + """{pauta: resultados_totales} = suma de Resultados de TODAS las filas de los + conjuntos ligados a esa pauta. Sin filtro de fecha (analogo a _importe_por_pauta).""" + def _load(): + conj_pauta = _conjunto_pauta() + res_norm = {} + for f in _meta_filas(): + 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", ("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.""" @@ -233,6 +351,9 @@ def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa= 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) + # Conjuntos BRANDING/COPEAIRE: su importe SÍ suma, pero sus resultados NO. + conj_brand = _conjuntos_branding() + # 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, @@ -248,7 +369,9 @@ def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa= 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) + # Resultados: NO contar los conjuntos BRANDING/COPEAIRE. + tr = sum(res.get(" ".join(str(c).split()).upper(), 0.0) for c in conjuntos + if " ".join(str(c).split()).upper() not in conj_brand) if ti == 0 and tr == 0: continue # sin gasto en el mes -> no aparece # filtros de arriba @@ -696,7 +819,8 @@ def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sed 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()) + _campanias_sede_cargo(), + codigos_branding=_codigos_branding()) ci = _cartera_idx() cartera_canal = { "PAUTA": {"all": ci.get("all_pauta"), "asig": ci.get("asig_pauta"), "tels": ci.get("tels_pauta")}, @@ -708,7 +832,8 @@ def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sed _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()) + cartera_canal, _conjunto_pauta(), _pautas_de_indice(), + _resultados_por_pauta()) return {"kpis": kpis, "estado_objecion": tabla, "matriculas_por_dia": por_dia, "leads_por_dia": leads_dia, "tabla_pauta": pauta, "matriz_cursos": matriz} @@ -779,30 +904,39 @@ def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): 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 + else: # lun-vie: 3 franjas + if 9 <= h < 13: act[key]["manana"] += 1 + elif 14 <= h < 18: act[key]["tarde"] += 1 # tarde = 14-18 (T1) + elif 18 <= h < 22: act[key]["noche"] += 1 # noche = 18-22 (T2) 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.""" + o None si no trabajo. Reglas lun-vie: + Turno 1 = actividad en manana (9-13) Y en tarde (14-18). + Turno 2 = actividad en manana (9-13) Y en noche (18-22). + Si cumple ambos, gana el que tenga mas actividad (tarde vs noche). + Si no cumple ninguno completo -> None (no trabajo).""" 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) + if d.weekday() == 5: # sabado (4h) -> regla SIN cambios 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: + # lun-vie: se exige la manana + (tarde o noche) + man = fr.get("manana", 0); t = fr.get("tarde", 0); n = fr.get("noche", 0) + cumple_t1 = man > 0 and t > 0 + cumple_t2 = man > 0 and n > 0 + if cumple_t1 and cumple_t2: + return [(9, 13), (18, 22)] if n > t else [(9, 13), (14, 18)] + if cumple_t2: return [(9, 13), (18, 22)] # Turno 2 - return [(9, 13), (14, 18)] # Turno 1 (refrigerio 13-14 fuera) + if cumple_t1: + return [(9, 13), (14, 18)] # Turno 1 + return None # no cumple ningun turno completo def _dt(d, hh): return datetime(d.year, d.month, d.day) + timedelta(hours=hh) @@ -814,11 +948,9 @@ def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): 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) + bloques = _turno_dia(v, d) + # Turno 1 = bloques [(9,13),(14,18)] (contiene la tarde 14-18) + es_t1 = bloques == [(9, 13), (14, 18)] h = ts.hour + ts.minute / 60.0 return es_t1 and (13 <= h < 15) @@ -949,6 +1081,147 @@ def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load) +def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS", + ano="TODOS", mes="TODOS"): + """Detalle de los leads que forman el promedio de UN día del gráfico + 'Tiempo de Respuesta por Día'. Reproduce la MISMA lógica de turnos/minutos. + modo: 'asignacion' (agrupa por día de created_at) o 'respuesta' (por respuesta_fecha). + Devuelve, por asesor, la lista de leads con T1, T2 y minutos, y el promedio.""" + from datetime import datetime, timedelta + def _load(): + from collections import defaultdict + ase = _asesores(); amap = ase["map"]; lista = ase["lista"] + + 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 + + act = defaultdict(lambda: defaultdict(int)) + 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: + continue + if d.weekday() == 5: + if 9 <= h < 13: act[key]["sab_manana"] += 1 + elif 14 <= h < 18: act[key]["sab_tarde"] += 1 + else: # lun-vie: 3 franjas + if 9 <= h < 13: act[key]["manana"] += 1 + elif 14 <= h < 18: act[key]["tarde"] += 1 + elif 18 <= h < 22: act[key]["noche"] += 1 + + def _turno_dia(v, d): + # Turno 1 = manana(9-13) Y tarde(14-18). Turno 2 = manana(9-13) Y noche(18-22). + if d.weekday() == 6: return None + fr = act.get((v, d)) + if not fr: return None + if d.weekday() == 5: + 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)] + man = fr.get("manana", 0); t = fr.get("tarde", 0); n = fr.get("noche", 0) + cumple_t1 = man > 0 and t > 0 + cumple_t2 = man > 0 and n > 0 + if cumple_t1 and cumple_t2: + return [(9, 13), (18, 22)] if n > t else [(9, 13), (14, 18)] + if cumple_t2: return [(9, 13), (18, 22)] + if cumple_t1: return [(9, 13), (14, 18)] + return None + + def _dt(d, hh): + return datetime(d.year, d.month, d.day) + timedelta(hours=hh) + + def _en_refrigerio_t1(v, ts): + d = ts.date() + if d.weekday() >= 5: return False + es_t1 = _turno_dia(v, d) == [(9, 13), (14, 18)] + h = ts.hour + ts.minute / 60.0 + return es_t1 and (13 <= h < 15) + + def _minutos_laborales(v, t1, t2): + 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: + d = cur.date(); bloques = _turno_dia(v, d) + if not bloques: + cur = _dt(d, 24); saltos += 1; continue + for (hi, hf) in bloques: + ini = _dt(d, hi); fin = _dt(d, hf) + if t2 <= ini: break + if cur >= fin: continue + a = max(cur, ini); b = min(t2, fin) + if b > a: total += (b - a).total_seconds() / 60.0 + cur = fin + if cur >= t2: break + if cur.date() == d: + cur = _dt(d, 24); saltos += 1 + return round(total) + + LIMITE_TARDIA = 960 + d_click = int(dia_click) + es_resp = str(modo).lower().startswith("resp") + por_asesor = defaultdict(list) # vend -> [ {t1,t2,mins} ] + + for r in _asignacion_respuesta_crudos(): + v = _norm_asesor(r.get("user_name"), amap) + if not v: continue + if vendedor not in ("TODOS", None, "") and v != vendedor: + continue + t1 = r.get("created_at"); t2 = r.get("respuesta_fecha") + if not isinstance(t1, datetime): continue + + if es_resp: + # agrupar por fecha de respuesta; solo respondidos, <=960 min + if not isinstance(t2, datetime): continue + if not L._en_periodo(t2.date(), ano, mes, "TODOS"): continue + if t2.day != d_click: continue + 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: continue # tardío -> fuera del promedio + else: + # agrupar por fecha de asignación; solo respondidos entran al promedio + if not L._en_periodo(t1.date(), ano, mes, "TODOS"): continue + if t1.day != d_click: continue + if not isinstance(t2, datetime): continue # sin responder: no promedia + 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) + + por_asesor[v].append({ + "telefono": str(r.get("telefono") or "").strip(), + "t1": t1.strftime("%d/%m/%Y %H:%M"), + "t2": t2.strftime("%d/%m/%Y %H:%M"), + "minutos": int(mins), + }) + + # Armar salida por asesor con su promedio; y total del día + asesores = [] + tot_sum = 0; tot_cnt = 0 + for v in sorted(por_asesor.keys()): + leads = sorted(por_asesor[v], key=lambda x: x["t1"]) + s = sum(x["minutos"] for x in leads); c = len(leads) + tot_sum += s; tot_cnt += c + asesores.append({"asesor": v, "leads": leads, + "cantidad": c, "promedio_min": round(s / c) if c else 0}) + return {"dia": d_click, "modo": ("respuesta" if es_resp else "asignacion"), + "asesores": asesores, + "total": {"cantidad": tot_cnt, "promedio_min": round(tot_sum / tot_cnt) if tot_cnt else 0}} + return cache_get_or_set("tiempo_resp_det", (dia_click, modo, vendedor, ano, mes), _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. @@ -959,14 +1232,14 @@ def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): 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. + # COPITO/OTROS: se clasifica el telefono con SU FILA ORIGEN + # (Copito = origen_base=POSTGRE O canal en PAUTA_WSP/PAUTA_WSP_FACE/COPITO). 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) + cont_tels = defaultdict(set) # vendedor -> telefonos ya contados (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 @@ -977,21 +1250,17 @@ def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): if not v: # el asesor del origen no es uno de los 8 continue tel = str(r.get("telefono") or "").strip() + if tel and tel in cont_tels[v]: + continue # ese telefono ya se conto para este vendedor 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) + if (ob == "POSTGRE") or (canal in _CANALES_COPITO): + cart_copito[v] += 1 else: cart_otros[v] += 1 if tel: + cont_tels[v].add(tel) tels_por_vend[v].add(tel) # 2) MATRICULAS por vendedor. Se CRUZAN por TELEFONO con los leads del periodo @@ -1191,6 +1460,39 @@ def _conjunto_sede_prog(): lambda: get_dm().traer_conjunto_sede_programa()) +# Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca). +_PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} + + +def _conjuntos_branding(): + """Set de CONJUNTOS (UPPER) asignados manualmente como BRANDING/COPEAIRE. + Sus resultados NO deben contarse (su importe sí).""" + def _load(): + asign = _conjunto_sede_prog() # {conjunto_UPPER: {sede, programa}} + return {k for k, v in asign.items() + if str(v.get("programa") or "").strip().upper() in _PROG_SIN_RESULTADO} + return cache_get_or_set("conjuntos_branding", ("GLOBAL",), _load) + + +def _codigos_branding(): + """Set de codigos(pauta) cuyos conjuntos estan asignados manualmente como + BRANDING/COPEAIRE. Sirve para NO contar sus resultados en las tablas.""" + def _load(): + conj_pauta = _conjunto_pauta() # {pauta: [conjuntos]} + asign = _conjunto_sede_prog() # {conjunto_UPPER: {sede, programa}} + # conjuntos_branding (UPPER) + conj_brand = {k for k, v in asign.items() + if str(v.get("programa") or "").strip().upper() in _PROG_SIN_RESULTADO} + cods = set() + for pauta, conjuntos in conj_pauta.items(): + for co in conjuntos: + if " ".join(str(co).split()).upper() in conj_brand: + cods.add(str(pauta).strip()) + break + return cods + return cache_get_or_set("codigos_branding", ("GLOBAL",), _load) + + 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 @@ -1257,6 +1559,10 @@ def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): camp = _campanias_sede_cargo() # {pauta: {sede, cargo}} PROGS_OK = {"TEAC", "TERC", "SEMINARIOS", "OTROS"} + # Programas cuyos RESULTADOS NO deben sumar (son de otro tipo: alcance/marca). + # Su importe SÍ suma; sus resultados se muestran solo en el detalle, y el + # subtotal del programa aparece en 0 para no inflar los totales. + PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} # conjunto (normalizado) -> (sede, programa) via su pauta -> campania conj_sede = {} # conjunto -> sede @@ -1283,13 +1589,25 @@ def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): if prog: # acepta CUALQUIER programa asignado manualmente (no solo los fijos) conj_prog[k] = prog + # conjunto(UPPER) -> [pautas] (para cruzar con leads por codigo) + pautas_de_conj = {} + for pauta, conjuntos in conj_pauta.items(): + for co in conjuntos: + k = " ".join(str(co).split()).upper() + pautas_de_conj.setdefault(k, []).append(str(pauta).strip()) + # leads (recibidos/procesados del periodo) por codigo de pauta + lxc = _leads_por_codigo_periodo(ano, mes, dia) + # 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 + # tels de leads por nivel (sets para no duplicar telefonos) + rec_s = {s: set() for s in SEDES}; proc_s = {s: set() for s in SEDES} # por sede + rec_pg = {}; proc_pg = {} # (sede,prog) # conjuntos por (sede, prog): nombre_conjunto -> {importe, resultados} - conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados} } + conj_det = {} # (sede, prog) -> { conjunto_original: {importe, resultados, rec, proc} } 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): @@ -1301,16 +1619,36 @@ def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): 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") + # BRANDING/COPEAIRE: su importe suma, pero sus resultados NO cuentan en + # los totales (sede y general). Solo se ven en el detalle del conjunto. + rr_sede = 0 if prog in PROG_SIN_RESULTADO else rr + imp[sede] += i + res[sede] += rr_sede imp_pg[(sede, prog)] = imp_pg.get((sede, prog), 0.0) + i - res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr + res_pg[(sede, prog)] = res_pg.get((sede, prog), 0) + rr_sede dd = conj_det.setdefault((sede, prog), {}) - d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0}) + d = dd.setdefault(co, {"conjunto": co, "importe": 0.0, "resultados": 0, + "rec": 0, "proc": 0}) d["importe"] += i d["resultados"] += rr + # ── Leads Recibidos/Procesados del mes por conjunto (via su pauta -> codigo). + # Se hace UNA vez por conjunto (no por fila del CSV). Los sets evitan + # duplicar telefonos al agregar por programa y por sede. + for (sede, prog), dd in conj_det.items(): + for co, d in dd.items(): + k = co.upper() + rec = set(); proc = set() + for pa in pautas_de_conj.get(k, []): + lp = lxc.get(pa) + if lp: + rec |= lp["rec"]; proc |= lp["proc"] + d["rec"] = len(rec); d["proc"] = len(proc) + rec_pg.setdefault((sede, prog), set()).update(rec) + proc_pg.setdefault((sede, prog), set()).update(proc) + rec_s[sede].update(rec); proc_s[sede].update(proc) + ORDEN_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS", "SIN PROGRAMA"] def _conjuntos(sede, prog): @@ -1333,6 +1671,8 @@ def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): 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, + "rec": len(rec_pg.get((sede, p), set())), + "proc": len(proc_pg.get((sede, p), set())), "conjuntos": _conjuntos(sede, p)}) return out @@ -1342,12 +1682,318 @@ def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): if s == "OTROS" and imp[s] == 0 and res[s] == 0: continue filas.append({"sede": s, "importe": round(imp[s], 2), "resultados": res[s], + "rec": len(rec_s[s]), "proc": len(proc_s[s]), "programas": _sub(s)}) - tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values())} + tot_rec = set(); tot_proc = set() + for s in SEDES: + tot_rec |= rec_s[s]; tot_proc |= proc_s[s] + tot = {"importe": round(sum(imp.values()), 2), "resultados": sum(res.values()), + "rec": len(tot_rec), "proc": len(tot_proc)} return {"filas": filas, "total": tot} return cache_get_or_set("roas_dash", (ano, mes, dia), _load) +_CURSOS_EXCLUIDOS = {"1121", "1154"} + + +def _curso_excluido(c, pmap): + """Mismo criterio que 'Detalle por Curso': excluye cursos en la lista fija, + o con contar=NO / estado=SUSPENDIDO en basebi.""" + ni = str(c.get("num_indice", "")).strip() + if ni in _CURSOS_EXCLUIDOS: + return True + info = pmap.get(ni) + if not info: + return False + return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO" + + +def programa_opciones(ano="TODOS", mes="TODOS"): + """Lista de programas (dsc_det_programa) cuyos cursos tienen fch_inicio en el + periodo. Para el selector del apartado PROGRAMA. Ordenada por fch_inicio ASC.""" + def _load(): + from datetime import date + pmap = _pauta_cruda() + prog_fecha = {} # nombre -> fecha_inicio mas temprana + for c in _cursos_crudos(): + f = L._to_date(c.get("fch_inicio")) + if not L._en_periodo(f, ano, mes, "TODOS"): + continue + if _curso_excluido(c, pmap): # mismo filtro que 'Detalle por Curso' + continue + nom = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() + if not nom: + continue + if nom not in prog_fecha or (f and f < prog_fecha[nom]): + prog_fecha[nom] = f or date.max + # ordenar por fecha de inicio ascendente + return [n for n, _ in sorted(prog_fecha.items(), key=lambda x: (x[1] or date.max, x[0]))] + return cache_get_or_set("programa_opciones", (ano, mes), _load) + + +def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"): + """Datos agregados de UN programa (dsc_det_programa) en el periodo: + Cartera Total/Copito/Otros, Leads Recibidos/Procesados/Contactados y %. + - Filtro por fch_inicio del curso para elegir los cursos del programa. + - Cartera: teléfonos origen (es_origen=SI) cuya sede+programa (via pauta-> + campania) coincide con los cursos del programa, en el periodo. + - Leads: por codigo de pauta de esos cursos (recibidos/procesados/contactados + del periodo), sin duplicar telefonos.""" + def _load(): + prog_sel = str(programa or "").strip().upper() + pmap = _pauta_cruda() + # 1) cursos del programa (dsc_det_programa) en el periodo -> sus pautas. + # Aplica el MISMO filtro de exclusion que 'Detalle por Curso'. + cods = set() + cursos_prog = [] # [(fch_inicio, [pautas del curso])] para la cartera + for c in _cursos_crudos(): + nom = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip().upper() + if prog_sel != "TODOS" and nom != prog_sel: + continue + f = L._to_date(c.get("fch_inicio")) + if not L._en_periodo(f, ano, mes, "TODOS"): + continue + if _curso_excluido(c, pmap): + continue + ni = _norm_ni(c.get("num_indice")) + cds_curso = set() + for p in _pautas_de_indice().get(ni, []): + cds_curso.add(str(p).strip()) + info = _pauta_cruda().get(ni) + if info and info.get("pauta"): + cds_curso.add(str(info.get("pauta")).strip()) + cods |= cds_curso + cursos_prog.append((f, list(cds_curso))) + + # 2) LEADS por esas pautas (recibidos/procesados/contactados TOTAL/historico, + # sin filtro de mes -> igual que la columna "L. Recibidos" de Detalle por Curso). + lxc = _leads_por_codigo_periodo("TODOS", "TODOS", "TODOS") + rec = set(); proc = set(); cont = set() + for cd in cods: + lp = lxc.get(cd) + if lp: + rec |= lp["rec"]; proc |= lp["proc"]; cont |= lp.get("cont", set()) + + # 2b) MATRIZ ESTADO/OBJECION: por ultima etiqueta de los leads (procesados) + # de las pautas del programa. Telefonos unicos por etiqueta. + from collections import defaultdict as _dd + etiq_tels = _dd(set) + cods_set0 = set(cods) + for l in _leads_crudos(): + if not l.get("es_unico") or not l.get("asesor"): + continue + if str(l.get("codigo", "-")).strip() not in cods_set0: + continue + tel = l.get("telefono") + if not tel: + continue + ult = l.get("ultima") or "NO TRABAJADO" + etiq_tels[ult].add(tel) + matriz_etiq = sorted( + [{"estado": k, "cantidad": len(v)} for k, v in etiq_tels.items()], + key=lambda x: -x["cantidad"]) + + # 3) CARTERA TOTAL: MISMA logica que 'Detalle por Curso'. Por cada curso, + # telefonos con la sede+programa de sus pautas y fecha_creada <= fch_inicio. + # Union entre cursos del programa (sin duplicar telefono). + # COPITO/OTROS: se clasifica cada telefono por su FILA MAS ANTIGUA DENTRO + # de esta seleccion (Copito = origen_base=POSTGRE O canal en + # PAUTA_WSP/PAUTA_WSP_FACE/COPITO). + cmap = _campanias_map() # {codigo: (sede, programa)} + # {(sede,prog): fch_corte maximo} de las pautas de los cursos del programa. + sp_corte = {} + for (fch, cds_curso) in cursos_prog: + if not fch: + continue + for cd in cds_curso: + sp = cmap.get(str(cd).upper()) + if not sp: + continue + if sp not in sp_corte or fch > sp_corte[sp]: + sp_corte[sp] = fch + # recorrer cartera cruda; fila mas antigua por telefono dentro de la + # seleccion (sede+prog de una pauta + fecha_creada <= fch_inicio del curso). + mejor = {} # tel -> (fecha, es_copito) + for r in _cartera_rows_cache(): + sede = " ".join(str(r.get("sede") or "").upper().split()) + prog = " ".join(str(r.get("programa") or "").upper().split()) + corte = sp_corte.get((sede, prog)) + if corte is None: + continue + f = L._to_date(r.get("fecha_creada")) + if f is None or f > corte: + continue + tel = str(r.get("telefono") or "").strip() + if not tel: + continue + if tel not in mejor or f < mejor[tel][0]: + canal = " ".join(str(r.get("canal") or "").upper().split()) + ob = " ".join(str(r.get("origen_base") or "").upper().split()) + es_cop = (ob == "POSTGRE") or (canal in _CANALES_COPITO) + mejor[tel] = (f, es_cop) + cart_total = len(mejor) + cart_cop = sum(1 for (f, ec) in mejor.values() if ec) + cart_otros = cart_total - cart_cop + + # 4) IMPORTE (gasto Meta) y RESULTADOS totales de las pautas del programa. + imp_map = _importe_por_pauta(); res_map = _resultados_por_pauta() + importe = round(sum(float(imp_map.get(cd, 0.0)) for cd in cods), 2) + resultados = int(sum(int(res_map.get(cd, 0)) for cd in cods)) + tiene_pauta = len(cods) > 0 + + # 4b) V. VENTA INICIAL = suma de INV_NETA (en soles) de las matriculas del + # programa (dsc_promocion == programa). Igual que Rentabilidad: DOL -> soles + # con el imp_tc del comprobante (fallback 3.45 si viene 0/invalido). + # ROAS = venta_inicial (soles) / (importe pauta en soles = importe * 3.5). + # cartera para clasificar matriculas (nuevo/antiguo/no iden) + _ci = _cartera_idx() + cart_origen = _ci.get("origen", {}) # tel -> fecha origen del lead + cart_tels = _ci.get("tels", set()) # telefonos que estan en cartera + venta_inicial = 0.0 + mat_por_fecha = {} # date -> cantidad de matriculas (no ANU) + mat_no_iden = 0; mat_nuevos = 0; mat_antiguos = 0 + dias_lista = [] # dias lead->matricula (promedio general) + dias_nuevos = [] # dias de los <= 45 + dias_antiguos = [] # dias de los > 45 + for m in _matriculas_crudas(): + nomp = str(m.get("dsc_promocion") or m.get("dsc_programa") or "").strip().upper() + if nomp != prog_sel: + continue + if str(m.get("estado_matricula") or "").strip().upper() == "ANU": + continue + try: + inv = float(m.get("INV_NETA", 0.0) or 0.0) + except Exception: + inv = 0.0 + if str(m.get("cod_moneda") or "").strip().upper() == "DOL": + try: + tc = float(m.get("imp_tc", 0) or 0) + except Exception: + tc = 0 + if tc < 2: + tc = 3.45 # TC por defecto, igual que Rentabilidad + inv *= tc + venta_inicial += inv + # serie de matriculas por dia (fch_matricula) + fm = L._to_date(m.get("fch_matricula")) + if fm: + mat_por_fecha[fm] = mat_por_fecha.get(fm, 0) + 1 + # telefono de la matricula (tel_1 o tel_2) + ph = str(m.get("dsc_telefono_1") or "").strip() or str(m.get("dsc_telefono_2") or "").strip() + if not ph or ph not in cart_tels: + mat_no_iden += 1 + continue + forig = cart_origen.get(ph) + if fm and forig: + d = (fm - forig).days + if 0 <= d <= 45: + mat_nuevos += 1 + dias_nuevos.append(d) + else: + mat_antiguos += 1 + if d > 45: + dias_antiguos.append(d) + if d >= 0: + dias_lista.append(d) # promedio general: TODOS los que tienen origen + else: + mat_antiguos += 1 + venta_inicial = round(venta_inicial, 2) # se muestra al 100% + tiempo_mat_prom = round(sum(dias_lista) / len(dias_lista)) if dias_lista else 0 + prom_nuevos = round(sum(dias_nuevos) / len(dias_nuevos)) if dias_nuevos else 0 + prom_antiguos = round(sum(dias_antiguos) / len(dias_antiguos)) if dias_antiguos else 0 + importe_soles = round(importe * 3.5, 2) # importe Meta (dolares) -> soles con 3.5 + # ROAS usa el 85% de la venta inicial (descuento estimado de retiros/mora) + roas = round((venta_inicial * 0.85) / importe_soles, 2) if importe_soles else 0.0 + + # 5) SERIE DIARIA (por fecha del lead) para el grafico: recibidos/procesados/ + # contactados por FECHA COMPLETA (dd/mm/yyyy). NO filtra por mes: muestra + # TODO el historial del programa. Solo aparecen dias con al menos 1 lead. + cods_set = set(cods) + por_fecha = {} # date -> {"rec":set,"proc":set,"cont":set} + for l in _leads_crudos(): + if not l.get("es_unico"): + continue + if str(l.get("codigo", "-")).strip() not in cods_set: + continue + fx = l.get("fecha") + if not fx: + continue + tel = l.get("telefono") + if not tel: + continue + d = por_fecha.setdefault(fx, {"rec": set(), "proc": set(), "cont": set()}) + d["rec"].add(tel) + if l.get("asesor"): + d["proc"].add(tel) + if (l.get("ultima") or "NO TRABAJADO") != "NO TRABAJADO": + d["cont"].add(tel) + _MES_ES = ["", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", + "Julio", "Agosto", "Setiembre", "Octubre", "Noviembre", "Diciembre"] + serie = [{"fecha": f.strftime("%d/%m/%Y"), + "dia": f.day, + "mes": f"{_MES_ES[f.month]} {f.year}", + "recibidos": len(v["rec"]), + "procesados": len(v["proc"]), + "contactados": len(v["cont"])} + for f, v in sorted(por_fecha.items())] + + # 5b) LEADS SIN RESPONDER por dia: de los telefonos del programa (rec), + # los que estan asignados pero NO tienen respuesta del asesor. + # Se agrupa por dia de la FECHA DE ASIGNACION (created_at). + from datetime import datetime as _dt + sin_resp_dia = {} # date -> set(tel) + for a in _asignacion_respuesta_crudos(): + tel = str(a.get("telefono") or "").strip() + if tel not in rec: # solo leads de este programa + continue + t2 = a.get("respuesta_fecha") + if isinstance(t2, _dt): + continue # tiene respuesta -> no cuenta + t1 = a.get("created_at") + if not isinstance(t1, _dt): + continue + sin_resp_dia.setdefault(t1.date(), set()).add(tel) + serie_sin_resp = [{"fecha": f.strftime("%d/%m/%Y"), "dia": f.day, + "mes": f"{_MES_ES[f.month]} {f.year}", + "cantidad": len(v)} + for f, v in sorted(sin_resp_dia.items()) if len(v) >= 1] + + # 5c) SERIE de MATRICULAS por dia (fch_matricula) del programa. + serie_mat = [{"fecha": f.strftime("%d/%m/%Y"), "dia": f.day, + "mes": f"{_MES_ES[f.month]} {f.year}", "cantidad": n} + for f, n in sorted(mat_por_fecha.items()) if n >= 1] + + n_rec = len(rec); n_proc = len(proc); n_cont = len(cont) + return { + "programa": programa, + "tiene_pauta": tiene_pauta, + "serie": serie, + "serie_sin_resp": serie_sin_resp, + "serie_mat": serie_mat, + "matriz_etiq": matriz_etiq, + "importe": importe, + "resultados": resultados, + "cpl": round(importe / resultados, 2) if resultados else 0.0, # CPL = importe / resultados + "venta_inicial": venta_inicial, # V. Venta Inicial (soles) + "roas": roas, # Venta inicial / (importe * 3.5) + "mat_no_iden": mat_no_iden, + "mat_nuevos": mat_nuevos, + "mat_antiguos": mat_antiguos, + "tiempo_mat_prom": tiempo_mat_prom, # dias promedio lead->matricula + "prom_nuevos": prom_nuevos, # dias prom de los <=45 + "prom_antiguos": prom_antiguos, # dias prom de los >45 + "cartera_total": cart_total, + "cartera_copito": cart_cop, + "cartera_otros": cart_otros, + "leads_recibidos": n_rec, + "leads_procesados": n_proc, + "pct_procesados": round(n_proc / n_rec * 100, 1) if n_rec else 0.0, + "leads_contactados": n_cont, + "pct_contactados": round(n_cont / n_proc * 100, 1) if n_proc else 0.0, + } + return cache_get_or_set("programa_dash", (ano, mes, programa), _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.""" @@ -1366,6 +2012,309 @@ def conjuntos_sin_pauta(): return get_dm().traer_conjuntos_sin_pauta() +def _pautas_de_curso_ni(ni): + """Pautas (codigos) de un num_indice = pauta de basebi + programa_pautas.""" + cds = set() + for p in _pautas_de_indice().get(ni, []): + cds.add(str(p).strip()) + info = _pauta_cruda().get(ni) + if info and info.get("pauta"): + cds.add(str(info.get("pauta")).strip()) + return cds + + +def alertas_dashboard(): + """ALERTAS: cursos que inician en los proximos 30 dias (con pauta, importe>0). + Estado (CRITICO/ADVERTENCIA/BUEN RUMBO) segun MATRICULAS vs meta ESPERADA por + dias que faltan. CPL alto / pocos leads son SENALES (no cambian el estado). + Pares = cursos mismo sede+programa con importe>0 (info real desde ~diciembre).""" + def _load(): + import datetime as _dt + from collections import defaultdict as _dd + HOY = _dt.date.today() + LIM = HOY + _dt.timedelta(days=30) + + imp_map = _importe_por_pauta() + res_map = _resultados_por_pauta() + lxc = _leads_por_codigo_periodo("TODOS", "TODOS", "TODOS") + mat_idx = _mat_por_indice() + pmap = _pauta_cruda() + + # telefono -> set(num_indice donde se matriculo) para el analisis "este vs otros" + tel_mats = _dd(set) + for ni_m, filas in mat_idx.items(): + for (tel, _f) in filas: + if tel: + tel_mats[str(tel).strip()].add(ni_m) + + def datos(c): + ni = _norm_ni(c.get("num_indice")) + cds = _pautas_de_curso_ni(ni) + importe = round(sum(float(imp_map.get(cd, 0.0)) for cd in cds), 2) + resultados = int(sum(int(res_map.get(cd, 0)) for cd in cds)) + rec = set(); proc = set() + for cd in cds: + lp = lxc.get(cd) + if lp: + rec |= lp["rec"]; proc |= lp["proc"] + nom = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() + info = pmap.get(ni) or {} + susp = (str(c.get("cod_estado", "")).strip().upper() == "SUS" + or info.get("estado") == "SUSPENDIDO") + return {"ni": ni, "nom": nom, + "sede": clasificar_sede_meta(nom), "prog": clasificar_prog_meta(nom), + "fch": L._to_date(c.get("fch_inicio")), + "importe": importe, "resultados": resultados, + "rec": rec, "proc": proc, "suspendido": susp, + "mats": len(mat_idx.get(ni, [])), "meta": meta_de_curso(nom)} + + cursos = [] + susp_ini = [] # cursos SUSPENDIDOS que ya iniciaron (se muestran marcados) + for c in _cursos_crudos(): + if _curso_excluido(c, pmap): # MISMO filtro que 'Detalle por Curso' + # Los suspendidos SÍ se listan en "Ya iniciados" (con etiqueta), + # pero NO entran en cursos/pares/próximos (no afectan promedios). + d = datos(c) + if d["fch"] and d["suspendido"]: + susp_ini.append(d) + continue + d = datos(c) + if d["fch"]: + cursos.append(d) + + # pares por (sede,prog) SOLO con importe>0 + pares = _dd(list) + for d in cursos: + if d["importe"] > 0: + pares[(d["sede"], d["prog"])].append(d) + + proximos = [d for d in cursos if HOY <= d["fch"] <= LIM and d["importe"] > 0] + proximos.sort(key=lambda x: x["fch"]) + + # Programas que YA INICIARON: desde ayer hasta hace 30 días (con importe>0). + INI_MIN = HOY - _dt.timedelta(days=30) + INI_MAX = HOY - _dt.timedelta(days=1) + iniciados_src = [d for d in cursos if INI_MIN <= d["fch"] <= INI_MAX and d["importe"] > 0] + # Suspendidos que ya iniciaron en el rango (se muestran marcados, sin exigir importe). + iniciados_src += [d for d in susp_ini if INI_MIN <= d["fch"] <= INI_MAX] + iniciados_src.sort(key=lambda x: x["fch"], reverse=True) # más recientes primero + + def _armar_item(d, ya_inicio=False): + grp = [p for p in pares.get((d["sede"], d["prog"]), []) if p["ni"] != d["ni"]] + sum_imp = sum(p["importe"] for p in grp) + sum_res = sum(p["resultados"] for p in grp) + cpl_prom = round(sum_imp / sum_res, 2) if sum_res > 0 else 0.0 + imp_prom = round(sum_imp / len(grp), 2) if grp else 0.0 # importe prom por curso par + res_prom = round(sum_res / len(grp), 0) if grp else 0.0 # resultados prom por curso par + leads_prom = round(sum(len(p["rec"]) for p in grp) / len(grp), 1) if grp else 0.0 + proc_prom = round(sum(len(p["proc"]) for p in grp) / len(grp), 0) if grp else 0.0 + leads = len(d["rec"]); proc = len(d["proc"]) + cpl = round(d["importe"] / d["resultados"], 2) if d["resultados"] > 0 else 0.0 + dias = (d["fch"] - HOY).days + dias_desde = (HOY - d["fch"]).days # días transcurridos desde el inicio (>=1) + + # meta esperada por dias que faltan (si ya inició, se espera el 100%) + if ya_inicio: + pct_esp = 1.0 + else: + pct_esp = 0.30 if dias > 20 else (0.60 if dias >= 8 else 0.85) + esperado = round(d["meta"] * pct_esp) + avance = (d["mats"] / esperado) if esperado > 0 else 1.0 + if d["meta"] > 0 and d["mats"] >= d["meta"]: + nivel = "ok" + elif avance >= 1.0: + nivel = "ok" + elif avance >= 0.60: + nivel = "advertencia" + else: + nivel = "critico" + + # senales CPL/leads (rojo) + cpl_rojo = cpl_prom > 0 and cpl > 0 and (cpl - cpl_prom) / cpl_prom > 0.75 + leads_rojo = leads_prom > 0 and (leads - leads_prom) / leads_prom < -0.50 + + # conversion Meta -> base y atencion + conv = round(leads / d["resultados"] * 100, 1) if d["resultados"] > 0 else 0.0 + aten = round(proc / leads * 100, 0) if leads > 0 else 0.0 + + # matriculados de ESTE curso vs OTROS (cruce por telefono de los recibidos) + este = otros = 0 + for tel in d["rec"]: + ms = tel_mats.get(tel) + if not ms: + continue + if d["ni"] in ms: + este += 1 + else: + otros += 1 + + def est_cpl(): + if cpl_prom <= 0 or cpl <= 0: return "verde" + dif = (cpl - cpl_prom) / cpl_prom + return "rojo" if dif > 0.75 else ("amarillo" if dif > 0.25 else "verde") + + def est_leads(): + if leads_prom <= 0: return "verde" + dif = (leads - leads_prom) / leads_prom + return "rojo" if dif < -0.50 else ("amarillo" if dif < -0.20 else "verde") + + est_mat = {"ok": "verde", "advertencia": "amarillo", "critico": "rojo"}[nivel] + + def est_vs(actual, prom): + # Mas es mejor: verde >=prom, naranja 50-99% del prom, rojo <50%. + if prom <= 0: return "verde" + r = actual / prom + return "verde" if r >= 1.0 else ("amarillo" if r >= 0.50 else "rojo") + + def est_gasto(actual, prom): + # Importe: menos es mejor. verde <=100%, naranja 101-150%, rojo >150%. + if prom <= 0: return "verde" + r = actual / prom + return "verde" if r <= 1.0 else ("amarillo" if r <= 1.5 else "rojo") + + metricas = [ + {"l": "Importe", "v": f"$ {d['importe']:.2f}", "sub": f"prom $ {imp_prom:.2f}", "est": est_gasto(d['importe'], imp_prom)}, + {"l": "CPL", "v": f"$ {cpl:.2f}", "sub": f"prom $ {cpl_prom:.2f}", "est": est_cpl()}, + {"l": "Resultados", "v": f"{d['resultados']}", "sub": f"prom {res_prom:.0f}", + "est": est_vs(d['resultados'], res_prom)}, + {"l": "Leads recibidos", "v": f"{leads}", "sub": f"prom {leads_prom:.0f}", "est": est_leads()}, + {"l": "Leads procesados", "v": f"{proc}", "sub": f"prom {proc_prom:.0f}", + "est": est_vs(proc, proc_prom)}, + {"l": "Matrículas", "v": f"{d['mats']} / {d['meta']}", + "sub": (f"cerró con {d['mats']} de {d['meta']}" if ya_inicio else f"esperado ~{esperado} a {dias}d"), + "est": est_mat}, + ] + + # analisis narrado: cada punto lleva su estado (verde/amarillo/rojo) + # para agruparlo luego en Óptimo / En observación / Crítico. + puntos = [] # lista de {"txt":..., "est":...} + + # Matriculas + if ya_inicio: + puntos.append({"est": est_mat, + "txt": f"El curso inició hace {dias_desde} días y cerró con {d['mats']} de {d['meta']} matrículas."}) + else: + puntos.append({"est": est_mat, + "txt": f"Faltan {dias} días para el inicio y lleva {d['mats']} de {d['meta']} matrículas " + f"(se esperaría ~{esperado} a esta altura)."}) + # CPL + if cpl_prom > 0: + if cpl > cpl_prom: + puntos.append({"est": est_cpl(), + "txt": f"El CPL es $ {cpl:.2f} siendo el promedio $ {cpl_prom:.2f}: está costando más " + f"conseguir cada resultado que en cursos similares — la pauta se encareció."}) + else: + puntos.append({"est": est_cpl(), + "txt": f"El CPL es $ {cpl:.2f}, por debajo del promedio $ {cpl_prom:.2f} — la pauta rinde bien."}) + # Importe (punto propio) + if imp_prom > 0: + pct_imp = d["importe"] / imp_prom * 100 + puntos.append({"est": est_gasto(d["importe"], imp_prom), + "txt": f"Llevas invertido $ {d['importe']:.2f} de un promedio de $ {imp_prom:.2f} por curso similar " + f"({pct_imp:.0f}% del gasto habitual)."}) + # Resultados (punto propio) + if res_prom > 0: + pct_res = d["resultados"] / res_prom * 100 + puntos.append({"est": est_vs(d["resultados"], res_prom), + "txt": f"Tienes {d['resultados']} resultados de un promedio de {res_prom:.0f} ({pct_res:.0f}%)." + + (" Aún no alcanzas el promedio de resultados, tienes margen para invertir más." + if pct_res < 100 else " Ya superaste el promedio de resultados.")}) + # Recibidos vs promedio + if leads_prom > 0: + dif_pct = (leads - leads_prom) / leads_prom * 100 + p = f"Tenemos {leads} leads recibidos; antes recibíamos en promedio {leads_prom:.0f}." + if leads < leads_prom: + p += (f" Es {abs(dif_pct):.0f}% menos de lo habitual: está entrando poca gente a la base, " + "hay que reforzar la captación (pauta/anuncio).") + else: + p += " Estamos por encima del promedio de captación." + puntos.append({"est": est_leads(), "txt": p}) + # Procesados vs promedio + if proc_prom > 0: + p = f"De esos, {proc} pasaron a un asesor (procesados); antes en promedio {proc_prom:.0f}." + if proc < proc_prom: + dp = (proc - proc_prom) / proc_prom * 100 + p += (f" Es {abs(dp):.0f}% menos: llegan menos interesados a la etapa de asesor, " + "consecuencia de los pocos recibidos.") + else: + p += " Buen paso a asesor frente al promedio." + puntos.append({"est": est_vs(proc, proc_prom), "txt": p}) + + # (el cruce por telefono se muestra aparte, ver "cruce" abajo) + + # Aviso por baja conversion: muchos resultados de Meta pero pocos llegan + # al WhatsApp/base (conv < 30%). Indica fuga entre el anuncio y el chat. + conv_baja = "" + if d["resultados"] >= 20 and conv < 30: + conv_baja = (f"Atención: de {d['resultados']} resultados solo {leads} llegaron a nuestra base " + f"({conv:.0f}%). Se está perdiendo mucha conversión entre resultados y recibidos: " + f"de muchos resultados, pocos llegan a nuestra base de datos. Si bien vamos bien en " + f"CPL e importe, tenemos pocas adiciones a nuestra base. ") + + # recomendacion + if ya_inicio: + # Curso ya iniciado: la lectura es de cierre/aprendizaje, no de acción urgente. + if nivel == "critico": + reco = (f"El curso cerró con {d['mats']} de {d['meta']} matrículas, por debajo de la meta. " + + conv_baja + + ("El CPL quedó alto: revisar el anuncio para próximos cursos similares. " if cpl_rojo else "") + + ("Entró poca gente a la base: reforzar captación en el siguiente. " if leads_rojo else "") + + "Usar este resultado para ajustar la pauta del próximo inicio.") + elif nivel == "advertencia": + reco = (conv_baja + "Cerró cerca de la meta; hay margen de mejora en captación para el próximo curso.") + else: + reco = (conv_baja + "Cerró en buen nivel de matrículas.") if conv_baja else "Cerró en buen nivel de matrículas." + elif nivel == "critico": + reco = (f"Con {d['mats']} matrículas y {dias} días para el inicio va por debajo del ritmo esperado. " + + conv_baja + + ("Revisar la segmentación/mensaje del anuncio o aumentar inversión. " if leads_rojo else "") + + ("El CPL está alto, revisar el anuncio. " if cpl_rojo else "") + + "Priorizar la captación y el seguimiento de los leads pendientes.") + elif nivel == "advertencia": + reco = (conv_baja + + "Va encaminado pero necesita un empujón: reforzar seguimiento de leads y vigilar el CPL.") + else: + reco = (conv_baja + "Sin acciones urgentes, va a buen ritmo.") if conv_baja else "Sin acciones urgentes, va a buen ritmo." + + señal = "" + if cpl_rojo: señal += "CPL alto" + if leads_rojo: señal = (señal + " · " if señal else "") + "pocos leads" + + # Agrupa los PUNTOS de analisis segun su estado (verde/amarillo/rojo). + g_opt = [x["txt"] for x in puntos if x["est"] == "verde"] + g_med = [x["txt"] for x in puntos if x["est"] == "amarillo"] + g_mal = [x["txt"] for x in puntos if x["est"] == "rojo"] + grupos = [ + {"titulo": "Óptimo", "color": "verde", "items": g_opt}, + {"titulo": "En observación", "color": "amarillo", "items": g_med}, + {"titulo": "Crítico", "color": "rojo", "items": g_mal}, + ] + # Cruce por telefono, separado del resto. + cruce = f"{este} se matricularon a ESTE curso" + (f" y {otros} en OTROS programas." if otros else ".") + + return { + "id": d["ni"], "curso": d["nom"], "dias_inicio": dias, "dias_desde": dias_desde, + "ya_inicio": ya_inicio, "suspendido": bool(d.get("suspendido")), "nivel": nivel, + "sede": d["sede"], "programa": d["prog"], + "resumen": señal or ("Cerca de la meta" if nivel != "critico" else "Requiere atención"), + "metricas": metricas, "puntos": puntos, "reco": reco, + "grupos": grupos, "cruce": cruce, + } + + items = [_armar_item(d, ya_inicio=False) for d in proximos] + iniciados = [_armar_item(d, ya_inicio=True) for d in iniciados_src] + + return {"items": items, "iniciados": iniciados} + # NO cachear si sale vacio por data aun no lista (evita quedar pegado en []). + # Si hay cursos cargados y aun asi no hay proximos, se cachea normal. + res = cache_get_or_set("alertas_dash", ("GLOBAL",), _load) + if not res.get("items") and _cursos_crudos(): + from cache_manager import cache_invalidate + cache_invalidate("alertas_dash") + res = _load() + return res + + def programas_ocultos(): """Programas con contar=NO en basebi_programacion (ocultos de la matriz). Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}.""" @@ -1499,10 +2448,14 @@ def guardar_leyenda(cambios): # en programa_pautas (muchos-a-muchos, se SUMA, no reemplaza). if ni and pauta: dm.agregar_programa_pauta(str(ni).strip(), pauta) + # Invalida TODO lo que depende de pauta/conjunto (incluye ROAS, Programa, etc.) 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"): + "resultados_pauta_periodo", "resultados_pauta", + "leads_dash", "otros_general", "matriz_always", "matriz_webform", + "matriz_asignados", "matriz_plantillas", "leyenda_anuncios", + "roas_dash", "programa_dash", "programa_opciones", + "leads_x_codigo", "leads_filtros", "vendedores_dash"): cache_invalidate(pref) return {"guardados": len(cambios)} @@ -1713,6 +2666,7 @@ def precargar_todo(): lambda: vendedores_dashboard(a0, m0, "TODOS"), apartado="Vendedores") _cron("Apartado ROAS", "cache", lambda: roas_dashboard(a0, m0, "TODOS"), apartado="ROAS") + _cron("Apartado Alertas", "cache", alertas_dashboard, apartado="Alertas") _marcar_actualizacion() print(">>> Hilo 1 (Leads) LISTO.", flush=True) _log_carga(">>> Hilo 1 (Leads) LISTO.")