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:
2026-10-11 12:57:19 +00:00
parent a916753ebe
commit 3c4f1b14d9
27 changed files with 3399 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import regla
from . import meta
from . import liquidacion
from . import motor

View File

@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""Liquidación de comisiones por periodo.
Es el único lugar donde la comisión se PERSISTE: al generar el borrador se corre
el motor y el desglose completo (por venta/línea/regla y metas alcanzadas) queda
congelado en `detalle` (JSON). Así el recibo de un periodo ya cerrado no cambia
aunque después se editen reglas, precios o devoluciones.
Estados: borrador -> pagada, o borrador -> cancelada. Una liquidación pagada o
cancelada es inmutable (se protege el histórico de lo ya entregado).
"""
import json
from odoo import models, fields, api
from odoo.exceptions import UserError
ESTADOS = ('borrador', 'pagada', 'cancelada')
ESTADO_LABEL = {'borrador': 'Borrador', 'pagada': 'Pagada', 'cancelada': 'Cancelada'}
class SkeenComisionLiquidacion(models.Model):
_name = 'skeen.comision.liquidacion'
_description = 'Liquidación de comisiones SKEEN'
_order = 'periodo_inicio desc, id desc'
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
empleado_id = fields.Many2one('hr.employee', string='Empleado', required=True,
ondelete='restrict', index=True)
periodo_inicio = fields.Date(string='Periodo desde', required=True)
periodo_fin = fields.Date(string='Periodo hasta', required=True)
periodo = fields.Char(string='Periodo (AAAA-MM)', compute='_compute_periodo',
store=True, index=True)
total_calculado = fields.Float(string='Comisión calculada', default=0.0)
total_ajustes = fields.Float(string='Ajustes', compute='_compute_total',
store=True)
total = fields.Float(string='Total a pagar', compute='_compute_total', store=True)
estado = fields.Selection([(e, ESTADO_LABEL[e]) for e in ESTADOS],
string='Estado', default='borrador', required=True,
index=True)
fecha_pago = fields.Date(string='Fecha de pago')
metodo_pago = fields.Char(string='Método de pago')
referencia = fields.Char(string='Referencia')
notas = fields.Text(string='Notas')
detalle = fields.Text(string='Desglose (JSON)')
creado_por_login = fields.Char(string='Creada por')
pagada_por_login = fields.Char(string='Pagada por')
ajuste_ids = fields.One2many('skeen.comision.ajuste', 'liquidacion_id',
string='Ajustes')
_sql_constraints = [
('unique_empleado_periodo',
'unique(empleado_id, periodo_inicio, periodo_fin)',
'Ya existe una liquidación de este empleado para el mismo periodo.'),
]
@api.depends('periodo_inicio')
def _compute_periodo(self):
for l in self:
l.periodo = l.periodo_inicio.strftime('%Y-%m') if l.periodo_inicio else ''
@api.depends('ajuste_ids.monto', 'total_calculado')
def _compute_total(self):
for l in self:
l.total_ajustes = sum(a.monto for a in l.ajuste_ids)
l.total = (l.total_calculado or 0.0) + l.total_ajustes
@api.model_create_multi
def create(self, vals_list):
seq = self.env['ir.sequence'].sudo().search(
[('code', '=', 'skeen.comision.liquidacion')], limit=1)
for vals in vals_list:
if not vals.get('name'):
vals['name'] = (seq.next_by_id() if seq else False) or 'LIQ-0001'
return super().create(vals_list)
def ajustar(self, monto, motivo='', usuario=''):
"""Agrega un ajuste manual con signo (+/-)."""
self.ensure_one()
if self.estado != 'borrador':
raise UserError('Solo una liquidación en borrador admite ajustes.')
try:
monto = float(monto)
except (TypeError, ValueError):
raise UserError('El monto del ajuste debe ser un número.')
if abs(monto) < 1e-9:
raise UserError('El monto del ajuste no puede ser cero.')
return self.env['skeen.comision.ajuste'].create({
'liquidacion_id': self.id,
'empleado_id': self.empleado_id.id,
'periodo': self.periodo,
'monto': monto,
'motivo': (motivo or '').strip(),
'usuario': usuario or '',
'fecha': fields.Datetime.now(),
})
def pagar(self, fecha=None, metodo='', referencia='', usuario=''):
self.ensure_one()
if self.estado != 'borrador':
raise UserError('Solo una liquidación en borrador se puede pagar.')
self.write({
'estado': 'pagada',
'fecha_pago': fecha or fields.Date.today(),
'metodo_pago': (metodo or '').strip(),
'referencia': (referencia or '').strip(),
'pagada_por_login': usuario or '',
})
return True
def cancelar(self, motivo=''):
self.ensure_one()
if self.estado == 'cancelada':
raise UserError('La liquidación ya está cancelada.')
if self.estado == 'pagada':
raise UserError('Una liquidación pagada no se cancela; registra un ajuste.')
texto = (self.notas or '').strip()
self.write({'estado': 'cancelada',
'notas': (texto + ('\n' if texto else '') +
'Cancelada: ' + (motivo or 'sin motivo')).strip()})
return True
def detalle_dict(self):
self.ensure_one()
try:
return json.loads(self.detalle or '{}')
except (ValueError, TypeError):
return {}
class SkeenComisionAjuste(models.Model):
_name = 'skeen.comision.ajuste'
_description = 'Ajuste manual de comisión'
_order = 'fecha desc, id desc'
liquidacion_id = fields.Many2one('skeen.comision.liquidacion', string='Liquidación',
ondelete='cascade', index=True)
empleado_id = fields.Many2one('hr.employee', string='Empleado', required=True,
ondelete='cascade', index=True)
periodo = fields.Char(string='Periodo (AAAA-MM)')
monto = fields.Float(string='Monto', default=0.0,
help='Positivo suma a la comisión; negativo la reduce.')
motivo = fields.Char(string='Motivo')
usuario = fields.Char(string='Usuario')
fecha = fields.Datetime(string='Fecha', default=fields.Datetime.now)

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""Metas de comisión por empleado y periodo.
Una meta compara la venta (base comisionable del periodo, según la base
configurada) del empleado contra `monto_objetivo`. Al alcanzarla se paga el
bono: `fijo` (monto en m.n.) o `porcentaje` (sobre la base del empleado). Se
evalúa al cerrar el periodo (al generar la liquidación) y también en vivo.
`periodo` acepta 'AAAA-MM' (mes concreto) o 'mensual' (cualquier mes). Se
documenta así para no ambigüedad: una meta 'mensual' aplica a todo periodo
consultado; una meta 'AAAA-MM' solo al mes que coincide con el inicio del rango.
"""
from odoo import models, fields, api
from odoo.exceptions import ValidationError
TIPOS_BONO = ('fijo', 'porcentaje')
class SkeenComisionMeta(models.Model):
_name = 'skeen.comision.meta'
_description = 'Meta de comisión SKEEN'
_order = 'empleado_id, periodo'
empleado_id = fields.Many2one('hr.employee', string='Empleado', required=True,
ondelete='cascade', index=True)
periodo = fields.Char(string='Periodo', required=True,
help="'AAAA-MM' para un mes concreto o 'mensual' para cualquiera.")
monto_objetivo = fields.Float(string='Monto objetivo', default=0.0)
bono = fields.Float(string='Bono', default=0.0)
tipo_bono = fields.Selection(
[('fijo', 'Monto fijo'), ('porcentaje', 'Porcentaje sobre la venta')],
string='Tipo de bono', default='fijo', required=True)
activo = fields.Boolean(string='Activa', default=True)
notas = fields.Text(string='Notas')
company_id = fields.Many2one('res.company', string='Compañía',
default=lambda self: self.env.company)
@api.constrains('periodo')
def _check_periodo(self):
for m in self:
p = (m.periodo or '').strip()
if p == 'mensual':
continue
try:
anio, mes = p.split('-')
int(anio)
mes_i = int(mes)
if not (1 <= mes_i <= 12) or len(mes) != 2:
raise ValueError
except (ValueError, AttributeError):
raise ValidationError(
"El periodo debe ser 'AAAA-MM' (p. ej. 2026-10) o 'mensual'.")
def aplica_a(self, periodo_mes):
"""True si la meta cubre el mes 'AAAA-MM' dado."""
self.ensure_one()
p = (self.periodo or '').strip()
return p == 'mensual' or p == periodo_mes

View File

@@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
"""Motor de comisiones: cálculo en vivo del periodo.
Reglas de negocio (documentadas para que el resultado sea predecible):
* Universal: se comisiona la LÍNEA recetada (`skeen.venta.line.prescribed_by_id`)
de ventas en estado confirmed/paid/partial dentro del rango. El empleado de la
comisión es el `prescribed_by_id` de la línea.
* Base: `vendido` = subtotal de la línea; `cobrado` = esa base multiplicada por
`amount_paid / total` de la venta (lo efectivamente pagado). Con
`incluir_impuestos` la base suma el impuesto de la línea.
* `neto_de_devoluciones`: si la venta está devuelta, la base se reduce en la
proporción devuelta (`refund_amount / total`).
* Regla ganadora: mayor `prioridad`; a igual prioridad, alcance más específico;
a igual alcance, regla por empleado > por staff_type > genérica.
* Escalonado: el porcentaje se decide con la venta TOTAL del periodo del empleado
(suma de las bases que aplican a esa regla) y se aplica a cada línea cubierta.
* Monto fijo: `valor × cantidad` de la línea.
* Sin ninguna regla aplicable a una línea: respaldo `hr.employee.commission_pct`.
* Metas: si la base del empleado alcanza `monto_objetivo` se suma el bono
(`fijo` = monto; `porcentaje` = % sobre la base del empleado).
* Ajustes: manuales, del empleado y mes, firmados (+/-).
"""
import json
from collections import defaultdict
from odoo import models
from odoo.addons.skeen_citas.clinic_settings import get_comisiones
from .regla import ESPECIFICIDAD
def _num(v, d=0.0):
try:
return float(v)
except (TypeError, ValueError):
return d
class SkeenComisionMotor(models.AbstractModel):
_name = 'skeen.comision.motor'
_description = 'Motor de cálculo de comisiones SKEEN'
# ------------------------------------------------------------------
# Regla ganadora
# ------------------------------------------------------------------
def _emp_rank(self, regla):
if regla.empleado_id:
return 2
if regla.staff_type:
return 1
return 0
def _match(self, regla, line, venta, emp):
if not regla.activo:
return False
if regla.empleado_id and regla.empleado_id.id != emp.id:
return False
if regla.staff_type and regla.staff_type != (emp.staff_type or 'doctor'):
return False
fecha = venta.date
if regla.fecha_desde and fecha and fecha < regla.fecha_desde:
return False
if regla.fecha_hasta and fecha and fecha > regla.fecha_hasta:
return False
a = regla.alcance
if a == 'global':
return True
if a == 'categoria':
return bool(line.service_id) and regla.categoria_code == line.service_id.category
if a == 'servicio':
return bool(line.service_id) and regla.servicio_id.id == line.service_id.id
if a == 'articulo':
return bool(line.item_id) and regla.item_id.id == line.item_id.id
if a == 'membresia':
if 'membresia_id' not in venta._fields or not venta.membresia_id:
return False
return bool(regla.plan_id) and regla.plan_id.id == venta.membresia_id.plan_id.id
if a == 'paquete':
return bool(line.paquete_id)
return False
def _mejor_regla(self, reglas, line, venta, emp):
candidatas = [r for r in reglas if self._match(r, line, venta, emp)]
if not candidatas:
return None
candidatas.sort(
key=lambda r: (r.prioridad or 0, ESPECIFICIDAD.get(r.alcance, 0),
self._emp_rank(r)))
return candidatas[-1]
# ------------------------------------------------------------------
# Cálculo de la base de una línea
# ------------------------------------------------------------------
def _base_linea(self, line, venta, cfg, base_forzada=None):
"""Base comisionable de la línea, ya neta de devolución si aplica."""
base = line.subtotal or 0.0
if cfg.get('incluir_impuestos'):
base += (line.tax_amount or 0.0)
base_efectiva = base_forzada or cfg.get('base') or 'vendido'
if base_efectiva == 'cobrado':
total = venta.total or 0.0
if total <= 0:
return 0.0
factor = max(0.0, min(1.0, (venta.amount_paid or 0.0) / total))
base *= factor
if cfg.get('neto_de_devoluciones') and venta.refunded and (venta.refund_amount or 0.0) > 0:
total = venta.total or 0.0
if total > 0:
base *= max(0.0, 1.0 - (venta.refund_amount / total))
return base
def _comisionable(self, line, venta, cfg):
"""(incluye, tipo_linea) según los interruptores del bloque."""
if line.paquete_id:
return bool(cfg.get('comisionar_paquetes')), 'paquete'
if 'membresia_id' in venta._fields and venta.membresia_id and not line.item_id:
# La línea de una venta de membresía (cargo del plan).
return bool(cfg.get('comisionar_membresias')), 'membresia'
if line.item_id:
return bool(cfg.get('comisionar_productos')), 'producto'
return True, 'servicio'
def _referencia(self, line, venta, regla, tipo_linea):
partes = [venta.name or ('Venta #%s' % venta.id)]
if line.service_id:
partes.append(line.service_id.name)
elif line.item_id:
partes.append(line.item_id.name)
elif line.description:
partes.append(line.description)
if regla:
partes.append('Regla «%s»' % regla.name)
else:
partes.append('Respaldo (% comisión del empleado)')
if tipo_linea:
partes.append(tipo_linea)
return ' · '.join([p for p in partes if p])
# ------------------------------------------------------------------
# Cálculo del periodo
# ------------------------------------------------------------------
def calcular(self, start, end, empleado_ids=None, cfg=None):
env = self.env
cfg = cfg or get_comisiones(env)
redondeo = int(cfg.get('redondeo', 2) or 0)
def rnd(v):
return round(_num(v), redondeo)
vdomain = [('state', 'in', ['confirmed', 'paid', 'partial']),
('date', '>=', start), ('date', '<=', end)]
if empleado_ids:
vdomain.append(('line_ids.prescribed_by_id', 'in', list(empleado_ids)))
ventas = env['skeen.venta'].sudo().search(vdomain)
lineas = env['skeen.venta.line'].sudo().search([
('venta_id', 'in', ventas.ids),
('prescribed_by_id', '!=', False),
])
reglas = env['skeen.comision.regla'].sudo().search([('activo', '=', True)])
items = [] # dicts por línea calculada
empleados_vistos = {}
for line in lineas:
emp = line.prescribed_by_id
if empleado_ids and emp.id not in empleado_ids:
continue
venta = line.venta_id
incluye, tipo_linea = self._comisionable(line, venta, cfg)
if not incluye:
continue
regla = self._mejor_regla(reglas, line, venta, emp)
base_forzada = regla.base_override if regla else None
base = self._base_linea(line, venta, cfg, base_forzada)
items.append({
'emp': emp, 'line': line, 'venta': venta, 'regla': regla,
'tipo_linea': tipo_linea, 'base': base, 'cantidad': line.quantity or 1.0,
})
empleados_vistos[emp.id] = emp
# Total de venta por (empleado, regla) para escalonados.
totales_esc = defaultdict(float)
for it in items:
if it['regla'] and it['regla'].tipo == 'escalonado':
totales_esc[(it['emp'].id, it['regla'].id)] += it['base']
# Comisión por línea.
for it in items:
regla = it['regla']
base = it['base']
pct = None
monto = 0.0
if regla:
if regla.tipo == 'porcentaje':
pct = regla.valor or 0.0
monto = base * pct / 100.0
elif regla.tipo == 'monto_fijo':
monto = (regla.valor or 0.0) * it['cantidad']
elif regla.tipo == 'escalonado':
pct = regla.porcentaje_para(totales_esc[(it['emp'].id, regla.id)])
pct = pct if pct is not None else 0.0
monto = base * pct / 100.0
else:
pct = it['emp'].commission_pct or 0.0
monto = base * pct / 100.0
it['pct'] = pct
it['comision'] = rnd(monto)
# Citas completadas por doctor.
citas = {}
for g in env['skeen.cita'].sudo().read_group(
[('state', '=', 'done'), ('date', '>=', start), ('date', '<=', end),
('doctor_id', '!=', False)], ['doctor_id'], ['doctor_id']):
citas[g['doctor_id'][0]] = g.get('__count') or g.get('doctor_id_count', 0)
# Metas y ajustes.
periodo_mes = start.strftime('%Y-%m') if hasattr(start, 'strftime') else str(start)[:7]
metas = env['skeen.comision.meta'].sudo().search([('activo', '=', True)])
ajustes = env['skeen.comision.ajuste'].sudo().search([
('periodo', '=', periodo_mes),
'|', ('liquidacion_id', '=', False), ('liquidacion_id.estado', '=', 'borrador'),
])
# Agregación por empleado.
por_emp = defaultdict(list)
for it in items:
por_emp[it['emp'].id].append(it)
resultado = []
for emp_id in set(list(por_emp.keys()) + list(empleados_vistos.keys())
+ [m.empleado_id.id for m in metas]
+ [a.empleado_id.id for a in ajustes]):
emp = env['hr.employee'].sudo().browse(emp_id)
if not emp.exists():
continue
if empleado_ids and emp_id not in empleado_ids:
continue
lins = por_emp.get(emp_id, [])
base_total = rnd(sum(i['base'] for i in lins))
comision_total = rnd(sum(i['comision'] for i in lins))
desglose = [{
'venta_id': i['venta'].id,
'folio': i['venta'].name,
'fecha': i['venta'].date.strftime('%Y-%m-%d') if i['venta'].date else None,
'linea_id': i['line'].id,
'servicio': (i['line'].service_id.name or i['line'].item_id.name
or i['line'].description or ''),
'categoria': i['line'].service_id.category if i['line'].service_id else '',
'tipo_linea': i['tipo_linea'],
'base': rnd(i['base']),
'regla_id': i['regla'].id if i['regla'] else None,
'regla': i['regla'].name if i['regla'] else 'Respaldo (% comisión)',
'tipo': i['regla'].tipo if i['regla'] else 'porcentaje',
'porcentaje': i['pct'],
'comision': i['comision'],
'referencia': self._referencia(i['line'], i['venta'], i['regla'], i['tipo_linea']),
} for i in lins]
desglose.sort(key=lambda d: d['fecha'] or '')
# Comisión por regla.
por_regla = defaultdict(lambda: {'base': 0.0, 'comision': 0.0, 'lineas': 0})
for d in desglose:
key = (d['regla_id'], d['regla'])
por_regla[key]['base'] += d['base']
por_regla[key]['comision'] += d['comision']
por_regla[key]['lineas'] += 1
por_regla_list = [{
'regla_id': k[0], 'regla': k[1],
'base': rnd(v['base']), 'comision': rnd(v['comision']),
'lineas': v['lineas'],
} for k, v in por_regla.items()]
por_regla_list.sort(key=lambda x: -x['comision'])
# Metas.
metas_emp = []
bonos = 0.0
for m in metas.filtered(lambda m: m.empleado_id.id == emp_id and m.aplica_a(periodo_mes)):
alcanzada = base_total >= (m.monto_objetivo or 0.0)
bono = 0.0
if alcanzada:
bono = (m.bono or 0.0) if m.tipo_bono == 'fijo' \
else base_total * (m.bono or 0.0) / 100.0
bonos += bono
metas_emp.append({
'meta_id': m.id, 'periodo': m.periodo,
'monto_objetivo': m.monto_objetivo, 'alcanzada': alcanzada,
'bono': rnd(bono), 'tipo_bono': m.tipo_bono,
'bono_config': m.bono,
})
bonos = rnd(bonos)
ajustes_emp = ajustes.filtered(lambda a: a.empleado_id.id == emp_id)
total_ajustes = rnd(sum(a.monto for a in ajustes_emp))
total = rnd(comision_total + bonos + total_ajustes)
resultado.append({
'empleado_id': emp.id,
'empleado': emp.name,
'staff_type': emp.staff_type or 'doctor',
'job_title': emp.job_title or '',
'commission_pct': emp.commission_pct or 0.0,
'citas_done': citas.get(emp_id, 0),
'lineas_count': len(lins),
'base': base_total,
'comision': comision_total,
'bonos': bonos,
'ajustes': total_ajustes,
'total': total,
'pagable': total >= _num(cfg.get('pago_minimo', 0)),
'desglose': desglose,
'por_regla': por_regla_list,
'metas': metas_emp,
'ajustes_detalle': [{
'id': a.id, 'monto': a.monto, 'motivo': a.motivo or '',
'usuario': a.usuario or '',
'fecha': a.fecha.strftime('%Y-%m-%d %H:%M') if a.fecha else None,
} for a in ajustes_emp],
})
resultado.sort(key=lambda r: -r['total'])
return {
'periodo': periodo_mes,
'start': start.strftime('%Y-%m-%d') if hasattr(start, 'strftime') else str(start),
'end': end.strftime('%Y-%m-%d') if hasattr(end, 'strftime') else str(end),
'config': cfg,
'empleados': resultado,
'totales': {
'base': rnd(sum(r['base'] for r in resultado)),
'comision': rnd(sum(r['comision'] for r in resultado)),
'bonos': rnd(sum(r['bonos'] for r in resultado)),
'ajustes': rnd(sum(r['ajustes'] for r in resultado)),
'total': rnd(sum(r['total'] for r in resultado)),
},
}
def generar_detalle_liquidacion(self, start, end, empleado_id, cfg=None):
"""Desglose congelado para una liquidación (un empleado)."""
data = self.calcular(start, end, empleado_ids=[empleado_id], cfg=cfg)
emp = data['empleados'][0] if data['empleados'] else None
if not emp:
return {'empleado_id': empleado_id, 'base': 0.0, 'comision': 0.0,
'bonos': 0.0, 'ajustes': 0.0, 'total': 0.0,
'desglose': [], 'por_regla': [], 'metas': []}
return emp

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