Files
SKEEN-Proyecto/odoo-addons/skeen_citas/clinic_settings.py

1038 lines
45 KiB
Python

# -*- coding: utf-8 -*-
"""Ajustes de la clínica por instancia (white-label).
Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos
datos solo existían en el localStorage del navegador de cada equipo; ahora son
compartidos por toda la instancia y los consumen el frontend, la impresión de
tickets y las URLs de pago.
El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import json
import re
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
BRAND_KEY = 'skeen.clinic.brand'
BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts'
HORA_APERTURA_DEFAULT = '08:00'
HORA_CIERRE_DEFAULT = '19:00'
ZONA_HORARIA_DEFAULT = 'America/Tijuana'
MONEDA_DEFAULT = 'MXN'
# Campos del contrato del endpoint.
CAMPOS = (
'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc',
'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica',
'moneda_base', 'nota_ticket',
)
# ---------------------------------------------------------------------------
# Impuestos (bloque anidado dentro del mismo JSON de ajustes).
#
# El impuesto es propio y simple: `account`/`l10n_mx` están desinstalados, así
# que no hay motor fiscal de Odoo. Con `activo=False` TODO el sistema se
# comporta como antes (sin cálculo, sin desglose, sin renglón en tickets); esa
# es la regla de oro y por eso los defaults son apagados y en cero.
#
# activo: enciende el cálculo en POS, tickets y reportes
# nombre: etiqueta del impuesto (p. ej. 'IVA')
# tasa_default: % que se aplica cuando el catálogo no fija una
# precios_incluyen_impuesto: el precio capturado YA trae impuesto; el
# subtotal se desglosa hacia atrás
# tasa_opciones: tasas ofrecidas en los formularios (%)
# ---------------------------------------------------------------------------
IMPUESTOS_DEFAULT = {
'activo': False,
'nombre': 'IVA',
'tasa_default': 0.0,
'precios_incluyen_impuesto': False,
'tasa_opciones': [0, 8, 16],
}
def _num(valor, por_defecto=0.0):
"""Número finito de un valor JSON, sin explotar con None/texto."""
try:
n = float(valor)
except (TypeError, ValueError):
return por_defecto
if n != n or n in (float('inf'), float('-inf')): # NaN / infinitos
return por_defecto
return n
def _bool(valor, por_defecto=False):
if isinstance(valor, bool):
return valor
if valor in ('1', 'true', 'True', 'si', 'sí', 1):
return True
if valor in ('0', 'false', 'False', 'no', 0):
return False
return por_defecto
def normalizar_impuestos(raw):
"""Bloque `impuestos` completo, con defaults, a partir de lo guardado.
Tolerante: nunca lanza. Lo inválido cae al default (una instancia sin
configurar debe verse EXACTAMENTE como hoy).
"""
out = dict(IMPUESTOS_DEFAULT)
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), IMPUESTOS_DEFAULT['activo'])
out['precios_incluyen_impuesto'] = _bool(
raw.get('precios_incluyen_impuesto'),
IMPUESTOS_DEFAULT['precios_incluyen_impuesto'])
nombre = str(raw.get('nombre') or '').strip()
out['nombre'] = (nombre or IMPUESTOS_DEFAULT['nombre'])[:40]
out['tasa_default'] = min(max(_num(raw.get('tasa_default')), 0.0), 100.0)
opciones = raw.get('tasa_opciones')
if isinstance(opciones, list):
limpias = []
for o in opciones:
n = _num(o, None)
if n is None:
continue
n = min(max(n, 0.0), 100.0)
if n not in limpias:
limpias.append(n)
if limpias:
out['tasa_opciones'] = sorted(limpias)
return out
def validar_impuestos(raw):
"""Valida el bloque `impuestos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400 en el controlador).
Devuelve SOLO las claves presentes (ya normalizadas) para que el merge no
reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de impuestos debe ser un objeto')
out = {}
if 'activo' in raw:
if not isinstance(raw['activo'], bool):
raise ValueError('El campo activo de impuestos debe ser verdadero o falso')
out['activo'] = raw['activo']
if 'precios_incluyen_impuesto' in raw:
if not isinstance(raw['precios_incluyen_impuesto'], bool):
raise ValueError('precios_incluyen_impuesto debe ser verdadero o falso')
out['precios_incluyen_impuesto'] = raw['precios_incluyen_impuesto']
if 'nombre' in raw:
if not isinstance(raw['nombre'], str) or not raw['nombre'].strip():
raise ValueError('El nombre del impuesto no puede estar vacío')
if len(raw['nombre'].strip()) > 40:
raise ValueError('El nombre del impuesto es demasiado largo (máx. 40)')
out['nombre'] = raw['nombre'].strip()[:40]
if 'tasa_default' in raw:
n = _num(raw['tasa_default'], None)
if n is None:
raise ValueError('La tasa por defecto debe ser un número')
if not (0 <= n <= 100):
raise ValueError('La tasa por defecto debe estar entre 0 y 100')
out['tasa_default'] = n
if 'tasa_opciones' in raw:
opciones = raw['tasa_opciones']
if not isinstance(opciones, list) or not opciones:
raise ValueError('tasa_opciones debe ser una lista de números')
limpias = []
for o in opciones:
n = _num(o, None)
if n is None:
raise ValueError('Cada opción de tasa debe ser un número')
if not (0 <= n <= 100):
raise ValueError('Las opciones de tasa deben estar entre 0 y 100')
if n not in limpias:
limpias.append(n)
out['tasa_opciones'] = sorted(limpias)
return out
def get_impuestos(env):
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
return normalizar_impuestos(get_settings(env).get('impuestos'))
# ---------------------------------------------------------------------------
# Reservas en línea (portal público de auto-agendado).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` NADA cambia en la instancia (el portal público
# responde 404 y el frontend no muestra enlaces). Por eso el default es apagado.
# ---------------------------------------------------------------------------
RESERVAS_ONLINE_DEFAULT = {
'activo': False,
'titulo': 'Reserva tu cita en línea',
'descripcion': '',
'requiere_confirmacion': True,
'permitir_eleccion_doctor': True,
'permitir_sucursal': False,
'max_dias_futuro': 60,
'min_horas_anticipacion': 2,
'permitir_cancelar': True,
'cancelar_hasta_horas': 24,
'mensaje_confirmacion': '',
'telefono_contacto': '',
}
# Rangos razonables de validación del PUT (días / horas).
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana
RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana
def _int(valor, por_defecto=0):
try:
return int(valor)
except (TypeError, ValueError):
return por_defecto
def normalizar_reservas_online(raw):
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RESERVAS_ONLINE_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
'permitir_sucursal', 'permitir_cancelar'):
out[k] = _bool(raw.get(k), RESERVAS_ONLINE_DEFAULT[k])
out['titulo'] = (str(raw.get('titulo') or '').strip()
or RESERVAS_ONLINE_DEFAULT['titulo'])[:120]
out['descripcion'] = str(raw.get('descripcion') or '')[:500]
out['mensaje_confirmacion'] = str(raw.get('mensaje_confirmacion') or '')[:500]
out['telefono_contacto'] = str(raw.get('telefono_contacto') or '').strip()[:40]
out['max_dias_futuro'] = min(max(_int(raw.get('max_dias_futuro'),
RESERVAS_ONLINE_DEFAULT['max_dias_futuro']),
RESERVAS_MAX_DIAS_MIN), RESERVAS_MAX_DIAS_MAX)
out['min_horas_anticipacion'] = min(max(_int(raw.get('min_horas_anticipacion'),
RESERVAS_ONLINE_DEFAULT['min_horas_anticipacion']),
0), RESERVAS_MIN_ANTICIPACION_MAX)
out['cancelar_hasta_horas'] = min(max(_int(raw.get('cancelar_hasta_horas'),
RESERVAS_ONLINE_DEFAULT['cancelar_hasta_horas']),
0), RESERVAS_CANCELAR_HASTA_MAX)
return out
def validar_reservas_online(raw):
"""Valida el bloque `reservas_online` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de reservas en línea debe ser un objeto')
out = {}
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
'permitir_sucursal', 'permitir_cancelar'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
for k, limite in (('titulo', 120), ('descripcion', 500),
('mensaje_confirmacion', 500), ('telefono_contacto', 40)):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > limite:
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, limite))
out[k] = raw[k].strip()
if 'titulo' in raw and not raw['titulo'].strip():
raise ValueError('El título no puede estar vacío')
rangos = (
('max_dias_futuro', RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX),
('min_horas_anticipacion', 0, RESERVAS_MIN_ANTICIPACION_MAX),
('cancelar_hasta_horas', 0, RESERVAS_CANCELAR_HASTA_MAX),
)
for k, lo, hi in rangos:
if k in raw:
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
raise ValueError('El campo %s debe ser un número entero' % k)
if not (lo <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
out[k] = raw[k]
return out
def get_reservas_online(env):
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
# ---------------------------------------------------------------------------
# Recordatorios y notificaciones automáticas de citas.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y
# `reservas_online`. La regla de oro: con `activo=False` NADA se envía ni se
# encola (cero cambios de comportamiento y cero filas nuevas); por eso el
# default es apagado. `modo_prueba=True` (default) es la segunda llave: las
# notificaciones se registran como "simuladas" y no se llama a ningún proveedor
# externo, de modo que se puede probar sin credenciales reales.
#
# activo: enciende recordatorios y notificaciones
# modo_prueba: no envía nada real; solo registra "simulada"
# canal: whatsapp | email | ambos
# recordatorio_horas_antes: ventanas (0-168 h) en que se avisa antes de la cita
# confirmacion_al_crear: avisar al paciente al reservar en línea
# cumpleanos: felicitación el día del cumpleaños
# hora_envio_cumpleanos: hora (HH:MM) a partir de la cual se felicita
# post_visita_horas: horas tras la visita para el mensaje de seguimiento
# recuperacion_no_show: mensaje a quien no se presentó
# plantilla_*: textos con variables {nombre} {cita} {servicio}...
# whatsapp_numero: número de la clínica que firma los mensajes
# email_remitente: remitente de los correos
# ---------------------------------------------------------------------------
NOTIFICACIONES_DEFAULT = {
'activo': False,
'modo_prueba': True,
'canal': 'whatsapp',
'recordatorio_horas_antes': [24, 2],
'confirmacion_al_crear': False,
'cumpleanos': False,
'hora_envio_cumpleanos': '09:00',
'post_visita_horas': 0,
'recuperacion_no_show': False,
'plantilla_recordatorio': (
'Hola {nombre}, te recordamos tu {cita} de {servicio} el {fecha} a las '
'{hora} con {doctor} en {sucursal}. Responde CONFIRMAR o CANCELAR.'
),
'plantilla_confirmacion': (
'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} '
'a las {hora} con {doctor} en {sucursal}. Te esperamos.'
),
'plantilla_cumpleanos': (
'¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un '
'gran día. Te esperamos pronto.'
),
'plantilla_post_visita': (
'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes '
'dudas o quieres agendar de nuevo, llámanos al {telefono}.'
),
'plantilla_formulario': (
'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. '
'Ábrelo desde tu celular aquí: {enlace}'
),
# Aviso de renovación de una membresía (bloque `membresias`). Usa variables
# propias ({plan} {monto} {sesiones} {periodo}) que el motor recibe como
# contexto extra al encolar; ver `skeen.membresia.service`.
'plantilla_renovacion_membresia': (
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
),
'whatsapp_numero': '',
'email_remitente': '',
}
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono', 'enlace', 'formulario',
# Variables del aviso de membresías.
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita',
'plantilla_formulario', 'plantilla_renovacion_membresia')
NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
# Variables reconocidas dentro de una plantilla: {nombre}, {cita}, ...
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
def horas_recordatorio_validas(valor, por_defecto=None):
"""Lista de horas de recordatorio (enteros 0-168, sin repetir, ordenada)."""
if valor is None:
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(valor, (list, tuple)):
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
limpias = []
for v in valor:
try:
n = int(v)
except (TypeError, ValueError):
continue
if isinstance(v, bool) or not (0 <= n <= NOTIF_HORAS_MAX):
continue
if n not in limpias:
limpias.append(n)
return sorted(limpias) if limpias else list(por_defecto or [])
def _hhmm_valida(valor, por_defecto='09:00'):
"""Normaliza 'HH:MM'. Devuelve el default si el formato no es válido."""
partes = str(valor or '').strip().split(':')
if len(partes) != 2:
return por_defecto
try:
h, m = int(partes[0]), int(partes[1])
except (TypeError, ValueError):
return por_defecto
if not (0 <= h <= 23 and 0 <= m <= 59):
return por_defecto
return '%02d:%02d' % (h, m)
def variables_de_plantilla(texto):
"""Variables {x} usadas en una plantilla (sin repetir, en orden)."""
usadas = []
for m in _VARIABLE_RE.finditer(str(texto or '')):
if m.group(1) not in usadas:
usadas.append(m.group(1))
return usadas
def normalizar_notificaciones(raw):
"""Bloque `notificaciones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(NOTIFICACIONES_DEFAULT)
out['recordatorio_horas_antes'] = list(NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
out[k] = _bool(raw.get(k), NOTIFICACIONES_DEFAULT[k])
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in NOTIF_CANALES else NOTIFICACIONES_DEFAULT['canal']
out['recordatorio_horas_antes'] = horas_recordatorio_validas(
raw.get('recordatorio_horas_antes'))
out['hora_envio_cumpleanos'] = _hhmm_valida(
raw.get('hora_envio_cumpleanos'), NOTIFICACIONES_DEFAULT['hora_envio_cumpleanos'])
out['post_visita_horas'] = min(max(_int(raw.get('post_visita_horas'),
NOTIFICACIONES_DEFAULT['post_visita_horas']),
0), NOTIF_HORAS_MAX)
for k in NOTIF_PLANTILLAS:
texto = raw.get(k)
if texto is None:
continue
out[k] = str(texto)[:NOTIF_PLANTILLA_MAX]
out['whatsapp_numero'] = str(raw.get('whatsapp_numero') or '').strip()[:NOTIF_TEXTO_MAX]
out['email_remitente'] = str(raw.get('email_remitente') or '').strip()[:NOTIF_TEXTO_MAX]
return out
def validar_notificaciones(raw):
"""Valida el bloque `notificaciones` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de notificaciones debe ser un objeto')
out = {}
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in NOTIF_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'recordatorio_horas_antes' in raw:
valor = raw['recordatorio_horas_antes']
if not isinstance(valor, list) or not valor:
raise ValueError('Las horas de recordatorio deben ser una lista')
horas = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada ventana de recordatorio debe ser un número entero de horas')
if not (0 <= v <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de recordatorio deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
if v not in horas:
horas.append(v)
out['recordatorio_horas_antes'] = sorted(horas)
if 'hora_envio_cumpleanos' in raw:
partes = str(raw['hora_envio_cumpleanos'] or '').strip().split(':')
valido = False
if len(partes) == 2:
try:
h, m = int(partes[0]), int(partes[1])
valido = 0 <= h <= 23 and 0 <= m <= 59
except (TypeError, ValueError):
valido = False
if not valido:
raise ValueError('La hora de cumpleaños debe tener el formato HH:MM')
out['hora_envio_cumpleanos'] = _hhmm_valida(raw['hora_envio_cumpleanos'])
if 'post_visita_horas' in raw:
if isinstance(raw['post_visita_horas'], bool) or not isinstance(raw['post_visita_horas'], int):
raise ValueError('Las horas de post-visita deben ser un número entero')
if not (0 <= raw['post_visita_horas'] <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de post-visita deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
out['post_visita_horas'] = raw['post_visita_horas']
for k in NOTIF_PLANTILLAS:
if k not in raw:
continue
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k]) > NOTIF_PLANTILLA_MAX:
raise ValueError('La plantilla es demasiado larga (máx. %d caracteres)' % NOTIF_PLANTILLA_MAX)
desconocidas = [v for v in variables_de_plantilla(raw[k]) if v not in NOTIF_VARIABLES]
if desconocidas:
raise ValueError('Variable no permitida en la plantilla: {%s}. Permitidas: %s'
% (desconocidas[0], ', '.join('{%s}' % v for v in NOTIF_VARIABLES)))
out[k] = raw[k]
for k in ('whatsapp_numero', 'email_remitente'):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > NOTIF_TEXTO_MAX:
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, NOTIF_TEXTO_MAX))
out[k] = raw[k].strip()
remitente = out.get('email_remitente')
if remitente and '@' not in remitente:
raise ValueError('El correo remitente no es válido')
return out
def get_notificaciones(env):
"""Bloque de notificaciones vigente (copia). Fuente única para el motor."""
return normalizar_notificaciones(get_settings(env).get('notificaciones'))
# ---------------------------------------------------------------------------
# Membresías y suscripciones.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Membresías no aparece en el menú, el POS no ofrece sesiones de membresía, no
# corre el cron y no se genera ningún cargo ni aviso. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de membresías
# dias_aviso_renovacion: días antes del cobro en que se avisa
# permitir_congelamiento: permite congelar membresías
# dias_max_congelamiento_anio: tope de días congelados por año
# renovacion_automatica_default: valor por defecto al dar de alta
# descuento_porcentaje_default: descuento por defecto en servicios no incluidos
# permitir_pausa_pago: permite pausar la membresía por falta de pago
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
# dias_gracia: días tras el vencimiento antes de marcarla vencida
# ---------------------------------------------------------------------------
MEMBRESIAS_DEFAULT = {
'activo': False,
'dias_aviso_renovacion': [7, 1],
'permitir_congelamiento': True,
'dias_max_congelamiento_anio': 30,
'renovacion_automatica_default': False,
'descuento_porcentaje_default': 0,
'permitir_pausa_pago': False,
'generar_cargo_al_renovar': True,
'dias_gracia': 5,
}
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
def normalizar_membresias(raw):
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
out = dict(MEMBRESIAS_DEFAULT)
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
out[k] = _bool(raw.get(k), MEMBRESIAS_DEFAULT[k])
out['dias_aviso_renovacion'] = horas_recordatorio_validas(
raw.get('dias_aviso_renovacion'), MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
out['dias_max_congelamiento_anio'] = min(max(_int(
raw.get('dias_max_congelamiento_anio'),
MEMBRESIAS_DEFAULT['dias_max_congelamiento_anio']), 0), MEMBRESIAS_DIAS_MAX)
out['descuento_porcentaje_default'] = min(max(_num(
raw.get('descuento_porcentaje_default'),
MEMBRESIAS_DEFAULT['descuento_porcentaje_default']), 0.0), 100.0)
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'),
MEMBRESIAS_DEFAULT['dias_gracia']), 0), 365)
return out
def validar_membresias(raw):
"""Valida el bloque `membresias` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de membresías debe ser un objeto')
out = {}
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'dias_aviso_renovacion' in raw:
valor = raw['dias_aviso_renovacion']
if not isinstance(valor, list) or not valor:
raise ValueError('Los días de aviso de renovación deben ser una lista')
dias = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada día de aviso debe ser un número entero')
if not (0 <= v <= MEMBRESIAS_AVISO_MAX):
raise ValueError('Los días de aviso deben estar entre 0 y %d' % MEMBRESIAS_AVISO_MAX)
if v not in dias:
dias.append(v)
out['dias_aviso_renovacion'] = sorted(set(dias))
if 'dias_max_congelamiento_anio' in raw:
if isinstance(raw['dias_max_congelamiento_anio'], bool) or \
not isinstance(raw['dias_max_congelamiento_anio'], int):
raise ValueError('El máximo de días de congelamiento debe ser un número entero')
if not (0 <= raw['dias_max_congelamiento_anio'] <= MEMBRESIAS_DIAS_MAX):
raise ValueError('El máximo de días de congelamiento debe estar entre 0 y %d'
% MEMBRESIAS_DIAS_MAX)
out['dias_max_congelamiento_anio'] = raw['dias_max_congelamiento_anio']
if 'descuento_porcentaje_default' in raw:
n = _num(raw['descuento_porcentaje_default'], None)
if n is None:
raise ValueError('El descuento por defecto debe ser un número')
if not (0 <= n <= 100):
raise ValueError('El descuento por defecto debe estar entre 0 y 100')
out['descuento_porcentaje_default'] = n
if 'dias_gracia' in raw:
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
raise ValueError('Los días de gracia deben ser un número entero')
if not (0 <= raw['dias_gracia'] <= 365):
raise ValueError('Los días de gracia deben estar entre 0 y 365')
out['dias_gracia'] = raw['dias_gracia']
return out
def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
# ---------------------------------------------------------------------------
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la agenda — no aparece la
# sección en Configuración ni el selector en el modal de cita, la agenda se ve
# igual que hoy y no corre ninguna validación de solape de recursos. Por eso el
# default es apagado.
#
# activo: enciende todo el módulo de recursos
# validar_solape: impide dos citas/bloqueos en el mismo recurso a la
# misma hora (solo aplica si activo)
# obligatorio_al_agendar: exige elegir al menos un recurso al crear la cita
# permitir_multiples: permite asignar más de un recurso a una cita
# mostrar_en_agenda: muestra el recurso (chips/color) en las tarjetas
# filtrar_como_doctores: ofrece el filtro por recurso junto al de doctores
# ---------------------------------------------------------------------------
RECURSOS_DEFAULT = {
'activo': False,
'validar_solape': True,
'obligatorio_al_agendar': False,
'permitir_multiples': True,
'mostrar_en_agenda': True,
'filtrar_como_doctores': True,
}
RECURSOS_BOOLEANOS = tuple(RECURSOS_DEFAULT)
def normalizar_recursos(raw):
"""Bloque `recursos` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RECURSOS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in RECURSOS_BOOLEANOS:
out[k] = _bool(raw.get(k), RECURSOS_DEFAULT[k])
return out
def validar_recursos(raw):
"""Valida el bloque `recursos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de recursos debe ser un objeto')
out = {}
for k in RECURSOS_BOOLEANOS:
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
return out
def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos'))
# ---------------------------------------------------------------------------
# Compras y proveedores.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Compras no aparece en el menú, el inventario y el POS se ven idénticos, no
# corre ningún cron y las rutas del módulo responden 403. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de compras
# actualizar_costo_promedio: al recibir, recalcula el costo promedio ponderado
# permitir_recepcion_parcial: permite recibir menos de lo pedido y recibir después
# requiere_autorizacion: las órdenes nacen en borrador y exigen enviarse
# dias_credito_default: días de crédito por defecto de un proveedor nuevo
# margen_minimo_alerta: margen (%) bajo el cual el reporte avisa
# permitir_compras_sin_orden: permite registrar una recepción sin orden de compra
# ---------------------------------------------------------------------------
COMPRAS_DEFAULT = {
'activo': False,
'actualizar_costo_promedio': True,
'permitir_recepcion_parcial': True,
'requiere_autorizacion': False,
'dias_credito_default': 0,
'margen_minimo_alerta': 0,
'permitir_compras_sin_orden': False,
}
COMPRAS_DIAS_CREDITO_MAX = 365
COMPRAS_MARGEN_MAX = 1000 # % (permite margen negativo / muy alto sin tope real)
def normalizar_compras(raw):
"""Bloque `compras` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMPRAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
out[k] = _bool(raw.get(k), COMPRAS_DEFAULT[k])
out['dias_credito_default'] = min(max(_int(
raw.get('dias_credito_default'), COMPRAS_DEFAULT['dias_credito_default']),
0), COMPRAS_DIAS_CREDITO_MAX)
out['margen_minimo_alerta'] = min(max(_num(
raw.get('margen_minimo_alerta'), COMPRAS_DEFAULT['margen_minimo_alerta']),
-COMPRAS_MARGEN_MAX), COMPRAS_MARGEN_MAX)
return out
def validar_compras(raw):
"""Valida el bloque `compras` de un PUT. Lanza ValueError si algo no cuadra
(se traduce a 400). Devuelve SOLO las claves presentes y normalizadas para
que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de compras debe ser un objeto')
out = {}
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'dias_credito_default' in raw:
if isinstance(raw['dias_credito_default'], bool) or \
not isinstance(raw['dias_credito_default'], int):
raise ValueError('Los días de crédito deben ser un número entero')
if not (0 <= raw['dias_credito_default'] <= COMPRAS_DIAS_CREDITO_MAX):
raise ValueError('Los días de crédito deben estar entre 0 y %d'
% COMPRAS_DIAS_CREDITO_MAX)
out['dias_credito_default'] = raw['dias_credito_default']
if 'margen_minimo_alerta' in raw:
n = _num(raw['margen_minimo_alerta'], None)
if n is None:
raise ValueError('El margen mínimo de alerta debe ser un número')
if not (-COMPRAS_MARGEN_MAX <= n <= COMPRAS_MARGEN_MAX):
raise ValueError('El margen mínimo de alerta está fuera de rango')
out['margen_minimo_alerta'] = n
return out
def get_compras(env):
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
return normalizar_compras(get_settings(env).get('compras'))
# ---------------------------------------------------------------------------
# Comisiones por reglas y liquidación.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — el reporte de
# comisiones de siempre (`/reports/comisiones` y `/reports/commissions`) sigue
# devolviendo el mismo JSON y los mismos números (porcentaje plano por empleado),
# no aparece ninguna sección nueva en el menú y no se ejecuta el motor de reglas.
#
# activo: enciende el motor de reglas y las liquidaciones
# base: 'cobrado' (sobre lo pagado) | 'vendido' (subtotal)
# incluir_impuestos: la base suma el impuesto de la línea
# neto_de_devoluciones: resta la parte devuelta de la venta
# comisionar_membresias: comisiona las ventas de membresía
# comisionar_paquetes: comisiona los paquetes
# comisionar_productos: comisiona los artículos de inventario
# permitir_ajustes_manuales: habilita sumar/restar ajustes en la liquidación
# dia_corte: día del mes de corte sugerido (1-28)
# redondeo: decimales a los que se redondea la comisión
# pago_minimo: total mínimo a liquidar (por debajo no es pagable)
# ---------------------------------------------------------------------------
COMISIONES_DEFAULT = {
'activo': False,
'base': 'cobrado',
'incluir_impuestos': False,
'neto_de_devoluciones': True,
'comisionar_membresias': True,
'comisionar_paquetes': True,
'comisionar_productos': True,
'permitir_ajustes_manuales': True,
'dia_corte': 1,
'redondeo': 2,
'pago_minimo': 0,
}
COMISIONES_BASES = ('cobrado', 'vendido')
COMISIONES_REDONDEO_MAX = 6
def normalizar_comisiones(raw):
"""Bloque `comisiones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMISIONES_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
'permitir_ajustes_manuales'):
out[k] = _bool(raw.get(k), COMISIONES_DEFAULT[k])
base = str(raw.get('base') or '').strip().lower()
out['base'] = base if base in COMISIONES_BASES else COMISIONES_DEFAULT['base']
out['dia_corte'] = min(max(_int(raw.get('dia_corte'), COMISIONES_DEFAULT['dia_corte']), 1), 28)
out['redondeo'] = min(max(_int(raw.get('redondeo'), COMISIONES_DEFAULT['redondeo']),
0), COMISIONES_REDONDEO_MAX)
out['pago_minimo'] = max(0.0, _num(raw.get('pago_minimo'), COMISIONES_DEFAULT['pago_minimo']))
return out
def validar_comisiones(raw):
"""Valida el bloque `comisiones` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de comisiones debe ser un objeto')
out = {}
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
'permitir_ajustes_manuales'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'base' in raw:
base = str(raw['base'] or '').strip().lower()
if base not in COMISIONES_BASES:
raise ValueError("La base debe ser 'cobrado' o 'vendido'")
out['base'] = base
if 'dia_corte' in raw:
if isinstance(raw['dia_corte'], bool) or not isinstance(raw['dia_corte'], int):
raise ValueError('El día de corte debe ser un número entero')
if not (1 <= raw['dia_corte'] <= 28):
raise ValueError('El día de corte debe estar entre 1 y 28')
out['dia_corte'] = raw['dia_corte']
if 'redondeo' in raw:
if isinstance(raw['redondeo'], bool) or not isinstance(raw['redondeo'], int):
raise ValueError('El redondeo debe ser un número entero de decimales')
if not (0 <= raw['redondeo'] <= COMISIONES_REDONDEO_MAX):
raise ValueError('El redondeo debe estar entre 0 y %d' % COMISIONES_REDONDEO_MAX)
out['redondeo'] = raw['redondeo']
if 'pago_minimo' in raw:
n = _num(raw['pago_minimo'], None)
if n is None:
raise ValueError('El pago mínimo debe ser un número')
if n < 0:
raise ValueError('El pago mínimo no puede ser negativo')
out['pago_minimo'] = n
return out
def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
def brand_dict(env):
"""Brand vigente (subconjunto suficiente), sin defaults de logos."""
raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
return guardado
return {}
def brand_nombre(env):
nombre = brand_dict(env).get('nombre')
nombre = str(nombre).strip() if nombre is not None else ''
return nombre or BRAND_NOMBRE_DEFAULT
def get_settings(env):
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
out = {
'nombre': brand_nombre(env),
'telefono': '',
'email': '',
'direccion': '',
'web': '',
'rfc': '',
'horario_apertura': HORA_APERTURA_DEFAULT,
'horario_cierre': HORA_CIERRE_DEFAULT,
'zona_horaria': ZONA_HORARIA_DEFAULT,
'url_publica': '',
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
for k in CAMPOS:
if k in guardado and guardado[k] is not None:
out[k] = str(guardado[k])
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
return out
def set_settings(env, vals):
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
if not isinstance(vals, dict):
raise ValueError('Los ajustes deben ser un objeto')
actual = get_settings(env)
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
if 'impuestos' in vals:
bloque = validar_impuestos(vals['impuestos'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['impuestos'] = {**actual['impuestos'], **bloque}
if 'reservas_online' in vals:
bloque = validar_reservas_online(vals['reservas_online'])
if bloque is not None:
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
if 'notificaciones' in vals:
bloque = validar_notificaciones(vals['notificaciones'])
if bloque is not None:
actual['notificaciones'] = {**actual['notificaciones'], **bloque}
if 'membresias' in vals:
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque}
if 'recursos' in vals:
bloque = validar_recursos(vals['recursos'])
if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque}
if 'compras' in vals:
bloque = validar_compras(vals['compras'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['compras'] = {**actual['compras'], **bloque}
if 'comisiones' in vals:
bloque = validar_comisiones(vals['comisiones'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['comisiones'] = {**actual['comisiones'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)
def _hhmm_to_float(valor):
"""'08:00' -> 8.0. None si el formato no es válido."""
try:
partes = str(valor).strip().split(':')
h = int(partes[0])
m = int(partes[1]) if len(partes) > 1 else 0
if not (0 <= h <= 24 and 0 <= m <= 59):
return None
return h + m / 60.0
except (ValueError, TypeError, IndexError):
return None
def agenda_bounds(env):
"""(inicio, fin) de la agenda en horas float.
Lee horario_apertura/horario_cierre de los ajustes; si faltan o son
incoherentes usa el horario de siempre (8:00-19:00), de modo que una
instancia sin configurar se comporta igual que hoy.
"""
s = get_settings(env)
inicio = _hhmm_to_float(s.get('horario_apertura'))
fin = _hhmm_to_float(s.get('horario_cierre'))
if inicio is None:
inicio = AGENDA_START_DEFAULT
if fin is None:
fin = AGENDA_END_DEFAULT
if fin <= inicio:
return AGENDA_START_DEFAULT, AGENDA_END_DEFAULT
return inicio, fin
def _es_url_privada(url):
"""True si el host es local/privado (no sirve para URLs de pago públicas)."""
try:
host = (urlparse(url).hostname or '').lower()
except ValueError:
return True
if not host or host in ('localhost', '127.0.0.1', '::1'):
return True
if host.startswith('10.') or host.startswith('192.168.'):
return True
if host.startswith('172.'):
try:
segundo = int(host.split('.')[1])
except (IndexError, ValueError):
return True
if 16 <= segundo <= 31:
return True
return False
def url_publica(env, fallback=''):
"""URL pública base de la instancia, sin barra final.
Orden: ajustes de la clínica -> brand -> `web.base.url` (solo si es público)
-> `fallback` (el valor que usaba el código antes). El último escalón
preserva el comportamiento actual en instancias sin configurar.
"""
url = (get_settings(env).get('url_publica') or '').strip()
if not url:
url = str(brand_dict(env).get('url_publica') or '').strip()
if not url:
base = env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
if base and not _es_url_privada(base):
url = base
if not url:
url = fallback
return (url or '').rstrip('/')