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