bloque 3: lealtad configurable (motor de puntos con multiplicadores por servicio/categoria/sucursal/dia/nivel, niveles VIP con descuento y factor, expiracion, ajuste manual con motivo y reportes) + propinas en punto de venta con reparto entre el personal, desglose en ticket y corte, y opcion en la liquidacion de comisiones; modo clasico y propinas apagadas preservan el comportamiento actual

This commit is contained in:
2026-10-12 01:37:07 +00:00
parent 1bd4cad6a0
commit 705b5a2842
28 changed files with 2748 additions and 59 deletions

View File

@@ -13,6 +13,7 @@
'depends': ['base', 'skeen_pacientes'],
'data': [
'security/ir.model.access.csv',
'data/lealtad_cron.xml',
'views/monedero_views.xml',
'views/menu_views.xml',
],

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Vencimiento de puntos de lealtad. Con `expira_meses = 0` (default)
el método regresa sin tocar nada: cero filas nuevas. Con un plazo
configurado expira, una vez al día, los puntos no usados más
antiguos que el plazo, dejando la transacción en la bitácora. -->
<record id="cron_lealtad_expirar" model="ir.cron">
<field name="name">SKEEN Lealtad: vencer puntos caducados</field>
<field name="model_id" ref="model_skeen_monedero"/>
<field name="state">code</field>
<field name="code">model.expirar_puntos()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
<!-- Recálculo diario del nivel VIP. Sin niveles configurados (default)
el método regresa sin tocar nada. -->
<record id="cron_lealtad_niveles" model="ir.cron">
<field name="name">SKEEN Lealtad: recalcular niveles VIP</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">model._lealtad_recalcular_todos()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,200 @@
# -*- 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,
}

View File

@@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..lealtad import get_lealtad, nivel_por_codigo, puntos_venta
class SkeenMonedero(models.Model):
_name = 'skeen.monedero'
@@ -28,6 +32,68 @@ class SkeenMonedero(models.Model):
def _compute_equivalent(self):
for monedero in self:
monedero.equivalent_mxn = monedero.points * 1.0 # 1 punto = $1 MXN
# ------------------------------------------------------------------
# Nivel de lealtad (comodidad para el frontend y las vistas)
# ------------------------------------------------------------------
nivel_codigo = fields.Char(related='partner_id.lealtad_nivel_codigo',
string='Nivel', readonly=True)
nivel_nombre = fields.Char(related='partner_id.lealtad_nivel_nombre',
string='Nivel (nombre)', readonly=True)
# ------------------------------------------------------------------
# Motor de puntos (lealtad configurable)
# ------------------------------------------------------------------
@api.model
def puntos_de_venta(self, venta, cfg=None):
"""Puntos que genera una venta según la configuración vigente."""
return puntos_venta(self.env, venta, cfg=cfg)[0]
@api.model
def expirar_puntos(self, cfg=None):
"""Expira los puntos no usados más antiguos que `expira_meses`.
FIFO sobre las transacciones `accrual`: cada canje/ajuste/vencimiento
consume primero los lotes más viejos. Solo se expiran los lotes que
siguen vivos y ya pasaron el plazo. Devuelve el total expirado.
"""
cfg = cfg or get_lealtad(self.env)
meses = int(cfg.get('expira_meses') or 0)
# En modo clásico no se expira nada (el comportamiento de siempre).
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or meses <= 0:
return 0
cutoff = fields.Datetime.now() - relativedelta(months=meses)
Tx = self.env['skeen.monedero.transaction']
total = 0
for monedero in self.search([]):
txs = Tx.search([('monedero_id', '=', monedero.id)],
order='create_date asc, id asc')
lotes = []
for tx in txs:
pts = int(tx.points or 0)
if pts > 0 and tx.type == 'accrual':
lotes.append([tx.create_date, pts])
elif pts < 0:
consumo = -pts
for lote in lotes:
if consumo <= 0:
break
if lote[1] <= 0:
continue
toma = min(lote[1], consumo)
lote[1] -= toma
consumo -= toma
expira = sum(l[1] for l in lotes
if l[1] > 0 and l[0] and l[0] < cutoff)
expira = min(expira, monedero.points)
if expira > 0:
monedero.adjust_points(
-expira,
description='Vencimiento de puntos (%d meses)' % meses,
reference='EXPIRACION',
tx_type='expiration')
total += expira
return total
def get_balance(self, phone):
"""Obtener saldo por teléfono (para API)"""
@@ -107,10 +173,13 @@ class SkeenMonedero(models.Model):
})
return True
def adjust_points(self, delta, description='', reference='', operator=''):
def adjust_points(self, delta, description='', reference='', operator='',
tx_type='adjustment'):
"""Ajuste de puntos (puede ser negativo). Hace piso en 0 para no dejar saldo negativo.
Usado por devoluciones para revertir puntos acumulados por una venta.
`tx_type` permite registrar la bitácora como 'expiration' (vencimiento)
sin cambiar el comportamiento de los ajustes normales.
Devuelve el delta efectivamente aplicado (puede ser menor si se pisó en 0).
"""
self.ensure_one()
@@ -123,7 +192,7 @@ class SkeenMonedero(models.Model):
self.write({'points': new_points})
self.env['skeen.monedero.transaction'].create({
'monedero_id': self.id,
'type': 'adjustment',
'type': tx_type,
'points': effective_delta,
'description': description or 'Ajuste de puntos',
'reference': reference,
@@ -131,6 +200,13 @@ class SkeenMonedero(models.Model):
})
return effective_delta
def nivel_dict(self, cfg=None):
"""Beneficios del nivel vigente del paciente (o dict vacío)."""
self.ensure_one()
cfg = cfg or get_lealtad(self.env)
nivel = nivel_por_codigo(cfg, self.partner_id.lealtad_nivel_codigo)
return dict(nivel) if nivel else {}
class SkeenMonederoTransaction(models.Model):
_name = 'skeen.monedero.transaction'

View File

@@ -1,7 +1,11 @@
# -*- coding: utf-8 -*-
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api
from ..lealtad import get_lealtad, nivel_por_codigo, nivel_para_gasto
class ResPartner(models.Model):
_inherit = 'res.partner'
@@ -10,8 +14,116 @@ class ResPartner(models.Model):
monedero_ids = fields.One2many('skeen.monedero', 'partner_id', string='Monederos')
wallet_points = fields.Integer(string='Puntos Monedero', compute='_compute_wallet_points', store=True)
# ------------------------------------------------------------------
# Nivel de lealtad (VIP). El nivel se decide por el gasto acumulado en
# una ventana y se guarda su código (los niveles viven en el bloque
# `lealtad` de los ajustes). `lealtad_manual` + `lealtad_nivel_revisar`
# permiten asignarlo a mano sin que se sobreescriba solo.
# ------------------------------------------------------------------
lealtad_nivel_codigo = fields.Char(string='Nivel de lealtad', copy=False)
lealtad_nivel_desde = fields.Date(string='Nivel desde', copy=False)
lealtad_nivel_revisar = fields.Date(string='Revisar nivel el', copy=False)
lealtad_manual = fields.Boolean(string='Nivel asignado a mano', copy=False,
default=False)
lealtad_nivel_nombre = fields.Char(
string='Nivel (nombre)', compute='_compute_lealtad_beneficios')
lealtad_descuento_pct = fields.Float(
string='Descuento de nivel (%)', compute='_compute_lealtad_beneficios')
lealtad_factor_puntos = fields.Float(
string='Factor de puntos', compute='_compute_lealtad_beneficios')
lealtad_gasto = fields.Float(
string='Gasto en la ventana', compute='_compute_lealtad_beneficios')
@api.depends('monedero_ids.points')
def _compute_wallet_points(self):
for partner in self:
monedero = partner.monedero_ids[:1]
partner.wallet_points = monedero.points if monedero else 0
@api.depends('lealtad_nivel_codigo')
def _compute_lealtad_beneficios(self):
cfg = get_lealtad(self.env)
for partner in self:
nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
partner.lealtad_nivel_nombre = (nivel or {}).get('nombre') or ''
partner.lealtad_descuento_pct = float((nivel or {}).get('descuento_pct') or 0.0)
partner.lealtad_factor_puntos = float((nivel or {}).get('factor_puntos') or 1.0)
ventana = 12
if cfg.get('niveles'):
ventana = max(int(n.get('ventana_meses') or 12) for n in cfg['niveles'])
partner.lealtad_gasto = partner._lealtad_gasto_ventana(ventana)
# ------------------------------------------------------------------
# Consultas y recálculo de nivel
# ------------------------------------------------------------------
def _lealtad_gasto_ventana(self, meses):
"""Gasto (suma de ventas no canceladas) del paciente en `meses` meses."""
self.ensure_one()
meses = max(int(meses or 12), 1)
desde = fields.Date.today() - relativedelta(months=meses)
ventas = self.env['skeen.venta'].sudo().search([
('partner_id', '=', self.id),
('state', '!=', 'cancelled'),
('date', '>=', desde),
])
return sum(v.total for v in ventas)
def _lealtad_recalcular(self, cfg=None):
"""Recalcula el nivel VIP de los pacientes, respetando vigencias.
- Un nivel no baja de inmediato: se conserva hasta que venza su
`vigencia_meses` contados desde `lealtad_nivel_desde`.
- Una asignación manual con fecha de revisión futura no se toca.
Solo aplica con `modo='configurable'`; en clásico no hay niveles.
"""
cfg = cfg or get_lealtad(self.env)
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or not cfg.get('niveles'):
return 0
hoy = fields.Date.today()
cambios = 0
for partner in self:
if partner.lealtad_manual and partner.lealtad_nivel_revisar \
and partner.lealtad_nivel_revisar > hoy:
continue
vals = {}
if partner.lealtad_manual and partner.lealtad_nivel_revisar \
and partner.lealtad_nivel_revisar <= hoy:
partner.lealtad_manual = False
partner.lealtad_nivel_revisar = False
ventana = max(int(n.get('ventana_meses') or 12) for n in cfg['niveles'])
gasto = partner._lealtad_gasto_ventana(ventana)
nuevo = nivel_para_gasto(cfg, gasto)
actual = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
# Bajar de nivel: solo cuando venció la vigencia del actual.
if actual and (nuevo is None or nuevo['gasto_minimo'] < actual['gasto_minimo']):
desde = partner.lealtad_nivel_desde
vence = (desde + relativedelta(
months=int(actual.get('vigencia_meses') or 0))) if desde else None
if vence and hoy < vence:
continue
if not nuevo:
if partner.lealtad_nivel_codigo:
vals = {'lealtad_nivel_codigo': False, 'lealtad_nivel_desde': False}
elif partner.lealtad_manual:
vals = {'lealtad_manual': False, 'lealtad_nivel_revisar': False}
elif nuevo['codigo'] != partner.lealtad_nivel_codigo:
vals = {
'lealtad_nivel_codigo': nuevo['codigo'],
'lealtad_nivel_desde': hoy,
'lealtad_manual': False,
'lealtad_nivel_revisar': False,
}
if vals:
partner.write(vals)
cambios += 1
return cambios
@api.model
def _lealtad_recalcular_todos(self):
"""Proceso diario: recalcula el nivel de todos los pacientes."""
cfg = get_lealtad(self.env)
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or not cfg.get('niveles'):
return True
partners = self.sudo().search([('is_patient', '=', True)])
partners._lealtad_recalcular(cfg)
return True