fase 3.3: comisiones por reglas y liquidacion (modulo skeen_comisiones: motor con reglas por servicio/categoria/articulo/membresia/paquete, monto fijo y escalonado por metas, base vendido/cobrado con y sin impuestos, neto de devoluciones, metas con bonos, ajustes y liquidacion por periodo); reporte legacy intacto con el motor apagado
This commit is contained in:
199
odoo-addons/skeen_comisiones/models/regla.py
Normal file
199
odoo-addons/skeen_comisiones/models/regla.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# -*- 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 '')
|
||||
Reference in New Issue
Block a user