238 lines
9.5 KiB
Python
238 lines
9.5 KiB
Python
# -*- 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'
|
|
_description = 'Monedero Digital de Fidelidad'
|
|
_order = 'create_date desc'
|
|
|
|
# Paciente
|
|
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
|
domain=[('is_patient', '=', True)])
|
|
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
|
|
|
|
# Puntos
|
|
points = fields.Integer(string='Puntos', default=0)
|
|
equivalent_mxn = fields.Float(string='Equivalente MXN', compute='_compute_equivalent')
|
|
|
|
# Historial de transacciones
|
|
transaction_ids = fields.One2many('skeen.monedero.transaction', 'monedero_id', string='Transacciones')
|
|
|
|
# Estado
|
|
active = fields.Boolean(string='Activo', default=True)
|
|
|
|
@api.depends('points')
|
|
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)"""
|
|
partner = self.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
|
if not partner:
|
|
return {'points': 0, 'equivalent_mxn': 0, 'history': []}
|
|
|
|
monedero = self.search([('partner_id', '=', partner.id)], limit=1)
|
|
if not monedero:
|
|
return {'points': 0, 'equivalent_mxn': 0, 'history': []}
|
|
|
|
history = []
|
|
for tx in monedero.transaction_ids:
|
|
history.append({
|
|
'date': tx.create_date.strftime('%d/%m/%Y'),
|
|
'type': tx.type,
|
|
'points': tx.points,
|
|
'description': tx.description,
|
|
})
|
|
|
|
return {
|
|
'points': monedero.points,
|
|
'equivalent_mxn': monedero.equivalent_mxn,
|
|
'history': history,
|
|
}
|
|
|
|
def add_deposit(self, amount, description='', reference='', operator=''):
|
|
"""Recarga de saldo a favor (dinero a cuenta, p. ej. un anticipo).
|
|
|
|
A diferencia de add_points (puntos ganados por compra), el depósito es
|
|
dinero que el paciente paga por adelantado: suma al mismo saldo y se
|
|
puede canjear en el POS. El saldo se lleva en pesos enteros (1 pt = $1).
|
|
"""
|
|
self.ensure_one()
|
|
amount = int(round(float(amount or 0)))
|
|
if amount <= 0:
|
|
raise ValidationError(_('El monto de la recarga debe ser mayor a 0'))
|
|
self.write({'points': self.points + amount})
|
|
tx = self.env['skeen.monedero.transaction'].create({
|
|
'monedero_id': self.id,
|
|
'type': 'deposito',
|
|
'points': amount,
|
|
'description': description or 'Recarga de saldo a favor',
|
|
'reference': reference,
|
|
'operator': operator,
|
|
})
|
|
return tx
|
|
|
|
def add_points(self, points, description='', reference='', operator=''):
|
|
"""Agregar puntos al monedero"""
|
|
self.ensure_one()
|
|
self.write({'points': self.points + points})
|
|
self.env['skeen.monedero.transaction'].create({
|
|
'monedero_id': self.id,
|
|
'type': 'accrual',
|
|
'points': points,
|
|
'description': description or 'Acumulación de puntos',
|
|
'reference': reference,
|
|
'operator': operator,
|
|
})
|
|
return True
|
|
|
|
def redeem_points(self, points, description='', reference='', operator=''):
|
|
"""Redimir puntos del monedero"""
|
|
self.ensure_one()
|
|
if points > self.points:
|
|
raise ValidationError(_('No tiene suficientes puntos!'))
|
|
|
|
self.write({'points': self.points - points})
|
|
self.env['skeen.monedero.transaction'].create({
|
|
'monedero_id': self.id,
|
|
'type': 'redemption',
|
|
'points': -points,
|
|
'description': description or 'Redención de puntos',
|
|
'reference': reference,
|
|
'operator': operator,
|
|
})
|
|
return True
|
|
|
|
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()
|
|
delta = int(delta or 0)
|
|
new_points = self.points + delta
|
|
effective_delta = delta
|
|
if new_points < 0:
|
|
effective_delta = -self.points # solo revertir hasta 0
|
|
new_points = 0
|
|
self.write({'points': new_points})
|
|
self.env['skeen.monedero.transaction'].create({
|
|
'monedero_id': self.id,
|
|
'type': tx_type,
|
|
'points': effective_delta,
|
|
'description': description or 'Ajuste de puntos',
|
|
'reference': reference,
|
|
'operator': operator,
|
|
})
|
|
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'
|
|
_description = 'Transacción de Monedero'
|
|
_order = 'create_date desc'
|
|
|
|
monedero_id = fields.Many2one('skeen.monedero', string='Monedero', required=True, ondelete='cascade')
|
|
partner_id = fields.Many2one(related='monedero_id.partner_id', string='Paciente', readonly=True)
|
|
|
|
# Transacción
|
|
type = fields.Selection([
|
|
('deposito', 'Depósito (saldo a favor)'),
|
|
('accrual', 'Acumulación'),
|
|
('redemption', 'Redención'),
|
|
('adjustment', 'Ajuste'),
|
|
('reversion', 'Reversión'),
|
|
('expiration', 'Vencimiento'),
|
|
], string='Tipo', required=True)
|
|
|
|
points = fields.Integer(string='Puntos', required=True)
|
|
description = fields.Text(string='Descripción')
|
|
reference = fields.Char(string='Referencia')
|
|
# Usuario del frontend que capturó el movimiento (los controladores
|
|
# auth='none' no dejan uid de Odoo, así que se guarda explícito).
|
|
operator = fields.Char(string='Registrado por')
|
|
|
|
# Fecha
|
|
create_date = fields.Datetime(string='Fecha', readonly=True, default=fields.Datetime.now)
|