201 lines
7.5 KiB
Python
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,
|
|
}
|