# -*- 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)