bloque 5: tablero de direccion (pagina /direccion con permiso propio: resumen con comparativos, cohortes de retencion, valor por cliente, clientes en riesgo, ocupacion por medico/sucursal/recurso, ingresos por servicio y sucursal, membresias y paquetes, pasivo de lealtad, metas por sucursal con proyeccion y exportacion) con cifras cruzadas contra los reportes existentes y consultas agrupadas (menos de 2 s)

This commit is contained in:
2026-10-12 03:34:00 +00:00
parent a6d6df4ebd
commit 6139df6327
15 changed files with 2256 additions and 3 deletions

View File

@@ -1730,6 +1730,134 @@ def get_encuestas(env):
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
return normalizar_encuestas(get_settings(env).get('encuestas'))
# ---------------------------------------------------------------------------
# Tablero de dirección (indicadores del dueño).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La clave es
# `tablero` (no `direccion`, que ya existe a nivel raíz para la dirección
# postal de la clínica). No enciende ni apaga nada por sí solo: son los
# PARÁMETROS del tablero y las metas por sucursal. Con los defaults (sin metas,
# 90 días de inactividad y 3 visitas mínimas) el tablero funciona y no altera
# ninguna cifra existente.
#
# metas_sucursal: [{sucursal: <code>, meta_mensual: <monto>}]
# meta_mensual_global: meta de venta mensual de toda la clínica (0 = sin meta)
# dias_inactividad: días sin aparecer para considerar a un cliente "en riesgo"
# visitas_minimas_riesgo: visitas históricas mínimas para entrar a "en riesgo"
# (un paciente de una sola visita no se considera en riesgo)
# comparativo_default: anterior | anio | ninguno (interruptor del tablero)
# ---------------------------------------------------------------------------
DIRECCION_DEFAULT = {
'metas_sucursal': [],
'meta_mensual_global': 0.0,
'dias_inactividad': 90,
'visitas_minimas_riesgo': 3,
'comparativo_default': 'anterior',
}
DIRECCION_COMPARATIVOS = ('anterior', 'anio', 'ninguno')
DIRECCION_DIAS_MAX = 3650
DIRECCION_VISITAS_MAX = 1000
DIRECCION_METAS_MAX = 100
def _normalizar_metas_sucursal(raw):
"""Lista de metas por sucursal normalizada (una por código)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
vistos = set()
for item in raw:
if not isinstance(item, dict):
continue
codigo = str(item.get('sucursal') or '').strip()[:40]
if not codigo or codigo in vistos:
continue
vistos.add(codigo)
out.append({
'sucursal': codigo,
'meta_mensual': max(0.0, _num(item.get('meta_mensual'), 0.0)),
})
return out[:DIRECCION_METAS_MAX]
def normalizar_direccion(raw):
"""Bloque `direccion` completo, con defaults. Tolerante: nunca lanza."""
out = dict(DIRECCION_DEFAULT)
out['metas_sucursal'] = []
if not isinstance(raw, dict):
return out
out['metas_sucursal'] = _normalizar_metas_sucursal(raw.get('metas_sucursal'))
out['meta_mensual_global'] = max(0.0, _num(
raw.get('meta_mensual_global'), DIRECCION_DEFAULT['meta_mensual_global']))
out['dias_inactividad'] = min(max(_int(
raw.get('dias_inactividad'), DIRECCION_DEFAULT['dias_inactividad']),
1), DIRECCION_DIAS_MAX)
out['visitas_minimas_riesgo'] = min(max(_int(
raw.get('visitas_minimas_riesgo'), DIRECCION_DEFAULT['visitas_minimas_riesgo']),
0), DIRECCION_VISITAS_MAX)
comp = str(raw.get('comparativo_default') or '').strip().lower()
out['comparativo_default'] = (
comp if comp in DIRECCION_COMPARATIVOS else DIRECCION_DEFAULT['comparativo_default'])
return out
def validar_direccion(raw):
"""Valida el bloque `direccion` de un PUT. Lanza ValueError si algo no
cuadra. Devuelve SOLO las claves presentes y normalizadas (merge)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de dirección debe ser un objeto')
out = {}
if 'metas_sucursal' in raw:
valor = raw['metas_sucursal']
if not isinstance(valor, list):
raise ValueError('Las metas por sucursal deben ser una lista')
for item in valor:
if not isinstance(item, dict):
raise ValueError('Cada meta de sucursal debe ser un objeto')
if not str(item.get('sucursal') or '').strip():
raise ValueError('Cada meta de sucursal necesita una sucursal')
n = _num(item.get('meta_mensual'), None)
if n is None or n < 0:
raise ValueError('La meta mensual debe ser un número no negativo')
out['metas_sucursal'] = _normalizar_metas_sucursal(valor)
if 'meta_mensual_global' in raw:
n = _num(raw['meta_mensual_global'], None)
if n is None or n < 0:
raise ValueError('La meta mensual global debe ser un número no negativo')
out['meta_mensual_global'] = n
if 'dias_inactividad' in raw:
if isinstance(raw['dias_inactividad'], bool) or not isinstance(raw['dias_inactividad'], int):
raise ValueError('Los días de inactividad deben ser un número entero')
if not (1 <= raw['dias_inactividad'] <= DIRECCION_DIAS_MAX):
raise ValueError('Los días de inactividad deben estar entre 1 y %d' % DIRECCION_DIAS_MAX)
out['dias_inactividad'] = raw['dias_inactividad']
if 'visitas_minimas_riesgo' in raw:
if isinstance(raw['visitas_minimas_riesgo'], bool) or \
not isinstance(raw['visitas_minimas_riesgo'], int):
raise ValueError('Las visitas mínimas deben ser un número entero')
if not (0 <= raw['visitas_minimas_riesgo'] <= DIRECCION_VISITAS_MAX):
raise ValueError('Las visitas mínimas deben estar entre 0 y %d'
% DIRECCION_VISITAS_MAX)
out['visitas_minimas_riesgo'] = raw['visitas_minimas_riesgo']
if 'comparativo_default' in raw:
comp = str(raw['comparativo_default'] or '').strip().lower()
if comp not in DIRECCION_COMPARATIVOS:
raise ValueError("El comparativo debe ser 'anterior', 'anio' o 'ninguno'")
out['comparativo_default'] = comp
return out
def get_direccion(env):
"""Bloque del tablero de dirección vigente (copia).
La clave se llama `tablero` (no `direccion`) para no chocar con el campo
`direccion` de nivel raíz, que guarda la DIRECCIÓN POSTAL de la clínica.
"""
return normalizar_direccion(get_settings(env).get('tablero'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
@@ -2163,6 +2291,7 @@ def get_settings(env, aplicar_licencia=True):
'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_DEFAULT),
'tablero': normalizar_direccion(None),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
@@ -2187,6 +2316,7 @@ def get_settings(env, aplicar_licencia=True):
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
out['tablero'] = normalizar_direccion(guardado.get('tablero'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -2268,6 +2398,11 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['encuestas'] = {**actual['encuestas'], **bloque}
if 'tablero' in vals:
bloque = validar_direccion(vals['tablero'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo las metas) no borra el resto.
actual['tablero'] = {**actual['tablero'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None: