Files
SKEEN-Proyecto/odoo-addons/skeen_comisiones/models/regla.py

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 '')