From de2e2b94d0f4496fcd49c2ce03922b3ae2cead60 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 10 Sep 2026 11:41:08 -0500 Subject: [PATCH] Actualizar backend/services.py --- backend/services.py | 5265 ++++++++++++++++++++++--------------------- 1 file changed, 2636 insertions(+), 2629 deletions(-) diff --git a/backend/services.py b/backend/services.py index 4ed83bc..f5f13f6 100644 --- a/backend/services.py +++ b/backend/services.py @@ -1,2733 +1,2740 @@ - # 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 +# 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 +_DM = None - def get_dm() -> DataManager: - global _DM - if _DM is None: - _DM = DataManager() - return _DM +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", - "resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash", - ) +# ── 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", + "resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash", +) - 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) +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())) +# ── 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 _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 _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 _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 _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 _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 _campanias_map(): + return cache_get_or_set("campanias_map", ("GLOBAL",), + 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 _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": +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()) + + +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) + + +# 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 - 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": + f = L._to_date(r.get("fecha_creada")) + if f is None: 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()) - - - 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) - - - # 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.""" - 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) - - # 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, - "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) - # 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 - 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(), - 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")}, - "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(), - _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} - - 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: 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. 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) -> 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: 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 - 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) - - 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 - 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) - - 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 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. - - 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"] - # 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) - 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 - 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() - if tel and tel in cont_tels[v]: - continue # ese telefono ya se conto para este vendedor + 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()) - cart_total[v] += 1 - 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) + 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) - # 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 +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" - 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 + out[cod].setdefault("cont", set()).add(tel) + return dict(out) + return cache_get_or_set("leads_x_codigo", (ano, mes, dia), _load) - # 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): + +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.""" + 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) + + # 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, + "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) + # 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 + 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 - 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 + if fm >= fenvio_d: + _vistas.add(nm) + matriculas[plantilla] += 1 + venta[plantilla] += inv - 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 + 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} - 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) + return cache_get_or_set("matriz_plantillas", (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 _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 - # Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca). - _PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} +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 _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 _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 _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()) +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(), + 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")}, + "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(), + _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} + + 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: 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. 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) -> 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: 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 + 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) + + 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 + 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) + + 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 - 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 - 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"} - # 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 - 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 - - # 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, 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): - 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)) - 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_sede - dd = conj_det.setdefault((sede, prog), {}) - 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): - """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, - "rec": len(rec_pg.get((sede, p), set())), - "proc": len(proc_pg.get((sede, p), set())), - "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], - "rec": len(rec_s[s]), "proc": len(proc_s[s]), - "programas": _sub(s)}) - 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) - etiq_ase_tels = _dd(lambda: _dd(set)) # estado -> asesor -> {telefonos} - 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" - ase = l.get("asesor") or "SIN ASESOR" - etiq_tels[ult].add(tel) - etiq_ase_tels[ult][ase].add(tel) - matriz_etiq = sorted( - [{"estado": k, "cantidad": len(v), - "asesores": sorted( - [{"asesor": a, "cantidad": len(t), "telefonos": sorted(t)} - for a, t in etiq_ase_tels[k].items()], - key=lambda x: -x["cantidad"])} - 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: + if cur >= fin: # ya paso este bloque 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 + 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) - # 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 + # ── 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 - # 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 + # ── 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 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: - 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 + 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) - # 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())] + 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), + }) - # 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) + # 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 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 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"] + # 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) + 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 + 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() + 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 + 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 + # (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 conjuntos_sin_pauta(): - """Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up).""" - return get_dm().traer_conjuntos_sin_pauta() +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 _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 +# Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca). +_PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} - 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) +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) - 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 _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 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)) + +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"} + # 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 + 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 + + # 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, 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): + 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)) + 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_sede + dd = conj_det.setdefault((sede, prog), {}) + 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 cd in cds: - lp = lxc.get(cd) + for pa in pautas_de_conj.get(k, []): + lp = lxc.get(pa) 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)} + 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) - 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) + ORDEN_PROG = ["TEAC", "TERC", "SEMINARIOS", "OTROS", "SIN PROGRAMA"] - # 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) + 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 - proximos = [d for d in cursos if HOY <= d["fch"] <= LIM and d["importe"] > 0] - proximos.sort(key=lambda x: x["fch"]) + 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, + "rec": len(rec_pg.get((sede, p), set())), + "proc": len(proc_pg.get((sede, p), set())), + "conjuntos": _conjuntos(sede, p)}) + return out - # 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}.""" - 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 "" + # Las 4 sedes reales siempre se muestran. OTROS solo si tiene algo (>0). 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} + 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], + "rec": len(rec_s[s]), "proc": len(proc_s[s]), + "programas": _sub(s)}) + 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) - 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)} +_CURSOS_EXCLUIDOS = {"1121", "1154"} - 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}.""" +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 - corte = date(2026, 1, 1) - vistos = {} + 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 f or f < corte: + 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")) - nombre = str(c.get("dsc_det_programa") or c.get("dsc_programa") or "").strip() - if not nombre: + 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) + etiq_ase_tels = _dd(lambda: _dd(set)) # estado -> asesor -> {telefonos} + cods_set0 = set(cods) + for l in _leads_crudos(): + if not l.get("es_unico") or not l.get("asesor"): 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} + 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" + ase = l.get("asesor") or "SIN ASESOR" + etiq_tels[ult].add(tel) + etiq_ase_tels[ult][ase].add(tel) + matriz_etiq = sorted( + [{"estado": k, "cantidad": len(v), + "asesores": sorted( + [{"asesor": a, "cantidad": len(t), "telefonos": sorted(t)} + for a, t in etiq_ase_tels[k].items()], + key=lambda x: -x["cantidad"])} + for k, v in etiq_tels.items()], + key=lambda x: -x["cantidad"]) - - # ── 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: + # 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 - 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) + 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 - 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) - # 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", "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)} + # 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())] - 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} + # 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] - 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, - }) + n_rec = len(rec); n_proc = len(proc); n_cont = len(cont) 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, + "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) - # ── Precarga / refresco ───────────────────────────────────────── - def _periodos_actual_anterior(): - """[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy).""" - from datetime import date - hoy = date.today() - ano, mes = hoy.year, hoy.month - if mes == 1: - prev = (ano - 1, 12) - else: - prev = (ano, mes - 1) - return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))] +def 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) - import os as _os - _LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log") +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 _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 +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 - 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 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 _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.""" +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) + # 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", "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)} + + +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 - 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, reset=True) + _log_carga(" CARGA DEL DASHBOARD LEADS — " + + _t.strftime("%d/%m/%Y %H:%M:%S")) _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") - _cron("Apartado Alertas", "cache", alertas_dashboard, apartado="Alertas") - _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 set_forzar - from data_manager_v2 import invalidar_campanias_cache - invalidar_campanias_cache() # tambien las campañas (cache aparte) - set_forzar(True) # recalcula y sobrescribe (no lee cache viejo) - try: - precargar_todo() # leads (mes actual/anterior) - precargar_otros_general() # cartera + Otros General - finally: - set_forzar(False) + 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") + _cron("Apartado Alertas", "cache", alertas_dashboard, apartado="Alertas") _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(): + """Refresco de los 30 min SIN dejar la caché vacía (stale-while-revalidate). + NO se vacía nada al inicio: se recalcula TODO con datos frescos de las bases + en 2º plano (modo 'forzar'), y cada entrada se reemplaza recién cuando su + valor nuevo está listo. Mientras dura la recarga, el usuario sigue viendo los + datos viejos; al terminar, el cambio es un solo parpadeo. NO pantalla de carga.""" + from cache_manager import set_forzar + from data_manager_v2 import invalidar_campanias_cache + # Las campañas (cache aparte de data_manager) sí se invalidan: son livianas y + # las relee la primera consulta forzada de abajo, sin costo perceptible. + invalidar_campanias_cache() + set_forzar(True) # este hilo recalcula y sobrescribe (no lee cache viejo) + try: + precargar_todo() # leads (mes actual/anterior) -> reemplaza al terminar + precargar_otros_general() # cartera + Otros General -> reemplaza al terminar + finally: + set_forzar(False) # vuelve a modo normal para no afectar otras llamadas + _marcar_actualizacion()