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

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

View File

@@ -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)