# -*- coding: utf-8 -*- """Motor del programa de lealtad configurable (puntos y niveles VIP). Vive aquí (y no en `skeen_citas/clinic_settings.py`) porque es LÓGICA, no configuración: la configuración sigue en el bloque `lealtad` de los ajustes de la clínica y se lee con `get_lealtad(env)`. Reglas de compatibilidad (regla de oro): con `modo='clasico'` (el default) el cálculo de puntos reproduce EXACTAMENTE el de siempre: puntos = int(total * (subtotal_elegible / subtotal_total) / 10) donde se considera elegible toda línea de servicio/artículo cuyo catálogo tenga `genera_monedero`. Así, ninguna instancia cambia de cifras al desplegar. PRECEDENCIA de multiplicadores (gana la más específica; a igual especificidad gana la última de la lista): servicio > articulo > categoria > nivel > sucursal > rango_fechas > dia_semana """ from odoo.addons.skeen_citas.clinic_settings import get_lealtad # noqa: F401 (reexport) # Orden de especificidad (menor índice = más específico). PRECEDENCIA = ('servicio', 'articulo', 'categoria', 'nivel', 'sucursal', 'rango_fechas', 'dia_semana') # Tipos de línea que puede filtrar `aplicar_a`. TIPOS_LINEA = ('servicios', 'productos', 'membresias', 'paquetes') def lineas_elegibles(venta, aplicar_a): """Líneas de la venta que generan puntos según `aplicar_a`. Además del tipo de línea, se respeta el interruptor `genera_monedero` del catálogo (igual que el cálculo clásico). """ aplicar = set(aplicar_a or []) out = venta.line_ids.filtered( lambda l: l.tipo_monedero in aplicar and l.genera_monedero) return out def _aplica_alcance(regla, ctx): """True si la regla multiplicadora aplica al contexto dado.""" alcance = regla.get('alcance') valor = str(regla.get('valor') or '').strip() if not valor: return False if alcance == 'servicio': return ctx.get('servicio_id') is not None and str(ctx['servicio_id']) == valor if alcance == 'articulo': return ctx.get('articulo_id') is not None and str(ctx['articulo_id']) == valor if alcance == 'categoria': return str(ctx.get('categoria') or '') == valor if alcance == 'nivel': return str(ctx.get('nivel_codigo') or '') == valor if alcance == 'sucursal': return str(ctx.get('sucursal') or '') == valor if alcance == 'dia_semana': dias = [d.strip() for d in valor.split(',') if d.strip()] return str(ctx.get('dia_semana')) in dias if alcance == 'rango_fechas': fecha = ctx.get('fecha') if not fecha: return False desde = str(regla.get('fecha_desde') or '') hasta = str(regla.get('fecha_hasta') or '') if desde and str(fecha) < desde: return False if hasta and str(fecha) > hasta: return False return bool(desde or hasta) return False def _vigente(regla, fecha): """True si la regla está dentro de su propia vigencia (fecha_desde/hasta).""" if not fecha: return True desde = str(regla.get('fecha_desde') or '') hasta = str(regla.get('fecha_hasta') or '') if desde and str(fecha) < desde: return False if hasta and str(fecha) > hasta: return False return True def regla_multiplicador(cfg, ctx): """Regla multiplicadora más específica que aplica (o None). Devuelve el dict de la regla; `factor` es su factor de acumulación. """ fecha = ctx.get('fecha') mejor = None mejor_rango = None for regla in (cfg or {}).get('multiplicadores') or []: if not regla.get('activo', True): continue if not _vigente(regla, fecha): continue if not _aplica_alcance(regla, ctx): continue try: rango = PRECEDENCIA.index(regla.get('alcance')) except ValueError: continue # Menor rango gana; a igual rango, la última de la lista gana (<=). if mejor is None or rango <= mejor_rango: mejor = regla mejor_rango = rango return mejor def factor_multiplicador(cfg, ctx): """Factor a aplicar (1.0 si ninguna regla aplica).""" regla = regla_multiplicador(cfg, ctx) return float(regla.get('factor', 1.0)) if regla else 1.0 def nivel_para_gasto(cfg, gasto): """Nivel que corresponde a un gasto acumulado (o None). Los niveles vienen ordenados de mayor a menor `gasto_minimo`; el primero cuyo umbral ya se alcanzó es el nivel vigente. """ for nivel in (cfg or {}).get('niveles') or []: if float(gasto or 0.0) >= float(nivel.get('gasto_minimo') or 0.0): return nivel return None def nivel_por_codigo(cfg, codigo): """Nivel por su código (o None).""" for nivel in (cfg or {}).get('niveles') or []: if nivel.get('codigo') == codigo: return nivel return None def _redondear(valor, modo): """'abajo' trunca; 'normal' redondea comercialmente.""" if modo == 'normal': return int(round(valor)) return int(valor) def puntos_venta(env, venta, cfg=None): """Puntos que genera una venta. Devuelve (puntos, detalle). Detalle: {modo, base, factor, elegible, subtotal, sin_propina}. """ cfg = cfg or get_lealtad(env) vacio = {'modo': cfg.get('modo'), 'base': 0.0, 'factor': 1.0, 'elegible': 0.0, 'subtotal': 0.0, 'sin_propina': 0.0} if not cfg.get('activo'): return 0, vacio subtotal_total = sum(l.subtotal for l in venta.line_ids) sin_propina = max(0.0, (venta.total or 0.0) - (venta.propina or 0.0)) if cfg.get('modo') != 'configurable': # CLÁSICO: fórmula exacta de siempre (1 pt por cada $10 del total). # Se evalúa con el predicado original, sin mirar `aplicar_a`, para no # alterar ninguna cifra histórica. elegible_sub = sum( l.subtotal for l in venta.line_ids if (l.service_id and l.service_id.genera_monedero) or (l.item_id and l.item_id.genera_monedero)) if not subtotal_total or elegible_sub <= 0: return 0, dict(vacio, subtotal=subtotal_total, elegible=elegible_sub, sin_propina=sin_propina) puntos = int(sin_propina * (elegible_sub / subtotal_total) / 10) return puntos, {'modo': 'clasico', 'base': sin_propina, 'factor': 1.0, 'elegible': elegible_sub, 'subtotal': subtotal_total, 'sin_propina': sin_propina} # CONFIGURABLE elegibles = lineas_elegibles(venta, cfg.get('aplicar_a')) elegible_sub = sum(l.subtotal for l in elegibles) por_unidad = float(cfg.get('puntos_por_unidad') or 0.0) sobre = cfg.get('acumular_sobre') base_vendida = sin_propina base_pagada = max(0.0, min(venta.amount_paid or 0.0, sin_propina)) ratio_pago = (base_pagada / base_vendida) if base_vendida else 0.0 nivel = nivel_por_codigo(cfg, venta.partner_id.lealtad_nivel_codigo or '') factor_nivel = float((nivel or {}).get('factor_puntos', 1.0)) acumulado = 0.0 if subtotal_total: for line in elegibles: base_linea = line.subtotal if sobre == 'pagado': base_linea *= ratio_pago factor = factor_multiplicador(cfg, venta.contexto_lealtad(line)) acumulado += base_linea * por_unidad * factor acumulado *= factor_nivel return _redondear(acumulado, cfg.get('redondear')), { 'modo': 'configurable', 'base': base_vendida, 'factor': factor_nivel, 'elegible': elegible_sub, 'subtotal': subtotal_total, 'sin_propina': sin_propina, }