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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user