Files
SKEEN-Proyecto/odoo-addons/skeen_monedero/lealtad.py

201 lines
7.5 KiB
Python

# -*- coding: utf-8 -*-
"""Motor del programa de lealtad configurable (puntos y niveles VIP).
Vive aquí (y no en `skeen_citas/clinic_settings.py`) porque es LÓGICA, no
configuración: la configuración sigue en el bloque `lealtad` de los ajustes de
la clínica y se lee con `get_lealtad(env)`.
Reglas de compatibilidad (regla de oro): con `modo='clasico'` (el default) el
cálculo de puntos reproduce EXACTAMENTE el de siempre:
puntos = int(total * (subtotal_elegible / subtotal_total) / 10)
donde se considera elegible toda línea de servicio/artículo cuyo catálogo tenga
`genera_monedero`. Así, ninguna instancia cambia de cifras al desplegar.
PRECEDENCIA de multiplicadores (gana la más específica; a igual especificidad
gana la última de la lista):
servicio > articulo > categoria > nivel > sucursal > rango_fechas > dia_semana
"""
from odoo.addons.skeen_citas.clinic_settings import get_lealtad # noqa: F401 (reexport)
# Orden de especificidad (menor índice = más específico).
PRECEDENCIA = ('servicio', 'articulo', 'categoria', 'nivel', 'sucursal',
'rango_fechas', 'dia_semana')
# Tipos de línea que puede filtrar `aplicar_a`.
TIPOS_LINEA = ('servicios', 'productos', 'membresias', 'paquetes')
def lineas_elegibles(venta, aplicar_a):
"""Líneas de la venta que generan puntos según `aplicar_a`.
Además del tipo de línea, se respeta el interruptor `genera_monedero` del
catálogo (igual que el cálculo clásico).
"""
aplicar = set(aplicar_a or [])
out = venta.line_ids.filtered(
lambda l: l.tipo_monedero in aplicar and l.genera_monedero)
return out
def _aplica_alcance(regla, ctx):
"""True si la regla multiplicadora aplica al contexto dado."""
alcance = regla.get('alcance')
valor = str(regla.get('valor') or '').strip()
if not valor:
return False
if alcance == 'servicio':
return ctx.get('servicio_id') is not None and str(ctx['servicio_id']) == valor
if alcance == 'articulo':
return ctx.get('articulo_id') is not None and str(ctx['articulo_id']) == valor
if alcance == 'categoria':
return str(ctx.get('categoria') or '') == valor
if alcance == 'nivel':
return str(ctx.get('nivel_codigo') or '') == valor
if alcance == 'sucursal':
return str(ctx.get('sucursal') or '') == valor
if alcance == 'dia_semana':
dias = [d.strip() for d in valor.split(',') if d.strip()]
return str(ctx.get('dia_semana')) in dias
if alcance == 'rango_fechas':
fecha = ctx.get('fecha')
if not fecha:
return False
desde = str(regla.get('fecha_desde') or '')
hasta = str(regla.get('fecha_hasta') or '')
if desde and str(fecha) < desde:
return False
if hasta and str(fecha) > hasta:
return False
return bool(desde or hasta)
return False
def _vigente(regla, fecha):
"""True si la regla está dentro de su propia vigencia (fecha_desde/hasta)."""
if not fecha:
return True
desde = str(regla.get('fecha_desde') or '')
hasta = str(regla.get('fecha_hasta') or '')
if desde and str(fecha) < desde:
return False
if hasta and str(fecha) > hasta:
return False
return True
def regla_multiplicador(cfg, ctx):
"""Regla multiplicadora más específica que aplica (o None).
Devuelve el dict de la regla; `factor` es su factor de acumulación.
"""
fecha = ctx.get('fecha')
mejor = None
mejor_rango = None
for regla in (cfg or {}).get('multiplicadores') or []:
if not regla.get('activo', True):
continue
if not _vigente(regla, fecha):
continue
if not _aplica_alcance(regla, ctx):
continue
try:
rango = PRECEDENCIA.index(regla.get('alcance'))
except ValueError:
continue
# Menor rango gana; a igual rango, la última de la lista gana (<=).
if mejor is None or rango <= mejor_rango:
mejor = regla
mejor_rango = rango
return mejor
def factor_multiplicador(cfg, ctx):
"""Factor a aplicar (1.0 si ninguna regla aplica)."""
regla = regla_multiplicador(cfg, ctx)
return float(regla.get('factor', 1.0)) if regla else 1.0
def nivel_para_gasto(cfg, gasto):
"""Nivel que corresponde a un gasto acumulado (o None).
Los niveles vienen ordenados de mayor a menor `gasto_minimo`; el primero
cuyo umbral ya se alcanzó es el nivel vigente.
"""
for nivel in (cfg or {}).get('niveles') or []:
if float(gasto or 0.0) >= float(nivel.get('gasto_minimo') or 0.0):
return nivel
return None
def nivel_por_codigo(cfg, codigo):
"""Nivel por su código (o None)."""
for nivel in (cfg or {}).get('niveles') or []:
if nivel.get('codigo') == codigo:
return nivel
return None
def _redondear(valor, modo):
"""'abajo' trunca; 'normal' redondea comercialmente."""
if modo == 'normal':
return int(round(valor))
return int(valor)
def puntos_venta(env, venta, cfg=None):
"""Puntos que genera una venta. Devuelve (puntos, detalle).
Detalle: {modo, base, factor, elegible, subtotal, sin_propina}.
"""
cfg = cfg or get_lealtad(env)
vacio = {'modo': cfg.get('modo'), 'base': 0.0, 'factor': 1.0,
'elegible': 0.0, 'subtotal': 0.0, 'sin_propina': 0.0}
if not cfg.get('activo'):
return 0, vacio
subtotal_total = sum(l.subtotal for l in venta.line_ids)
sin_propina = max(0.0, (venta.total or 0.0) - (venta.propina or 0.0))
if cfg.get('modo') != 'configurable':
# CLÁSICO: fórmula exacta de siempre (1 pt por cada $10 del total).
# Se evalúa con el predicado original, sin mirar `aplicar_a`, para no
# alterar ninguna cifra histórica.
elegible_sub = sum(
l.subtotal for l in venta.line_ids
if (l.service_id and l.service_id.genera_monedero)
or (l.item_id and l.item_id.genera_monedero))
if not subtotal_total or elegible_sub <= 0:
return 0, dict(vacio, subtotal=subtotal_total, elegible=elegible_sub,
sin_propina=sin_propina)
puntos = int(sin_propina * (elegible_sub / subtotal_total) / 10)
return puntos, {'modo': 'clasico', 'base': sin_propina, 'factor': 1.0,
'elegible': elegible_sub, 'subtotal': subtotal_total,
'sin_propina': sin_propina}
# CONFIGURABLE
elegibles = lineas_elegibles(venta, cfg.get('aplicar_a'))
elegible_sub = sum(l.subtotal for l in elegibles)
por_unidad = float(cfg.get('puntos_por_unidad') or 0.0)
sobre = cfg.get('acumular_sobre')
base_vendida = sin_propina
base_pagada = max(0.0, min(venta.amount_paid or 0.0, sin_propina))
ratio_pago = (base_pagada / base_vendida) if base_vendida else 0.0
nivel = nivel_por_codigo(cfg, venta.partner_id.lealtad_nivel_codigo or '')
factor_nivel = float((nivel or {}).get('factor_puntos', 1.0))
acumulado = 0.0
if subtotal_total:
for line in elegibles:
base_linea = line.subtotal
if sobre == 'pagado':
base_linea *= ratio_pago
factor = factor_multiplicador(cfg, venta.contexto_lealtad(line))
acumulado += base_linea * por_unidad * factor
acumulado *= factor_nivel
return _redondear(acumulado, cfg.get('redondear')), {
'modo': 'configurable', 'base': base_vendida, 'factor': factor_nivel,
'elegible': elegible_sub, 'subtotal': subtotal_total,
'sin_propina': sin_propina,
}