347 lines
15 KiB
Python
347 lines
15 KiB
Python
# -*- 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
|