fase 3.3: comisiones por reglas y liquidacion (modulo skeen_comisiones: motor con reglas por servicio/categoria/articulo/membresia/paquete, monto fijo y escalonado por metas, base vendido/cobrado con y sin impuestos, neto de devoluciones, metas con bonos, ajustes y liquidacion por periodo); reporte legacy intacto con el motor apagado

This commit is contained in:
2026-10-11 12:57:19 +00:00
parent a916753ebe
commit 3c4f1b14d9
27 changed files with 3399 additions and 17 deletions

View File

@@ -751,6 +751,110 @@ def get_compras(env):
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
return normalizar_compras(get_settings(env).get('compras'))
# ---------------------------------------------------------------------------
# Comisiones por reglas y liquidación.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — el reporte de
# comisiones de siempre (`/reports/comisiones` y `/reports/commissions`) sigue
# devolviendo el mismo JSON y los mismos números (porcentaje plano por empleado),
# no aparece ninguna sección nueva en el menú y no se ejecuta el motor de reglas.
#
# activo: enciende el motor de reglas y las liquidaciones
# base: 'cobrado' (sobre lo pagado) | 'vendido' (subtotal)
# incluir_impuestos: la base suma el impuesto de la línea
# neto_de_devoluciones: resta la parte devuelta de la venta
# comisionar_membresias: comisiona las ventas de membresía
# comisionar_paquetes: comisiona los paquetes
# comisionar_productos: comisiona los artículos de inventario
# permitir_ajustes_manuales: habilita sumar/restar ajustes en la liquidación
# dia_corte: día del mes de corte sugerido (1-28)
# redondeo: decimales a los que se redondea la comisión
# pago_minimo: total mínimo a liquidar (por debajo no es pagable)
# ---------------------------------------------------------------------------
COMISIONES_DEFAULT = {
'activo': False,
'base': 'cobrado',
'incluir_impuestos': False,
'neto_de_devoluciones': True,
'comisionar_membresias': True,
'comisionar_paquetes': True,
'comisionar_productos': True,
'permitir_ajustes_manuales': True,
'dia_corte': 1,
'redondeo': 2,
'pago_minimo': 0,
}
COMISIONES_BASES = ('cobrado', 'vendido')
COMISIONES_REDONDEO_MAX = 6
def normalizar_comisiones(raw):
"""Bloque `comisiones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMISIONES_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
'permitir_ajustes_manuales'):
out[k] = _bool(raw.get(k), COMISIONES_DEFAULT[k])
base = str(raw.get('base') or '').strip().lower()
out['base'] = base if base in COMISIONES_BASES else COMISIONES_DEFAULT['base']
out['dia_corte'] = min(max(_int(raw.get('dia_corte'), COMISIONES_DEFAULT['dia_corte']), 1), 28)
out['redondeo'] = min(max(_int(raw.get('redondeo'), COMISIONES_DEFAULT['redondeo']),
0), COMISIONES_REDONDEO_MAX)
out['pago_minimo'] = max(0.0, _num(raw.get('pago_minimo'), COMISIONES_DEFAULT['pago_minimo']))
return out
def validar_comisiones(raw):
"""Valida el bloque `comisiones` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de comisiones debe ser un objeto')
out = {}
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
'permitir_ajustes_manuales'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'base' in raw:
base = str(raw['base'] or '').strip().lower()
if base not in COMISIONES_BASES:
raise ValueError("La base debe ser 'cobrado' o 'vendido'")
out['base'] = base
if 'dia_corte' in raw:
if isinstance(raw['dia_corte'], bool) or not isinstance(raw['dia_corte'], int):
raise ValueError('El día de corte debe ser un número entero')
if not (1 <= raw['dia_corte'] <= 28):
raise ValueError('El día de corte debe estar entre 1 y 28')
out['dia_corte'] = raw['dia_corte']
if 'redondeo' in raw:
if isinstance(raw['redondeo'], bool) or not isinstance(raw['redondeo'], int):
raise ValueError('El redondeo debe ser un número entero de decimales')
if not (0 <= raw['redondeo'] <= COMISIONES_REDONDEO_MAX):
raise ValueError('El redondeo debe estar entre 0 y %d' % COMISIONES_REDONDEO_MAX)
out['redondeo'] = raw['redondeo']
if 'pago_minimo' in raw:
n = _num(raw['pago_minimo'], None)
if n is None:
raise ValueError('El pago mínimo debe ser un número')
if n < 0:
raise ValueError('El pago mínimo no puede ser negativo')
out['pago_minimo'] = n
return out
def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -796,6 +900,7 @@ def get_settings(env):
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -813,6 +918,7 @@ def get_settings(env):
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
return out
@@ -850,6 +956,11 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['compras'] = {**actual['compras'], **bloque}
if 'comisiones' in vals:
bloque = validar_comisiones(vals['comisiones'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['comisiones'] = {**actual['comisiones'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)