200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Reglas de comisión.
|
|
|
|
Una regla decide CÓMO se comisiona una línea de venta (una línea recetada por un
|
|
empleado). El motor (`skeen.comision.motor`) elige, entre todas las reglas
|
|
aplicables a una línea, la ganadora. La precedencia es:
|
|
|
|
1. `prioridad` más alta.
|
|
2. A igual prioridad, el alcance más específico:
|
|
articulo > servicio > membresia = paquete > categoria > global.
|
|
3. A igual alcance, la regla dirigida al empleado concreto gana sobre la
|
|
dirigida a su `staff_type`, y esta sobre la genérica.
|
|
|
|
Si NINGUNA regla aplica a una línea, se usa el respaldo de compatibilidad:
|
|
`hr.employee.commission_pct` sobre la base de esa línea (el comportamiento de
|
|
siempre).
|
|
"""
|
|
|
|
import json
|
|
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import ValidationError
|
|
|
|
ALCANCES = ('global', 'categoria', 'servicio', 'articulo', 'membresia', 'paquete')
|
|
ALCANCE_LABEL = {
|
|
'global': 'Global (todo)',
|
|
'categoria': 'Categoría de servicio',
|
|
'servicio': 'Servicio',
|
|
'articulo': 'Artículo de inventario',
|
|
'membresia': 'Membresía / plan',
|
|
'paquete': 'Paquete',
|
|
}
|
|
TIPOS = ('porcentaje', 'monto_fijo', 'escalonado')
|
|
TIPO_LABEL = {
|
|
'porcentaje': 'Porcentaje',
|
|
'monto_fijo': 'Monto fijo',
|
|
'escalonado': 'Escalonado por metas',
|
|
}
|
|
# Rango de especificidad del alcance (mayor = más específico).
|
|
ESPECIFICIDAD = {
|
|
'global': 10,
|
|
'categoria': 20,
|
|
'paquete': 25,
|
|
'membresia': 25,
|
|
'servicio': 30,
|
|
'articulo': 40,
|
|
}
|
|
|
|
|
|
class SkeenComisionRegla(models.Model):
|
|
_name = 'skeen.comision.regla'
|
|
_description = 'Regla de comisión SKEEN'
|
|
_order = 'prioridad desc, id desc'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
activo = fields.Boolean(string='Activa', default=True)
|
|
prioridad = fields.Integer(string='Prioridad', default=10,
|
|
help='A mayor prioridad, gana. A igual prioridad '
|
|
'gana el alcance más específico.')
|
|
empleado_id = fields.Many2one('hr.employee', string='Empleado', ondelete='cascade',
|
|
index=True,
|
|
help='Si se deja vacío, la regla aplica a todos los empleados.')
|
|
staff_type = fields.Selection(
|
|
[('doctor', 'Doctor'), ('cosmetologa', 'Cosmetóloga')],
|
|
string='Tipo de staff',
|
|
help='Si se define, la regla solo aplica a ese tipo de personal.')
|
|
alcance = fields.Selection(
|
|
[(a, ALCANCE_LABEL[a]) for a in ALCANCES], string='Alcance',
|
|
default='global', required=True)
|
|
categoria_code = fields.Char(string='Categoría (código)')
|
|
servicio_id = fields.Many2one('skeen.servicio', string='Servicio', ondelete='cascade')
|
|
item_id = fields.Many2one('skeen.inventario.item', string='Artículo', ondelete='cascade')
|
|
plan_id = fields.Many2one('skeen.membresia.plan', string='Plan de membresía',
|
|
ondelete='cascade')
|
|
tipo = fields.Selection([(t, TIPO_LABEL[t]) for t in TIPOS], string='Tipo',
|
|
default='porcentaje', required=True)
|
|
valor = fields.Float(string='Valor (% o monto)', default=0.0,
|
|
help='Porcentaje (%) o monto fijo (m.n.) según el tipo.')
|
|
escalones = fields.Text(
|
|
string='Escalones (JSON)',
|
|
help='Para el tipo escalonado: [{"desde":0,"hasta":50000,"porcentaje":5},'
|
|
' {"desde":50000,"hasta":null,"porcentaje":8}]. Se evalúa sobre la '
|
|
'venta del periodo del empleado.')
|
|
base_override = fields.Selection(
|
|
[('vendido', 'Vendido'), ('cobrado', 'Cobrado')],
|
|
string='Base (forzar)',
|
|
help='Si se define, esta regla usa su propia base en vez de la del bloque general.')
|
|
fecha_desde = fields.Date(string='Vigente desde')
|
|
fecha_hasta = fields.Date(string='Vigente hasta')
|
|
notas = fields.Text(string='Notas')
|
|
company_id = fields.Many2one('res.company', string='Compañía',
|
|
default=lambda self: self.env.company)
|
|
|
|
@api.constrains('fecha_desde', 'fecha_hasta')
|
|
def _check_vigencia(self):
|
|
for r in self:
|
|
if r.fecha_desde and r.fecha_hasta and r.fecha_hasta < r.fecha_desde:
|
|
raise ValidationError('La fecha "hasta" no puede ser anterior a la "desde".')
|
|
|
|
@api.constrains('alcance', 'categoria_code', 'servicio_id', 'item_id', 'plan_id')
|
|
def _check_coherencia(self):
|
|
for r in self:
|
|
requeridos = {
|
|
'global': (),
|
|
'categoria': ('categoria_code',),
|
|
'servicio': ('servicio_id',),
|
|
'articulo': ('item_id',),
|
|
'membresia': ('plan_id',),
|
|
'paquete': (),
|
|
}.get(r.alcance, ())
|
|
faltan = [c for c in requeridos if not r[c]]
|
|
if faltan:
|
|
raise ValidationError(
|
|
'El alcance "%s" requiere: %s.'
|
|
% (ALCANCE_LABEL.get(r.alcance, r.alcance), ', '.join(faltan)))
|
|
|
|
@api.constrains('tipo', 'escalones', 'valor')
|
|
def _check_tipo(self):
|
|
for r in self:
|
|
if r.tipo == 'escalonado':
|
|
if not r._escalones_validos():
|
|
raise ValidationError(
|
|
'Los escalones deben ser una lista JSON de objetos con '
|
|
'"desde", "hasta" (o null) y "porcentaje".')
|
|
elif r.tipo == 'porcentaje' and not (0 <= (r.valor or 0.0) <= 100):
|
|
raise ValidationError('El porcentaje debe estar entre 0 y 100.')
|
|
|
|
def _escalones_validos(self):
|
|
self.ensure_one()
|
|
try:
|
|
data = json.loads(self.escalones or '[]')
|
|
except (ValueError, TypeError):
|
|
return False
|
|
if not isinstance(data, list) or not data:
|
|
return False
|
|
for e in data:
|
|
if not isinstance(e, dict):
|
|
return False
|
|
if e.get('desde') is None:
|
|
return False
|
|
try:
|
|
float(e.get('desde'))
|
|
pct = float(e.get('porcentaje'))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if not (0 <= pct <= 100):
|
|
return False
|
|
return True
|
|
|
|
def escalones_ordenados(self):
|
|
"""Escalones parseados y ordenados por `desde` (o lista vacía)."""
|
|
self.ensure_one()
|
|
try:
|
|
data = json.loads(self.escalones or '[]')
|
|
except (ValueError, TypeError):
|
|
return []
|
|
limpios = []
|
|
for e in (data if isinstance(data, list) else []):
|
|
if not isinstance(e, dict):
|
|
continue
|
|
try:
|
|
desde = float(e.get('desde'))
|
|
pct = float(e.get('porcentaje'))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
hasta = e.get('hasta')
|
|
hasta = float(hasta) if hasta is not None else None
|
|
limpios.append({'desde': desde, 'hasta': hasta, 'porcentaje': pct})
|
|
limpios.sort(key=lambda x: x['desde'])
|
|
return limpios
|
|
|
|
def porcentaje_para(self, monto_empleado):
|
|
"""Porcentaje del escalón que aplica a la venta del periodo del empleado.
|
|
|
|
Devuelve `None` si la regla no es escalonada o no hay escalones válidos.
|
|
"""
|
|
self.ensure_one()
|
|
if self.tipo != 'escalonado':
|
|
return None
|
|
monto = float(monto_empleado or 0.0)
|
|
for e in self.escalones_ordenados():
|
|
if monto >= e['desde'] and (e['hasta'] is None or monto < e['hasta']):
|
|
return e['porcentaje']
|
|
return None
|
|
|
|
def _etiqueta_corta(self):
|
|
self.ensure_one()
|
|
if self.alcance == 'categoria':
|
|
detalle = self.categoria_code or ''
|
|
elif self.alcance == 'servicio':
|
|
detalle = self.servicio_id.name or ''
|
|
elif self.alcance == 'articulo':
|
|
detalle = self.item_id.name or ''
|
|
elif self.alcance == 'membresia':
|
|
detalle = self.plan_id.name or ''
|
|
else:
|
|
detalle = ''
|
|
alcance = ALCANCE_LABEL.get(self.alcance, self.alcance)
|
|
return '%s%s' % (alcance, (' · %s' % detalle) if detalle else '')
|