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:
|
||||
|
||||
@@ -174,6 +174,7 @@ class SkeenComisionesController(http.Controller):
|
||||
'periodo_fin': l.periodo_fin.strftime('%Y-%m-%d') if l.periodo_fin else None,
|
||||
'periodo': l.periodo or '',
|
||||
'total_calculado': l.total_calculado,
|
||||
'total_propinas': l.total_propinas,
|
||||
'total_ajustes': l.total_ajustes,
|
||||
'total': l.total,
|
||||
'estado': l.estado,
|
||||
@@ -596,9 +597,12 @@ class SkeenComisionesController(http.Controller):
|
||||
emp_ids = [int(data['empleado_id'])]
|
||||
calc = motor.calcular(start, end, empleado_ids=emp_ids)
|
||||
|
||||
from odoo.addons.skeen_comisiones.models.liquidacion import propinas_de_empleado
|
||||
|
||||
creadas, omitidas = [], []
|
||||
for emp in calc['empleados']:
|
||||
if emp['lineas_count'] == 0 and abs(emp['total']) < 1e-9:
|
||||
propina = propinas_de_empleado(request.env, emp['empleado_id'], start, end)
|
||||
if emp['lineas_count'] == 0 and abs(emp['total']) < 1e-9 and propina <= 0:
|
||||
continue
|
||||
existente = Liq.search([
|
||||
('empleado_id', '=', emp['empleado_id']),
|
||||
@@ -610,12 +614,15 @@ class SkeenComisionesController(http.Controller):
|
||||
'folio': existente.name,
|
||||
'motivo': 'ya existe liquidación del periodo'})
|
||||
continue
|
||||
detalle = dict(emp)
|
||||
detalle['propinas'] = propina
|
||||
liq = Liq.create({
|
||||
'empleado_id': emp['empleado_id'],
|
||||
'periodo_inicio': start,
|
||||
'periodo_fin': end,
|
||||
'total_calculado': emp['total'],
|
||||
'detalle': json.dumps(emp, ensure_ascii=False),
|
||||
'total_propinas': propina,
|
||||
'detalle': json.dumps(detalle, ensure_ascii=False),
|
||||
'creado_por_login': _login(),
|
||||
'notas': (data.get('notas') or '').strip(),
|
||||
})
|
||||
|
||||
@@ -15,10 +15,29 @@ import json
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_propinas
|
||||
|
||||
ESTADOS = ('borrador', 'pagada', 'cancelada')
|
||||
ESTADO_LABEL = {'borrador': 'Borrador', 'pagada': 'Pagada', 'cancelada': 'Cancelada'}
|
||||
|
||||
|
||||
def propinas_de_empleado(env, empleado_id, start, end):
|
||||
"""Propina que le toca a un empleado en el periodo (0 si no aplica).
|
||||
|
||||
Solo se suma a la liquidación cuando la configuración de propinas lo pide
|
||||
(`incluir_en_liquidacion_comisiones`); apagado, devuelve 0 y no altera
|
||||
ninguna liquidación.
|
||||
"""
|
||||
cfg = get_propinas(env)
|
||||
if not cfg.get('activo') or not cfg.get('incluir_en_liquidacion_comisiones'):
|
||||
return 0.0
|
||||
repartos = env['skeen.propina.reparto'].sudo().search([
|
||||
('employee_id', '=', empleado_id),
|
||||
('date', '>=', start), ('date', '<=', end),
|
||||
])
|
||||
return sum(r.monto or 0.0 for r in repartos)
|
||||
|
||||
|
||||
class SkeenComisionLiquidacion(models.Model):
|
||||
_name = 'skeen.comision.liquidacion'
|
||||
_description = 'Liquidación de comisiones SKEEN'
|
||||
@@ -33,6 +52,10 @@ class SkeenComisionLiquidacion(models.Model):
|
||||
store=True, index=True)
|
||||
|
||||
total_calculado = fields.Float(string='Comisión calculada', default=0.0)
|
||||
# Propina del empleado en el periodo. Solo tiene valor con
|
||||
# `propinas.incluir_en_liquidacion_comisiones = true`; en cualquier otro
|
||||
# caso queda en 0 y no cambia la liquidación.
|
||||
total_propinas = fields.Float(string='Propinas', default=0.0)
|
||||
total_ajustes = fields.Float(string='Ajustes', compute='_compute_total',
|
||||
store=True)
|
||||
total = fields.Float(string='Total a pagar', compute='_compute_total', store=True)
|
||||
@@ -62,11 +85,11 @@ class SkeenComisionLiquidacion(models.Model):
|
||||
for l in self:
|
||||
l.periodo = l.periodo_inicio.strftime('%Y-%m') if l.periodo_inicio else ''
|
||||
|
||||
@api.depends('ajuste_ids.monto', 'total_calculado')
|
||||
@api.depends('ajuste_ids.monto', 'total_calculado', 'total_propinas')
|
||||
def _compute_total(self):
|
||||
for l in self:
|
||||
l.total_ajustes = sum(a.monto for a in l.ajuste_ids)
|
||||
l.total = (l.total_calculado or 0.0) + l.total_ajustes
|
||||
l.total = (l.total_calculado or 0.0) + l.total_ajustes + (l.total_propinas or 0.0)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
'depends': ['base', 'skeen_pacientes'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/lealtad_cron.xml',
|
||||
'views/monedero_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
|
||||
32
odoo-addons/skeen_monedero/data/lealtad_cron.xml
Normal file
32
odoo-addons/skeen_monedero/data/lealtad_cron.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Vencimiento de puntos de lealtad. Con `expira_meses = 0` (default)
|
||||
el método regresa sin tocar nada: cero filas nuevas. Con un plazo
|
||||
configurado expira, una vez al día, los puntos no usados más
|
||||
antiguos que el plazo, dejando la transacción en la bitácora. -->
|
||||
<record id="cron_lealtad_expirar" model="ir.cron">
|
||||
<field name="name">SKEEN Lealtad: vencer puntos caducados</field>
|
||||
<field name="model_id" ref="model_skeen_monedero"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.expirar_puntos()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Recálculo diario del nivel VIP. Sin niveles configurados (default)
|
||||
el método regresa sin tocar nada. -->
|
||||
<record id="cron_lealtad_niveles" model="ir.cron">
|
||||
<field name="name">SKEEN Lealtad: recalcular niveles VIP</field>
|
||||
<field name="model_id" ref="model_res_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._lealtad_recalcular_todos()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
200
odoo-addons/skeen_monedero/lealtad.py
Normal file
200
odoo-addons/skeen_monedero/lealtad.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# -*- 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,
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from ..lealtad import get_lealtad, nivel_por_codigo, puntos_venta
|
||||
|
||||
|
||||
class SkeenMonedero(models.Model):
|
||||
_name = 'skeen.monedero'
|
||||
@@ -28,6 +32,68 @@ class SkeenMonedero(models.Model):
|
||||
def _compute_equivalent(self):
|
||||
for monedero in self:
|
||||
monedero.equivalent_mxn = monedero.points * 1.0 # 1 punto = $1 MXN
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nivel de lealtad (comodidad para el frontend y las vistas)
|
||||
# ------------------------------------------------------------------
|
||||
nivel_codigo = fields.Char(related='partner_id.lealtad_nivel_codigo',
|
||||
string='Nivel', readonly=True)
|
||||
nivel_nombre = fields.Char(related='partner_id.lealtad_nivel_nombre',
|
||||
string='Nivel (nombre)', readonly=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Motor de puntos (lealtad configurable)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def puntos_de_venta(self, venta, cfg=None):
|
||||
"""Puntos que genera una venta según la configuración vigente."""
|
||||
return puntos_venta(self.env, venta, cfg=cfg)[0]
|
||||
|
||||
@api.model
|
||||
def expirar_puntos(self, cfg=None):
|
||||
"""Expira los puntos no usados más antiguos que `expira_meses`.
|
||||
|
||||
FIFO sobre las transacciones `accrual`: cada canje/ajuste/vencimiento
|
||||
consume primero los lotes más viejos. Solo se expiran los lotes que
|
||||
siguen vivos y ya pasaron el plazo. Devuelve el total expirado.
|
||||
"""
|
||||
cfg = cfg or get_lealtad(self.env)
|
||||
meses = int(cfg.get('expira_meses') or 0)
|
||||
# En modo clásico no se expira nada (el comportamiento de siempre).
|
||||
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or meses <= 0:
|
||||
return 0
|
||||
cutoff = fields.Datetime.now() - relativedelta(months=meses)
|
||||
Tx = self.env['skeen.monedero.transaction']
|
||||
total = 0
|
||||
for monedero in self.search([]):
|
||||
txs = Tx.search([('monedero_id', '=', monedero.id)],
|
||||
order='create_date asc, id asc')
|
||||
lotes = []
|
||||
for tx in txs:
|
||||
pts = int(tx.points or 0)
|
||||
if pts > 0 and tx.type == 'accrual':
|
||||
lotes.append([tx.create_date, pts])
|
||||
elif pts < 0:
|
||||
consumo = -pts
|
||||
for lote in lotes:
|
||||
if consumo <= 0:
|
||||
break
|
||||
if lote[1] <= 0:
|
||||
continue
|
||||
toma = min(lote[1], consumo)
|
||||
lote[1] -= toma
|
||||
consumo -= toma
|
||||
expira = sum(l[1] for l in lotes
|
||||
if l[1] > 0 and l[0] and l[0] < cutoff)
|
||||
expira = min(expira, monedero.points)
|
||||
if expira > 0:
|
||||
monedero.adjust_points(
|
||||
-expira,
|
||||
description='Vencimiento de puntos (%d meses)' % meses,
|
||||
reference='EXPIRACION',
|
||||
tx_type='expiration')
|
||||
total += expira
|
||||
return total
|
||||
|
||||
def get_balance(self, phone):
|
||||
"""Obtener saldo por teléfono (para API)"""
|
||||
@@ -107,10 +173,13 @@ class SkeenMonedero(models.Model):
|
||||
})
|
||||
return True
|
||||
|
||||
def adjust_points(self, delta, description='', reference='', operator=''):
|
||||
def adjust_points(self, delta, description='', reference='', operator='',
|
||||
tx_type='adjustment'):
|
||||
"""Ajuste de puntos (puede ser negativo). Hace piso en 0 para no dejar saldo negativo.
|
||||
|
||||
Usado por devoluciones para revertir puntos acumulados por una venta.
|
||||
`tx_type` permite registrar la bitácora como 'expiration' (vencimiento)
|
||||
sin cambiar el comportamiento de los ajustes normales.
|
||||
Devuelve el delta efectivamente aplicado (puede ser menor si se pisó en 0).
|
||||
"""
|
||||
self.ensure_one()
|
||||
@@ -123,7 +192,7 @@ class SkeenMonedero(models.Model):
|
||||
self.write({'points': new_points})
|
||||
self.env['skeen.monedero.transaction'].create({
|
||||
'monedero_id': self.id,
|
||||
'type': 'adjustment',
|
||||
'type': tx_type,
|
||||
'points': effective_delta,
|
||||
'description': description or 'Ajuste de puntos',
|
||||
'reference': reference,
|
||||
@@ -131,6 +200,13 @@ class SkeenMonedero(models.Model):
|
||||
})
|
||||
return effective_delta
|
||||
|
||||
def nivel_dict(self, cfg=None):
|
||||
"""Beneficios del nivel vigente del paciente (o dict vacío)."""
|
||||
self.ensure_one()
|
||||
cfg = cfg or get_lealtad(self.env)
|
||||
nivel = nivel_por_codigo(cfg, self.partner_id.lealtad_nivel_codigo)
|
||||
return dict(nivel) if nivel else {}
|
||||
|
||||
|
||||
class SkeenMonederoTransaction(models.Model):
|
||||
_name = 'skeen.monedero.transaction'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
from ..lealtad import get_lealtad, nivel_por_codigo, nivel_para_gasto
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
@@ -10,8 +14,116 @@ class ResPartner(models.Model):
|
||||
monedero_ids = fields.One2many('skeen.monedero', 'partner_id', string='Monederos')
|
||||
wallet_points = fields.Integer(string='Puntos Monedero', compute='_compute_wallet_points', store=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nivel de lealtad (VIP). El nivel se decide por el gasto acumulado en
|
||||
# una ventana y se guarda su código (los niveles viven en el bloque
|
||||
# `lealtad` de los ajustes). `lealtad_manual` + `lealtad_nivel_revisar`
|
||||
# permiten asignarlo a mano sin que se sobreescriba solo.
|
||||
# ------------------------------------------------------------------
|
||||
lealtad_nivel_codigo = fields.Char(string='Nivel de lealtad', copy=False)
|
||||
lealtad_nivel_desde = fields.Date(string='Nivel desde', copy=False)
|
||||
lealtad_nivel_revisar = fields.Date(string='Revisar nivel el', copy=False)
|
||||
lealtad_manual = fields.Boolean(string='Nivel asignado a mano', copy=False,
|
||||
default=False)
|
||||
lealtad_nivel_nombre = fields.Char(
|
||||
string='Nivel (nombre)', compute='_compute_lealtad_beneficios')
|
||||
lealtad_descuento_pct = fields.Float(
|
||||
string='Descuento de nivel (%)', compute='_compute_lealtad_beneficios')
|
||||
lealtad_factor_puntos = fields.Float(
|
||||
string='Factor de puntos', compute='_compute_lealtad_beneficios')
|
||||
lealtad_gasto = fields.Float(
|
||||
string='Gasto en la ventana', compute='_compute_lealtad_beneficios')
|
||||
|
||||
@api.depends('monedero_ids.points')
|
||||
def _compute_wallet_points(self):
|
||||
for partner in self:
|
||||
monedero = partner.monedero_ids[:1]
|
||||
partner.wallet_points = monedero.points if monedero else 0
|
||||
|
||||
@api.depends('lealtad_nivel_codigo')
|
||||
def _compute_lealtad_beneficios(self):
|
||||
cfg = get_lealtad(self.env)
|
||||
for partner in self:
|
||||
nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
|
||||
partner.lealtad_nivel_nombre = (nivel or {}).get('nombre') or ''
|
||||
partner.lealtad_descuento_pct = float((nivel or {}).get('descuento_pct') or 0.0)
|
||||
partner.lealtad_factor_puntos = float((nivel or {}).get('factor_puntos') or 1.0)
|
||||
ventana = 12
|
||||
if cfg.get('niveles'):
|
||||
ventana = max(int(n.get('ventana_meses') or 12) for n in cfg['niveles'])
|
||||
partner.lealtad_gasto = partner._lealtad_gasto_ventana(ventana)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consultas y recálculo de nivel
|
||||
# ------------------------------------------------------------------
|
||||
def _lealtad_gasto_ventana(self, meses):
|
||||
"""Gasto (suma de ventas no canceladas) del paciente en `meses` meses."""
|
||||
self.ensure_one()
|
||||
meses = max(int(meses or 12), 1)
|
||||
desde = fields.Date.today() - relativedelta(months=meses)
|
||||
ventas = self.env['skeen.venta'].sudo().search([
|
||||
('partner_id', '=', self.id),
|
||||
('state', '!=', 'cancelled'),
|
||||
('date', '>=', desde),
|
||||
])
|
||||
return sum(v.total for v in ventas)
|
||||
|
||||
def _lealtad_recalcular(self, cfg=None):
|
||||
"""Recalcula el nivel VIP de los pacientes, respetando vigencias.
|
||||
|
||||
- Un nivel no baja de inmediato: se conserva hasta que venza su
|
||||
`vigencia_meses` contados desde `lealtad_nivel_desde`.
|
||||
- Una asignación manual con fecha de revisión futura no se toca.
|
||||
Solo aplica con `modo='configurable'`; en clásico no hay niveles.
|
||||
"""
|
||||
cfg = cfg or get_lealtad(self.env)
|
||||
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or not cfg.get('niveles'):
|
||||
return 0
|
||||
hoy = fields.Date.today()
|
||||
cambios = 0
|
||||
for partner in self:
|
||||
if partner.lealtad_manual and partner.lealtad_nivel_revisar \
|
||||
and partner.lealtad_nivel_revisar > hoy:
|
||||
continue
|
||||
vals = {}
|
||||
if partner.lealtad_manual and partner.lealtad_nivel_revisar \
|
||||
and partner.lealtad_nivel_revisar <= hoy:
|
||||
partner.lealtad_manual = False
|
||||
partner.lealtad_nivel_revisar = False
|
||||
ventana = max(int(n.get('ventana_meses') or 12) for n in cfg['niveles'])
|
||||
gasto = partner._lealtad_gasto_ventana(ventana)
|
||||
nuevo = nivel_para_gasto(cfg, gasto)
|
||||
actual = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
|
||||
# Bajar de nivel: solo cuando venció la vigencia del actual.
|
||||
if actual and (nuevo is None or nuevo['gasto_minimo'] < actual['gasto_minimo']):
|
||||
desde = partner.lealtad_nivel_desde
|
||||
vence = (desde + relativedelta(
|
||||
months=int(actual.get('vigencia_meses') or 0))) if desde else None
|
||||
if vence and hoy < vence:
|
||||
continue
|
||||
if not nuevo:
|
||||
if partner.lealtad_nivel_codigo:
|
||||
vals = {'lealtad_nivel_codigo': False, 'lealtad_nivel_desde': False}
|
||||
elif partner.lealtad_manual:
|
||||
vals = {'lealtad_manual': False, 'lealtad_nivel_revisar': False}
|
||||
elif nuevo['codigo'] != partner.lealtad_nivel_codigo:
|
||||
vals = {
|
||||
'lealtad_nivel_codigo': nuevo['codigo'],
|
||||
'lealtad_nivel_desde': hoy,
|
||||
'lealtad_manual': False,
|
||||
'lealtad_nivel_revisar': False,
|
||||
}
|
||||
if vals:
|
||||
partner.write(vals)
|
||||
cambios += 1
|
||||
return cambios
|
||||
|
||||
@api.model
|
||||
def _lealtad_recalcular_todos(self):
|
||||
"""Proceso diario: recalcula el nivel de todos los pacientes."""
|
||||
cfg = get_lealtad(self.env)
|
||||
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or not cfg.get('niveles'):
|
||||
return True
|
||||
partners = self.sudo().search([('is_patient', '=', True)])
|
||||
partners._lealtad_recalcular(cfg)
|
||||
return True
|
||||
|
||||
@@ -29,8 +29,10 @@ from odoo.http import request, Response
|
||||
from odoo.addons.skeen_citas.clinic_settings import (
|
||||
get_settings as get_clinic_settings,
|
||||
get_reservas_online,
|
||||
get_lealtad,
|
||||
brand_dict,
|
||||
)
|
||||
from odoo.addons.skeen_monedero.lealtad import nivel_por_codigo as lealtad_nivel_por_codigo
|
||||
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
|
||||
|
||||
@@ -797,7 +799,9 @@ class SkeenPortalPublicoController(http.Controller):
|
||||
monedero = request.env['skeen.monedero'].sudo().search(
|
||||
[('partner_id', '=', partner.id)], limit=1)
|
||||
points = monedero.points if monedero else 0
|
||||
mxn = float(points) * 1.0 # 1 punto = $1 MXN
|
||||
cfg_lea = get_lealtad(request.env)
|
||||
valor = float(cfg_lea.get('valor_punto') or 1.0) # clásico: 1 pt = $1
|
||||
mxn = float(points) * valor
|
||||
rate = 0.0
|
||||
try:
|
||||
rate = request.env['skeen.tipo.cambio'].sudo().get_current_rate()
|
||||
@@ -815,12 +819,20 @@ class SkeenPortalPublicoController(http.Controller):
|
||||
'descripcion': tx.description or '',
|
||||
'referencia': tx.reference or '',
|
||||
})
|
||||
nivel = lealtad_nivel_por_codigo(cfg_lea, partner.lealtad_nivel_codigo)
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'points': points,
|
||||
'equivalent_mxn': round(mxn, 2),
|
||||
'equivalent_usd': usd,
|
||||
'rate': rate or None,
|
||||
'valor_punto': valor,
|
||||
'nivel': ({
|
||||
'codigo': nivel['codigo'],
|
||||
'nombre': nivel['nombre'],
|
||||
'descuento_pct': nivel.get('descuento_pct') or 0.0,
|
||||
'color': nivel.get('color') or '',
|
||||
} if nivel else None),
|
||||
'movimientos': movimientos,
|
||||
})
|
||||
except Exception as exc:
|
||||
|
||||
@@ -5,7 +5,9 @@ from odoo.exceptions import ValidationError
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_impuestos, get_lealtad, get_propinas
|
||||
from odoo.addons.skeen_monedero.lealtad import (
|
||||
get_lealtad as _get_lealtad, nivel_por_codigo, puntos_venta, regla_multiplicador)
|
||||
|
||||
|
||||
def _fmt_mxn(v):
|
||||
@@ -99,6 +101,21 @@ class SkeenVenta(models.Model):
|
||||
points_redeemed = fields.Integer(string='Puntos Redimidos', default=0)
|
||||
points_discount = fields.Float(string='Descuento por Puntos', default=0.0)
|
||||
|
||||
# Lealtad: descuento automático por el nivel VIP del cliente (0 si no hay
|
||||
# niveles o la lealtad está en modo clásico).
|
||||
nivel_codigo = fields.Char(string='Nivel del cliente', copy=False)
|
||||
nivel_discount = fields.Float(string='Descuento por nivel', default=0.0)
|
||||
|
||||
# Propinas (opcionales; 0 = comportamiento de siempre). La propina NO se
|
||||
# mezcla con el importe de los servicios: va en su propio campo y se suma
|
||||
# al total cobrado al cliente. `propina_employee_id` es a quién corresponde
|
||||
# cuando el reparto está en 'ninguno'.
|
||||
propina = fields.Float(string='Propina', default=0.0)
|
||||
propina_moneda = fields.Char(string='Moneda de la propina', default='MXN')
|
||||
propina_employee_id = fields.Many2one('hr.employee', string='Propina para')
|
||||
reparto_ids = fields.One2many('skeen.propina.reparto', 'venta_id',
|
||||
string='Reparto de propina')
|
||||
|
||||
# Devolución
|
||||
refunded = fields.Boolean(string='Devuelta', default=False)
|
||||
refund_amount = fields.Float(string='Monto Devuelto', default=0.0)
|
||||
@@ -140,15 +157,18 @@ class SkeenVenta(models.Model):
|
||||
notes = fields.Text(string='Notas')
|
||||
|
||||
@api.depends('line_ids.subtotal', 'line_ids.tax_amount', 'discount', 'tax',
|
||||
'amount_paid', 'points_discount')
|
||||
'amount_paid', 'points_discount', 'nivel_discount', 'propina')
|
||||
def _compute_totals(self):
|
||||
for venta in self:
|
||||
subtotal = sum(line.subtotal for line in venta.line_ids)
|
||||
venta.subtotal = subtotal
|
||||
# La fórmula de siempre: subtotal - descuento - puntos + impuesto.
|
||||
# Con impuestos apagados `tax` conserva el valor manual, así que el
|
||||
# total sale idéntico al de antes.
|
||||
venta.total = max(0.0, subtotal - venta.discount - venta.points_discount + venta.tax)
|
||||
# total sale idéntico al de antes. `nivel_discount` y `propina` son
|
||||
# 0 por defecto: no alteran ninguna cifra existente.
|
||||
venta.total = max(
|
||||
0.0, subtotal - venta.discount - venta.nivel_discount
|
||||
- venta.points_discount + venta.tax + (venta.propina or 0.0))
|
||||
venta.amount_due = max(0.0, venta.total - venta.amount_paid)
|
||||
|
||||
@api.depends('line_ids.subtotal', 'line_ids.tax_amount')
|
||||
@@ -191,18 +211,16 @@ class SkeenVenta(models.Model):
|
||||
'payment_state': 'paid',
|
||||
'amount_paid': venta.total,
|
||||
})
|
||||
# Acumular puntos (proporcional a líneas que generan monedero)
|
||||
# Acumular puntos según la configuración de lealtad vigente
|
||||
# (clásico: 1 pt por cada $10; configurable: bloque `lealtad`).
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
|
||||
if not monedero:
|
||||
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
|
||||
subtotal_total = sum(l.subtotal for l in venta.line_ids)
|
||||
elegible = 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)
|
||||
)
|
||||
points = int(venta.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
|
||||
points = venta._wallet_points_expected()
|
||||
if points > 0:
|
||||
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
|
||||
# El nivel VIP del cliente se recalcula al cerrar la venta.
|
||||
venta.partner_id._lealtad_recalcular()
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
@@ -326,17 +344,117 @@ class SkeenVenta(models.Model):
|
||||
def _wallet_points_expected(self):
|
||||
"""Puntos que DEBIÓ generar esta venta con sus líneas y total actuales.
|
||||
|
||||
Es la misma fórmula de action_mark_paid: el total se reparte
|
||||
proporcionalmente entre las líneas que generan monedero ($10 = 1 pt).
|
||||
En modo clásico es la misma fórmula de action_mark_paid (el total se
|
||||
reparte proporcionalmente entre las líneas que generan monedero,
|
||||
$10 = 1 pt). En modo configurable la calcula el motor de lealtad.
|
||||
"""
|
||||
self.ensure_one()
|
||||
subtotal_total = sum(l.subtotal for l in self.line_ids)
|
||||
elegible = sum(
|
||||
l.subtotal for l in self.line_ids
|
||||
if (l.service_id and l.service_id.genera_monedero)
|
||||
or (l.item_id and l.item_id.genera_monedero)
|
||||
)
|
||||
return int(self.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
|
||||
return puntos_venta(self.env, self)[0]
|
||||
|
||||
def contexto_lealtad(self, line):
|
||||
"""Contexto de una línea para resolver multiplicadores y nivel."""
|
||||
self.ensure_one()
|
||||
fecha = self.date
|
||||
return {
|
||||
'servicio_id': line.service_id.id or None,
|
||||
'articulo_id': line.item_id.id or None,
|
||||
'categoria': line.service_id.category if line.service_id else '',
|
||||
'sucursal': self.cita_id.branch if self.cita_id else '',
|
||||
'nivel_codigo': self.partner_id.lealtad_nivel_codigo or '',
|
||||
'fecha': fecha,
|
||||
'dia_semana': fecha.weekday() if fecha else None,
|
||||
}
|
||||
|
||||
def _aplicar_descuento_nivel(self, partner=None):
|
||||
"""Aplica el descuento (%) del nivel VIP del cliente. Solo con lealtad
|
||||
configurable y niveles; en clásico no hay descuento (queda en 0)."""
|
||||
self.ensure_one()
|
||||
cfg = _get_lealtad(self.env)
|
||||
partner = partner or self.partner_id
|
||||
if not cfg.get('activo') or cfg.get('modo') != 'configurable':
|
||||
return 0.0
|
||||
nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
|
||||
pct = float((nivel or {}).get('descuento_pct') or 0.0)
|
||||
if pct <= 0:
|
||||
self.write({'nivel_codigo': (nivel or {}).get('codigo') or False})
|
||||
return 0.0
|
||||
descuento = round((self.subtotal - self.discount) * pct / 100.0, 2)
|
||||
descuento = max(0.0, descuento)
|
||||
self.write({'nivel_discount': descuento,
|
||||
'nivel_codigo': (nivel or {}).get('codigo') or False})
|
||||
return descuento
|
||||
|
||||
def _generar_reparto_propina(self, cfg=None, cobrador_employee=None,
|
||||
cobrador_login=''):
|
||||
"""Reparte la propina de la venta entre el personal implicado.
|
||||
|
||||
Con `reparto='ninguno'` no se crea ningún registro: la propina queda en
|
||||
la venta (y en `propina_employee_id` si llega). Devuelve los registros
|
||||
creados (vacío si no aplica).
|
||||
"""
|
||||
self.ensure_one()
|
||||
cfg = cfg or get_propinas(self.env)
|
||||
Reparto = self.env['skeen.propina.reparto']
|
||||
if not cfg.get('activo') or (self.propina or 0.0) <= 0:
|
||||
return Reparto
|
||||
if cfg.get('reparto') not in ('equitativo', 'por_participacion'):
|
||||
return Reparto
|
||||
self.reparto_ids.unlink()
|
||||
atendio = self.cita_id.doctor_id if self.cita_id else self.env['hr.employee']
|
||||
participantes = []
|
||||
if atendio:
|
||||
participantes.append((atendio, 'atendio'))
|
||||
if cobrador_employee and cobrador_employee != atendio:
|
||||
participantes.append((cobrador_employee, 'cobro'))
|
||||
if not participantes:
|
||||
participantes = [(None, 'cobro')]
|
||||
|
||||
montos = {}
|
||||
if cfg.get('reparto') == 'por_participacion' and self.line_ids:
|
||||
pesos = {}
|
||||
for emp, rol in participantes:
|
||||
if not emp:
|
||||
continue
|
||||
pesos[emp.id] = pesos.get(emp.id, 0.0)
|
||||
total_lineas = 0.0
|
||||
for line in self.line_ids:
|
||||
total_lineas += line.subtotal
|
||||
if line.prescribed_by_id and line.prescribed_by_id.id in pesos:
|
||||
pesos[line.prescribed_by_id.id] += line.subtotal
|
||||
elif atendio and atendio.id in pesos and not line.prescribed_by_id:
|
||||
pesos[atendio.id] += line.subtotal
|
||||
suma = sum(pesos.values())
|
||||
if suma > 0:
|
||||
for emp, rol in participantes:
|
||||
if emp:
|
||||
montos[emp.id] = round(self.propina * pesos[emp.id] / suma, 2)
|
||||
if not montos:
|
||||
# equitativo (o respaldo de por_participacion sin pesos)
|
||||
cupo = round(self.propina / len(participantes), 2)
|
||||
for emp, rol in participantes:
|
||||
montos[emp.id if emp else 0] = cupo
|
||||
creados = Reparto
|
||||
repartido = 0.0
|
||||
for emp, rol in participantes:
|
||||
monto = montos.get(emp.id if emp else 0, 0.0)
|
||||
if monto <= 0:
|
||||
continue
|
||||
creados |= Reparto.create({
|
||||
'venta_id': self.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'employee_id': emp.id if emp else False,
|
||||
'rol': rol,
|
||||
'monto': monto,
|
||||
'moneda': self.propina_moneda or 'MXN',
|
||||
'cuenta': cfg.get('cuenta_propinas') or '',
|
||||
'user_login': cobrador_login or self.user_login or '',
|
||||
'date': self.date,
|
||||
})
|
||||
repartido += monto
|
||||
# Ajuste de centavos: el primero absorbe la diferencia.
|
||||
if creados and abs(repartido - self.propina) >= 0.005:
|
||||
creados[0].monto = round(creados[0].monto + (self.propina - repartido), 2)
|
||||
return creados
|
||||
|
||||
def _wallet_points_accrued(self):
|
||||
"""Puntos realmente acumulados por esta venta (suma de abonos a su referencia)."""
|
||||
@@ -683,8 +801,13 @@ class SkeenVenta(models.Model):
|
||||
|
||||
new_amount_paid = round(self.amount_paid - refund_amount, 2)
|
||||
|
||||
# Revertir puntos acumulados en proporción a lo devuelto ($10 MXN = 1 punto)
|
||||
points_to_reverse = int(refund_amount / 10)
|
||||
# Revertir puntos acumulados en proporción a lo devuelto. Clásico:
|
||||
# $10 MXN = 1 punto. Configurable: según `puntos_por_unidad`.
|
||||
cfg_lea = _get_lealtad(self.env)
|
||||
if cfg_lea.get('modo') == 'configurable':
|
||||
points_to_reverse = int(refund_amount * float(cfg_lea.get('puntos_por_unidad') or 0.0))
|
||||
else:
|
||||
points_to_reverse = int(refund_amount / 10)
|
||||
if points_to_reverse > 0:
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if monedero and monedero.points > 0:
|
||||
@@ -729,20 +852,26 @@ class SkeenVenta(models.Model):
|
||||
}
|
||||
|
||||
def action_apply_wallet(self, points):
|
||||
"""Redime puntos del monedero como descuento en la venta"""
|
||||
"""Redime puntos del monedero como descuento en la venta.
|
||||
|
||||
El tope de canje (`canje_max_pct`) y el valor del punto (`valor_punto`)
|
||||
salen de la configuración de lealtad; en modo clásico equivalen al tope
|
||||
de 50% y 1 punto = $1 de siempre.
|
||||
"""
|
||||
self.ensure_one()
|
||||
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if not monedero or monedero.points < points:
|
||||
raise ValidationError(_('Puntos insuficientes'))
|
||||
|
||||
# Máximo 50% del total
|
||||
max_discount = self.total * 0.5
|
||||
point_value = min(points, int(max_discount))
|
||||
cfg = _get_lealtad(self.env)
|
||||
valor = float(cfg.get('valor_punto') or 1.0) or 1.0
|
||||
max_discount = self.total * float(cfg.get('canje_max_pct') or 0.0) / 100.0
|
||||
point_value = min(points, int(max_discount / valor))
|
||||
|
||||
monedero.redeem_points(point_value, description=f'Redención venta {self.name}', reference=self.name)
|
||||
self.write({
|
||||
'points_redeemed': point_value,
|
||||
'points_discount': point_value * 1.0,
|
||||
'points_discount': point_value * valor,
|
||||
})
|
||||
return True
|
||||
|
||||
@@ -768,6 +897,37 @@ class SkeenVentaLine(models.Model):
|
||||
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
|
||||
prescribed_by_id = fields.Many2one('hr.employee', string='Recetado por')
|
||||
|
||||
# Clasificación para el programa de lealtad (`aplicar_a` y multiplicadores).
|
||||
tipo_monedero = fields.Selection([
|
||||
('servicios', 'Servicios'),
|
||||
('productos', 'Productos'),
|
||||
('membresias', 'Membresías'),
|
||||
('paquetes', 'Paquetes'),
|
||||
], string='Tipo (lealtad)', compute='_compute_lealtad_linea')
|
||||
genera_monedero = fields.Boolean(
|
||||
string='Genera monedero', compute='_compute_lealtad_linea',
|
||||
help='Igual que el indicador del catálogo: si el servicio/artículo acumula puntos.')
|
||||
|
||||
@api.depends('service_id', 'item_id', 'paquete_id',
|
||||
'service_id.genera_monedero', 'item_id.genera_monedero')
|
||||
def _compute_lealtad_linea(self):
|
||||
planes = None
|
||||
if 'skeen.membresia.plan' in self.env:
|
||||
planes = set(self.env['skeen.membresia.plan'].sudo().search(
|
||||
[('servicio_id', '!=', False)]).mapped('servicio_id').ids)
|
||||
for line in self:
|
||||
if line.item_id:
|
||||
line.tipo_monedero = 'productos'
|
||||
elif line.paquete_id:
|
||||
line.tipo_monedero = 'paquetes'
|
||||
elif line.service_id and planes is not None and line.service_id.id in planes:
|
||||
line.tipo_monedero = 'membresias'
|
||||
else:
|
||||
line.tipo_monedero = 'servicios'
|
||||
line.genera_monedero = bool(
|
||||
(line.service_id and line.service_id.genera_monedero)
|
||||
or (line.item_id and line.item_id.genera_monedero))
|
||||
|
||||
_sql_constraints = [
|
||||
('service_o_item', 'CHECK(service_id IS NOT NULL OR item_id IS NOT NULL)',
|
||||
'La línea debe tener un servicio o un artículo de inventario.')
|
||||
@@ -868,6 +1028,36 @@ class SkeenVentaLine(models.Model):
|
||||
self.tax_rate = self._tasa_para_linea({'item_id': self.item_id.id})
|
||||
|
||||
|
||||
class SkeenPropinaReparto(models.Model):
|
||||
"""Detalle del reparto de una propina entre el personal.
|
||||
|
||||
Una fila por persona con lo que le toca de la propina de una venta. Se usa
|
||||
en el reporte de propinas por empleado y, si la configuración lo pide, se
|
||||
suma a su liquidación de comisiones. Con `activo=False` (default) no se
|
||||
genera ninguna fila.
|
||||
"""
|
||||
_name = 'skeen.propina.reparto'
|
||||
_description = 'Reparto de Propina'
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
venta_name = fields.Char(related='venta_id.name', string='Folio', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', index=True)
|
||||
employee_id = fields.Many2one('hr.employee', string='Empleado', index=True)
|
||||
rol = fields.Selection([
|
||||
('atendio', 'Atendió la cita'),
|
||||
('cobro', 'Cobró'),
|
||||
('otro', 'Otro'),
|
||||
], string='Rol', default='otro')
|
||||
monto = fields.Float(string='Monto', default=0.0)
|
||||
moneda = fields.Char(string='Moneda', default='MXN')
|
||||
cuenta = fields.Char(string='Cuenta destino')
|
||||
user_login = fields.Char(string='Registrado por')
|
||||
date = fields.Date(string='Fecha', default=fields.Date.today, index=True)
|
||||
notes = fields.Char(string='Notas')
|
||||
|
||||
|
||||
class SkeenCorteCaja(models.Model):
|
||||
_name = 'skeen.corte.caja'
|
||||
_description = 'Corte de Caja SKEEN'
|
||||
@@ -890,6 +1080,11 @@ class SkeenCorteCaja(models.Model):
|
||||
total_transfer = fields.Float(string='Transferencia', compute='_compute_totals', store=True)
|
||||
total_other = fields.Float(string='Otros', compute='_compute_totals', store=True)
|
||||
total_sales = fields.Float(string='Total Ventas', compute='_compute_totals', store=True)
|
||||
# Desglose de propinas del día (0 cuando el módulo está apagado). No altera
|
||||
# ninguna cifra existente: es un renglón aparte.
|
||||
total_propinas = fields.Float(string='Propinas', compute='_compute_totals', store=True)
|
||||
total_servicios = fields.Float(string='Servicios (sin propina)',
|
||||
compute='_compute_totals', store=True)
|
||||
|
||||
# Cierre guiado: efectivo contado por el cajero y conteo informativo
|
||||
# de los demás métodos.
|
||||
@@ -929,12 +1124,18 @@ class SkeenCorteCaja(models.Model):
|
||||
card = sum(p.amount for p in pagos if p.payment_method in ('card', 'stripe', 'mercadopago'))
|
||||
transfer = sum(p.amount for p in pagos if p.payment_method == 'transfer')
|
||||
other = sum(p.amount for p in pagos if p.payment_method not in ('cash', 'card', 'stripe', 'mercadopago', 'transfer'))
|
||||
ventas = self.env['skeen.venta'].sudo().search([
|
||||
('date', '=', self.date), ('state', '!=', 'cancelled'),
|
||||
])
|
||||
propinas = sum(v.propina or 0.0 for v in ventas)
|
||||
return {
|
||||
'total_cash': cash,
|
||||
'total_card': card,
|
||||
'total_transfer': transfer,
|
||||
'total_other': other,
|
||||
'total_sales': cash + card + transfer + other,
|
||||
'total_propinas': propinas,
|
||||
'total_servicios': (cash + card + transfer + other) - propinas,
|
||||
}
|
||||
|
||||
def refresh_totals(self):
|
||||
|
||||
@@ -6,3 +6,4 @@ access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cam
|
||||
access_skeen_paquete_user,Acceso a Paquetes SKEEN,model_skeen_paquete,base.group_user,1,1,1,1
|
||||
access_skeen_paquete_compra_user,Acceso a Compras de Paquete SKEEN,model_skeen_paquete_compra,base.group_user,1,1,1,1
|
||||
access_skeen_paquete_consumo_user,Acceso a Consumos de Paquete SKEEN,model_skeen_paquete_consumo,base.group_user,1,1,1,1
|
||||
access_skeen_propina_reparto_user,Acceso a Reparto de Propinas SKEEN,model_skeen_propina_reparto,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -22,6 +22,11 @@ from odoo.addons.skeen_citas.clinic_settings import (
|
||||
get_impuestos,
|
||||
get_recursos,
|
||||
get_compras,
|
||||
get_lealtad,
|
||||
get_propinas,
|
||||
)
|
||||
from odoo.addons.skeen_monedero.lealtad import (
|
||||
nivel_por_codigo as lealtad_nivel_por_codigo,
|
||||
)
|
||||
from odoo.addons.skeen_citas.glosario import (
|
||||
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
|
||||
@@ -2967,15 +2972,19 @@ class SkeenFrontendController(http.Controller):
|
||||
if refs:
|
||||
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
|
||||
totales[v.name] = v.total
|
||||
cfg_lea = get_lealtad(request.env)
|
||||
valor = float(cfg_lea.get('valor_punto') or 1.0)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'patient_id': partner.id,
|
||||
'patient': partner.name,
|
||||
'wallet_id': monedero.id if monedero else None,
|
||||
'points': saldo,
|
||||
'equivalent_mxn': float(saldo),
|
||||
'equivalent_usd': round(saldo / rate, 2) if rate else None,
|
||||
'valor_punto': valor,
|
||||
'equivalent_mxn': float(saldo) * valor,
|
||||
'equivalent_usd': round(saldo * valor / rate, 2) if rate else None,
|
||||
'rate': rate,
|
||||
'nivel': self._lealtad_nivel_dict(cfg_lea, partner),
|
||||
'depositado': float(sum(t.points for t in (monedero.transaction_ids if monedero else []) if t.type == 'deposito')),
|
||||
'movimientos': [{
|
||||
'id': t.id,
|
||||
@@ -2993,6 +3002,255 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Lealtad: puntos, niveles y reportes
|
||||
# ============================================================
|
||||
def _lealtad_nivel_dict(self, cfg, partner):
|
||||
nivel = lealtad_nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
|
||||
return {
|
||||
'codigo': partner.lealtad_nivel_codigo or '',
|
||||
'nombre': (nivel or {}).get('nombre', '') or '',
|
||||
'descuento_pct': float((nivel or {}).get('descuento_pct') or 0.0),
|
||||
'factor_puntos': float((nivel or {}).get('factor_puntos') or 1.0),
|
||||
'color': (nivel or {}).get('color', '') or '',
|
||||
'desde': partner.lealtad_nivel_desde.strftime('%Y-%m-%d') if partner.lealtad_nivel_desde else None,
|
||||
'manual': bool(partner.lealtad_manual),
|
||||
'revisar': partner.lealtad_nivel_revisar.strftime('%Y-%m-%d') if partner.lealtad_nivel_revisar else None,
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/lealtad',
|
||||
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('lealtad.ver')
|
||||
def patient_lealtad(self, patient_id, **kw):
|
||||
"""Nivel VIP, saldo de puntos, valor y gasto del paciente."""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
cfg = get_lealtad(request.env)
|
||||
monedero = self._monedero_de(partner, crear=False)
|
||||
saldo = monedero.points if monedero else 0
|
||||
valor = float(cfg.get('valor_punto') or 1.0)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'patient_id': partner.id,
|
||||
'patient': partner.name,
|
||||
'activo': bool(cfg.get('activo')),
|
||||
'modo': cfg.get('modo'),
|
||||
'valor_punto': valor,
|
||||
'puntos': saldo,
|
||||
'equivalente_mxn': round(saldo * valor, 2),
|
||||
'gasto_ventana': partner.lealtad_gasto,
|
||||
'nivel': self._lealtad_nivel_dict(cfg, partner),
|
||||
'niveles': cfg.get('niveles') or [],
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/lealtad/recalcular',
|
||||
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
@require_perm('lealtad.ajustar')
|
||||
def patient_lealtad_recalcular(self, patient_id, **kw):
|
||||
"""Recalcula el nivel VIP (respeta la asignación manual vigente)."""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
cfg = get_lealtad(request.env)
|
||||
partner._lealtad_recalcular(cfg)
|
||||
return json_response({'status': 'success',
|
||||
'nivel': self._lealtad_nivel_dict(cfg, partner),
|
||||
'gasto_ventana': partner.lealtad_gasto})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/lealtad',
|
||||
type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
@require_perm('lealtad.nivel')
|
||||
def patient_lealtad_set(self, patient_id, **kw):
|
||||
"""Asigna el nivel a mano con fecha de revisión (no se sobreescribe)."""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
cfg = get_lealtad(request.env)
|
||||
data = _parse_json_body()
|
||||
codigo = (data.get('codigo') or '').strip()
|
||||
if codigo:
|
||||
nivel = lealtad_nivel_por_codigo(cfg, codigo)
|
||||
if not nivel:
|
||||
return json_response({'status': 'error', 'message': 'Nivel desconocido'}, 400)
|
||||
revisar = (data.get('revisar') or '').strip()
|
||||
if revisar:
|
||||
try:
|
||||
revisar_d = fields.Date.to_date(revisar)
|
||||
except Exception:
|
||||
return json_response({'status': 'error', 'message': 'Fecha de revisión inválida'}, 400)
|
||||
else:
|
||||
revisar_d = False
|
||||
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
partner.write({
|
||||
'lealtad_nivel_codigo': codigo or False,
|
||||
'lealtad_nivel_desde': fields.Date.today() if codigo else False,
|
||||
'lealtad_manual': True,
|
||||
'lealtad_nivel_revisar': revisar_d,
|
||||
})
|
||||
self._auditar('lealtad', f'paciente:{partner.id}', 'nivel_manual',
|
||||
False, codigo, entity_label=partner.name,
|
||||
notes='Asignación manual de nivel (%s)' % usuario)
|
||||
return json_response({'status': 'success',
|
||||
'nivel': self._lealtad_nivel_dict(cfg, partner)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/points/adjust',
|
||||
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
@require_perm('lealtad.ajustar')
|
||||
def patient_points_adjust(self, patient_id, **kw):
|
||||
"""Ajuste manual del saldo de puntos (con motivo, para auditoría)."""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
delta = int(data.get('points') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Puntos inválidos'}, 400)
|
||||
motivo = (data.get('motivo') or '').strip()
|
||||
if not delta:
|
||||
return json_response({'status': 'error', 'message': 'Indica los puntos a ajustar'}, 400)
|
||||
if not motivo:
|
||||
return json_response({'status': 'error', 'message': 'El motivo es obligatorio'}, 400)
|
||||
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
monedero = self._monedero_de(partner, crear=True)
|
||||
antes = monedero.points
|
||||
aplicado = monedero.adjust_points(
|
||||
delta, description=motivo,
|
||||
reference=(data.get('reference') or 'AJUSTE MANUAL'),
|
||||
operator=usuario)
|
||||
self._auditar('monedero', f'paciente:{partner.id}', 'ajuste', antes,
|
||||
monedero.points, entity_label=partner.name, notes=motivo)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'points': monedero.points,
|
||||
'applied': aplicado,
|
||||
'equivalente_mxn': round(monedero.points * float(
|
||||
get_lealtad(request.env).get('valor_punto') or 1.0), 2),
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/reports/lealtad', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('lealtad.ver')
|
||||
def report_lealtad(self, **kw):
|
||||
"""Puntos otorgados, canjeados, expirados y el pasivo en pesos."""
|
||||
try:
|
||||
cfg = get_lealtad(request.env)
|
||||
valor = float(cfg.get('valor_punto') or 1.0)
|
||||
Tx = request.env['skeen.monedero.transaction'].sudo()
|
||||
domain = []
|
||||
if kw.get('start'):
|
||||
domain.append(('create_date', '>=', f"{kw['start']} 00:00:00"))
|
||||
if kw.get('end'):
|
||||
domain.append(('create_date', '<=', f"{kw['end']} 23:59:59"))
|
||||
by_type = {}
|
||||
for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']):
|
||||
by_type[g['type']] = g['points']
|
||||
monederos = request.env['skeen.monedero'].sudo().search([])
|
||||
activos = sum(m.points for m in monederos)
|
||||
otorgados = by_type.get('accrual', 0) + by_type.get('deposito', 0)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'modo': cfg.get('modo'),
|
||||
'valor_punto': valor,
|
||||
'otorgados': by_type.get('accrual', 0),
|
||||
'canjeados': abs(by_type.get('redemption', 0)),
|
||||
'expirados': abs(by_type.get('expiration', 0)),
|
||||
'ajustes': by_type.get('adjustment', 0),
|
||||
'depositos': by_type.get('deposito', 0),
|
||||
'by_type': by_type,
|
||||
'puntos_activos': activos,
|
||||
'pasivo_mxn': round(activos * valor, 2),
|
||||
'cuentas': len(monederos),
|
||||
'niveles': cfg.get('niveles') or [],
|
||||
'conteo_niveles': self._conteo_niveles(cfg),
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _conteo_niveles(self, cfg):
|
||||
"""Cuántos pacientes hay en cada nivel (para el reporte de lealtad)."""
|
||||
conteo = {}
|
||||
for codigo in [n['codigo'] for n in cfg.get('niveles') or []]:
|
||||
conteo[codigo] = 0
|
||||
partners = request.env['res.partner'].sudo().search([
|
||||
('is_patient', '=', True), ('lealtad_nivel_codigo', '!=', False)])
|
||||
for p in partners:
|
||||
if p.lealtad_nivel_codigo in conteo:
|
||||
conteo[p.lealtad_nivel_codigo] += 1
|
||||
return conteo
|
||||
|
||||
@http.route('/skeen/frontend/v1/reports/propinas', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
@require_perm('propinas.ver')
|
||||
def report_propinas(self, **kw):
|
||||
"""Propinas por empleado y periodo (con desglose de reparto)."""
|
||||
try:
|
||||
cfg = get_propinas(request.env)
|
||||
start = kw.get('start') or (date.today().replace(day=1)).strftime('%Y-%m-%d')
|
||||
end = kw.get('end') or date.today().strftime('%Y-%m-%d')
|
||||
Reparto = request.env['skeen.propina.reparto'].sudo()
|
||||
domain = [('date', '>=', start), ('date', '<=', end)]
|
||||
grupos = Reparto.read_group(domain, ['employee_id', 'monto:sum'], ['employee_id'])
|
||||
rows = []
|
||||
for g in grupos:
|
||||
emp = g['employee_id']
|
||||
rows.append({
|
||||
'employee_id': emp[0] if emp else None,
|
||||
'empleado': emp[1] if emp else 'Sin asignar',
|
||||
'monto': g['monto'],
|
||||
'propinas': g.get('employee_id_count') or g.get('__count') or 0,
|
||||
})
|
||||
rows.sort(key=lambda r: -r['monto'])
|
||||
ventas = request.env['skeen.venta'].sudo().search([
|
||||
('date', '>=', start), ('date', '<=', end),
|
||||
('state', '!=', 'cancelled'), ('propina', '>', 0)])
|
||||
total_ventas = sum(v.propina for v in ventas)
|
||||
detalle = [{
|
||||
'venta_id': r.venta_id.id,
|
||||
'folio': r.venta_id.name,
|
||||
'date': r.date.strftime('%Y-%m-%d') if r.date else None,
|
||||
'empleado': r.employee_id.name if r.employee_id else '',
|
||||
'employee_id': r.employee_id.id if r.employee_id else None,
|
||||
'rol': r.rol,
|
||||
'monto': r.monto,
|
||||
'moneda': r.moneda or 'MXN',
|
||||
'cuenta': r.cuenta or '',
|
||||
} for r in Reparto.search(domain, order='date desc, id desc', limit=500)]
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'start': start,
|
||||
'end': end,
|
||||
'activo': bool(cfg.get('activo')),
|
||||
'reparto': cfg.get('reparto'),
|
||||
'por_empleado': rows,
|
||||
'detalle': detalle,
|
||||
'total': total_ventas,
|
||||
'total_repartido': sum(r['monto'] for r in rows),
|
||||
'total_ventas': len(ventas),
|
||||
'cuenta_propinas': cfg.get('cuenta_propinas') or '',
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_perm('ventas.cobrar')
|
||||
@@ -5098,6 +5356,11 @@ class SkeenFrontendController(http.Controller):
|
||||
'cancel_points_restored': v.cancel_points_restored,
|
||||
'payment_method': v.payment_method or '',
|
||||
'user_login': v.user_login or '',
|
||||
'propina': v.propina or 0.0,
|
||||
'propina_moneda': v.propina_moneda or 'MXN',
|
||||
'nivel_discount': v.nivel_discount or 0.0,
|
||||
'nivel_codigo': v.nivel_codigo or '',
|
||||
'cita_id': v.cita_id.id if v.cita_id else None,
|
||||
'lines': [{
|
||||
'id': line.id,
|
||||
'service_id': line.service_id.id if line.service_id else None,
|
||||
@@ -5249,6 +5512,17 @@ class SkeenFrontendController(http.Controller):
|
||||
tax_total += bruto * tasa / 100.0
|
||||
total_est = max(0.0, base_total - discount + tax_total)
|
||||
|
||||
# Lealtad y propinas de la instancia. Con propinas apagadas y
|
||||
# lealtad clásica, `propina=0` y `nivel_discount=0`: el POS sale
|
||||
# idéntico al de siempre.
|
||||
cfg_lea = get_lealtad(request.env)
|
||||
cfg_prop = get_propinas(request.env)
|
||||
cita_id = int(data.get('cita_id', 0) or 0)
|
||||
propina_pct = data.get('propina_pct')
|
||||
propina_monto = data.get('propina')
|
||||
propina_employee_id = int(data.get('propina_employee_id', 0) or 0)
|
||||
cobrador_employee_id = int(data.get('cobrador_employee_id', 0) or 0)
|
||||
|
||||
# Moneda y tipo de cambio: validar ANTES de crear nada
|
||||
currency = data.get('currency', 'MXN')
|
||||
rate_used = None
|
||||
@@ -5278,9 +5552,47 @@ class SkeenFrontendController(http.Controller):
|
||||
'discount': discount,
|
||||
'line_ids': line_vals,
|
||||
'user_login': cobrador,
|
||||
'cita_id': cita_id or False,
|
||||
})
|
||||
venta.action_confirm()
|
||||
|
||||
# Descuento automático del nivel VIP (0 con lealtad clásica).
|
||||
nivel_pct = venta._aplicar_descuento_nivel(partner) or 0.0
|
||||
|
||||
# Propina: solo con el módulo encendido. Se calcula sobre los
|
||||
# servicios (sin impuesto no; base = subtotal - descuentos + impuesto)
|
||||
# y se guarda APARTE del importe de los servicios.
|
||||
propina = 0.0
|
||||
propina_moneda = (currency if currency in ('MXN', 'USD') else 'MXN')
|
||||
if cfg_prop.get('activo'):
|
||||
servicios_total = max(
|
||||
0.0, venta.subtotal - venta.discount - venta.nivel_discount + venta.tax)
|
||||
if propina_pct not in (None, ''):
|
||||
try:
|
||||
pct = float(propina_pct)
|
||||
except (TypeError, ValueError):
|
||||
pct = 0.0
|
||||
if pct < 0:
|
||||
pct = 0.0
|
||||
if not cfg_prop.get('permitir_personalizado') and pct <= 0:
|
||||
pct = 0.0
|
||||
propina = round(servicios_total * pct / 100.0, 2)
|
||||
elif propina_monto not in (None, ''):
|
||||
try:
|
||||
propina = round(float(propina_monto or 0), 2)
|
||||
except (TypeError, ValueError):
|
||||
propina = 0.0
|
||||
elif not cfg_prop.get('permitir_sin_propina'):
|
||||
return json_response({'status': 'error', 'message': 'Esta clínica exige registrar propina'}, 400)
|
||||
if propina < 0:
|
||||
propina = 0.0
|
||||
if propina > 0:
|
||||
venta.write({
|
||||
'propina': propina,
|
||||
'propina_moneda': propina_moneda,
|
||||
'propina_employee_id': propina_employee_id or False,
|
||||
})
|
||||
|
||||
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
|
||||
# las compras a las líneas reales de la venta para poder revertirlas.
|
||||
compras_creadas = []
|
||||
@@ -5312,14 +5624,24 @@ class SkeenFrontendController(http.Controller):
|
||||
'notes': 'Venta POS',
|
||||
})
|
||||
|
||||
# Canje de puntos como forma de pago (hasta cubrir el total)
|
||||
# Canje de puntos como forma de pago (hasta cubrir el total).
|
||||
# Clásico: cubre el 100% del servicio (comportamiento de siempre).
|
||||
# Configurable: se respeta `canje_max_pct`. La propina nunca se
|
||||
# cubre con puntos: siempre se paga en dinero.
|
||||
puntos_usados = 0
|
||||
if pay_with_points:
|
||||
total = venta.total
|
||||
puntos = int(total) if float(total).is_integer() else int(total) + 1
|
||||
import math
|
||||
valor_punto = float(cfg_lea.get('valor_punto') or 1.0) or 1.0
|
||||
base_canje = venta.total
|
||||
if cfg_lea.get('modo') == 'configurable':
|
||||
tope = venta.total * float(cfg_lea.get('canje_max_pct') or 0.0) / 100.0
|
||||
base_canje = min(base_canje, tope)
|
||||
base_canje = min(base_canje, max(0.0, venta.total - venta.propina))
|
||||
puntos = int(math.ceil(base_canje / valor_punto - 1e-9))
|
||||
puntos = min(puntos, saldo)
|
||||
monedero.redeem_points(puntos, description=f'Canje puntos venta {venta.name}', reference=venta.name)
|
||||
venta.write({'points_redeemed': puntos, 'points_discount': float(puntos)})
|
||||
venta.write({'points_redeemed': puntos,
|
||||
'points_discount': round(puntos * valor_punto, 2)})
|
||||
puntos_usados = puntos
|
||||
|
||||
# Pago monetario del restante
|
||||
@@ -5378,6 +5700,22 @@ class SkeenFrontendController(http.Controller):
|
||||
else:
|
||||
venta.action_mark_paid()
|
||||
|
||||
# Reparto de la propina entre el personal (solo con propinas
|
||||
# activas y regla distinta de 'ninguno').
|
||||
if cfg_prop.get('activo') and (venta.propina or 0) > 0:
|
||||
Emp = request.env['hr.employee'].sudo()
|
||||
cobrador_emp = Emp.browse()
|
||||
for cand in (cobrador_employee_id, propina_employee_id):
|
||||
if cand:
|
||||
rec = Emp.browse(cand)
|
||||
if rec.exists():
|
||||
cobrador_emp = rec
|
||||
break
|
||||
venta._generar_reparto_propina(
|
||||
cfg_prop,
|
||||
cobrador_employee=cobrador_emp or None,
|
||||
cobrador_login=cobrador)
|
||||
|
||||
partner.invalidate_recordset()
|
||||
# Desglose de impuestos cobrados (con impuestos apagados: base =
|
||||
# subtotal, impuesto = 0, total = el de siempre).
|
||||
@@ -5409,8 +5747,23 @@ class SkeenFrontendController(http.Controller):
|
||||
'rate_used': rate_used,
|
||||
'recibido_usd': recibido_usd,
|
||||
'puntos_usados': puntos_usados,
|
||||
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
|
||||
'puntos_ganados': venta._wallet_points_expected() if venta.state == 'paid' else 0,
|
||||
'wallet_points': partner.wallet_points,
|
||||
'propina': venta.propina or 0.0,
|
||||
'propina_moneda': venta.propina_moneda or 'MXN',
|
||||
'nivel_discount': venta.nivel_discount or 0.0,
|
||||
'nivel': ({
|
||||
'codigo': partner.lealtad_nivel_codigo or '',
|
||||
'nombre': partner.lealtad_nivel_nombre or '',
|
||||
'descuento_pct': partner.lealtad_descuento_pct or 0.0,
|
||||
} if cfg_lea.get('modo') == 'configurable' and partner.lealtad_nivel_codigo else None),
|
||||
'reparto_propina': [{
|
||||
'employee_id': r.employee_id.id if r.employee_id else None,
|
||||
'empleado': r.employee_id.name if r.employee_id else '',
|
||||
'rol': r.rol,
|
||||
'monto': r.monto,
|
||||
'moneda': r.moneda or 'MXN',
|
||||
} for r in venta.reparto_ids],
|
||||
'stock_warnings': stock_warnings,
|
||||
'packages': [{
|
||||
'id': c.id,
|
||||
@@ -5691,6 +6044,8 @@ class SkeenFrontendController(http.Controller):
|
||||
'total_sales': sum(v.total for v in ventas),
|
||||
'total_paid': sum(v.amount_paid for v in ventas),
|
||||
'total_due': sum(v.amount_due for v in ventas),
|
||||
'total_propinas': sum(v.propina or 0.0 for v in ventas),
|
||||
'total_servicios': sum(v.total - (v.propina or 0.0) for v in ventas),
|
||||
'count': len(ventas),
|
||||
'by_day': [{'date': k, 'total': by_day_map[k]} for k in sorted(by_day_map)],
|
||||
})
|
||||
@@ -5778,6 +6133,8 @@ class SkeenFrontendController(http.Controller):
|
||||
'total': sum(v.total for v in ventas),
|
||||
'total_paid': sum(v.amount_paid for v in ventas),
|
||||
'total_due': sum(v.amount_due for v in ventas),
|
||||
'total_propinas': sum(v.propina or 0.0 for v in ventas),
|
||||
'total_servicios': sum(v.total - (v.propina or 0.0) for v in ventas),
|
||||
},
|
||||
'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
|
||||
'inventario': {'total': len(moves), 'by_type': moves_by_type},
|
||||
@@ -6366,6 +6723,8 @@ class SkeenFrontendController(http.Controller):
|
||||
'total': sum(v.total for v in ventas),
|
||||
'total_paid': sum(v.amount_paid for v in ventas),
|
||||
'total_due': sum(v.amount_due for v in ventas),
|
||||
'total_propinas': sum(v.propina or 0.0 for v in ventas),
|
||||
'total_servicios': sum(v.total - (v.propina or 0.0) for v in ventas),
|
||||
},
|
||||
'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
|
||||
'devoluciones': {'count': len(devoluciones), 'total': sum(v.refund_amount for v in devoluciones)},
|
||||
@@ -6561,12 +6920,19 @@ class SkeenFrontendController(http.Controller):
|
||||
for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']):
|
||||
by_type[g['type']] = g['points']
|
||||
monederos = request.env['skeen.monedero'].sudo().search([])
|
||||
activos = sum(m.points for m in monederos)
|
||||
# El valor del punto sale de la lealtad; en modo clásico es 1.0, así
|
||||
# que `equivalente_mxn` conserva la cifra de siempre.
|
||||
valor = float(get_lealtad(request.env).get('valor_punto') or 1.0)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'by_type': by_type,
|
||||
'puntos_activos': sum(m.points for m in monederos),
|
||||
'puntos_activos': activos,
|
||||
'cuentas': len(monederos),
|
||||
'equivalente_mxn': sum(m.points for m in monederos) * 1.0,
|
||||
'equivalente_mxn': activos * valor,
|
||||
'valor_punto': valor,
|
||||
'expirados': abs(by_type.get('expiration', 0)),
|
||||
'pasivo_mxn': round(activos * valor, 2),
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@@ -123,6 +123,17 @@ PERMISSION_GROUPS = [
|
||||
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
|
||||
('comisiones.liquidar', 'Generar, ajustar y pagar liquidaciones'),
|
||||
]),
|
||||
('lealtad', 'Lealtad y puntos', [
|
||||
('lealtad.ver', 'Ver saldo, nivel y reportes de lealtad'),
|
||||
('lealtad.ajustar', 'Ajustar puntos a mano y recalcular el nivel'),
|
||||
('lealtad.nivel', 'Asignar el nivel VIP a mano'),
|
||||
('lealtad.configurar', 'Configurar el programa de lealtad y sus niveles'),
|
||||
]),
|
||||
('propinas', 'Propinas', [
|
||||
('propinas.ver', 'Ver el reporte de propinas'),
|
||||
('propinas.aplicar', 'Aplicar propinas en el punto de venta'),
|
||||
('propinas.configurar', 'Configurar propinas y su reparto'),
|
||||
]),
|
||||
('cobros', 'Cobros y enlaces de pago', [
|
||||
('cobros.ver', 'Ver enlaces de cobro pendientes y pagados'),
|
||||
('cobros.administrar', 'Administrar enlaces de cobro'),
|
||||
@@ -167,6 +178,8 @@ READ_PERM_KEYS = (
|
||||
'proveedores.ver',
|
||||
'comisiones.ver',
|
||||
'cobros.ver',
|
||||
'lealtad.ver',
|
||||
'propinas.ver',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -289,6 +302,19 @@ DEFAULT_PERM_ROLES = {
|
||||
# Cobros y enlaces de pago. Ver es de todos; administrar (crear/cancelar) es
|
||||
# de admin/gerencia; forzar el cobro (registrar un pago sin pasarela) lo
|
||||
# hace recepción/gerencia/admin; cancelar retenciones, admin/gerencia.
|
||||
# Lealtad y puntos. Ver es de todos; ajustar puntos (equivalen a dinero) y
|
||||
# asignar el nivel a mano es de admin/gerencia; configurar el programa
|
||||
# (reglas, niveles) también.
|
||||
'lealtad.ver': ALL,
|
||||
'lealtad.ajustar': AG,
|
||||
'lealtad.nivel': AG,
|
||||
'lealtad.configurar': AG,
|
||||
# Propinas. Ver el reporte es de todos; aplicar la propina al cobrar lo
|
||||
# hace quien cobra (recepción); configurar el módulo y el reparto es de
|
||||
# admin/gerencia.
|
||||
'propinas.ver': ALL,
|
||||
'propinas.aplicar': MONEY,
|
||||
'propinas.configurar': AG,
|
||||
'cobros.ver': ALL,
|
||||
'cobros.administrar': AG,
|
||||
'cobros.forzar': ('admin', 'recepcion', 'gerencia'),
|
||||
|
||||
Reference in New Issue
Block a user