fase 2.6: membresias y suscripciones (modulo skeen_membresias: planes recurrentes con sesiones incluidas o ilimitadas, alta/cobro/renovacion/congelamiento/cancelacion, consumo de sesiones desde POS y expediente, cron de vencimientos y avisos, reporte de ingresos recurrentes); apagado por defecto

This commit is contained in:
2026-10-11 10:49:44 +00:00
parent 9286ab8ee2
commit 8ff13b9c2a
33 changed files with 3873 additions and 22 deletions

View File

@@ -316,16 +316,25 @@ NOTIFICACIONES_DEFAULT = {
'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')
'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_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
@@ -484,6 +493,119 @@ 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'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -526,6 +648,7 @@ def get_settings(env):
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -540,6 +663,7 @@ def get_settings(env):
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'))
return out
@@ -564,6 +688,10 @@ def set_settings(env, 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}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)