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