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