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