# -*- 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