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:
3
odoo-addons/skeen_comisiones/__init__.py
Normal file
3
odoo-addons/skeen_comisiones/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
42
odoo-addons/skeen_comisiones/__manifest__.py
Normal file
42
odoo-addons/skeen_comisiones/__manifest__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Comisiones por reglas y liquidación',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Motor de reglas de comisión (servicio, categoría, monto fijo, escalonado, productos, membresías) y liquidación por periodo',
|
||||
'description': """
|
||||
Motor de comisiones configurable y liquidación por periodo.
|
||||
|
||||
Sustituye el porcentaje plano por empleado (`hr.employee.commission_pct`)
|
||||
por un conjunto de reglas: por servicio, categoría, artículo, membresía o
|
||||
paquete; porcentaje, monto fijo o escalonado por metas; base vendida o
|
||||
cobrada, con o sin impuestos, neta de devoluciones. Agrega metas con bono,
|
||||
ajustes manuales y liquidaciones (borrador/pagada/cancelada) que marcan lo
|
||||
pagado.
|
||||
|
||||
El módulo está APAGADO por defecto (`comisiones.activo=false`): el reporte
|
||||
de comisiones de siempre (`/reports/comisiones` y `/reports/commissions`)
|
||||
sigue devolviendo EXACTAMENTE el mismo JSON y los mismos números, no
|
||||
aparece ninguna sección nueva en el menú y las tablas nuevas quedan en 0
|
||||
filas. El motor de reglas no se ejecuta en el camino del cálculo salvo que
|
||||
la clínica lo encienda.
|
||||
|
||||
Cálculo EN VIVO: la comisión se calcula al momento y solo se persiste en
|
||||
la liquidación (su desglose queda congelado en `detalle`). Así no hay
|
||||
líneas de comisión obsoletas cuando cambian precios, reglas o devoluciones.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': [
|
||||
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_ventas',
|
||||
'skeen_inventario', 'skeen_pagos', 'skeen_membresias', 'skeen_whatsapp',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
2
odoo-addons/skeen_comisiones/controllers/__init__.py
Normal file
2
odoo-addons/skeen_comisiones/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import comisiones
|
||||
703
odoo-addons/skeen_comisiones/controllers/comisiones.py
Normal file
703
odoo-addons/skeen_comisiones/controllers/comisiones.py
Normal file
@@ -0,0 +1,703 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""API del frontend React para Comisiones por reglas y liquidación.
|
||||
|
||||
Vive en su propio controlador (no en `skeen_whatsapp/controllers/frontend.py`)
|
||||
para no chocar con otros trabajos. Las rutas cuelgan de
|
||||
`/skeen/frontend/v1/comisiones` y `/skeen/frontend/v1/reports/comisiones_reglas`,
|
||||
y exigen token (`@require_role`) más el permiso por acción (`@require_perm`).
|
||||
|
||||
Dos familias de rutas:
|
||||
|
||||
* CONFIGURACIÓN (reglas y metas): disponibles aunque `comisiones.activo=false`,
|
||||
porque la pestaña Configuración → Comisiones es la excepción que se muestra
|
||||
siempre (para poder dejar todo listo antes de encender). Exigen
|
||||
`comisiones.editar` (admin/gerencia).
|
||||
* OPERACIÓN (cálculo, reporte de reglas y liquidaciones): exigen
|
||||
`comisiones.activo=true`; con el módulo apagado responden 403 y no tocan nada.
|
||||
Así apagar el motor devuelve el sistema al estado anterior.
|
||||
|
||||
OJO: las rutas operativas NO viven bajo `/reports/` para que el rol médico pueda
|
||||
ver sus propias liquidaciones (el veto de 'medico' bloquea `/reports/`).
|
||||
"""
|
||||
|
||||
import functools
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.skeen_whatsapp.controllers.auth import require_perm, require_role
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_comisiones
|
||||
|
||||
BASE = '/skeen/frontend/v1'
|
||||
COM = BASE + '/comisiones'
|
||||
|
||||
METODOS_PAGO = ('efectivo', 'transferencia', 'tarjeta', 'otro')
|
||||
|
||||
|
||||
def _json(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _body():
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True)
|
||||
return json.loads(raw) if raw else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _cfg():
|
||||
return get_comisiones(request.env)
|
||||
|
||||
|
||||
def _activo():
|
||||
return bool(_cfg().get('activo'))
|
||||
|
||||
|
||||
def comisiones_activas(func):
|
||||
"""Niega la operación si la instancia no tiene el módulo encendido."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if not _activo():
|
||||
return _json({'status': 'error',
|
||||
'message': 'Las comisiones por reglas no están activadas '
|
||||
'en esta clínica.'}, 403)
|
||||
return func(self, *args, **kw)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _login():
|
||||
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
|
||||
|
||||
def _num(v, d=None):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return d
|
||||
|
||||
|
||||
def _linked_employee(user):
|
||||
"""hr.employee vinculado a un usuario frontend (por login)."""
|
||||
if user is None:
|
||||
return request.env['hr.employee'].sudo().browse()
|
||||
Emp = request.env['hr.employee'].sudo()
|
||||
ru = request.env['res.users'].sudo().search([('login', '=', user.login)], limit=1)
|
||||
emp = ru.employee_ids[:1] if ru else Emp.browse()
|
||||
if not emp:
|
||||
emp = Emp.search([('user_id', '=', ru.id)], limit=1) if ru else Emp.browse()
|
||||
return emp
|
||||
|
||||
|
||||
class SkeenComisionesController(http.Controller):
|
||||
|
||||
# ==================================================================
|
||||
# Serializadores
|
||||
# ==================================================================
|
||||
def _regla_to_dict(self, r):
|
||||
return {
|
||||
'id': r.id,
|
||||
'name': r.name or '',
|
||||
'activo': r.activo,
|
||||
'prioridad': r.prioridad,
|
||||
'empleado_id': r.empleado_id.id if r.empleado_id else None,
|
||||
'empleado': r.empleado_id.name if r.empleado_id else '',
|
||||
'staff_type': r.staff_type or '',
|
||||
'alcance': r.alcance,
|
||||
'alcance_label': dict(r._fields['alcance'].selection).get(r.alcance, r.alcance),
|
||||
'categoria_code': r.categoria_code or '',
|
||||
'servicio_id': r.servicio_id.id if r.servicio_id else None,
|
||||
'servicio': r.servicio_id.name if r.servicio_id else '',
|
||||
'item_id': r.item_id.id if r.item_id else None,
|
||||
'item': r.item_id.name if r.item_id else '',
|
||||
'plan_id': r.plan_id.id if r.plan_id else None,
|
||||
'plan': r.plan_id.name if r.plan_id else '',
|
||||
'tipo': r.tipo,
|
||||
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo, r.tipo),
|
||||
'valor': r.valor,
|
||||
'escalones': r.escalones_ordenados(),
|
||||
'base_override': r.base_override or '',
|
||||
'fecha_desde': r.fecha_desde.strftime('%Y-%m-%d') if r.fecha_desde else None,
|
||||
'fecha_hasta': r.fecha_hasta.strftime('%Y-%m-%d') if r.fecha_hasta else None,
|
||||
'notas': r.notas or '',
|
||||
'resumen': r._etiqueta_corta(),
|
||||
}
|
||||
|
||||
def _meta_to_dict(self, m):
|
||||
return {
|
||||
'id': m.id,
|
||||
'empleado_id': m.empleado_id.id,
|
||||
'empleado': m.empleado_id.name or '',
|
||||
'periodo': m.periodo or '',
|
||||
'monto_objetivo': m.monto_objetivo,
|
||||
'bono': m.bono,
|
||||
'tipo_bono': m.tipo_bono,
|
||||
'activo': m.activo,
|
||||
'notas': m.notas or '',
|
||||
}
|
||||
|
||||
def _ajuste_to_dict(self, a):
|
||||
return {
|
||||
'id': a.id,
|
||||
'liquidacion_id': a.liquidacion_id.id if a.liquidacion_id else None,
|
||||
'empleado_id': a.empleado_id.id,
|
||||
'empleado': a.empleado_id.name or '',
|
||||
'periodo': a.periodo or '',
|
||||
'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,
|
||||
}
|
||||
|
||||
def _liq_to_dict(self, l, with_detalle=False):
|
||||
d = {
|
||||
'id': l.id,
|
||||
'folio': l.name or '',
|
||||
'empleado_id': l.empleado_id.id,
|
||||
'empleado': l.empleado_id.name or '',
|
||||
'staff_type': l.empleado_id.staff_type or 'doctor',
|
||||
'periodo_inicio': l.periodo_inicio.strftime('%Y-%m-%d') if l.periodo_inicio else None,
|
||||
'periodo_fin': l.periodo_fin.strftime('%Y-%m-%d') if l.periodo_fin else None,
|
||||
'periodo': l.periodo or '',
|
||||
'total_calculado': l.total_calculado,
|
||||
'total_ajustes': l.total_ajustes,
|
||||
'total': l.total,
|
||||
'estado': l.estado,
|
||||
'estado_label': dict(l._fields['estado'].selection).get(l.estado, l.estado),
|
||||
'fecha_pago': l.fecha_pago.strftime('%Y-%m-%d') if l.fecha_pago else None,
|
||||
'metodo_pago': l.metodo_pago or '',
|
||||
'referencia': l.referencia or '',
|
||||
'notas': l.notas or '',
|
||||
'creado_por_login': l.creado_por_login or '',
|
||||
'pagada_por_login': l.pagada_por_login or '',
|
||||
'ajustes': [self._ajuste_to_dict(a) for a in l.ajuste_ids],
|
||||
}
|
||||
if with_detalle:
|
||||
d['detalle'] = l.detalle_dict()
|
||||
return d
|
||||
|
||||
def _rango(self, kw, defecto_dias=30):
|
||||
start = fields.Date.to_date(kw.get('start')) or (fields.Date.today() - timedelta(days=defecto_dias))
|
||||
end = fields.Date.to_date(kw.get('end')) or fields.Date.today()
|
||||
if end < start:
|
||||
start, end = end, start
|
||||
return start, end
|
||||
|
||||
def _solo_propio(self, user):
|
||||
"""True si el usuario debe ver solo sus propias comisiones (rol médico)."""
|
||||
return bool(user and user.role == 'medico')
|
||||
|
||||
# ==================================================================
|
||||
# CONFIGURACIÓN: reglas (disponibles aunque el motor esté apagado)
|
||||
# ==================================================================
|
||||
@http.route(COM + '/reglas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def list_reglas(self, **kw):
|
||||
try:
|
||||
domain = []
|
||||
if kw.get('empleado_id'):
|
||||
try:
|
||||
domain = ['|', ('empleado_id', '=', int(kw['empleado_id'])),
|
||||
('empleado_id', '=', False)]
|
||||
except (TypeError, ValueError):
|
||||
return _json({'status': 'error', 'message': 'empleado_id inválido'}, 400)
|
||||
if kw.get('alcance'):
|
||||
domain.append(('alcance', '=', kw['alcance']))
|
||||
if kw.get('activo') in ('1', 'true', 'True'):
|
||||
domain.append(('activo', '=', True))
|
||||
if kw.get('q'):
|
||||
domain.append('|')
|
||||
domain.append(('name', 'ilike', kw['q']))
|
||||
domain.append(('categoria_code', 'ilike', kw['q']))
|
||||
reglas = request.env['skeen.comision.regla'].sudo().search(domain)
|
||||
return _json({'status': 'success',
|
||||
'reglas': [self._regla_to_dict(r) for r in reglas],
|
||||
'total': len(reglas)})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/reglas', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def create_regla(self, **kw):
|
||||
try:
|
||||
data = _body()
|
||||
if not (data.get('name') or '').strip():
|
||||
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
|
||||
vals = self._regla_vals(data)
|
||||
regla = request.env['skeen.comision.regla'].sudo().create(vals)
|
||||
return _json({'status': 'success', 'regla': self._regla_to_dict(regla)}, 201)
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/reglas/<int:rid>', type='http', auth='none',
|
||||
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def update_regla(self, rid, **kw):
|
||||
try:
|
||||
regla = request.env['skeen.comision.regla'].sudo().browse(rid)
|
||||
if not regla.exists():
|
||||
return _json({'status': 'error', 'message': 'Regla no encontrada'}, 404)
|
||||
regla.write(self._regla_vals(_body(), parcial=True))
|
||||
return _json({'status': 'success', 'regla': self._regla_to_dict(regla)})
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/reglas/<int:rid>', type='http', auth='none',
|
||||
methods=['DELETE', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def delete_regla(self, rid, **kw):
|
||||
try:
|
||||
regla = request.env['skeen.comision.regla'].sudo().browse(rid)
|
||||
if not regla.exists():
|
||||
return _json({'status': 'error', 'message': 'Regla no encontrada'}, 404)
|
||||
regla.write({'activo': False})
|
||||
return _json({'status': 'success', 'message': 'Regla desactivada',
|
||||
'regla': self._regla_to_dict(regla)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _regla_vals(self, data, parcial=False):
|
||||
campos = ('name', 'activo', 'prioridad', 'empleado_id', 'staff_type', 'alcance',
|
||||
'categoria_code', 'servicio_id', 'item_id', 'plan_id', 'tipo', 'valor',
|
||||
'base_override', 'fecha_desde', 'fecha_hasta', 'notas')
|
||||
vals = {}
|
||||
for k in campos:
|
||||
if k not in data:
|
||||
continue
|
||||
v = data[k]
|
||||
if k in ('empleado_id', 'servicio_id', 'item_id', 'plan_id'):
|
||||
vals[k] = int(v) if v else False
|
||||
elif k in ('fecha_desde', 'fecha_hasta'):
|
||||
vals[k] = fields.Date.to_date(v) if v else False
|
||||
elif v is None:
|
||||
vals[k] = False if k in ('base_override', 'staff_type') else v
|
||||
else:
|
||||
vals[k] = v
|
||||
if 'escalones' in data:
|
||||
esc = data['escalones']
|
||||
vals['escalones'] = json.dumps(esc, ensure_ascii=False) if esc else ''
|
||||
if not parcial and 'alcance' not in vals:
|
||||
vals['alcance'] = 'global'
|
||||
return vals
|
||||
|
||||
# ==================================================================
|
||||
# CONFIGURACIÓN: metas
|
||||
# ==================================================================
|
||||
@http.route(COM + '/metas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def list_metas(self, **kw):
|
||||
try:
|
||||
domain = []
|
||||
if kw.get('empleado_id'):
|
||||
domain.append(('empleado_id', '=', int(kw['empleado_id'])))
|
||||
if kw.get('activo') in ('1', 'true', 'True'):
|
||||
domain.append(('activo', '=', True))
|
||||
metas = request.env['skeen.comision.meta'].sudo().search(domain)
|
||||
return _json({'status': 'success',
|
||||
'metas': [self._meta_to_dict(m) for m in metas],
|
||||
'total': len(metas)})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/metas', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def create_meta(self, **kw):
|
||||
try:
|
||||
data = _body()
|
||||
vals = self._meta_vals(data)
|
||||
if not vals.get('empleado_id') or not vals.get('periodo'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'empleado_id y periodo son obligatorios'}, 400)
|
||||
meta = request.env['skeen.comision.meta'].sudo().create(vals)
|
||||
return _json({'status': 'success', 'meta': self._meta_to_dict(meta)}, 201)
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/metas/<int:mid>', type='http', auth='none',
|
||||
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def update_meta(self, mid, **kw):
|
||||
try:
|
||||
meta = request.env['skeen.comision.meta'].sudo().browse(mid)
|
||||
if not meta.exists():
|
||||
return _json({'status': 'error', 'message': 'Meta no encontrada'}, 404)
|
||||
meta.write(self._meta_vals(_body()))
|
||||
return _json({'status': 'success', 'meta': self._meta_to_dict(meta)})
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/metas/<int:mid>', type='http', auth='none',
|
||||
methods=['DELETE', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.editar')
|
||||
def delete_meta(self, mid, **kw):
|
||||
try:
|
||||
meta = request.env['skeen.comision.meta'].sudo().browse(mid)
|
||||
if not meta.exists():
|
||||
return _json({'status': 'error', 'message': 'Meta no encontrada'}, 404)
|
||||
meta.write({'activo': False})
|
||||
return _json({'status': 'success', 'message': 'Meta desactivada'})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _meta_vals(self, data):
|
||||
campos = ('empleado_id', 'periodo', 'monto_objetivo', 'bono', 'tipo_bono',
|
||||
'activo', 'notas')
|
||||
vals = {}
|
||||
for k in campos:
|
||||
if k not in data:
|
||||
continue
|
||||
v = data[k]
|
||||
if k == 'empleado_id':
|
||||
vals[k] = int(v) if v else False
|
||||
elif k in ('monto_objetivo', 'bono'):
|
||||
n = _num(v)
|
||||
vals[k] = n if n is not None else 0.0
|
||||
elif k == 'periodo':
|
||||
vals[k] = str(v or '').strip()
|
||||
else:
|
||||
vals[k] = v
|
||||
return vals
|
||||
|
||||
@http.route(COM + '/empleados', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.ver')
|
||||
def list_empleados(self, **kw):
|
||||
"""Empleados con su commission_pct de respaldo, reglas y metas activas."""
|
||||
try:
|
||||
Emp = request.env['hr.employee'].sudo()
|
||||
emps = Emp.search([('active', '=', True)], order='name')
|
||||
Regla = request.env['skeen.comision.regla'].sudo()
|
||||
Meta = request.env['skeen.comision.meta'].sudo()
|
||||
data = []
|
||||
for e in emps:
|
||||
reglas = Regla.search_count([
|
||||
('activo', '=', True),
|
||||
'|', ('empleado_id', '=', e.id),
|
||||
'&', ('empleado_id', '=', False),
|
||||
'|', ('staff_type', '=', False), ('staff_type', '=', e.staff_type or 'doctor'),
|
||||
])
|
||||
metas = Meta.search_count([('activo', '=', True), ('empleado_id', '=', e.id)])
|
||||
data.append({
|
||||
'id': e.id,
|
||||
'name': e.name,
|
||||
'staff_type': e.staff_type or 'doctor',
|
||||
'job_title': e.job_title or '',
|
||||
'commission_pct': e.commission_pct or 0.0,
|
||||
'reglas_count': reglas,
|
||||
'metas_count': metas,
|
||||
})
|
||||
return _json({'status': 'success', 'empleados': data})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ==================================================================
|
||||
# OPERACIÓN: cálculo en vivo
|
||||
# ==================================================================
|
||||
@http.route(COM + '/calculo', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.ver')
|
||||
@comisiones_activas
|
||||
def calculo(self, **kw):
|
||||
try:
|
||||
start, end = self._rango(kw)
|
||||
user = request.frontend_user
|
||||
emp_ids = None
|
||||
if self._solo_propio(user):
|
||||
emp = _linked_employee(user)
|
||||
if not emp:
|
||||
return _json({'status': 'success', 'start': start.strftime('%Y-%m-%d'),
|
||||
'end': end.strftime('%Y-%m-%d'), 'empleados': [],
|
||||
'totales': {'base': 0, 'comision': 0, 'bonos': 0,
|
||||
'ajustes': 0, 'total': 0},
|
||||
'medico_sin_employee': True})
|
||||
emp_ids = [emp.id]
|
||||
elif kw.get('empleado_id'):
|
||||
emp_ids = [int(kw['empleado_id'])]
|
||||
motor = request.env['skeen.comision.motor'].sudo()
|
||||
data = motor.calcular(start, end, empleado_ids=emp_ids)
|
||||
data['status'] = 'success'
|
||||
return _json(data)
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(BASE + '/reports/comisiones_reglas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.ver')
|
||||
@comisiones_activas
|
||||
def reporte_reglas(self, **kw):
|
||||
"""Reporte por empleado, por regla, por servicio/categoría + metas."""
|
||||
try:
|
||||
start, end = self._rango(kw)
|
||||
motor = request.env['skeen.comision.motor'].sudo()
|
||||
data = motor.calcular(start, end)
|
||||
por_servicio = {}
|
||||
por_categoria = {}
|
||||
for emp in data['empleados']:
|
||||
for d in emp['desglose']:
|
||||
svc = d['servicio'] or 'Sin servicio'
|
||||
por_servicio.setdefault(svc, {'servicio': svc, 'base': 0.0,
|
||||
'comision': 0.0, 'lineas': 0})
|
||||
por_servicio[svc]['base'] += d['base']
|
||||
por_servicio[svc]['comision'] += d['comision']
|
||||
por_servicio[svc]['lineas'] += 1
|
||||
cat = d['categoria'] or 'Sin categoría'
|
||||
por_categoria.setdefault(cat, {'categoria': cat, 'base': 0.0,
|
||||
'comision': 0.0, 'lineas': 0})
|
||||
por_categoria[cat]['base'] += d['base']
|
||||
por_categoria[cat]['comision'] += d['comision']
|
||||
por_categoria[cat]['lineas'] += 1
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'start': data['start'], 'end': data['end'],
|
||||
'config': data['config'],
|
||||
'empleados': data['empleados'],
|
||||
'por_servicio': sorted(por_servicio.values(), key=lambda x: -x['comision']),
|
||||
'por_categoria': sorted(por_categoria.values(), key=lambda x: -x['comision']),
|
||||
'totales': data['totales'],
|
||||
})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ==================================================================
|
||||
# OPERACIÓN: liquidaciones
|
||||
# ==================================================================
|
||||
@http.route(COM + '/liquidaciones', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.ver')
|
||||
@comisiones_activas
|
||||
def list_liquidaciones(self, **kw):
|
||||
try:
|
||||
user = request.frontend_user
|
||||
domain = []
|
||||
if self._solo_propio(user):
|
||||
emp = _linked_employee(user)
|
||||
domain.append(('empleado_id', '=', emp.id if emp else 0))
|
||||
# El médico ve solo sus liquidaciones PAGADAS (recibo). El
|
||||
# borrador es información interna de administración.
|
||||
domain.append(('estado', '=', 'pagada'))
|
||||
else:
|
||||
if kw.get('empleado_id'):
|
||||
domain.append(('empleado_id', '=', int(kw['empleado_id'])))
|
||||
if kw.get('estado'):
|
||||
domain.append(('estado', '=', kw['estado']))
|
||||
if kw.get('periodo'):
|
||||
domain.append(('periodo', '=', kw['periodo']))
|
||||
liqs = request.env['skeen.comision.liquidacion'].sudo().search(domain)
|
||||
return _json({'status': 'success',
|
||||
'liquidaciones': [self._liq_to_dict(l) for l in liqs],
|
||||
'total': len(liqs)})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/liquidaciones/<int:lid>', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.ver')
|
||||
@comisiones_activas
|
||||
def get_liquidacion(self, lid, **kw):
|
||||
try:
|
||||
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||
if not liq.exists():
|
||||
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||
user = request.frontend_user
|
||||
if self._solo_propio(user):
|
||||
emp = _linked_employee(user)
|
||||
if not emp or liq.empleado_id.id != emp.id or liq.estado != 'pagada':
|
||||
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/mis-liquidaciones', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.ver')
|
||||
@comisiones_activas
|
||||
def mis_liquidaciones(self, **kw):
|
||||
"""Recibo propio del empleado vinculado (rol médico). Solo pagadas."""
|
||||
try:
|
||||
emp = _linked_employee(request.frontend_user)
|
||||
if not emp:
|
||||
return _json({'status': 'success', 'liquidaciones': [],
|
||||
'medico_sin_employee': True})
|
||||
liqs = request.env['skeen.comision.liquidacion'].sudo().search([
|
||||
('empleado_id', '=', emp.id), ('estado', '=', 'pagada')])
|
||||
return _json({'status': 'success', 'empleado_id': emp.id,
|
||||
'empleado': emp.name,
|
||||
'liquidaciones': [self._liq_to_dict(l, True) for l in liqs]})
|
||||
except Exception as e:
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/liquidaciones', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.liquidar')
|
||||
@comisiones_activas
|
||||
def generar_liquidaciones(self, **kw):
|
||||
"""Genera borradores del periodo para uno, varios o todos los empleados."""
|
||||
try:
|
||||
data = _body()
|
||||
start, end = self._rango(data)
|
||||
Liq = request.env['skeen.comision.liquidacion'].sudo()
|
||||
motor = request.env['skeen.comision.motor'].sudo()
|
||||
|
||||
emp_ids = None
|
||||
if data.get('empleado_ids'):
|
||||
emp_ids = [int(x) for x in data['empleado_ids']]
|
||||
elif data.get('empleado_id'):
|
||||
emp_ids = [int(data['empleado_id'])]
|
||||
calc = motor.calcular(start, end, empleado_ids=emp_ids)
|
||||
|
||||
creadas, omitidas = [], []
|
||||
for emp in calc['empleados']:
|
||||
if emp['lineas_count'] == 0 and abs(emp['total']) < 1e-9:
|
||||
continue
|
||||
existente = Liq.search([
|
||||
('empleado_id', '=', emp['empleado_id']),
|
||||
('periodo_inicio', '=', start),
|
||||
('periodo_fin', '=', end)], limit=1)
|
||||
if existente:
|
||||
omitidas.append({'empleado_id': emp['empleado_id'],
|
||||
'empleado': emp['empleado'],
|
||||
'folio': existente.name,
|
||||
'motivo': 'ya existe liquidación del periodo'})
|
||||
continue
|
||||
liq = Liq.create({
|
||||
'empleado_id': emp['empleado_id'],
|
||||
'periodo_inicio': start,
|
||||
'periodo_fin': end,
|
||||
'total_calculado': emp['total'],
|
||||
'detalle': json.dumps(emp, ensure_ascii=False),
|
||||
'creado_por_login': _login(),
|
||||
'notas': (data.get('notas') or '').strip(),
|
||||
})
|
||||
creadas.append(self._liq_to_dict(liq, True))
|
||||
return _json({'status': 'success',
|
||||
'liquidaciones': creadas, 'omitidas': omitidas,
|
||||
'total_creadas': len(creadas)}, 201)
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/liquidaciones/<int:lid>/ajustar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.liquidar')
|
||||
@comisiones_activas
|
||||
def ajustar_liquidacion(self, lid, **kw):
|
||||
try:
|
||||
cfg = _cfg()
|
||||
if not cfg.get('permitir_ajustes_manuales', True):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Los ajustes manuales están desactivados'}, 400)
|
||||
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||
if not liq.exists():
|
||||
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||
data = _body()
|
||||
monto = _num(data.get('monto'))
|
||||
if monto is None:
|
||||
return _json({'status': 'error', 'message': 'Monto inválido'}, 400)
|
||||
liq.ajustar(monto, motivo=data.get('motivo') or '', usuario=_login())
|
||||
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/liquidaciones/<int:lid>/pagar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.liquidar')
|
||||
@comisiones_activas
|
||||
def pagar_liquidacion(self, lid, **kw):
|
||||
try:
|
||||
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||
if not liq.exists():
|
||||
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||
data = _body()
|
||||
fecha = fields.Date.to_date(data.get('fecha')) or fields.Date.today()
|
||||
metodo = data.get('metodo_pago') or data.get('metodo') or ''
|
||||
if metodo and metodo not in METODOS_PAGO:
|
||||
return _json({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
||||
liq.pagar(fecha=fecha, metodo=metodo,
|
||||
referencia=data.get('referencia') or '', usuario=_login())
|
||||
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route(COM + '/liquidaciones/<int:lid>/cancelar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('comisiones.liquidar')
|
||||
@comisiones_activas
|
||||
def cancelar_liquidacion(self, lid, **kw):
|
||||
try:
|
||||
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||
if not liq.exists():
|
||||
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||
data = _body()
|
||||
liq.cancelar(motivo=data.get('motivo') or '')
|
||||
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||
except (ValidationError, UserError) as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||
14
odoo-addons/skeen_comisiones/data/sequences.xml
Normal file
14
odoo-addons/skeen_comisiones/data/sequences.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Folio de liquidación: LIQ-0001, LIQ-0002, ... -->
|
||||
<record id="seq_skeen_comision_liquidacion" model="ir.sequence">
|
||||
<field name="name">Liquidación de comisiones SKEEN</field>
|
||||
<field name="code">skeen.comision.liquidacion</field>
|
||||
<field name="prefix">LIQ-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
5
odoo-addons/skeen_comisiones/models/__init__.py
Normal file
5
odoo-addons/skeen_comisiones/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import regla
|
||||
from . import meta
|
||||
from . import liquidacion
|
||||
from . import motor
|
||||
148
odoo-addons/skeen_comisiones/models/liquidacion.py
Normal file
148
odoo-addons/skeen_comisiones/models/liquidacion.py
Normal 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)
|
||||
59
odoo-addons/skeen_comisiones/models/meta.py
Normal file
59
odoo-addons/skeen_comisiones/models/meta.py
Normal 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
|
||||
346
odoo-addons/skeen_comisiones/models/motor.py
Normal file
346
odoo-addons/skeen_comisiones/models/motor.py
Normal 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
|
||||
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 '')
|
||||
@@ -0,0 +1,5 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_comision_regla_user,skeen.comision.regla.user,model_skeen_comision_regla,base.group_user,1,1,1,1
|
||||
access_skeen_comision_meta_user,skeen.comision.meta.user,model_skeen_comision_meta,base.group_user,1,1,1,1
|
||||
access_skeen_comision_liquidacion_user,skeen.comision.liquidacion.user,model_skeen_comision_liquidacion,base.group_user,1,1,1,1
|
||||
access_skeen_comision_ajuste_user,skeen.comision.ajuste.user,model_skeen_comision_ajuste,base.group_user,1,1,1,1
|
||||
|
Reference in New Issue
Block a user