Files
SKEEN-Proyecto/odoo-addons/skeen_comisiones/models/motor.py

347 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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