Actualizar backend/services.py

This commit is contained in:
2026-09-10 11:41:08 -05:00
parent 90f91e70cd
commit de2e2b94d0

View File

@@ -1,33 +1,33 @@
# backend/services.py # backend/services.py
"""Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads.""" """Servicios: traen datos crudos (cacheados) y arman la respuesta del módulo Leads."""
from data_manager_v2 import DataManager from data_manager_v2 import DataManager
import leads_logic as L import leads_logic as L
from cache_manager import cache_get_or_set, cache_invalidate from cache_manager import cache_get_or_set, cache_invalidate
_DM = None _DM = None
def get_dm() -> DataManager: def get_dm() -> DataManager:
global _DM global _DM
if _DM is None: if _DM is None:
_DM = DataManager() _DM = DataManager()
return _DM return _DM
# ── Prefijos de cache que dependen del mapa de campañas (leyendas). Al cambiar # ── 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 # 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 # 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. # los 15 min ni recargar todo. NO se toca la logica, solo se refresca el cache.
_PREF_CAMPANIAS = ( _PREF_CAMPANIAS = (
"campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros", "campanias_map", "campanias_sc", "leads_raw", "leads_dash", "leads_filtros",
"leads_alertas", "vendedores_dash", "roas_dash", "otros_general", "leads_alertas", "vendedores_dash", "roas_dash", "otros_general",
"matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas", "matriz_always", "matriz_webform", "matriz_asignados", "matriz_plantillas",
"tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo", "tiempo_resp", "importe_pauta_periodo", "resultados_pauta_periodo",
"resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash", "resultados_pauta", "leads_x_codigo", "tel_es_copito", "programa_dash",
) )
def _invalidar_por_campanias(): def _invalidar_por_campanias():
"""Invalida el cache de campañas (Supabase) y de todos los apartados que """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.""" dependen de las leyendas, para que el cambio se vea de inmediato."""
from data_manager_v2 import invalidar_campanias_cache from data_manager_v2 import invalidar_campanias_cache
@@ -36,31 +36,31 @@
cache_invalidate(p) cache_invalidate(p)
# ── Datos crudos cacheados (consultas pesadas, una sola vez) ──── # ── Datos crudos cacheados (consultas pesadas, una sola vez) ────
def _leads_crudos(): def _leads_crudos():
return cache_get_or_set("leads_raw", ("GLOBAL",), return cache_get_or_set("leads_raw", ("GLOBAL",),
lambda: L.procesar_leads(get_dm().traer_leads_chatwoot())) lambda: L.procesar_leads(get_dm().traer_leads_chatwoot()))
def _cursos_crudos(): def _cursos_crudos():
return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos()) return cache_get_or_set("cursos_raw", ("GLOBAL",), lambda: get_dm().traer_cursos())
def _matriculas_crudas(): def _matriculas_crudas():
return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas()) return cache_get_or_set("mat_raw", ("GLOBAL",), lambda: get_dm().traer_matriculas())
def _pauta_cruda(): def _pauta_cruda():
return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos()) return cache_get_or_set("pauta_raw", ("GLOBAL",), lambda: get_dm().traer_pauta_cursos())
def _programa_pautas(): def _programa_pautas():
"""{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos).""" """{num_indice: [pautas]} desde la tabla programa_pautas (muchos-a-muchos)."""
return cache_get_or_set("programa_pautas", ("GLOBAL",), return cache_get_or_set("programa_pautas", ("GLOBAL",),
lambda: get_dm().traer_programa_pautas()) lambda: get_dm().traer_programa_pautas())
def _pautas_de_indice(): def _pautas_de_indice():
"""{num_indice: [pautas]} combinando: """{num_indice: [pautas]} combinando:
- la pauta de basebi_programacion (1 por num_indice), y - la pauta de basebi_programacion (1 por num_indice), y
- las pautas extra de programa_pautas (varias). - las pautas extra de programa_pautas (varias).
@@ -82,18 +82,18 @@
return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load) return cache_get_or_set("pautas_de_indice", ("GLOBAL",), _load)
def _campanias_map(): def _campanias_map():
return cache_get_or_set("campanias_map", ("GLOBAL",), return cache_get_or_set("campanias_map", ("GLOBAL",),
lambda: get_dm().traer_campanias_map()) lambda: get_dm().traer_campanias_map())
def _clasif_meta(): def _clasif_meta():
"""JSON de clasificacion (sede/programa) de GitHub, MISMO que Ventas.""" """JSON de clasificacion (sede/programa) de GitHub, MISMO que Ventas."""
return cache_get_or_set("clasif_meta", ("GLOBAL",), return cache_get_or_set("clasif_meta", ("GLOBAL",),
lambda: get_dm().traer_clasificacion_meta()) lambda: get_dm().traer_clasificacion_meta())
def clasificar_sede_meta(dsc_programa): def clasificar_sede_meta(dsc_programa):
"""Sede segun el JSON (clasificacion_sedes). Default LIMA. Igual que Ventas.""" """Sede segun el JSON (clasificacion_sedes). Default LIMA. Igual que Ventas."""
up = str(dsc_programa or "").upper() up = str(dsc_programa or "").upper()
data = _clasif_meta().get("clasificacion_sedes", {}) data = _clasif_meta().get("clasificacion_sedes", {})
@@ -106,7 +106,7 @@
return data.get("DEFAULT", "LIMA") return data.get("DEFAULT", "LIMA")
def clasificar_prog_meta(dsc_programa): def clasificar_prog_meta(dsc_programa):
"""Programa (TEAC/TERC/SEMINARIOS/OTROS) segun clasificacion_filtro_programa """Programa (TEAC/TERC/SEMINARIOS/OTROS) segun clasificacion_filtro_programa
del JSON. Respeta el ORDEN del JSON (primero que coincide gana). Igual que Ventas.""" del JSON. Respeta el ORDEN del JSON (primero que coincide gana). Igual que Ventas."""
up = str(dsc_programa or "").upper() up = str(dsc_programa or "").upper()
@@ -120,7 +120,7 @@
return data.get("DEFAULT", "OTROS") return data.get("DEFAULT", "OTROS")
def meta_de_curso(dsc_programa): def meta_de_curso(dsc_programa):
"""Meta de inscritos segun sede+programa (JSON), como acordado: """Meta de inscritos segun sede+programa (JSON), como acordado:
LIMA TEAC/TERC=22, provincia TEAC/TERC=18, SEMINARIOS/OTROS=15.""" LIMA TEAC/TERC=22, provincia TEAC/TERC=18, SEMINARIOS/OTROS=15."""
sede = clasificar_sede_meta(dsc_programa) sede = clasificar_sede_meta(dsc_programa)
@@ -130,22 +130,22 @@
return 15 return 15
# Conecta los clasificadores del JSON a leads_logic (metas + tipo de matricula). # Conecta los clasificadores del JSON a leads_logic (metas + tipo de matricula).
L.set_clasificadores(clasificar_sede_meta, clasificar_prog_meta) L.set_clasificadores(clasificar_sede_meta, clasificar_prog_meta)
def _conjunto_pauta(): def _conjunto_pauta():
return cache_get_or_set("conjunto_pauta", ("GLOBAL",), return cache_get_or_set("conjunto_pauta", ("GLOBAL",),
lambda: get_dm().traer_conjunto_pauta()) lambda: get_dm().traer_conjunto_pauta())
def _meta_filas(): def _meta_filas():
"""Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...].""" """Filas crudas del CSV de Meta: [{conjunto, importe, inicio}, ...]."""
return cache_get_or_set("meta_filas", ("GLOBAL",), return cache_get_or_set("meta_filas", ("GLOBAL",),
lambda: get_dm().traer_meta_importe()) lambda: get_dm().traer_meta_importe())
def _importe_por_pauta(): def _importe_por_pauta():
"""{pauta: importe_gastado_total} = suma del importe de TODAS las filas de los """{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.""" conjuntos ligados a esa pauta (conjunto_pauta × Meta CSV). Sin filtro de fecha."""
def _load(): def _load():
@@ -162,11 +162,11 @@
return cache_get_or_set("importe_pauta", ("GLOBAL",), _load) return cache_get_or_set("importe_pauta", ("GLOBAL",), _load)
# Canales que cuentan como Copito (ademas de origen_base=POSTGRE). # Canales que cuentan como Copito (ademas de origen_base=POSTGRE).
_CANALES_COPITO = {"PAUTA_WSP", "PAUTA_WSP_FACE", "COPITO"} _CANALES_COPITO = {"PAUTA_WSP", "PAUTA_WSP_FACE", "COPITO"}
def _tel_es_copito(): def _tel_es_copito():
"""{telefono: True/False} = si el telefono es COPITO segun su FILA MAS ANTIGUA """{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). en la cartera. Copito = (origen_base=POSTGRE) OR (canal en _CANALES_COPITO).
Se calcula una vez sobre toda la cartera (cacheado).""" Se calcula una vez sobre toda la cartera (cacheado)."""
@@ -188,7 +188,7 @@
return cache_get_or_set("tel_es_copito", ("GLOBAL",), _load) return cache_get_or_set("tel_es_copito", ("GLOBAL",), _load)
def _leads_por_codigo_periodo(ano, mes, dia): def _leads_por_codigo_periodo(ano, mes, dia):
"""{codigo(pauta): {"rec": set(tels recibidos del periodo), """{codigo(pauta): {"rec": set(tels recibidos del periodo),
"proc": set(tels procesados del periodo)}}. "proc": set(tels procesados del periodo)}}.
Recibido = lead unico con ese codigo cuya fecha cae en el periodo. Recibido = lead unico con ese codigo cuya fecha cae en el periodo.
@@ -215,7 +215,7 @@
return cache_get_or_set("leads_x_codigo", (ano, mes, dia), _load) return cache_get_or_set("leads_x_codigo", (ano, mes, dia), _load)
def _resultados_por_pauta(): def _resultados_por_pauta():
"""{pauta: resultados_totales} = suma de Resultados de TODAS las filas de los """{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).""" conjuntos ligados a esa pauta. Sin filtro de fecha (analogo a _importe_por_pauta)."""
def _load(): def _load():
@@ -232,7 +232,7 @@
return cache_get_or_set("resultados_pauta", ("GLOBAL",), _load) return cache_get_or_set("resultados_pauta", ("GLOBAL",), _load)
def _importe_por_pauta_periodo(ano, mes, dia): def _importe_por_pauta_periodo(ano, mes, dia):
"""{pauta: importe} sumando solo las filas del CSV cuyo 'Inicio del informe' """{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.""" cae dentro del periodo filtrado (año/mes/día). Respeta el filtro de arriba."""
def _load(): def _load():
@@ -255,7 +255,7 @@
return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load) return cache_get_or_set("importe_pauta_periodo", (ano, mes, dia), _load)
def _resultados_por_pauta_periodo(ano, mes, dia): def _resultados_por_pauta_periodo(ano, mes, dia):
"""{pauta: resultados} sumando solo las filas del CSV cuyo 'Inicio del informe' """{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.""" cae en el periodo filtrado. Analogo a _importe_por_pauta_periodo pero con Resultados."""
def _load(): def _load():
@@ -275,12 +275,12 @@
return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load) return cache_get_or_set("resultados_pauta_periodo", (ano, mes, dia), _load)
def _campanias_sede_cargo(): def _campanias_sede_cargo():
return cache_get_or_set("campanias_sc", ("GLOBAL",), return cache_get_or_set("campanias_sc", ("GLOBAL",),
lambda: get_dm().traer_campanias_sede_cargo()) lambda: get_dm().traer_campanias_sede_cargo())
def matriz_always(ano="TODOS", mes="TODOS", dia="TODOS", sede="TODOS", programa="TODOS"): 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). """Matriz de gasto Meta Ads de pautas ALWAYS (con gasto pero SIN num_indice).
Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados, Agrupa Sede -> Programa (TEAC/TERC/SEMINARIOS) con Importe, Resultados,
Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor). Leads Nuevos (fecha_creada en el mes) y Leads Nuevos Asignados (con asesor).
@@ -426,7 +426,7 @@
return cache_get_or_set("matriz_always", (ano, mes, dia, sede, programa), _load) 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"): 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. """Matriz de leads con canal WEB_FORMULARIO (cartera_junta), agrupados Sede -> Programa.
Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y Valores: Leads Recibidos (todos), Leads Procesados (con asesor), Matriculas y
Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead).""" Valor Venta (cruce por telefono, fch_matricula >= fecha_creada del lead)."""
@@ -521,23 +521,23 @@
return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load) return cache_get_or_set("matriz_webform", (ano, mes, dia, sede, programa), _load)
def _asignacion_respuesta_crudos(): def _asignacion_respuesta_crudos():
return cache_get_or_set("asig_resp", ("GLOBAL",), return cache_get_or_set("asig_resp", ("GLOBAL",),
lambda: get_dm().traer_asignacion_respuesta()) lambda: get_dm().traer_asignacion_respuesta())
def _leads_asignados_crudos(): def _leads_asignados_crudos():
# UNIFICADO: usa la MISMA consulta/cache que el grafico (asignacion+respuesta+etiquetas), # 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. # asi la tarjeta, la matriz de asignados y el grafico nunca se desfasan.
return _asignacion_respuesta_crudos() return _asignacion_respuesta_crudos()
def _actividad_asesores_crudos(): def _actividad_asesores_crudos():
return cache_get_or_set("act_asesores", ("GLOBAL",), return cache_get_or_set("act_asesores", ("GLOBAL",),
lambda: get_dm().traer_actividad_asesores()) lambda: get_dm().traer_actividad_asesores())
def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"): def matriz_asignados(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del """Matriz pivot: filas = asesor (user_name) con sus telefonos; columnas = dias del
mes; valor = cantidad de telefonos asignados a ese asesor ese dia. mes; valor = cantidad de telefonos asignados a ese asesor ese dia.
Fecha = created_at del mensaje de asignacion (ya con -5h).""" Fecha = created_at del mensaje de asignacion (ya con -5h)."""
@@ -584,12 +584,12 @@
return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load) return cache_get_or_set("matriz_asignados", (ano, mes, dia), _load)
def _plantillas_crudas(): def _plantillas_crudas():
return cache_get_or_set("plantillas_raw", ("GLOBAL",), return cache_get_or_set("plantillas_raw", ("GLOBAL",),
lambda: get_dm().traer_plantillas()) lambda: get_dm().traer_plantillas())
def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"): def matriz_plantillas(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Matriz de plantillas COBRADAS por plantilla. Columnas: """Matriz de plantillas COBRADAS por plantilla. Columnas:
ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado ENVIADAS = Mas_de_24h=SI, created_at_peru en el periodo filtrado
RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1) RESPONDIDAS= de esas, con respuesta del contacto (siguiente_mensaje_1)
@@ -683,13 +683,13 @@
return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load) return cache_get_or_set("matriz_plantillas", (ano, mes, dia), _load)
def _norm_ni(x): def _norm_ni(x):
"""Normaliza num_indice a texto (quita el '.0' si viniera como float).""" """Normaliza num_indice a texto (quita el '.0' si viniera como float)."""
s = str(x or "").strip() s = str(x or "").strip()
return s[:-2] if s.endswith(".0") else s return s[:-2] if s.endswith(".0") else s
def _mat_por_indice(): def _mat_por_indice():
"""{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas. """{num_indice: [(telefono_resuelto, fecha_matricula), ...]} desde las matrículas.
Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2.""" Teléfono resuelto = dsc_telefono_1 si existe; si no, dsc_telefono_2."""
def _load(): def _load():
@@ -705,14 +705,14 @@
return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load) return cache_get_or_set("mat_por_indice", ("GLOBAL",), _load)
def _cartera_rows_cache(): def _cartera_rows_cache():
"""Filas crudas de cartera_junta cacheadas (se bajan de Supabase UNA sola vez). """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.""" Evita re-descargar las 86k filas en cada llamada/cambio de mes."""
return cache_get_or_set("cartera_rows", ("GLOBAL",), return cache_get_or_set("cartera_rows", ("GLOBAL",),
lambda: get_dm().traer_cartera_rows()) lambda: get_dm().traer_cartera_rows())
def _cartera_idx(): def _cartera_idx():
"""Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}. """Devuelve {'all':..., 'asig':...}, cada uno {(sede,programa):{telefono:fecha_min}}.
'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío). 'all' = todos los teléfonos; 'asig' = solo los que tienen asesor (no vacío).
Se baja la cartera UNA vez y queda cacheado.""" Se baja la cartera UNA vez y queda cacheado."""
@@ -783,8 +783,8 @@
return cache_get_or_set("cartera_idx", ("GLOBAL",), _load) return cache_get_or_set("cartera_idx", ("GLOBAL",), _load)
# ── Respuesta del dashboard de Leads (con filtros) ────────────── # ── Respuesta del dashboard de Leads (con filtros) ──────────────
def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"): def leads_dashboard(ano="TODOS", mes="TODOS", dia="TODOS", programa="TODOS", sede="TODOS"):
def _load(): def _load():
leads = _leads_crudos() leads = _leads_crudos()
cursos = _cursos_crudos() cursos = _cursos_crudos()
@@ -840,18 +840,18 @@
return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load) return cache_get_or_set("leads_dash", (ano, mes, dia, programa, sede), _load)
def _asesores(): def _asesores():
"""{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}.""" """{'map': {alias_norm: correcto}, 'lista': [los 8 vendedores]}."""
return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores()) return cache_get_or_set("asesores", ("GLOBAL",), lambda: get_dm().traer_asesores())
def _norm_asesor(nombre, mapa): def _norm_asesor(nombre, mapa):
"""Normaliza un nombre de asesor a su 'correcto' via el diccionario.""" """Normaliza un nombre de asesor a su 'correcto' via el diccionario."""
k = " ".join(str(nombre or "").upper().split()) k = " ".join(str(nombre or "").upper().split())
return mapa.get(k) # None si no es uno de los vendedores return mapa.get(k) # None si no es uno de los vendedores
def _norm_tel_pe(v): def _norm_tel_pe(v):
"""Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial """Normaliza telefono igual que la cartera: quita simbolos y el '51' inicial
de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta.""" de celulares peruanos (rango 51900000000-51999999999). Asi cruzan con cartera_junta."""
s = str(v or "") s = str(v or "")
@@ -863,7 +863,7 @@
return s return s
def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): def tiempo_respuesta_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion. """Tiempo de respuesta LABORAL (minutos) del asesor tras la asignacion.
- Turno detectado por dia/asesor segun donde hay MAS actividad: - Turno detectado por dia/asesor segun donde hay MAS actividad:
tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido). tarde (13-18) -> Turno 1 (09-18, refrigerio 13-14 excluido).
@@ -1081,7 +1081,7 @@
return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load) return cache_get_or_set("tiempo_resp", (ano, mes, dia), _load)
def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS", def tiempo_respuesta_detalle(dia_click, modo="asignacion", vendedor="TODOS",
ano="TODOS", mes="TODOS"): ano="TODOS", mes="TODOS"):
"""Detalle de los leads que forman el promedio de UN día del gráfico """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. 'Tiempo de Respuesta por Día'. Reproduce la MISMA lógica de turnos/minutos.
@@ -1222,7 +1222,7 @@
return cache_get_or_set("tiempo_resp_det", (dia_click, modo, vendedor, ano, mes), _load) return cache_get_or_set("tiempo_resp_det", (dia_click, modo, vendedor, ano, mes), _load)
def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): def vendedores_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día: """Por vendedor (los 8 de alias_normalizacion). Filtro Año/Mes/Día:
- Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta. - Cartera (total/copito/otros): filtra por fecha_creada de cartera_junta.
- Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI, - Matriculas: se cruzan por TELEFONO con los leads del mes (es_origen=SI,
@@ -1454,17 +1454,17 @@
return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load) return cache_get_or_set("vendedores_dash", (ano, mes, dia), _load)
def _conjunto_sede_prog(): def _conjunto_sede_prog():
"""{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase).""" """{conjunto_UPPER: {sede, programa}} asignaciones manuales (tabla Supabase)."""
return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",), return cache_get_or_set("conjunto_sede_prog", ("GLOBAL",),
lambda: get_dm().traer_conjunto_sede_programa()) lambda: get_dm().traer_conjunto_sede_programa())
# Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca). # Programas cuyos RESULTADOS no deben sumar (son de otro tipo: alcance/marca).
_PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"} _PROG_SIN_RESULTADO = {"BRANDING", "COPEAIRE"}
def _conjuntos_branding(): def _conjuntos_branding():
"""Set de CONJUNTOS (UPPER) asignados manualmente como BRANDING/COPEAIRE. """Set de CONJUNTOS (UPPER) asignados manualmente como BRANDING/COPEAIRE.
Sus resultados NO deben contarse (su importe sí).""" Sus resultados NO deben contarse (su importe sí)."""
def _load(): def _load():
@@ -1474,7 +1474,7 @@
return cache_get_or_set("conjuntos_branding", ("GLOBAL",), _load) return cache_get_or_set("conjuntos_branding", ("GLOBAL",), _load)
def _codigos_branding(): def _codigos_branding():
"""Set de codigos(pauta) cuyos conjuntos estan asignados manualmente como """Set de codigos(pauta) cuyos conjuntos estan asignados manualmente como
BRANDING/COPEAIRE. Sirve para NO contar sus resultados en las tablas.""" BRANDING/COPEAIRE. Sirve para NO contar sus resultados en las tablas."""
def _load(): def _load():
@@ -1493,7 +1493,7 @@
return cache_get_or_set("codigos_branding", ("GLOBAL",), _load) return cache_get_or_set("codigos_branding", ("GLOBAL",), _load)
def conjuntos_sin_sede(): def conjuntos_sin_sede():
"""Lista los conjuntos del Meta CSV que NO estan ligados a ninguna 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 (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). asignacion. Sin filtro de mes. Incluye la sede/programa YA asignados (si tiene).
@@ -1531,7 +1531,7 @@
return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load) return cache_get_or_set("conjuntos_sin_sede", ("GLOBAL",), _load)
def guardar_conjunto_sede(cambios): def guardar_conjunto_sede(cambios):
"""Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes.""" """Guarda asignaciones conjunto->sede/programa e invalida cachés dependientes."""
r = get_dm().guardar_conjunto_sede_programa(cambios) r = get_dm().guardar_conjunto_sede_programa(cambios)
for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"):
@@ -1539,7 +1539,7 @@
return r return r
def borrar_conjunto_sede(conjunto): def borrar_conjunto_sede(conjunto):
"""Borra la asignacion de un conjunto e invalida cachés dependientes.""" """Borra la asignacion de un conjunto e invalida cachés dependientes."""
r = get_dm().borrar_conjunto_sede_programa(conjunto) r = get_dm().borrar_conjunto_sede_programa(conjunto)
for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"): for pref in ("conjunto_sede_prog", "conjuntos_sin_sede", "roas_dash"):
@@ -1547,7 +1547,7 @@
return r return r
def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"): def roas_dashboard(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Matriz ROAS por SEDE (LIMA, PIURA, AREQUIPA, TRUJILLO): Importe Gastado y """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). Resultados del Google Sheet (Meta Ads), filtrado por Año/Mes (Inicio del informe).
Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin Cadena: conjunto -> pauta (conjunto_pauta) -> sede (campanias). Conjuntos sin
@@ -1693,10 +1693,10 @@
return cache_get_or_set("roas_dash", (ano, mes, dia), _load) return cache_get_or_set("roas_dash", (ano, mes, dia), _load)
_CURSOS_EXCLUIDOS = {"1121", "1154"} _CURSOS_EXCLUIDOS = {"1121", "1154"}
def _curso_excluido(c, pmap): def _curso_excluido(c, pmap):
"""Mismo criterio que 'Detalle por Curso': excluye cursos en la lista fija, """Mismo criterio que 'Detalle por Curso': excluye cursos en la lista fija,
o con contar=NO / estado=SUSPENDIDO en basebi.""" o con contar=NO / estado=SUSPENDIDO en basebi."""
ni = str(c.get("num_indice", "")).strip() ni = str(c.get("num_indice", "")).strip()
@@ -1708,7 +1708,7 @@
return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO" return info.get("contar") == "NO" or info.get("estado") == "SUSPENDIDO"
def programa_opciones(ano="TODOS", mes="TODOS"): def programa_opciones(ano="TODOS", mes="TODOS"):
"""Lista de programas (dsc_det_programa) cuyos cursos tienen fch_inicio en el """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.""" periodo. Para el selector del apartado PROGRAMA. Ordenada por fch_inicio ASC."""
def _load(): def _load():
@@ -1731,7 +1731,7 @@
return cache_get_or_set("programa_opciones", (ano, mes), _load) return cache_get_or_set("programa_opciones", (ano, mes), _load)
def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"): def programa_dashboard(ano="TODOS", mes="TODOS", programa="TODOS"):
"""Datos agregados de UN programa (dsc_det_programa) en el periodo: """Datos agregados de UN programa (dsc_det_programa) en el periodo:
Cartera Total/Copito/Otros, Leads Recibidos/Procesados/Contactados y %. Cartera Total/Copito/Otros, Leads Recibidos/Procesados/Contactados y %.
- Filtro por fch_inicio del curso para elegir los cursos del programa. - Filtro por fch_inicio del curso para elegir los cursos del programa.
@@ -2002,7 +2002,7 @@
return cache_get_or_set("programa_dash", (ano, mes, programa), _load) return cache_get_or_set("programa_dash", (ano, mes, programa), _load)
def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"): def otros_general(ano="TODOS", mes="TODOS", dia="TODOS"):
"""Solo las 4 matrices de la pagina 'Otros General' (sin KPIs/graficos/tabla_pauta). """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.""" Mas liviano que leads_dashboard -> filtrar por mes es mas rapido."""
def _load(): def _load():
@@ -2015,12 +2015,12 @@
return cache_get_or_set("otros_general", (ano, mes, dia), _load) return cache_get_or_set("otros_general", (ano, mes, dia), _load)
def conjuntos_sin_pauta(): def conjuntos_sin_pauta():
"""Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up).""" """Lista de conjuntos de anuncios sin pauta (para el desplegable del pop-up)."""
return get_dm().traer_conjuntos_sin_pauta() return get_dm().traer_conjuntos_sin_pauta()
def _pautas_de_curso_ni(ni): def _pautas_de_curso_ni(ni):
"""Pautas (codigos) de un num_indice = pauta de basebi + programa_pautas.""" """Pautas (codigos) de un num_indice = pauta de basebi + programa_pautas."""
cds = set() cds = set()
for p in _pautas_de_indice().get(ni, []): for p in _pautas_de_indice().get(ni, []):
@@ -2031,7 +2031,7 @@
return cds return cds
def alertas_dashboard(): def alertas_dashboard():
"""ALERTAS: cursos que inician en los proximos 30 dias (con pauta, importe>0). """ALERTAS: cursos que inician en los proximos 30 dias (con pauta, importe>0).
Estado (CRITICO/ADVERTENCIA/BUEN RUMBO) segun MATRICULAS vs meta ESPERADA por Estado (CRITICO/ADVERTENCIA/BUEN RUMBO) segun MATRICULAS vs meta ESPERADA por
dias que faltan. CPL alto / pocos leads son SENALES (no cambian el estado). dias que faltan. CPL alto / pocos leads son SENALES (no cambian el estado).
@@ -2323,7 +2323,7 @@
return res return res
def programas_ocultos(): def programas_ocultos():
"""Programas con contar=NO en basebi_programacion (ocultos de la matriz). """Programas con contar=NO en basebi_programacion (ocultos de la matriz).
Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}.""" Cada item: {num_indice, dsc_det_programa, fch_inicio, pauta}."""
pm = _pauta_cruda() pm = _pauta_cruda()
@@ -2345,7 +2345,7 @@
return {"filas": filas} return {"filas": filas}
def encender_programas(num_indices): def encender_programas(num_indices):
"""Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva).""" """Pone contar=SI a los num_indice dados en basebi_programacion (los reactiva)."""
from cache_manager import cache_invalidate from cache_manager import cache_invalidate
dm = get_dm() dm = get_dm()
@@ -2356,7 +2356,7 @@
return {"encendidos": len(num_indices)} return {"encendidos": len(num_indices)}
def programas_disponibles(): def programas_disponibles():
"""Lista de dsc_det_programa de cursos con fch_inicio >= 2026-01-01 (para el """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}.""" desplegable de Programa en la Leyenda). Cada item: {num_indice, dsc_det_programa}."""
from datetime import date from datetime import date
@@ -2378,30 +2378,30 @@
return {"programas": filas} return {"programas": filas}
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ── # ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
def campanias_listar(): def campanias_listar():
return get_dm().listar_campanias() return get_dm().listar_campanias()
def campania_agregar(datos): def campania_agregar(datos):
res = get_dm().agregar_campania(datos) res = get_dm().agregar_campania(datos)
_invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard _invalidar_por_campanias() # la leyenda nueva se refleja de inmediato en todo el dashboard
return res return res
def campania_editar(cid, datos): def campania_editar(cid, datos):
res = get_dm().editar_campania(cid, datos) res = get_dm().editar_campania(cid, datos)
_invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard _invalidar_por_campanias() # el cambio se refleja de inmediato en todo el dashboard
return res return res
def campania_borrar(cid): def campania_borrar(cid):
res = get_dm().borrar_campania(cid) res = get_dm().borrar_campania(cid)
_invalidar_por_campanias() _invalidar_por_campanias()
return res return res
def leyenda_anuncios(): def leyenda_anuncios():
"""Tabla: todos los conjuntos del Sheet con su pauta (si tiene) y el programa """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 (dsc_det_programa) del num_indice que usa esa pauta. Cacheado (usa insumos ya
en memoria) para que abrir la Leyenda sea rapido.""" en memoria) para que abrir la Leyenda sea rapido."""
@@ -2439,7 +2439,7 @@
return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load) return cache_get_or_set("leyenda_anuncios", ("GLOBAL",), _load)
def guardar_leyenda(cambios): def guardar_leyenda(cambios):
"""cambios = [{conjunto, pauta, num_indice?}]. """cambios = [{conjunto, pauta, num_indice?}].
- conjunto -> pauta: upsert en conjunto_pauta. - conjunto -> pauta: upsert en conjunto_pauta.
- si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion. - si viene num_indice: conecta ese num_indice a la pauta en basebi_programacion.
@@ -2468,7 +2468,7 @@
return {"guardados": len(cambios)} return {"guardados": len(cambios)}
def uso_de_pauta(pauta, excluir_num_indice=None): def uso_de_pauta(pauta, excluir_num_indice=None):
"""Verifica si una pauta ya esta en uso. Devuelve los cursos (dsc_det_programa) """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).""" y conjuntos que la usan (para la advertencia antes de guardar)."""
dm = get_dm() dm = get_dm()
@@ -2488,7 +2488,7 @@
"cursos": detalle, "num_indices": nis, "conjuntos": conjuntos} "cursos": detalle, "num_indices": nis, "conjuntos": conjuntos}
def guardar_edicion_curso(num_indice, pauta, conjunto=None, contar=None): 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ó, """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 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).""" dependen de pauta/conjunto (NO la cartera, para que la recarga sea rápida)."""
@@ -2506,7 +2506,7 @@
return res return res
def _tipo_de_curso(c): def _tipo_de_curso(c):
"""Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS).""" """Categoría de tipo de programa para el filtro (PROGRAMAS/PROVINCIA/SEMINARIOS/OTROS)."""
dscp = c.get("dsc_programa", "") dscp = c.get("dsc_programa", "")
otros = L.clasificar_tipo_programa(dscp) otros = L.clasificar_tipo_programa(dscp)
@@ -2519,8 +2519,8 @@
return L.tipo_programa_cohorte(sede, cat) return L.tipo_programa_cohorte(sede, cat)
# ── Listas para los filtros (años, tipos de programa) ─────────── # ── Listas para los filtros (años, tipos de programa) ───────────
def opciones_filtros(): def opciones_filtros():
def _load(): def _load():
cursos = _cursos_crudos() cursos = _cursos_crudos()
anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos anos = sorted({L._to_date(c.get("fch_inicio")).year for c in cursos
@@ -2531,14 +2531,14 @@
return cache_get_or_set("leads_filtros", ("GLOBAL",), _load) return cache_get_or_set("leads_filtros", ("GLOBAL",), _load)
# ── Alertas: valores de programa/sede no identificados en cartera_junta ── # ── Alertas: valores de programa/sede no identificados en cartera_junta ──
def alertas(): def alertas():
return cache_get_or_set("leads_alertas", ("GLOBAL",), return cache_get_or_set("leads_alertas", ("GLOBAL",),
lambda: get_dm().traer_no_identificados()) lambda: get_dm().traer_no_identificados())
# ── Diagnóstico de cursos (para cuadrar ocupabilidad) ─────────── # ── Diagnóstico de cursos (para cuadrar ocupabilidad) ───────────
def debug_cursos(ano, mes): def debug_cursos(ano, mes):
cursos = _cursos_crudos() cursos = _cursos_crudos()
periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")] periodo = [c for c in cursos if L._en_periodo(L._to_date(c.get("fch_inicio")), ano, mes, "TODOS")]
detalle = [] detalle = []
@@ -2568,8 +2568,8 @@
} }
# ── Precarga / refresco ───────────────────────────────────────── # ── Precarga / refresco ─────────────────────────────────────────
def _periodos_actual_anterior(): def _periodos_actual_anterior():
"""[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy).""" """[(ano, mes), ...] = mes actual y mes anterior (segun fecha de hoy)."""
from datetime import date from datetime import date
hoy = date.today() hoy = date.today()
@@ -2581,11 +2581,11 @@
return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))] return [(str(ano), str(mes)), (str(prev[0]), str(prev[1]))]
import os as _os import os as _os
_LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log") _LOG_CARGA = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "carga_tiempos.log")
def _log_carga(linea, reset=False): def _log_carga(linea, reset=False):
"""Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos).""" """Escribe una linea en carga_tiempos.log (lo lee la ventana de tiempos)."""
try: try:
with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f: with open(_LOG_CARGA, "w" if reset else "a", encoding="utf-8") as f:
@@ -2594,13 +2594,13 @@
pass pass
import threading as _thr import threading as _thr
_TIEMPOS = {} # etiqueta -> segundos (para el resumen final) _TIEMPOS = {} # etiqueta -> segundos (para el resumen final)
_TIEMPOS_LOCK = _thr.Lock() _TIEMPOS_LOCK = _thr.Lock()
_HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen _HILOS_LISTOS = set() # {"leads","otros"} — cuando estan los 2, se imprime resumen
def _cron(etiqueta, base, fn, apartado=None): def _cron(etiqueta, base, fn, apartado=None):
"""Ejecuta fn() midiendo el tiempo. Imprime y ademas guarda en el log: """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 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. ventana de TIEMPOS al iniciar, para saber que carga cada cosa.
@@ -2623,7 +2623,7 @@
return r return r
def _hilo_termino(nombre): def _hilo_termino(nombre):
"""Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan, """Marca un hilo como listo. Cuando ambos ("leads" y "otros") terminan,
imprime el resumen final por apartado en el log.""" imprime el resumen final por apartado en el log."""
with _TIEMPOS_LOCK: with _TIEMPOS_LOCK:
@@ -2647,7 +2647,7 @@
_log_carga("=" * 60) _log_carga("=" * 60)
def precargar_todo(): def precargar_todo():
"""Precarga en segundo plano: insumos crudos + el dashboard del mes actual y """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.""" anterior (con filtros en TODOS), para que el usuario no espere al abrir/filtrar."""
try: try:
@@ -2685,7 +2685,7 @@
_hilo_termino("leads") _hilo_termino("leads")
def precargar_otros_general(): def precargar_otros_general():
"""Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual """Precarga (aparte, mas pesada) las 4 matrices de Otros General del mes actual
y anterior. Se llama en su propio hilo para no bloquear.""" y anterior. Se llama en su propio hilo para no bloquear."""
try: try:
@@ -2705,29 +2705,36 @@
_hilo_termino("otros") _hilo_termino("otros")
# Hora de la última actualización (hora Perú) # Hora de la última actualización (hora Perú)
_ULTIMA_ACTUALIZACION = None _ULTIMA_ACTUALIZACION = None
def _marcar_actualizacion(): def _marcar_actualizacion():
global _ULTIMA_ACTUALIZACION global _ULTIMA_ACTUALIZACION
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
peru = timezone(timedelta(hours=-5)) peru = timezone(timedelta(hours=-5))
_ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M") _ULTIMA_ACTUALIZACION = datetime.now(peru).strftime("%d/%m/%Y %H:%M")
def ultima_actualizacion(): def ultima_actualizacion():
return {"hora": _ULTIMA_ACTUALIZACION} return {"hora": _ULTIMA_ACTUALIZACION}
def refrescar_todo(): 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 cache_manager import set_forzar
from data_manager_v2 import invalidar_campanias_cache from data_manager_v2 import invalidar_campanias_cache
invalidar_campanias_cache() # tambien las campañas (cache aparte) # Las campañas (cache aparte de data_manager) sí se invalidan: son livianas y
set_forzar(True) # recalcula y sobrescribe (no lee cache viejo) # 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: try:
precargar_todo() # leads (mes actual/anterior) precargar_todo() # leads (mes actual/anterior) -> reemplaza al terminar
precargar_otros_general() # cartera + Otros General precargar_otros_general() # cartera + Otros General -> reemplaza al terminar
finally: finally:
set_forzar(False) set_forzar(False) # vuelve a modo normal para no afectar otras llamadas
_marcar_actualizacion() _marcar_actualizacion()