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

@@ -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'