bloque 3: lealtad configurable (motor de puntos con multiplicadores por servicio/categoria/sucursal/dia/nivel, niveles VIP con descuento y factor, expiracion, ajuste manual con motivo y reportes) + propinas en punto de venta con reparto entre el personal, desglose en ticket y corte, y opcion en la liquidacion de comisiones; modo clasico y propinas apagadas preservan el comportamiento actual

This commit is contained in:
2026-10-12 01:37:07 +00:00
parent 1bd4cad6a0
commit 705b5a2842
28 changed files with 2748 additions and 59 deletions

View File

@@ -1186,6 +1186,355 @@ def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# ---------------------------------------------------------------------------
# Programa de lealtad (puntos del monedero) y niveles VIP.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con los valores por defecto (`modo='clasico'`) NADA cambia — se acumula
# 1 punto por cada $10 pagados, 1 punto = $1, el canje se topa al 50% y no hay
# niveles ni multiplicadores. `modo='configurable'` usa los valores del bloque.
#
# activo: enciende el programa de puntos
# modo: clasico | configurable
# puntos_por_unidad: puntos por cada peso (0.1 = 1 pt por $10)
# valor_punto: pesos que vale un punto al canjear
# moneda_base: MXN | USD (solo informativo / etiqueta)
# canje_max_pct: % máximo del total pagable con puntos
# expira_meses: 0 = no expiran; >0 expira lo no usado más antiguo
# acumular_sobre: pagado (monto cobrado) | vendido (monto vendido)
# aplicar_a: servicios | productos | membresias | paquetes
# redondear: abajo (truncar) | normal (redondeo comercial)
# niveles: [{codigo, nombre, gasto_minimo, ventana_meses,
# descuento_pct, factor_puntos, vigencia_meses, color}]
# multiplicadores: [{nombre, alcance, valor, factor, fecha_desde,
# fecha_hasta, activo}]
#
# PRECEDENCIA de multiplicadores (gana la MÁS específica; a igual
# especificidad, la última de la lista): servicio > articulo > categoria >
# nivel > sucursal > rango_fechas > dia_semana.
#
# NIVELES: el nivel se decide por el gasto acumulado en su ventana (por defecto
# 12 meses). Un cliente sube de nivel al cerrar la venta que lo alcanza (o por
# el proceso diario). NO baja de inmediato: el nivel conserva su vigencia
# (`vigencia_meses`) y solo al vencer se recalcula a la baja. Una asignación
# manual (con fecha de revisión) no se sobreescribe sola hasta esa fecha.
# ---------------------------------------------------------------------------
LEALTAD_DEFAULT = {
'activo': True,
'modo': 'clasico',
'puntos_por_unidad': 0.1,
'valor_punto': 1.0,
'moneda_base': 'MXN',
'canje_max_pct': 50,
'expira_meses': 0,
'acumular_sobre': 'pagado',
'aplicar_a': ['servicios', 'productos', 'membresias', 'paquetes'],
'redondear': 'abajo',
'niveles': [],
'multiplicadores': [],
}
LEALTAD_MODOS = ('clasico', 'configurable')
LEALTAD_ACUMULAR = ('pagado', 'vendido')
LEALTAD_APLICAR = ('servicios', 'productos', 'membresias', 'paquetes')
LEALTAD_REDONDEAR = ('abajo', 'normal')
LEALTAD_ALCANCES = ('servicio', 'articulo', 'categoria', 'nivel', 'sucursal',
'rango_fechas', 'dia_semana')
LEALTAD_MONEDAS = ('MXN', 'USD')
LEALTAD_EXPIRA_MAX = 120 # meses
LEALTAD_VENTANA_MAX = 120 # meses (ventana de gasto de un nivel)
LEALTAD_NIVELES_MAX = 20
LEALTAD_MULT_MAX = 100
def _fecha_iso(valor):
"""'YYYY-MM-DD' válida o ''."""
texto = str(valor or '').strip()[:10]
if not texto:
return ''
try:
date.fromisoformat(texto)
except ValueError:
return ''
return texto
def normalizar_niveles(raw):
"""Lista de niveles VIP normalizada (ordenada por gasto_minimo desc)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, dict):
continue
codigo = str(item.get('codigo') or '').strip()[:40]
nombre = str(item.get('nombre') or '').strip()[:80]
if not codigo and not nombre:
continue
if not codigo:
codigo = nombre.lower().replace(' ', '_')[:40]
out.append({
'codigo': codigo,
'nombre': nombre or codigo,
'gasto_minimo': max(0.0, _num(item.get('gasto_minimo'), 0.0)),
'ventana_meses': min(max(_int(item.get('ventana_meses'), 12), 1), LEALTAD_VENTANA_MAX),
'descuento_pct': min(max(_num(item.get('descuento_pct'), 0.0), 0.0), 100.0),
'factor_puntos': max(0.0, _num(item.get('factor_puntos'), 1.0)),
'vigencia_meses': min(max(_int(item.get('vigencia_meses'), 12), 0), LEALTAD_VENTANA_MAX),
'color': str(item.get('color') or '').strip()[:20],
})
out.sort(key=lambda n: -n['gasto_minimo'])
return out[:LEALTAD_NIVELES_MAX]
def normalizar_multiplicadores(raw):
"""Lista de reglas multiplicadoras normalizada (tolerante)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, dict):
continue
alcance = str(item.get('alcance') or '').strip().lower()
if alcance not in LEALTAD_ALCANCES:
continue
out.append({
'nombre': str(item.get('nombre') or '').strip()[:80],
'alcance': alcance,
'valor': str(item.get('valor') or '').strip()[:120],
'factor': max(0.0, _num(item.get('factor'), 1.0)),
'fecha_desde': _fecha_iso(item.get('fecha_desde')),
'fecha_hasta': _fecha_iso(item.get('fecha_hasta')),
'activo': _bool(item.get('activo'), True),
})
return out[:LEALTAD_MULT_MAX]
def normalizar_lealtad(raw):
"""Bloque `lealtad` completo, con defaults. Tolerante: nunca lanza."""
out = dict(LEALTAD_DEFAULT)
out['aplicar_a'] = list(LEALTAD_DEFAULT['aplicar_a'])
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), LEALTAD_DEFAULT['activo'])
modo = str(raw.get('modo') or '').strip().lower()
out['modo'] = modo if modo in LEALTAD_MODOS else LEALTAD_DEFAULT['modo']
acu = str(raw.get('acumular_sobre') or '').strip().lower()
out['acumular_sobre'] = acu if acu in LEALTAD_ACUMULAR else LEALTAD_DEFAULT['acumular_sobre']
red = str(raw.get('redondear') or '').strip().lower()
out['redondear'] = red if red in LEALTAD_REDONDEAR else LEALTAD_DEFAULT['redondear']
mon = str(raw.get('moneda_base') or '').strip().upper()
out['moneda_base'] = mon if mon in LEALTAD_MONEDAS else LEALTAD_DEFAULT['moneda_base']
out['puntos_por_unidad'] = max(0.0, _num(raw.get('puntos_por_unidad'),
LEALTAD_DEFAULT['puntos_por_unidad']))
out['valor_punto'] = max(0.0, _num(raw.get('valor_punto'), LEALTAD_DEFAULT['valor_punto']))
out['canje_max_pct'] = min(max(_num(raw.get('canje_max_pct'),
LEALTAD_DEFAULT['canje_max_pct']), 0.0), 100.0)
out['expira_meses'] = min(max(_int(raw.get('expira_meses'),
LEALTAD_DEFAULT['expira_meses']), 0), LEALTAD_EXPIRA_MAX)
aplicar = raw.get('aplicar_a')
if isinstance(aplicar, (list, tuple)):
limpio = [a for a in (str(x).strip().lower() for x in aplicar)
if a in LEALTAD_APLICAR]
out['aplicar_a'] = list(dict.fromkeys(limpio))
out['niveles'] = normalizar_niveles(raw.get('niveles'))
out['multiplicadores'] = normalizar_multiplicadores(raw.get('multiplicadores'))
if out['modo'] == 'clasico':
# El modo clásico reproduce EXACTAMENTE el comportamiento de siempre:
# se congelan los parámetros numéricos y se ignoran niveles y
# multiplicadores (que se conservan para el editor, pero no aplican).
out['puntos_por_unidad'] = LEALTAD_DEFAULT['puntos_por_unidad']
out['valor_punto'] = LEALTAD_DEFAULT['valor_punto']
out['canje_max_pct'] = LEALTAD_DEFAULT['canje_max_pct']
out['acumular_sobre'] = LEALTAD_DEFAULT['acumular_sobre']
out['aplicar_a'] = list(LEALTAD_DEFAULT['aplicar_a'])
out['redondear'] = LEALTAD_DEFAULT['redondear']
return out
def validar_lealtad(raw):
"""Valida el bloque `lealtad` de un PUT (devuelve SOLO lo presente)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de lealtad debe ser un objeto')
out = {}
for k in ('activo',):
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 'modo' in raw:
modo = str(raw['modo'] or '').strip().lower()
if modo not in LEALTAD_MODOS:
raise ValueError("El modo debe ser 'clasico' o 'configurable'")
out['modo'] = modo
if 'acumular_sobre' in raw:
acu = str(raw['acumular_sobre'] or '').strip().lower()
if acu not in LEALTAD_ACUMULAR:
raise ValueError("acumular_sobre debe ser 'pagado' o 'vendido'")
out['acumular_sobre'] = acu
if 'redondear' in raw:
red = str(raw['redondear'] or '').strip().lower()
if red not in LEALTAD_REDONDEAR:
raise ValueError("El redondeo debe ser 'abajo' o 'normal'")
out['redondear'] = red
if 'moneda_base' in raw:
mon = str(raw['moneda_base'] or '').strip().upper()
if mon not in LEALTAD_MONEDAS:
raise ValueError('La moneda base debe ser MXN o USD')
out['moneda_base'] = mon
for k, lo, hi in (('puntos_por_unidad', 0.0, 10000.0),
('valor_punto', 0.0, 100000.0),
('canje_max_pct', 0.0, 100.0)):
if k in raw:
n = _num(raw[k], None)
if n is None or not (lo <= n <= hi):
raise ValueError('El campo %s debe ser un número entre %s y %s' % (k, lo, hi))
out[k] = n
if 'expira_meses' in raw:
if isinstance(raw['expira_meses'], bool) or not isinstance(raw['expira_meses'], int):
raise ValueError('Los meses de expiración deben ser un número entero')
if not (0 <= raw['expira_meses'] <= LEALTAD_EXPIRA_MAX):
raise ValueError('Los meses de expiración deben estar entre 0 y %d' % LEALTAD_EXPIRA_MAX)
out['expira_meses'] = raw['expira_meses']
if 'aplicar_a' in raw:
valor = raw['aplicar_a']
if not isinstance(valor, list):
raise ValueError('aplicar_a debe ser una lista')
limpio = []
for a in valor:
a = str(a or '').strip().lower()
if a not in LEALTAD_APLICAR:
raise ValueError('Valor desconocido en aplicar_a: %s' % a)
if a not in limpio:
limpio.append(a)
out['aplicar_a'] = limpio
if 'niveles' in raw:
if not isinstance(raw['niveles'], list):
raise ValueError('Los niveles deben ser una lista')
for n in raw['niveles']:
if not isinstance(n, dict):
raise ValueError('Cada nivel debe ser un objeto')
out['niveles'] = normalizar_niveles(raw['niveles'])
if 'multiplicadores' in raw:
if not isinstance(raw['multiplicadores'], list):
raise ValueError('Los multiplicadores deben ser una lista')
for m in raw['multiplicadores']:
if not isinstance(m, dict):
raise ValueError('Cada multiplicador debe ser un objeto')
if str(m.get('alcance') or '').strip().lower() not in LEALTAD_ALCANCES:
raise ValueError('Alcance desconocido en un multiplicador')
out['multiplicadores'] = normalizar_multiplicadores(raw['multiplicadores'])
return out
def get_lealtad(env):
"""Bloque de lealtad vigente (copia). Fuente única para modelos y API."""
return normalizar_lealtad(get_settings(env).get('lealtad'))
# ---------------------------------------------------------------------------
# Propinas en el punto de venta y reparto entre el personal.
#
# Bloque anidado del mismo JSON de ajustes. La regla de oro: con `activo=False`
# (default) el punto de venta se ve y se comporta EXACTAMENTE igual que hoy —
# no aparece ningún campo de propina, la venta no guarda propina y el total es
# el de siempre.
#
# activo: enciende la propina en el POS
# sugerencias_pct: porcentajes sugeridos [10, 15, 20]
# permitir_personalizado: permite capturar un monto libre
# permitir_sin_propina: permite cobrar sin propina
# reparto: ninguno | equitativo | por_participacion
# incluir_en_liquidacion_comisiones: suma la propina a la liquidación
# cuenta_propinas: etiqueta de la cuenta destino (informativa)
# ---------------------------------------------------------------------------
PROPINAS_DEFAULT = {
'activo': False,
'sugerencias_pct': [10, 15, 20],
'permitir_personalizado': True,
'permitir_sin_propina': True,
'reparto': 'ninguno',
'incluir_en_liquidacion_comisiones': False,
'cuenta_propinas': '',
}
PROPINAS_REPARTO = ('ninguno', 'equitativo', 'por_participacion')
PROPINAS_SUGERENCIA_MAX = 100
PROPINAS_CUENTA_MAX = 80
def normalizar_propinas(raw):
"""Bloque `propinas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(PROPINAS_DEFAULT)
out['sugerencias_pct'] = list(PROPINAS_DEFAULT['sugerencias_pct'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_personalizado', 'permitir_sin_propina',
'incluir_en_liquidacion_comisiones'):
out[k] = _bool(raw.get(k), PROPINAS_DEFAULT[k])
rep = str(raw.get('reparto') or '').strip().lower()
out['reparto'] = rep if rep in PROPINAS_REPARTO else PROPINAS_DEFAULT['reparto']
sugerencias = raw.get('sugerencias_pct')
if isinstance(sugerencias, (list, tuple)):
limpias = []
for s in sugerencias:
n = _num(s, None)
if n is None:
continue
n = min(max(n, 0.0), PROPINAS_SUGERENCIA_MAX)
if n not in limpias:
limpias.append(n)
out['sugerencias_pct'] = sorted(limpias)
out['cuenta_propinas'] = str(raw.get('cuenta_propinas') or '').strip()[:PROPINAS_CUENTA_MAX]
return out
def validar_propinas(raw):
"""Valida el bloque `propinas` de un PUT (devuelve SOLO lo presente)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de propinas debe ser un objeto')
out = {}
for k in ('activo', 'permitir_personalizado', 'permitir_sin_propina',
'incluir_en_liquidacion_comisiones'):
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 'reparto' in raw:
rep = str(raw['reparto'] or '').strip().lower()
if rep not in PROPINAS_REPARTO:
raise ValueError("El reparto debe ser 'ninguno', 'equitativo' o 'por_participacion'")
out['reparto'] = rep
if 'sugerencias_pct' in raw:
valor = raw['sugerencias_pct']
if not isinstance(valor, list):
raise ValueError('Las sugerencias de propina deben ser una lista')
limpias = []
for s in valor:
n = _num(s, None)
if n is None:
raise ValueError('Cada sugerencia de propina debe ser un número')
if not (0 <= n <= PROPINAS_SUGERENCIA_MAX):
raise ValueError('Las sugerencias deben estar entre 0 y %d' % PROPINAS_SUGERENCIA_MAX)
if n not in limpias:
limpias.append(n)
out['sugerencias_pct'] = sorted(limpias)
if 'cuenta_propinas' in raw:
if not isinstance(raw['cuenta_propinas'], str):
raise ValueError('La cuenta de propinas debe ser texto')
if len(raw['cuenta_propinas'].strip()) > PROPINAS_CUENTA_MAX:
raise ValueError('El nombre de la cuenta es demasiado largo (máx. %d)' % PROPINAS_CUENTA_MAX)
out['cuenta_propinas'] = raw['cuenta_propinas'].strip()
return out
def get_propinas(env):
"""Bloque de propinas vigente (copia). Apagado por defecto."""
return normalizar_propinas(get_settings(env).get('propinas'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
@@ -1615,6 +1964,8 @@ def get_settings(env, aplicar_licencia=True):
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
'lealtad': normalizar_lealtad(None),
'propinas': dict(PROPINAS_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
@@ -1635,6 +1986,8 @@ def get_settings(env, aplicar_licencia=True):
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -1696,6 +2049,16 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['portal_cliente'] = {**actual['portal_cliente'], **bloque}
if 'lealtad' in vals:
bloque = validar_lealtad(vals['lealtad'])
if bloque is not None:
# Merge anidado: un PUT parcial no borra el resto. Las listas de
# niveles y multiplicadores sí se reemplazan completas.
actual['lealtad'] = {**actual['lealtad'], **bloque}
if 'propinas' in vals:
bloque = validar_propinas(vals['propinas'])
if bloque is not None:
actual['propinas'] = {**actual['propinas'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None: