|
|
|
|
@@ -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):
|
|
|
|
|
|