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