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

@@ -5,7 +5,9 @@ from odoo.exceptions import ValidationError
from markupsafe import Markup
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
from odoo.addons.skeen_citas.clinic_settings import get_impuestos, get_lealtad, get_propinas
from odoo.addons.skeen_monedero.lealtad import (
get_lealtad as _get_lealtad, nivel_por_codigo, puntos_venta, regla_multiplicador)
def _fmt_mxn(v):
@@ -99,6 +101,21 @@ class SkeenVenta(models.Model):
points_redeemed = fields.Integer(string='Puntos Redimidos', default=0)
points_discount = fields.Float(string='Descuento por Puntos', default=0.0)
# Lealtad: descuento automático por el nivel VIP del cliente (0 si no hay
# niveles o la lealtad está en modo clásico).
nivel_codigo = fields.Char(string='Nivel del cliente', copy=False)
nivel_discount = fields.Float(string='Descuento por nivel', default=0.0)
# Propinas (opcionales; 0 = comportamiento de siempre). La propina NO se
# mezcla con el importe de los servicios: va en su propio campo y se suma
# al total cobrado al cliente. `propina_employee_id` es a quién corresponde
# cuando el reparto está en 'ninguno'.
propina = fields.Float(string='Propina', default=0.0)
propina_moneda = fields.Char(string='Moneda de la propina', default='MXN')
propina_employee_id = fields.Many2one('hr.employee', string='Propina para')
reparto_ids = fields.One2many('skeen.propina.reparto', 'venta_id',
string='Reparto de propina')
# Devolución
refunded = fields.Boolean(string='Devuelta', default=False)
refund_amount = fields.Float(string='Monto Devuelto', default=0.0)
@@ -140,15 +157,18 @@ class SkeenVenta(models.Model):
notes = fields.Text(string='Notas')
@api.depends('line_ids.subtotal', 'line_ids.tax_amount', 'discount', 'tax',
'amount_paid', 'points_discount')
'amount_paid', 'points_discount', 'nivel_discount', 'propina')
def _compute_totals(self):
for venta in self:
subtotal = sum(line.subtotal for line in venta.line_ids)
venta.subtotal = subtotal
# La fórmula de siempre: subtotal - descuento - puntos + impuesto.
# Con impuestos apagados `tax` conserva el valor manual, así que el
# total sale idéntico al de antes.
venta.total = max(0.0, subtotal - venta.discount - venta.points_discount + venta.tax)
# total sale idéntico al de antes. `nivel_discount` y `propina` son
# 0 por defecto: no alteran ninguna cifra existente.
venta.total = max(
0.0, subtotal - venta.discount - venta.nivel_discount
- venta.points_discount + venta.tax + (venta.propina or 0.0))
venta.amount_due = max(0.0, venta.total - venta.amount_paid)
@api.depends('line_ids.subtotal', 'line_ids.tax_amount')
@@ -191,18 +211,16 @@ class SkeenVenta(models.Model):
'payment_state': 'paid',
'amount_paid': venta.total,
})
# Acumular puntos (proporcional a líneas que generan monedero)
# Acumular puntos según la configuración de lealtad vigente
# (clásico: 1 pt por cada $10; configurable: bloque `lealtad`).
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
if not monedero:
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
subtotal_total = sum(l.subtotal for l in venta.line_ids)
elegible = 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)
)
points = int(venta.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
points = venta._wallet_points_expected()
if points > 0:
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
# El nivel VIP del cliente se recalcula al cerrar la venta.
venta.partner_id._lealtad_recalcular()
def action_cancel(self):
self.write({'state': 'cancelled'})
@@ -326,17 +344,117 @@ class SkeenVenta(models.Model):
def _wallet_points_expected(self):
"""Puntos que DEBIÓ generar esta venta con sus líneas y total actuales.
Es la misma fórmula de action_mark_paid: el total se reparte
proporcionalmente entre las líneas que generan monedero ($10 = 1 pt).
En modo clásico es la misma fórmula de action_mark_paid (el total se
reparte proporcionalmente entre las líneas que generan monedero,
$10 = 1 pt). En modo configurable la calcula el motor de lealtad.
"""
self.ensure_one()
subtotal_total = sum(l.subtotal for l in self.line_ids)
elegible = sum(
l.subtotal for l in self.line_ids
if (l.service_id and l.service_id.genera_monedero)
or (l.item_id and l.item_id.genera_monedero)
)
return int(self.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
return puntos_venta(self.env, self)[0]
def contexto_lealtad(self, line):
"""Contexto de una línea para resolver multiplicadores y nivel."""
self.ensure_one()
fecha = self.date
return {
'servicio_id': line.service_id.id or None,
'articulo_id': line.item_id.id or None,
'categoria': line.service_id.category if line.service_id else '',
'sucursal': self.cita_id.branch if self.cita_id else '',
'nivel_codigo': self.partner_id.lealtad_nivel_codigo or '',
'fecha': fecha,
'dia_semana': fecha.weekday() if fecha else None,
}
def _aplicar_descuento_nivel(self, partner=None):
"""Aplica el descuento (%) del nivel VIP del cliente. Solo con lealtad
configurable y niveles; en clásico no hay descuento (queda en 0)."""
self.ensure_one()
cfg = _get_lealtad(self.env)
partner = partner or self.partner_id
if not cfg.get('activo') or cfg.get('modo') != 'configurable':
return 0.0
nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
pct = float((nivel or {}).get('descuento_pct') or 0.0)
if pct <= 0:
self.write({'nivel_codigo': (nivel or {}).get('codigo') or False})
return 0.0
descuento = round((self.subtotal - self.discount) * pct / 100.0, 2)
descuento = max(0.0, descuento)
self.write({'nivel_discount': descuento,
'nivel_codigo': (nivel or {}).get('codigo') or False})
return descuento
def _generar_reparto_propina(self, cfg=None, cobrador_employee=None,
cobrador_login=''):
"""Reparte la propina de la venta entre el personal implicado.
Con `reparto='ninguno'` no se crea ningún registro: la propina queda en
la venta (y en `propina_employee_id` si llega). Devuelve los registros
creados (vacío si no aplica).
"""
self.ensure_one()
cfg = cfg or get_propinas(self.env)
Reparto = self.env['skeen.propina.reparto']
if not cfg.get('activo') or (self.propina or 0.0) <= 0:
return Reparto
if cfg.get('reparto') not in ('equitativo', 'por_participacion'):
return Reparto
self.reparto_ids.unlink()
atendio = self.cita_id.doctor_id if self.cita_id else self.env['hr.employee']
participantes = []
if atendio:
participantes.append((atendio, 'atendio'))
if cobrador_employee and cobrador_employee != atendio:
participantes.append((cobrador_employee, 'cobro'))
if not participantes:
participantes = [(None, 'cobro')]
montos = {}
if cfg.get('reparto') == 'por_participacion' and self.line_ids:
pesos = {}
for emp, rol in participantes:
if not emp:
continue
pesos[emp.id] = pesos.get(emp.id, 0.0)
total_lineas = 0.0
for line in self.line_ids:
total_lineas += line.subtotal
if line.prescribed_by_id and line.prescribed_by_id.id in pesos:
pesos[line.prescribed_by_id.id] += line.subtotal
elif atendio and atendio.id in pesos and not line.prescribed_by_id:
pesos[atendio.id] += line.subtotal
suma = sum(pesos.values())
if suma > 0:
for emp, rol in participantes:
if emp:
montos[emp.id] = round(self.propina * pesos[emp.id] / suma, 2)
if not montos:
# equitativo (o respaldo de por_participacion sin pesos)
cupo = round(self.propina / len(participantes), 2)
for emp, rol in participantes:
montos[emp.id if emp else 0] = cupo
creados = Reparto
repartido = 0.0
for emp, rol in participantes:
monto = montos.get(emp.id if emp else 0, 0.0)
if monto <= 0:
continue
creados |= Reparto.create({
'venta_id': self.id,
'partner_id': self.partner_id.id,
'employee_id': emp.id if emp else False,
'rol': rol,
'monto': monto,
'moneda': self.propina_moneda or 'MXN',
'cuenta': cfg.get('cuenta_propinas') or '',
'user_login': cobrador_login or self.user_login or '',
'date': self.date,
})
repartido += monto
# Ajuste de centavos: el primero absorbe la diferencia.
if creados and abs(repartido - self.propina) >= 0.005:
creados[0].monto = round(creados[0].monto + (self.propina - repartido), 2)
return creados
def _wallet_points_accrued(self):
"""Puntos realmente acumulados por esta venta (suma de abonos a su referencia)."""
@@ -683,8 +801,13 @@ class SkeenVenta(models.Model):
new_amount_paid = round(self.amount_paid - refund_amount, 2)
# Revertir puntos acumulados en proporción a lo devuelto ($10 MXN = 1 punto)
points_to_reverse = int(refund_amount / 10)
# Revertir puntos acumulados en proporción a lo devuelto. Clásico:
# $10 MXN = 1 punto. Configurable: según `puntos_por_unidad`.
cfg_lea = _get_lealtad(self.env)
if cfg_lea.get('modo') == 'configurable':
points_to_reverse = int(refund_amount * float(cfg_lea.get('puntos_por_unidad') or 0.0))
else:
points_to_reverse = int(refund_amount / 10)
if points_to_reverse > 0:
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
if monedero and monedero.points > 0:
@@ -729,20 +852,26 @@ class SkeenVenta(models.Model):
}
def action_apply_wallet(self, points):
"""Redime puntos del monedero como descuento en la venta"""
"""Redime puntos del monedero como descuento en la venta.
El tope de canje (`canje_max_pct`) y el valor del punto (`valor_punto`)
salen de la configuración de lealtad; en modo clásico equivalen al tope
de 50% y 1 punto = $1 de siempre.
"""
self.ensure_one()
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
if not monedero or monedero.points < points:
raise ValidationError(_('Puntos insuficientes'))
# Máximo 50% del total
max_discount = self.total * 0.5
point_value = min(points, int(max_discount))
cfg = _get_lealtad(self.env)
valor = float(cfg.get('valor_punto') or 1.0) or 1.0
max_discount = self.total * float(cfg.get('canje_max_pct') or 0.0) / 100.0
point_value = min(points, int(max_discount / valor))
monedero.redeem_points(point_value, description=f'Redención venta {self.name}', reference=self.name)
self.write({
'points_redeemed': point_value,
'points_discount': point_value * 1.0,
'points_discount': point_value * valor,
})
return True
@@ -768,6 +897,37 @@ class SkeenVentaLine(models.Model):
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
prescribed_by_id = fields.Many2one('hr.employee', string='Recetado por')
# Clasificación para el programa de lealtad (`aplicar_a` y multiplicadores).
tipo_monedero = fields.Selection([
('servicios', 'Servicios'),
('productos', 'Productos'),
('membresias', 'Membresías'),
('paquetes', 'Paquetes'),
], string='Tipo (lealtad)', compute='_compute_lealtad_linea')
genera_monedero = fields.Boolean(
string='Genera monedero', compute='_compute_lealtad_linea',
help='Igual que el indicador del catálogo: si el servicio/artículo acumula puntos.')
@api.depends('service_id', 'item_id', 'paquete_id',
'service_id.genera_monedero', 'item_id.genera_monedero')
def _compute_lealtad_linea(self):
planes = None
if 'skeen.membresia.plan' in self.env:
planes = set(self.env['skeen.membresia.plan'].sudo().search(
[('servicio_id', '!=', False)]).mapped('servicio_id').ids)
for line in self:
if line.item_id:
line.tipo_monedero = 'productos'
elif line.paquete_id:
line.tipo_monedero = 'paquetes'
elif line.service_id and planes is not None and line.service_id.id in planes:
line.tipo_monedero = 'membresias'
else:
line.tipo_monedero = 'servicios'
line.genera_monedero = bool(
(line.service_id and line.service_id.genera_monedero)
or (line.item_id and line.item_id.genera_monedero))
_sql_constraints = [
('service_o_item', 'CHECK(service_id IS NOT NULL OR item_id IS NOT NULL)',
'La línea debe tener un servicio o un artículo de inventario.')
@@ -868,6 +1028,36 @@ class SkeenVentaLine(models.Model):
self.tax_rate = self._tasa_para_linea({'item_id': self.item_id.id})
class SkeenPropinaReparto(models.Model):
"""Detalle del reparto de una propina entre el personal.
Una fila por persona con lo que le toca de la propina de una venta. Se usa
en el reporte de propinas por empleado y, si la configuración lo pide, se
suma a su liquidación de comisiones. Con `activo=False` (default) no se
genera ninguna fila.
"""
_name = 'skeen.propina.reparto'
_description = 'Reparto de Propina'
_order = 'date desc, id desc'
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True,
ondelete='cascade', index=True)
venta_name = fields.Char(related='venta_id.name', string='Folio', readonly=True)
partner_id = fields.Many2one('res.partner', string='Paciente', index=True)
employee_id = fields.Many2one('hr.employee', string='Empleado', index=True)
rol = fields.Selection([
('atendio', 'Atendió la cita'),
('cobro', 'Cobró'),
('otro', 'Otro'),
], string='Rol', default='otro')
monto = fields.Float(string='Monto', default=0.0)
moneda = fields.Char(string='Moneda', default='MXN')
cuenta = fields.Char(string='Cuenta destino')
user_login = fields.Char(string='Registrado por')
date = fields.Date(string='Fecha', default=fields.Date.today, index=True)
notes = fields.Char(string='Notas')
class SkeenCorteCaja(models.Model):
_name = 'skeen.corte.caja'
_description = 'Corte de Caja SKEEN'
@@ -890,6 +1080,11 @@ class SkeenCorteCaja(models.Model):
total_transfer = fields.Float(string='Transferencia', compute='_compute_totals', store=True)
total_other = fields.Float(string='Otros', compute='_compute_totals', store=True)
total_sales = fields.Float(string='Total Ventas', compute='_compute_totals', store=True)
# Desglose de propinas del día (0 cuando el módulo está apagado). No altera
# ninguna cifra existente: es un renglón aparte.
total_propinas = fields.Float(string='Propinas', compute='_compute_totals', store=True)
total_servicios = fields.Float(string='Servicios (sin propina)',
compute='_compute_totals', store=True)
# Cierre guiado: efectivo contado por el cajero y conteo informativo
# de los demás métodos.
@@ -929,12 +1124,18 @@ class SkeenCorteCaja(models.Model):
card = sum(p.amount for p in pagos if p.payment_method in ('card', 'stripe', 'mercadopago'))
transfer = sum(p.amount for p in pagos if p.payment_method == 'transfer')
other = sum(p.amount for p in pagos if p.payment_method not in ('cash', 'card', 'stripe', 'mercadopago', 'transfer'))
ventas = self.env['skeen.venta'].sudo().search([
('date', '=', self.date), ('state', '!=', 'cancelled'),
])
propinas = sum(v.propina or 0.0 for v in ventas)
return {
'total_cash': cash,
'total_card': card,
'total_transfer': transfer,
'total_other': other,
'total_sales': cash + card + transfer + other,
'total_propinas': propinas,
'total_servicios': (cash + card + transfer + other) - propinas,
}
def refresh_totals(self):

View File

@@ -6,3 +6,4 @@ access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cam
access_skeen_paquete_user,Acceso a Paquetes SKEEN,model_skeen_paquete,base.group_user,1,1,1,1
access_skeen_paquete_compra_user,Acceso a Compras de Paquete SKEEN,model_skeen_paquete_compra,base.group_user,1,1,1,1
access_skeen_paquete_consumo_user,Acceso a Consumos de Paquete SKEEN,model_skeen_paquete_consumo,base.group_user,1,1,1,1
access_skeen_propina_reparto_user,Acceso a Reparto de Propinas SKEEN,model_skeen_propina_reparto,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_skeen_paquete_user Acceso a Paquetes SKEEN model_skeen_paquete base.group_user 1 1 1 1
7 access_skeen_paquete_compra_user Acceso a Compras de Paquete SKEEN model_skeen_paquete_compra base.group_user 1 1 1 1
8 access_skeen_paquete_consumo_user Acceso a Consumos de Paquete SKEEN model_skeen_paquete_consumo base.group_user 1 1 1 1
9 access_skeen_propina_reparto_user Acceso a Reparto de Propinas SKEEN model_skeen_propina_reparto base.group_user 1 1 1 1