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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user