fase 2.6: membresias y suscripciones (modulo skeen_membresias: planes recurrentes con sesiones incluidas o ilimitadas, alta/cobro/renovacion/congelamiento/cancelacion, consumo de sesiones desde POS y expediente, cron de vencimientos y avisos, reporte de ingresos recurrentes); apagado por defecto

This commit is contained in:
2026-10-11 10:49:44 +00:00
parent 9286ab8ee2
commit 8ff13b9c2a
33 changed files with 3873 additions and 22 deletions

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Membresías y Suscripciones',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Membresías recurrentes: planes, cobro periódico, sesiones incluidas, congelamiento y reportes',
'description': """
Membresías y suscripciones para cualquier giro (spa, clínica, estética).
Una membresía es un plan recurrente con vigencia (semanal..anual),
sesiones incluidas por periodo, cobro recurrente, congelamiento y
renovación. NO es un paquete: el paquete es prepago de N sesiones.
El módulo está APAGADO por defecto (`membresias.activo=false`): no
aparece en el menú, el POS no cambia, no corre ningún cron y no se
genera ningún cargo hasta que la clínica lo encienda.
Los cobros se registran como ventas y pagos normales (`skeen.venta`)
para que los cortes de caja, el concentrado y las comisiones los vean
como cualquier otra operación.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas', 'skeen_whatsapp'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
'views/membresia_views.xml',
'data/membresias_cron.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import membresias

View File

@@ -0,0 +1,717 @@
# -*- coding: utf-8 -*-
"""API del frontend React para Membresías y suscripciones.
Vive en su propio controlador (no en `skeen_whatsapp/controllers/frontend.py`)
para no chocar con otros trabajos. Todas las rutas cuelgan de
`/skeen/frontend/v1/membresias` y exigen token (`@require_role`) más el permiso
por acción (`@require_perm`).
Regla de oro: con `membresias.activo=False` TODAS las rutas responden 403 y no
tocan nada, así el despliegue en una instancia apagada no cambia ningún
comportamiento.
"""
import functools
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from odoo import fields, http
from odoo.exceptions import ValidationError
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_perm, require_role
from odoo.addons.skeen_citas.clinic_settings import get_membresias
BASE = '/skeen/frontend/v1/membresias'
# Semanas por mes para normalizar el precio de un plan a "ingreso mensual
# estimado" (MRR). Es una estimación de dirección, no contabilidad.
MESES_POR_PERIODICIDAD = {
'semanal': 52.0 / 12.0,
'mensual': 1.0,
'trimestral': 1.0 / 3.0,
'semestral': 1.0 / 6.0,
'anual': 1.0 / 12.0,
}
ESTADOS = ('activa', 'congelada', 'vencida', 'pausada', 'cancelada')
ESTADO_LABEL = {
'activa': 'Activa', 'congelada': 'Congelada', 'vencida': 'Vencida',
'pausada': 'Pausada', 'cancelada': 'Cancelada',
}
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _cfg():
return get_membresias(request.env)
def _activo():
return bool(_cfg().get('activo'))
def membresias_activas(func):
"""Niega la operación si la instancia no tiene el módulo encendido."""
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not _activo():
return _json({'status': 'error',
'message': 'Membresías no está activado en esta clínica.'}, 403)
return func(self, *args, **kw)
return wrapper
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
class SkeenMembresiasController(http.Controller):
# ------------------------------------------------------------------
# Serializadores
# ------------------------------------------------------------------
def _plan_to_dict(self, p):
return {
'id': p.id,
'name': p.name,
'descripcion': p.descripcion or '',
'precio': p.precio,
'moneda': p.moneda,
'periodicidad': p.periodicidad,
'sesiones_incluidas': p.sesiones_incluidas,
'servicios_incluidos': [{'id': s.id, 'name': s.name}
for s in p.servicios_incluidos],
'categorias_incluidas': [{'id': c.id, 'name': c.name, 'code': c.code}
for c in p.categorias_incluidas],
'descuento_porcentaje': p.descuento_porcentaje,
'cargo_inscripcion': p.cargo_inscripcion,
'meses_minimos': p.meses_minimos,
'permitir_congelamiento': p.permitir_congelamiento,
'activo': p.activo,
'sequence': p.sequence,
'sucursales': [{'id': s.id, 'name': s.name} for s in p.sucursales],
'servicio_id': p.servicio_cobro().id if p.servicio_cobro() else None,
'servicio': p.servicio_cobro().name if p.servicio_cobro() else '',
'notas': p.notas or '',
'membresias_count': p.membresias_count,
}
def _membresia_to_dict(self, m):
return {
'id': m.id,
'folio': m.folio or '',
'partner_id': m.partner_id.id,
'patient': m.partner_id.name or '',
'phone': m.partner_id.phone or '',
'plan_id': m.plan_id.id if m.plan_id else None,
'plan': m.plan_id.name if m.plan_id else '',
'periodicidad': m.plan_id.periodicidad if m.plan_id else '',
'precio': m.plan_id.precio if m.plan_id else 0.0,
'moneda': m.plan_id.moneda if m.plan_id else 'MXN',
'estado': m.estado,
'estado_label': ESTADO_LABEL.get(m.estado, m.estado),
'fecha_inicio': m.fecha_inicio.strftime('%Y-%m-%d') if m.fecha_inicio else None,
'fecha_fin': m.fecha_fin.strftime('%Y-%m-%d') if m.fecha_fin else None,
'periodo_actual_inicio': m.periodo_actual_inicio.strftime('%Y-%m-%d')
if m.periodo_actual_inicio else None,
'periodo_actual_fin': m.periodo_actual_fin.strftime('%Y-%m-%d')
if m.periodo_actual_fin else None,
'proximo_cobro': m.proximo_cobro.strftime('%Y-%m-%d') if m.proximo_cobro else None,
'sesiones_incluidas_periodo': m.sesiones_incluidas_periodo,
'sesiones_usadas_periodo': m.sesiones_usadas_periodo,
'sesiones_restantes': m.sesiones_restantes,
'ilimitadas': m.ilimitadas,
'renovacion_automatica': m.renovacion_automatica,
'descuento_porcentaje': m.descuento_porcentaje,
'congelada_desde': m.congelada_desde.strftime('%Y-%m-%d') if m.congelada_desde else None,
'congelada_hasta': m.congelada_hasta.strftime('%Y-%m-%d') if m.congelada_hasta else None,
'dias_congelados_anio': m.dias_congelados_anio,
'motivo_cancelacion': m.motivo_cancelacion or '',
'notas': m.notas or '',
'adeudo': m.adeudo,
'periodos_pagados': m.periodos_pagados,
'dias_para_cobro': m.dias_para_cobro,
'vigente': m.vigente,
'servicios_incluidos': [
{'id': s.id, 'name': s.name} for s in m.plan_id.servicios_incluidos
] if m.plan_id else [],
}
def _periodo_to_dict(self, p):
return {
'id': p.id,
'tipo': p.tipo,
'periodo_inicio': p.periodo_inicio.strftime('%Y-%m-%d') if p.periodo_inicio else None,
'periodo_fin': p.periodo_fin.strftime('%Y-%m-%d') if p.periodo_fin else None,
'monto': p.monto,
'moneda': p.moneda,
'estado': p.estado,
'venta_id': p.venta_id.id if p.venta_id else None,
'venta': p.venta_id.name if p.venta_id else '',
'fecha_pago': p.fecha_pago.strftime('%Y-%m-%d %H:%M') if p.fecha_pago else None,
'notas': p.notas or '',
}
def _consumo_to_dict(self, c):
return {
'id': c.id,
'fecha': c.fecha.strftime('%Y-%m-%d %H:%M') if c.fecha else None,
'cita_id': c.cita_id.id if c.cita_id else None,
'venta_id': c.venta_id.id if c.venta_id else None,
'servicio_id': c.servicio_id.id if c.servicio_id else None,
'servicio': c.servicio_id.name if c.servicio_id else '',
'sesiones': c.sesiones,
'usuario': c.usuario or '',
'notas': c.notas or '',
'state': c.state,
}
def _membresia(self, mid):
m = request.env['skeen.membresia'].sudo().browse(int(mid))
return m if m.exists() else None
def _rango_kw(self, kw):
inicio = fields.Date.to_date(kw.get('start')) or (fields.Date.today() - timedelta(days=30))
fin = fields.Date.to_date(kw.get('end')) or fields.Date.today()
return inicio, fin
# ==================================================================
# Planes
# ==================================================================
@http.route(BASE + '/planes', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def list_planes(self, **kw):
try:
domain = []
if not (kw.get('include_inactive') or '').strip():
domain.append(('activo', '=', True))
if kw.get('q'):
domain += ['|', ('name', 'ilike', kw['q']),
('descripcion', 'ilike', kw['q'])]
Plan = request.env['skeen.membresia.plan'].sudo()
planes = Plan.search(domain, order='sequence asc, name asc')
return _json({'status': 'success',
'planes': [self._plan_to_dict(p) for p in planes]})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/planes', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.editar')
@membresias_activas
def create_plan(self, **kw):
try:
data = _body()
nombre = (data.get('name') or '').strip()
if not nombre:
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
if request.env['skeen.membresia.plan'].sudo().search_count(
[('name', '=ilike', nombre)]):
return _json({'status': 'error',
'message': 'Ya existe un plan "%s".' % nombre}, 409)
vals = self._plan_vals(data, crear=True)
try:
plan = request.env['skeen.membresia.plan'].sudo().create(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'plan': self._plan_to_dict(plan)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _plan_vals(self, data, crear=False):
vals = {}
if crear:
vals['name'] = (data.get('name') or '').strip()
if 'name' in data and not crear:
vals['name'] = (data.get('name') or '').strip()
for key in ('descripcion', 'notas'):
if key in data:
vals[key] = data.get(key) or ''
for key in ('precio', 'descuento_porcentaje', 'cargo_inscripcion'):
if key in data:
vals[key] = float(data.get(key) or 0.0)
for key in ('sesiones_incluidas', 'meses_minimos', 'sequence'):
if key in data:
vals[key] = int(data.get(key) or 0)
for key in ('permitir_congelamiento', 'activo'):
if key in data:
vals[key] = bool(data.get(key))
if 'moneda' in data:
if data['moneda'] not in ('MXN', 'USD'):
raise ValidationError('La moneda debe ser MXN o USD')
vals['moneda'] = data['moneda']
if 'periodicidad' in data:
if data['periodicidad'] not in ('semanal', 'mensual', 'trimestral',
'semestral', 'anual'):
raise ValidationError('Periodicidad inválida')
vals['periodicidad'] = data['periodicidad']
if 'servicio_id' in data:
vals['servicio_id'] = int(data['servicio_id']) if data['servicio_id'] else False
for key, model in (('servicios_incluidos', 'skeen.servicio'),
('categorias_incluidas', 'skeen.service.category'),
('sucursales', 'skeen.sucursal')):
if key in data:
ids = [int(i) for i in (data.get(key) or [])]
vals[key] = [(6, 0, ids)]
return vals
@http.route(BASE + '/planes/<int:plan_id>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.editar')
@membresias_activas
def update_plan(self, plan_id, **kw):
try:
plan = request.env['skeen.membresia.plan'].sudo().browse(plan_id)
if not plan.exists():
return _json({'status': 'error', 'message': 'Plan no encontrado'}, 404)
try:
vals = self._plan_vals(_body(), crear=False)
plan.write(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'plan': self._plan_to_dict(plan)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/planes/<int:plan_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.editar')
@membresias_activas
def delete_plan(self, plan_id, **kw):
try:
plan = request.env['skeen.membresia.plan'].sudo().browse(plan_id)
if not plan.exists():
return _json({'status': 'error', 'message': 'Plan no encontrado'}, 404)
if plan.membresia_ids:
return _json({
'status': 'error',
'message': 'El plan ya tiene membresías: desactívalo en lugar '
'de borrarlo.',
}, 409)
plan.unlink()
return _json({'status': 'success', 'message': 'Plan eliminado'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Membresías
# ==================================================================
@http.route(BASE, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def list_membresias(self, **kw):
try:
domain = []
if kw.get('estado'):
if kw['estado'] not in ESTADOS:
return _json({'status': 'error', 'message': 'Estado inválido'}, 400)
domain.append(('estado', '=', kw['estado']))
if kw.get('plan_id'):
domain.append(('plan_id', '=', int(kw['plan_id'])))
if kw.get('partner_id'):
domain.append(('partner_id', '=', int(kw['partner_id'])))
if (kw.get('por_vencer') or '').strip() in ('1', 'true', 'True'):
ventanas = _cfg().get('dias_aviso_renovacion') or [7]
limite = fields.Date.today() + timedelta(days=max(ventanas))
domain += [('estado', 'in', ('activa', 'congelada')),
('proximo_cobro', '!=', False),
('proximo_cobro', '<=', limite)]
if kw.get('q'):
q = kw['q']
domain += ['|', '|', ('folio', 'ilike', q),
('partner_id.name', 'ilike', q),
('plan_id.name', 'ilike', q)]
Membresia = request.env['skeen.membresia'].sudo()
total = Membresia.search_count(domain)
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
rows = Membresia.search(domain, order='create_date desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
return _json({
'status': 'success',
'membresias': [self._membresia_to_dict(m) for m in rows],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'resumen': self._resumen(),
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _resumen(self):
Membresia = request.env['skeen.membresia'].sudo()
hoy = fields.Date.today()
ventanas = _cfg().get('dias_aviso_renovacion') or [7]
limite = hoy + timedelta(days=max(ventanas))
activas = Membresia.search([('estado', 'in', ('activa', 'congelada'))])
por_vencer = activas.filtered(
lambda m: m.proximo_cobro and hoy <= m.proximo_cobro <= limite)
vencidas = Membresia.search_count([('estado', '=', 'vencida')])
mrr = 0.0
for m in activas:
if m.plan_id:
mrr += (m.plan_id.precio or 0.0) * MESES_POR_PERIODICIDAD.get(
m.plan_id.periodicidad, 1.0)
return {
'activas': len(activas),
'por_vencer': len(por_vencer),
'vencidas': vencidas,
'mrr_estimado': round(mrr, 2),
}
@http.route(BASE + '/por_paciente', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def membresias_por_paciente(self, **kw):
try:
partner_id = int(kw.get('partner_id') or 0)
if not partner_id:
return _json({'status': 'error', 'message': 'Falta partner_id'}, 400)
rows = request.env['skeen.membresia'].sudo().search([
('partner_id', '=', partner_id),
('estado', '=', 'activa'),
], order='create_date desc')
return _json({'status': 'success',
'membresias': [self._membresia_to_dict(m) for m in rows]})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def membrecia_detalle(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
return _json({
'status': 'success',
'membresia': self._membresia_to_dict(m),
'periodos': [self._periodo_to_dict(p)
for p in m.periodo_ids.sorted('periodo_inicio')],
'consumos': [self._consumo_to_dict(c) for c in m.consumo_ids],
'ventas': [{
'id': v.id, 'name': v.name,
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
'total': v.total, 'state': v.state,
'payment_method': v.payment_method or '',
} for v in m.venta_ids.sorted('date')],
'saldos': {
'adeudo': m.adeudo,
'sesiones_restantes': m.sesiones_restantes,
'ilimitadas': m.ilimitadas,
'periodos_pagados': m.periodos_pagados,
},
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE, type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.crear')
@membresias_activas
def crear_membresia(self, **kw):
try:
data = _body()
partner = request.env['res.partner'].sudo().browse(
int(data.get('partner_id') or 0))
if not partner.exists():
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
plan = request.env['skeen.membresia.plan'].sudo().browse(
int(data.get('plan_id') or 0))
if not plan.exists() or not plan.activo:
return _json({'status': 'error', 'message': 'Plan no encontrado o inactivo'}, 404)
vals = {
'partner_id': partner.id,
'plan_id': plan.id,
'fecha_inicio': fields.Date.to_date(data.get('fecha_inicio'))
or fields.Date.today(),
'notas': data.get('notas') or '',
}
if data.get('renovacion_automatica') is not None:
vals['renovacion_automatica'] = bool(data['renovacion_automatica'])
if data.get('descuento_porcentaje') is not None:
vals['descuento_porcentaje'] = float(data['descuento_porcentaje'] or 0.0)
try:
m = request.env['skeen.membresia'].sudo().create(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m),
'inscripcion_cargo': plan.cargo_inscripcion}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/congelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.cancelar')
@membresias_activas
def congelar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
m.action_congelar(data.get('desde'), data.get('hasta'),
data.get('motivo') or '', login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/descongelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.cancelar')
@membresias_activas
def descongelar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
try:
m.action_descongelar(login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/renovar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.cobrar')
@membresias_activas
def renovar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
periodo = m.action_renovar(generar_cargo=data.get('generar_cargo'),
login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m),
'periodo': self._periodo_to_dict(periodo) if periodo else None})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/cancelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.cancelar')
@membresias_activas
def cancelar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
m.action_cancelar(data.get('motivo'), login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/cobrar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.cobrar')
@membresias_activas
def cobrar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
venta, pago, periodos = m.action_cobrar(
metodo=data.get('metodo') or 'cash',
monto=data.get('monto'),
moneda=data.get('moneda'),
cobrar_inscripcion=bool(data.get('cobrar_inscripcion')),
login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({
'status': 'success',
'membresia': self._membresia_to_dict(m),
'venta': {'id': venta.id, 'name': venta.name, 'total': venta.total,
'state': venta.state},
'pago': {'id': pago.id, 'name': pago.name, 'amount': pago.amount,
'payment_method': pago.payment_method},
'periodos': [self._periodo_to_dict(p) for p in periodos],
}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/consumir', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.cobrar')
@membresias_activas
def consumir(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
consumo = m.action_consumir(
sesiones=data.get('sesiones') or 1,
cita_id=data.get('cita_id') or False,
venta_id=data.get('venta_id') or False,
servicio_id=data.get('servicio_id') or False,
notas=data.get('notas') or '',
login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'consumo': self._consumo_to_dict(consumo),
'membresia': self._membresia_to_dict(m)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Reporte de membresías
# ==================================================================
@http.route(BASE + '/reporte', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def reporte(self, **kw):
try:
inicio, fin = self._rango_kw(kw)
Membresia = request.env['skeen.membresia'].sudo()
altas = Membresia.search_count([
('fecha_inicio', '>=', inicio), ('fecha_inicio', '<=', fin)])
cancelaciones = Membresia.search_count([
('estado', '=', 'cancelada'),
('fecha_cancelacion', '>=', datetime.combine(inicio, datetime.min.time())),
('fecha_cancelacion', '<=', datetime.combine(fin, datetime.max.time())),
])
periodos = request.env['skeen.membresia.periodo'].sudo().search([
('estado', '=', 'pagado'),
('fecha_pago', '>=', datetime.combine(inicio, datetime.min.time())),
('fecha_pago', '<=', datetime.combine(fin, datetime.max.time())),
])
por_mes = defaultdict(float)
recurrentes = defaultdict(float)
for p in periodos:
clave = p.fecha_pago.strftime('%Y-%m')
por_mes[clave] += p.monto
if p.tipo == 'periodo':
recurrentes[clave] += p.monto
ingresos = [{'mes': k, 'total': round(v, 2)}
for k, v in sorted(por_mes.items())]
recurrentes_mes = [{'mes': k, 'total': round(v, 2)}
for k, v in sorted(recurrentes.items())]
hoy = fields.Date.today()
proximas = Membresia.search([
('estado', 'in', ('activa', 'congelada')),
('proximo_cobro', '>=', hoy),
('proximo_cobro', '<=', hoy + timedelta(days=60)),
], order='proximo_cobro asc', limit=50)
consumos = request.env['skeen.membresia.consumo'].sudo().search([
('state', '=', 'consumed'),
('fecha', '>=', datetime.combine(inicio, datetime.min.time())),
('fecha', '<=', datetime.combine(fin, datetime.max.time())),
])
activas = Membresia.search([('estado', 'in', ('activa', 'congelada'))])
incluidas = sum((m.sesiones_incluidas_periodo or 0) for m in activas
if not m.ilimitadas)
return _json({
'status': 'success',
'start': inicio.strftime('%Y-%m-%d'),
'end': fin.strftime('%Y-%m-%d'),
'altas': altas,
'cancelaciones': cancelaciones,
'ingresos_por_mes': ingresos,
'recurrentes_por_mes': recurrentes_mes,
'total_ingresos': round(sum(por_mes.values()), 2),
'total_recurrente': round(sum(recurrentes.values()), 2),
'proximas_renovaciones': [
{'id': m.id, 'folio': m.folio, 'patient': m.partner_id.name,
'plan': m.plan_id.name, 'proximo_cobro':
m.proximo_cobro.strftime('%Y-%m-%d') if m.proximo_cobro else None,
'precio': m.plan_id.precio, 'moneda': m.plan_id.moneda}
for m in proximas],
'sesiones_consumidas': sum(c.sesiones for c in consumos),
'sesiones_incluidas_activas': incluidas,
'membresias_activas': len(activas),
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Motor de membresías. Con `membresias.activo=False` (default) el
método regresa sin tocar nada: cero cargos, cero avisos. Corre a
diario: marca vencidas (respetando dias_gracia), encola avisos de
renovación (idempotentes por periodo), genera el cargo del nuevo
periodo para membresías de renovación automática y descongela las
membresías cuyo plazo de congelamiento terminó. -->
<record id="cron_membresias" model="ir.cron">
<field name="name">SKEEN Membresías: vencimientos, avisos y cargos recurrentes</field>
<field name="model_id" ref="model_skeen_membresia_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</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>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Folio de membresía: MEM-0001, MEM-0002, ... -->
<record id="seq_skeen_membresia" model="ir.sequence">
<field name="name">Membresía SKEEN</field>
<field name="code">skeen.membresia</field>
<field name="prefix">MEM-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import membresia
from . import venta

View File

@@ -0,0 +1,806 @@
# -*- coding: utf-8 -*-
"""Membresías y suscripciones.
Una MEMBRESÍA no es un paquete. El paquete (`skeen.paquete.compra`) es prepago
de N sesiones de un servicio a precio cerrado. La membresía es un plan
recurrente con vigencia (semanal..anual), sesiones incluidas por periodo, cobro
recurrente, congelamiento y renovación.
Modelos:
skeen.membresia.plan catálogo de planes
skeen.membresia la suscripción de un paciente (folio MEM-)
skeen.membresia.periodo cada periodo cobrado / por cobrar (ingreso recurrente)
skeen.membresia.consumo cada sesión usada (cita o venta)
Regla de oro: con `membresias.activo=False` (default de la instancia) el cron
no hace nada y no se genera ningún cargo ni aviso.
"""
import logging
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from markupsafe import Markup
from odoo.addons.skeen_citas.clinic_settings import get_membresias
_logger = logging.getLogger(__name__)
PERIODICIDADES = [
('semanal', 'Semanal'),
('mensual', 'Mensual'),
('trimestral', 'Trimestral'),
('semestral', 'Semestral'),
('anual', 'Anual'),
]
# Duración de cada periodo. Se usa relativedelta para que los meses naturales
# coincidan (un mes desde el 31 de enero cae el 28/29 de febrero).
PERIODO_DELTA = {
'semanal': relativedelta(weeks=1),
'mensual': relativedelta(months=1),
'trimestral': relativedelta(months=3),
'semestral': relativedelta(months=6),
'anual': relativedelta(years=1),
}
ESTADOS_MEMBRESIA = [
('activa', 'Activa'),
('congelada', 'Congelada'),
('vencida', 'Vencida'),
('pausada', 'Pausada'),
('cancelada', 'Cancelada'),
]
ESTADOS_PERIODO = [
('pendiente', 'Pendiente'),
('pagado', 'Pagado'),
('vencido', 'Vencido'),
('condonado', 'Condonado'),
]
MONEDAS = [('MXN', 'MXN'), ('USD', 'USD')]
def _delta_periodo(periodicidad):
return PERIODO_DELTA.get(periodicidad or 'mensual', PERIODO_DELTA['mensual'])
def _money(v):
return '$%s' % format(round(float(v or 0.0), 2), ',.2f')
class SkeenMembresiaPlan(models.Model):
_name = 'skeen.membresia.plan'
_description = 'Plan de Membresía'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True)
descripcion = fields.Text(string='Descripción')
precio = fields.Float(string='Precio por periodo', required=True, default=0.0)
moneda = fields.Selection(MONEDAS, string='Moneda', default='MXN', required=True)
periodicidad = fields.Selection(PERIODICIDADES, string='Periodicidad',
default='mensual', required=True)
sesiones_incluidas = fields.Integer(
string='Sesiones incluidas', default=0,
help='Sesiones incluidas por periodo. 0 = ilimitadas.')
servicios_incluidos = fields.Many2many(
'skeen.servicio', 'skeen_mem_plan_servicio_rel', 'plan_id', 'servicio_id',
string='Servicios incluidos')
categorias_incluidas = fields.Many2many(
'skeen.service.category', 'skeen_mem_plan_categoria_rel', 'plan_id',
'categoria_id', string='Categorías incluidas',
help='Los servicios de estas categorías cuentan como incluidos.')
descuento_porcentaje = fields.Float(
string='Descuento en servicios no incluidos (%)', default=0.0)
cargo_inscripcion = fields.Float(string='Cargo de inscripción', default=0.0)
meses_minimos = fields.Integer(string='Meses mínimos', default=0,
help='Compromiso mínimo. 0 = sin mínimo.')
permitir_congelamiento = fields.Boolean(string='Permite congelamiento', default=True)
activo = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)
sucursales = fields.Many2many(
'skeen.sucursal', 'skeen_mem_plan_sucursal_rel', 'plan_id', 'sucursal_id',
string='Sucursales')
servicio_id = fields.Many2one(
'skeen.servicio', string='Servicio de cobro',
help='Servicio con el que se registra la venta del plan (los cortes y '
'reportes lo usan). Si se deja vacío se toma el primer servicio '
'incluido.')
notas = fields.Text(string='Notas')
membresia_ids = fields.One2many('skeen.membresia', 'plan_id', string='Membresías')
membresias_count = fields.Integer(string='Membresías activas',
compute='_compute_membresias_count')
_sql_constraints = [
('name_uniq', 'unique(name)', 'Ya existe un plan con ese nombre.'),
]
@api.depends('membresia_ids.estado')
def _compute_membresias_count(self):
for plan in self:
plan.membresias_count = len(
plan.membresia_ids.filtered(lambda m: m.estado in ('activa', 'congelada')))
@api.constrains('precio', 'sesiones_incluidas', 'descuento_porcentaje',
'cargo_inscripcion', 'meses_minimos')
def _check_positivos(self):
for plan in self:
if plan.precio < 0:
raise ValidationError(_('El precio del plan no puede ser negativo.'))
if plan.sesiones_incluidas < 0:
raise ValidationError(_('Las sesiones incluidas no pueden ser negativas.'))
if not (0 <= plan.descuento_porcentaje <= 100):
raise ValidationError(_('El descuento debe estar entre 0 y 100.'))
if plan.cargo_inscripcion < 0:
raise ValidationError(_('El cargo de inscripción no puede ser negativo.'))
if plan.meses_minimos < 0:
raise ValidationError(_('Los meses mínimos no pueden ser negativos.'))
def servicio_cobro(self):
"""Servicio con el que se factura el plan (o None).
Orden: `servicio_id` -> primer servicio incluido -> None. La venta de
una membresía necesita un servicio en la línea (regla de skeen.venta.line).
"""
self.ensure_one()
return self.servicio_id or self.servicios_incluidos[:1]
def incluye_servicio(self, servicio):
"""True si el servicio está incluido en el plan.
Se considera incluido si está en `servicios_incluidos` o si su categoría
(`skeen.servicio.category`, que es un código) coincide con el código de
alguna `categorias_incluidas`. Sin ninguna configurada, NO incluye nada
(plan solo de descuento).
"""
self.ensure_one()
if not servicio:
return False
if servicio in self.servicios_incluidos:
return True
codigos = set(self.categorias_incluidas.mapped('code'))
return bool(codigos and servicio.category in codigos)
def name_get(self):
return [(p.id, p.name) for p in self]
def unlink(self):
"""No se borra un plan con membresías: se desactiva."""
for plan in self:
if self.env['skeen.membresia'].sudo().search_count([('plan_id', '=', plan.id)]):
raise ValidationError(_(
'El plan "%s" ya tiene membresías y no se puede borrar. '
'Desactívalo para dejar de ofrecerlo.', plan.name))
return super().unlink()
class SkeenMembresia(models.Model):
_name = 'skeen.membresia'
_description = 'Membresía de Paciente'
_rec_name = 'folio'
_order = 'create_date desc, id desc'
_inherit = ['mail.thread']
folio = fields.Char(string='Folio', readonly=True, copy=False, index=True)
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
domain=[('is_patient', '=', True)], index=True)
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono',
readonly=True)
plan_id = fields.Many2one('skeen.membresia.plan', string='Plan', required=True,
ondelete='restrict')
estado = fields.Selection(ESTADOS_MEMBRESIA, string='Estado', default='activa',
required=True, tracking=True, index=True)
fecha_inicio = fields.Date(string='Inicio', required=True,
default=fields.Date.today)
fecha_fin = fields.Date(string='Fin compromiso')
periodo_actual_inicio = fields.Date(string='Periodo actual · inicio')
periodo_actual_fin = fields.Date(string='Periodo actual · fin')
proximo_cobro = fields.Date(string='Próximo cobro', index=True)
sesiones_incluidas_periodo = fields.Integer(string='Sesiones incluidas / periodo',
default=0)
sesiones_usadas_periodo = fields.Integer(string='Sesiones usadas / periodo',
default=0)
renovacion_automatica = fields.Boolean(string='Renovación automática', default=False)
descuento_porcentaje = fields.Float(string='Descuento en no incluidos (%)', default=0.0)
congelada_desde = fields.Date(string='Congelada desde')
congelada_hasta = fields.Date(string='Congelada hasta')
dias_congelados_anio = fields.Integer(string='Días congelados (año)', default=0)
motivo_cancelacion = fields.Text(string='Motivo de cancelación')
fecha_cancelacion = fields.Datetime(string='Cancelada el', readonly=True, copy=False)
notas = fields.Text(string='Notas')
venta_ids = fields.One2many('skeen.venta', 'membresia_id', string='Ventas')
periodo_ids = fields.One2many('skeen.membresia.periodo', 'membresia_id',
string='Periodos')
consumo_ids = fields.One2many('skeen.membresia.consumo', 'membresia_id',
string='Consumos')
# Derivados
ilimitadas = fields.Boolean(string='Sesiones ilimitadas',
compute='_compute_sesiones', store=True)
sesiones_restantes = fields.Integer(string='Sesiones restantes',
compute='_compute_sesiones', store=True)
adeudo = fields.Float(string='Adeudo', compute='_compute_adeudo', store=True)
periodos_pagados = fields.Integer(string='Periodos pagados',
compute='_compute_adeudo', store=True)
vigente = fields.Boolean(string='Vigente', compute='_compute_vigente', store=True)
dias_para_cobro = fields.Integer(string='Días para el cobro',
compute='_compute_vigente', store=True)
_sql_constraints = [
('folio_uniq', 'unique(folio)', 'El folio de la membresía debe ser único.'),
]
# ------------------------------------------------------------------
# Cómputos
# ------------------------------------------------------------------
@api.depends('sesiones_incluidas_periodo', 'sesiones_usadas_periodo')
def _compute_sesiones(self):
for m in self:
m.ilimitadas = (m.sesiones_incluidas_periodo or 0) <= 0
if m.ilimitadas:
m.sesiones_restantes = 0
else:
m.sesiones_restantes = max(
0, m.sesiones_incluidas_periodo - m.sesiones_usadas_periodo)
@api.depends('periodo_ids.estado', 'periodo_ids.monto')
def _compute_adeudo(self):
for m in self:
m.adeudo = sum(p.monto for p in m.periodo_ids
if p.estado in ('pendiente', 'vencido'))
m.periodos_pagados = len(
m.periodo_ids.filtered(lambda p: p.estado == 'pagado'))
@api.depends('estado', 'proximo_cobro')
def _compute_vigente(self):
hoy = fields.Date.today()
for m in self:
m.vigente = m.estado in ('activa', 'congelada')
m.dias_para_cobro = ((m.proximo_cobro - hoy).days
if m.proximo_cobro else 0)
# ------------------------------------------------------------------
# Alta
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('folio'):
# Se busca la secuencia por código y se usa next_by_id (no
# next_by_code): en peticiones auth='none' `env.company` puede
# no ser la compañía de la secuencia y next_by_code devolvería
# False.
seq = self.env['ir.sequence'].sudo().search(
[('code', '=', 'skeen.membresia')], limit=1)
folio = seq.next_by_id() if seq else False
if not folio:
raise ValidationError(_(
'No se pudo generar el folio de la membresía '
'(secuencia skeen.membresia).'))
vals['folio'] = folio
plan = self.env['skeen.membresia.plan'].browse(vals.get('plan_id'))
if plan.exists():
cfg = get_membresias(self.env)
vals.setdefault('sesiones_incluidas_periodo', plan.sesiones_incluidas)
vals.setdefault('descuento_porcentaje',
plan.descuento_porcentaje or cfg.get('descuento_porcentaje_default') or 0.0)
vals.setdefault('renovacion_automatica',
bool(cfg.get('renovacion_automatica_default')))
inicio = fields.Date.to_date(vals.get('fecha_inicio')) or fields.Date.today()
delta = _delta_periodo(plan.periodicidad)
vals.setdefault('periodo_actual_inicio', inicio)
vals.setdefault('periodo_actual_fin', inicio + delta - relativedelta(days=1))
vals.setdefault('proximo_cobro', inicio + delta)
if plan.meses_minimos and not vals.get('fecha_fin'):
vals['fecha_fin'] = inicio + relativedelta(months=plan.meses_minimos)
return super().create(vals_list)
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
def _usuario(self, login=''):
return (login or '').strip()
def _post(self, body):
"""Publica en el chatter con un autor válido.
Los controladores del frontend usan auth='none': en la petición no hay
usuario de Odoo y `message_post` falla con "Expected singleton". Se
publica como el administrador y el login real va en el cuerpo.
"""
author = self.env.ref('base.user_admin', raise_if_not_found=False)
if not author:
author = self.env['res.users'].sudo().search([('login', '=', 'admin')], limit=1)
(self.with_user(author) if author else self).message_post(body=body)
def action_congelar(self, desde, hasta, motivo='', login=''):
"""Congela la membresía respetando el límite anual configurado."""
self.ensure_one()
cfg = get_membresias(self.env)
if not cfg.get('permitir_congelamiento') or not self.plan_id.permitir_congelamiento:
raise ValidationError(_('Este plan no permite congelamiento.'))
if self.estado != 'activa':
raise ValidationError(_('Solo se puede congelar una membresía activa.'))
desde = fields.Date.to_date(desde)
hasta = fields.Date.to_date(hasta)
if not desde or not hasta or hasta < desde:
raise ValidationError(_('El rango de congelamiento no es válido.'))
dias = (hasta - desde).days + 1
tope = int(cfg.get('dias_max_congelamiento_anio') or 0)
if tope and (self.dias_congelados_anio + dias) > tope:
restantes = max(0, tope - self.dias_congelados_anio)
raise ValidationError(_(
'El límite de congelamiento anual es de %(tope)s días; solo te '
'quedan %(restantes)s.',
) % {'tope': tope, 'restantes': restantes})
vals = {
'estado': 'congelada',
'congelada_desde': desde,
'congelada_hasta': hasta,
'dias_congelados_anio': self.dias_congelados_anio + dias,
}
# Se corren el fin de periodo y el próximo cobro para no perder días.
if self.periodo_actual_fin:
vals['periodo_actual_fin'] = self.periodo_actual_fin + relativedelta(days=dias)
if self.proximo_cobro:
vals['proximo_cobro'] = self.proximo_cobro + relativedelta(days=dias)
self.write(vals)
self._post(body=Markup(
'<b>Membresía congelada</b><br/>Del %s al %s (%s días).<br/>Motivo: %s<br/>Por: %s'
) % (desde, hasta, dias, motivo or 'sin motivo', login or 'sistema'))
return True
def action_descongelar(self, login=''):
self.ensure_one()
if self.estado != 'congelada':
raise ValidationError(_('La membresía no está congelada.'))
self.write({'estado': 'activa', 'congelada_desde': False,
'congelada_hasta': False})
self._post(body=Markup('<b>Membresía reactivada</b><br/>Por: %s')
% (login or 'sistema'))
return True
def action_cancelar(self, motivo, login=''):
self.ensure_one()
motivo = (motivo or '').strip()
if not motivo:
raise ValidationError(_('El motivo de cancelación es obligatorio.'))
if self.estado == 'cancelada':
raise ValidationError(_('La membresía ya está cancelada.'))
self.write({'estado': 'cancelada', 'motivo_cancelacion': motivo,
'fecha_cancelacion': fields.Datetime.now()})
self._post(body=Markup(
'<b>Membresía cancelada</b><br/>Motivo: %s<br/>Por: %s'
) % (motivo, login or 'sistema'))
return True
def action_renovar(self, generar_cargo=None, login=''):
"""Avanza el periodo actual y, si toca, genera el cargo del siguiente."""
self.ensure_one()
if self.estado == 'cancelada':
raise ValidationError(_('No se puede renovar una membresía cancelada.'))
cfg = get_membresias(self.env)
delta = _delta_periodo(self.plan_id.periodicidad)
if self.proximo_cobro:
inicio = self.proximo_cobro
elif self.periodo_actual_fin:
inicio = self.periodo_actual_fin + relativedelta(days=1)
else:
inicio = fields.Date.today()
self.write({
'estado': 'activa',
'periodo_actual_inicio': inicio,
'periodo_actual_fin': inicio + delta - relativedelta(days=1),
'proximo_cobro': inicio + delta,
'sesiones_usadas_periodo': 0,
})
if generar_cargo is None:
generar_cargo = bool(cfg.get('generar_cargo_al_renovar'))
periodo = None
if generar_cargo:
periodo = self._crear_periodo_pendiente(self.periodo_actual_inicio,
self.periodo_actual_fin)
self._post(body=Markup(
'<b>Membresía renovada</b><br/>Periodo %s a %s.<br/>Cargo generado: %s<br/>Por: %s'
) % (self.periodo_actual_inicio, self.periodo_actual_fin,
'sí' if periodo else 'no', login or 'sistema'))
return periodo
def _servicio_requerido(self):
"""Servicio de cobro del plan o error claro.
La línea de venta EXIGE un servicio o un artículo (regla de
skeen.venta.line), así que un plan sin servicio de cobro ni servicios
incluidos no se puede facturar."""
self.ensure_one()
servicio = self.plan_id.servicio_cobro()
if not servicio:
raise ValidationError(_(
'El plan "%s" no tiene servicio de cobro ni servicios incluidos: '
'asigna uno para poder cobrar la membresía.',
) % self.plan_id.name)
return servicio
def _crear_periodo_pendiente(self, inicio, fin):
"""Crea el periodo y la venta-adeudo del periodo (sin pago)."""
self.ensure_one()
existente = self.periodo_ids.filtered(
lambda p: p.periodo_inicio == inicio and p.tipo == 'periodo'
and p.estado in ('pendiente', 'pagado'))[:1]
if existente:
return existente
servicio = self._servicio_requerido()
venta = self.env['skeen.venta'].sudo().create({
'partner_id': self.partner_id.id,
'membresia_id': self.id,
'date': fields.Date.today(),
'notes': _('Periodo de membresía %s (%s a %s)') % (
self.plan_id.name, inicio, fin),
'line_ids': [(0, 0, {
'service_id': servicio.id,
'description': _('Membresía %s · %s a %s') % (
self.plan_id.name, inicio, fin),
'quantity': 1.0,
'price_unit': self.plan_id.precio,
})],
})
venta.action_confirm()
return self.env['skeen.membresia.periodo'].sudo().create({
'membresia_id': self.id,
'tipo': 'periodo',
'periodo_inicio': inicio,
'periodo_fin': fin,
'monto': self.plan_id.precio,
'moneda': self.plan_id.moneda,
'venta_id': venta.id,
'estado': 'pendiente',
})
def action_cobrar(self, metodo='cash', monto=None, moneda=None,
cobrar_inscripcion=False, login=''):
"""Cobra el periodo actual registrando venta + pago normales.
Devuelve (venta, pago, lista_de_periodos). El corte de caja lo ve igual
que cualquier otra venta porque el pago es un `skeen.pago` completado.
"""
self.ensure_one()
if self.estado == 'cancelada':
raise ValidationError(_('No se puede cobrar una membresía cancelada.'))
if metodo not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago',
'saldo_favor'):
raise ValidationError(_('Método de pago inválido.'))
moneda = moneda or self.plan_id.moneda or 'MXN'
if moneda not in ('MXN', 'USD'):
raise ValidationError(_('Moneda no soportada (MXN/USD).'))
lineas = []
periodos_payload = []
servicio = self._servicio_requerido()
# Periodo recurrente
inicio = self.periodo_actual_inicio or self.fecha_inicio
fin = self.periodo_actual_fin or (inicio + _delta_periodo(
self.plan_id.periodicidad) - relativedelta(days=1))
monto_periodo = float(monto) if monto not in (None, '') else self.plan_id.precio
lineas.append((0, 0, {
'service_id': servicio.id,
'description': _('Membresía %s · %s a %s') % (self.plan_id.name, inicio, fin),
'quantity': 1.0,
'price_unit': monto_periodo,
}))
periodos_payload.append({
'tipo': 'periodo', 'periodo_inicio': inicio, 'periodo_fin': fin,
'monto': monto_periodo,
})
# Inscripción (solo la primera vez / cuando se pide)
if cobrar_inscripcion and self.plan_id.cargo_inscripcion:
ya = self.periodo_ids.filtered(lambda p: p.tipo == 'inscripcion')
if not ya:
lineas.append((0, 0, {
'service_id': servicio.id,
'description': _('Inscripción %s') % self.plan_id.name,
'quantity': 1.0,
'price_unit': self.plan_id.cargo_inscripcion,
}))
periodos_payload.append({
'tipo': 'inscripcion', 'periodo_inicio': inicio,
'periodo_fin': inicio, 'monto': self.plan_id.cargo_inscripcion,
})
venta = self.env['skeen.venta'].sudo().create({
'partner_id': self.partner_id.id,
'membresia_id': self.id,
'date': fields.Date.today(),
'user_login': self._usuario(login),
'notes': _('Cobro de membresía %s') % self.plan_id.name,
'line_ids': lineas,
})
venta.action_confirm()
total = venta.total
currency = self.env.ref('base.USD' if moneda == 'USD' else 'base.MXN')
pago = self.env['skeen.pago'].sudo().create({
'partner_id': self.partner_id.id,
'venta_id': venta.id,
'amount': total,
'currency_id': currency.id,
'payment_method': metodo,
'state': 'completed',
'payment_date': fields.Datetime.now(),
})
venta.write({'amount_paid': total})
venta.action_mark_paid()
# Periodos: se liquidan los que ya existían para ese rango y, si no, se crea.
periodos = self.env['skeen.membresia.periodo'].sudo()
for payload in periodos_payload:
existente = self.periodo_ids.filtered(
lambda p, pl=payload: p.tipo == pl['tipo']
and p.periodo_inicio == pl['periodo_inicio'])[:1]
if existente:
existente.write({'estado': 'pagado', 'monto': payload['monto'],
'venta_id': venta.id})
periodos |= existente
else:
periodos |= self.env['skeen.membresia.periodo'].sudo().create({
'membresia_id': self.id,
'venta_id': venta.id,
'moneda': moneda,
'estado': 'pagado',
**payload,
})
self._post(body=Markup(
'<b>Periodo cobrado</b><br/>Venta %s por %s (%s) · método %s.<br/>Por: %s'
) % (venta.name, _money(total), moneda, metodo, login or 'sistema'))
return venta, pago, periodos
def action_consumir(self, sesiones=1, cita_id=None, venta_id=None,
servicio_id=None, notas='', validar_servicio=True, login=''):
"""Registra el consumo de sesiones de la membresía.
Por diseño NO se descuenta en silencio: el POS/cita debe llamar a esta
acción explícitamente ("usar sesión de membresía").
"""
self.ensure_one()
if self.estado != 'activa':
raise ValidationError(_('La membresía no está activa.'))
try:
sesiones = int(sesiones or 1)
except (TypeError, ValueError):
raise ValidationError(_('El número de sesiones no es válido.'))
if sesiones <= 0:
raise ValidationError(_('Las sesiones a consumir deben ser mayores a cero.'))
if not self.ilimitadas and self.sesiones_restantes < sesiones:
raise ValidationError(_(
'Sin sesiones restantes: quedan %s de %s en el periodo.',
) % (self.sesiones_restantes, self.sesiones_incluidas_periodo))
servicio = self.env['skeen.servicio'].browse(servicio_id) if servicio_id else None
if validar_servicio and servicio and not self.plan_id.incluye_servicio(servicio):
raise ValidationError(_(
'El servicio "%s" no está incluido en el plan %s.',
) % (servicio.name, self.plan_id.name))
consumo = self.env['skeen.membresia.consumo'].sudo().create({
'membresia_id': self.id,
'cita_id': cita_id or False,
'venta_id': venta_id or False,
'servicio_id': servicio.id if servicio else False,
'sesiones': sesiones,
'usuario': self._usuario(login),
'notas': notas or '',
})
self.sesiones_usadas_periodo += sesiones
return consumo
# ------------------------------------------------------------------
# Reversión del consumo (al cancelar una cita que usó una sesión)
# ------------------------------------------------------------------
def action_liberar_consumo(self, consumo):
"""Libera un consumo (devuelve la sesión al periodo)."""
self.ensure_one()
consumo = consumo.exists()
if not consumo or consumo.membresia_id != self or consumo.state != 'consumed':
return False
consumo.write({'state': 'cancelled'})
self.sesiones_usadas_periodo = max(0, self.sesiones_usadas_periodo - consumo.sesiones)
return True
class SkeenMembresiaPeriodo(models.Model):
_name = 'skeen.membresia.periodo'
_description = 'Periodo de Membresía'
_order = 'periodo_inicio desc, id desc'
membresia_id = fields.Many2one('skeen.membresia', string='Membresía',
required=True, ondelete='cascade', index=True)
partner_id = fields.Many2one(related='membresia_id.partner_id', string='Paciente',
store=True, readonly=True)
tipo = fields.Selection([
('periodo', 'Periodo'),
('inscripcion', 'Inscripción'),
('ajuste', 'Ajuste'),
], string='Tipo', default='periodo', required=True)
periodo_inicio = fields.Date(string='Inicio', required=True)
periodo_fin = fields.Date(string='Fin', required=True)
monto = fields.Float(string='Monto', required=True, default=0.0)
moneda = fields.Selection(MONEDAS, string='Moneda', default='MXN')
venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null')
estado = fields.Selection(ESTADOS_PERIODO, string='Estado', default='pendiente',
required=True, index=True)
fecha_pago = fields.Datetime(string='Pagado el')
notas = fields.Char(string='Notas')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('estado') == 'pagado' and not vals.get('fecha_pago'):
vals['fecha_pago'] = fields.Datetime.now()
return super().create(vals_list)
def write(self, vals):
if vals.get('estado') == 'pagado' and not vals.get('fecha_pago'):
vals = dict(vals, fecha_pago=fields.Datetime.now())
return super().write(vals)
class SkeenMembresiaConsumo(models.Model):
_name = 'skeen.membresia.consumo'
_description = 'Consumo de Sesión de Membresía'
_order = 'fecha desc, id desc'
membresia_id = fields.Many2one('skeen.membresia', string='Membresía',
required=True, ondelete='cascade', index=True)
partner_id = fields.Many2one(related='membresia_id.partner_id', string='Paciente',
store=True, readonly=True)
fecha = fields.Datetime(string='Fecha', default=fields.Datetime.now, required=True)
cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null')
venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null')
servicio_id = fields.Many2one('skeen.servicio', string='Servicio')
sesiones = fields.Integer(string='Sesiones', default=1, required=True)
usuario = fields.Char(string='Usuario')
notas = fields.Text(string='Notas')
state = fields.Selection([
('consumed', 'Consumida'),
('cancelled', 'Liberada'),
], string='Estado', default='consumed')
class SkeenMembresiaService(models.AbstractModel):
"""Motor de membresías: configuración, vencimientos y cron diario."""
_name = 'skeen.membresia.service'
_description = 'Servicio de membresías de SKEEN'
@api.model
def _cfg(self):
return get_membresias(self.env)
@api.model
def activo(self):
return bool(self._cfg().get('activo'))
# ------------------------------------------------------------------
# Cron diario
# ------------------------------------------------------------------
@api.model
def _cron_tick(self):
"""Corre la lógica diaria. Con `membresias.activo=False` no hace nada."""
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0}
cfg = self._cfg()
if not cfg.get('activo'):
return resultado
resultado['descongeladas'] = self._aplicar_limite_congelamiento(cfg)
resultado['vencidas'] = self._marcar_vencidas(cfg)
resultado['avisos'] = self._encolar_avisos(cfg)
resultado['cargos'] = self._generar_cargos(cfg)
if any(resultado.values()):
_logger.info('SKEEN membresías: %s', resultado)
return resultado
@api.model
def _marcar_vencidas(self, cfg):
"""Marca vencidas las membresías con el periodo vencido + días de gracia."""
hoy = fields.Date.today()
gracia = int(cfg.get('dias_gracia') or 0)
Membresia = self.env['skeen.membresia'].sudo()
candidatas = Membresia.search([('estado', 'in', ('activa', 'pausada'))])
total = 0
for m in candidatas:
fin = m.periodo_actual_fin or m.fecha_fin
if not fin:
continue
if fin + relativedelta(days=gracia) < hoy:
# Si hay un adeudo del periodo, queda vencida; si no hay cargos
# pendientes (p. ej. ilimitada sin cobro) también vence.
m.write({'estado': 'vencida'})
m.periodo_ids.filtered(
lambda p: p.estado == 'pendiente' and p.periodo_fin <= fin
).write({'estado': 'vencido'})
total += 1
return total
@api.model
def _encolar_avisos(self, cfg):
"""Encola avisos de renovación en cada ventana configurada.
Idempotencia por `clave_unica = mem:<id>:<proximo_cobro>:<dias>`, así el
cron diario no repite el mismo aviso.
"""
Notif = self.env['skeen.notificacion.service'].sudo()
Membresia = self.env['skeen.membresia'].sudo()
hoy = fields.Date.today()
ventanas = [int(d) for d in (cfg.get('dias_aviso_renovacion') or [])]
total = 0
for m in Membresia.search([('estado', 'in', ('activa', 'congelada'))]):
if not m.proximo_cobro:
continue
faltan = (m.proximo_cobro - hoy).days
for dias in ventanas:
if faltan != dias:
continue
clave = 'mem:%s:%s:%s' % (m.id, m.proximo_cobro, dias)
if self.env['skeen.notificacion'].sudo().search_count(
[('clave_unica', 'like', clave + '%')]):
continue
creadas = Notif.encolar(
partner=m.partner_id,
tipo='renovacion_membresia',
cuando=fields.Datetime.now(),
ventana='%s:%s' % (m.proximo_cobro, dias),
clave_unica=clave,
contexto_extra={
'plan': m.plan_id.name,
'membresia': m.folio or '',
'monto': _money(m.plan_id.precio),
'sesiones': (str(m.sesiones_incluidas_periodo)
if m.sesiones_incluidas_periodo else 'ilimitadas'),
'periodo': '%s a %s' % (m.periodo_actual_inicio or '',
m.periodo_actual_fin or ''),
'fecha': m.proximo_cobro.strftime('%d/%m/%Y')
if m.proximo_cobro else '',
})
total += len(creadas)
return total
@api.model
def _generar_cargos(self, cfg):
"""Genera el adeudo del nuevo periodo para membresías de renovación automática."""
if not cfg.get('generar_cargo_al_renovar'):
return 0
Membresia = self.env['skeen.membresia'].sudo()
hoy = fields.Date.today()
total = 0
for m in Membresia.search([('estado', '=', 'activa'),
('renovacion_automatica', '=', True)]):
if not m.proximo_cobro or m.proximo_cobro > hoy:
continue
delta = _delta_periodo(m.plan_id.periodicidad)
inicio = m.proximo_cobro
fin = inicio + delta - relativedelta(days=1)
periodo = m._crear_periodo_pendiente(inicio, fin)
# Avanza el periodo para no repetir el cargo en la siguiente corrida.
m.write({
'periodo_actual_inicio': inicio,
'periodo_actual_fin': fin,
'proximo_cobro': fin + relativedelta(days=1),
'sesiones_usadas_periodo': 0,
})
if periodo:
total += 1
return total
@api.model
def _aplicar_limite_congelamiento(self, cfg):
"""Descongela las membresías cuya ventana de congelamiento ya terminó."""
hoy = fields.Date.today()
Membresia = self.env['skeen.membresia'].sudo()
total = 0
for m in Membresia.search([('estado', '=', 'congelada')]):
if m.congelada_hasta and m.congelada_hasta < hoy:
m.write({'estado': 'activa', 'congelada_desde': False,
'congelada_hasta': False})
total += 1
return total

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Enlace de las ventas con la membresía que las generó.
El campo vive en skeen.venta (módulo de ventas) para que `skeen.membresia` pueda
declarar su One2many sin crear un ciclo de dependencias. Sirve para el detalle
de la membresía (ventas ligadas) y para los reportes de ingreso recurrente.
"""
from odoo import fields, models
class SkeenVentaMembresia(models.Model):
_inherit = 'skeen.venta'
membresia_id = fields.Many2one('skeen.membresia', string='Membresía',
ondelete='set null', index=True,
help='Membresía cuyo periodo/inscripción generó '
'esta venta.')

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_membresia_plan_user,skeen.membresia.plan.user,model_skeen_membresia_plan,base.group_user,1,1,1,1
access_membresia_user,skeen.membresia.user,model_skeen_membresia,base.group_user,1,1,1,1
access_membresia_periodo_user,skeen.membresia.periodo.user,model_skeen_membresia_periodo,base.group_user,1,1,1,1
access_membresia_consumo_user,skeen.membresia.consumo.user,model_skeen_membresia_consumo,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_membresia_plan_user skeen.membresia.plan.user model_skeen_membresia_plan base.group_user 1 1 1 1
3 access_membresia_user skeen.membresia.user model_skeen_membresia base.group_user 1 1 1 1
4 access_membresia_periodo_user skeen.membresia.periodo.user model_skeen_membresia_periodo base.group_user 1 1 1 1
5 access_membresia_consumo_user skeen.membresia.consumo.user model_skeen_membresia_consumo base.group_user 1 1 1 1

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ Planes ============ -->
<record id="view_membresia_plan_tree" model="ir.ui.view">
<field name="name">skeen.membresia.plan.tree</field>
<field name="model">skeen.membresia.plan</field>
<field name="arch" type="xml">
<tree string="Planes de membresía">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="periodicidad"/>
<field name="precio"/>
<field name="moneda"/>
<field name="sesiones_incluidas"/>
<field name="membresias_count"/>
<field name="activo"/>
</tree>
</field>
</record>
<record id="view_membresia_plan_form" model="ir.ui.view">
<field name="name">skeen.membresia.plan.form</field>
<field name="model">skeen.membresia.plan</field>
<field name="arch" type="xml">
<form string="Plan de membresía">
<sheet>
<group>
<group>
<field name="name"/>
<field name="periodicidad"/>
<field name="precio"/>
<field name="moneda"/>
<field name="sesiones_incluidas"/>
<field name="servicio_id"/>
</group>
<group>
<field name="cargo_inscripcion"/>
<field name="descuento_porcentaje"/>
<field name="meses_minimos"/>
<field name="permitir_congelamiento"/>
<field name="activo"/>
<field name="sequence"/>
</group>
</group>
<group string="Incluye">
<field name="servicios_incluidos" widget="many2many_tags"/>
<field name="categorias_incluidas" widget="many2many_tags"/>
<field name="sucursales" widget="many2many_tags"/>
</group>
<group>
<field name="descripcion"/>
<field name="notas"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ Membresías ============ -->
<record id="view_membresia_tree" model="ir.ui.view">
<field name="name">skeen.membresia.tree</field>
<field name="model">skeen.membresia</field>
<field name="arch" type="xml">
<tree string="Membresías">
<field name="folio"/>
<field name="partner_id"/>
<field name="plan_id"/>
<field name="estado"/>
<field name="fecha_inicio"/>
<field name="proximo_cobro"/>
<field name="sesiones_restantes"/>
<field name="adeudo"/>
</tree>
</field>
</record>
<record id="view_membresia_form" model="ir.ui.view">
<field name="name">skeen.membresia.form</field>
<field name="model">skeen.membresia</field>
<field name="arch" type="xml">
<form string="Membresía">
<sheet>
<group>
<group>
<field name="folio"/>
<field name="partner_id"/>
<field name="plan_id"/>
<field name="estado"/>
<field name="fecha_inicio"/>
<field name="fecha_fin"/>
</group>
<group>
<field name="periodo_actual_inicio"/>
<field name="periodo_actual_fin"/>
<field name="proximo_cobro"/>
<field name="sesiones_incluidas_periodo"/>
<field name="sesiones_usadas_periodo"/>
<field name="renovacion_automatica"/>
<field name="descuento_porcentaje"/>
</group>
</group>
<group string="Congelamiento">
<field name="congelada_desde"/>
<field name="congelada_hasta"/>
<field name="dias_congelados_anio"/>
</group>
<notebook>
<page string="Periodos">
<field name="periodo_ids">
<tree editable="bottom">
<field name="tipo"/>
<field name="periodo_inicio"/>
<field name="periodo_fin"/>
<field name="monto"/>
<field name="moneda"/>
<field name="estado"/>
<field name="venta_id"/>
<field name="fecha_pago"/>
</tree>
</field>
</page>
<page string="Consumos">
<field name="consumo_ids">
<tree>
<field name="fecha"/>
<field name="servicio_id"/>
<field name="sesiones"/>
<field name="cita_id"/>
<field name="venta_id"/>
<field name="state"/>
</tree>
</field>
</page>
<page string="Ventas">
<field name="venta_ids">
<tree>
<field name="name"/>
<field name="date"/>
<field name="total"/>
<field name="state"/>
</tree>
</field>
</page>
</notebook>
<group>
<field name="motivo_cancelacion"/>
<field name="notas"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
</odoo>