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:
@@ -316,16 +316,25 @@ NOTIFICACIONES_DEFAULT = {
|
||||
'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. '
|
||||
'Ábrelo desde tu celular aquí: {enlace}'
|
||||
),
|
||||
# Aviso de renovación de una membresía (bloque `membresias`). Usa variables
|
||||
# propias ({plan} {monto} {sesiones} {periodo}) que el motor recibe como
|
||||
# contexto extra al encolar; ver `skeen.membresia.service`.
|
||||
'plantilla_renovacion_membresia': (
|
||||
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
|
||||
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
|
||||
),
|
||||
'whatsapp_numero': '',
|
||||
'email_remitente': '',
|
||||
}
|
||||
|
||||
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
|
||||
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
||||
'sucursal', 'telefono', 'enlace', 'formulario')
|
||||
'sucursal', 'telefono', 'enlace', 'formulario',
|
||||
# Variables del aviso de membresías.
|
||||
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
|
||||
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
||||
'plantilla_cumpleanos', 'plantilla_post_visita',
|
||||
'plantilla_formulario')
|
||||
'plantilla_formulario', 'plantilla_renovacion_membresia')
|
||||
NOTIF_HORAS_MAX = 168 # 1 semana
|
||||
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
||||
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
|
||||
@@ -484,6 +493,119 @@ def get_notificaciones(env):
|
||||
"""Bloque de notificaciones vigente (copia). Fuente única para el motor."""
|
||||
return normalizar_notificaciones(get_settings(env).get('notificaciones'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Membresías y suscripciones.
|
||||
#
|
||||
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
|
||||
# Membresías no aparece en el menú, el POS no ofrece sesiones de membresía, no
|
||||
# corre el cron y no se genera ningún cargo ni aviso. Por eso el default es
|
||||
# apagado.
|
||||
#
|
||||
# activo: enciende TODO el módulo de membresías
|
||||
# dias_aviso_renovacion: días antes del cobro en que se avisa
|
||||
# permitir_congelamiento: permite congelar membresías
|
||||
# dias_max_congelamiento_anio: tope de días congelados por año
|
||||
# renovacion_automatica_default: valor por defecto al dar de alta
|
||||
# descuento_porcentaje_default: descuento por defecto en servicios no incluidos
|
||||
# permitir_pausa_pago: permite pausar la membresía por falta de pago
|
||||
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
|
||||
# dias_gracia: días tras el vencimiento antes de marcarla vencida
|
||||
# ---------------------------------------------------------------------------
|
||||
MEMBRESIAS_DEFAULT = {
|
||||
'activo': False,
|
||||
'dias_aviso_renovacion': [7, 1],
|
||||
'permitir_congelamiento': True,
|
||||
'dias_max_congelamiento_anio': 30,
|
||||
'renovacion_automatica_default': False,
|
||||
'descuento_porcentaje_default': 0,
|
||||
'permitir_pausa_pago': False,
|
||||
'generar_cargo_al_renovar': True,
|
||||
'dias_gracia': 5,
|
||||
}
|
||||
|
||||
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
|
||||
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
|
||||
|
||||
|
||||
def normalizar_membresias(raw):
|
||||
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(MEMBRESIAS_DEFAULT)
|
||||
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
|
||||
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
|
||||
out[k] = _bool(raw.get(k), MEMBRESIAS_DEFAULT[k])
|
||||
out['dias_aviso_renovacion'] = horas_recordatorio_validas(
|
||||
raw.get('dias_aviso_renovacion'), MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
|
||||
out['dias_max_congelamiento_anio'] = min(max(_int(
|
||||
raw.get('dias_max_congelamiento_anio'),
|
||||
MEMBRESIAS_DEFAULT['dias_max_congelamiento_anio']), 0), MEMBRESIAS_DIAS_MAX)
|
||||
out['descuento_porcentaje_default'] = min(max(_num(
|
||||
raw.get('descuento_porcentaje_default'),
|
||||
MEMBRESIAS_DEFAULT['descuento_porcentaje_default']), 0.0), 100.0)
|
||||
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'),
|
||||
MEMBRESIAS_DEFAULT['dias_gracia']), 0), 365)
|
||||
return out
|
||||
|
||||
|
||||
def validar_membresias(raw):
|
||||
"""Valida el bloque `membresias` de un PUT. Lanza ValueError si algo no
|
||||
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
|
||||
para que el merge anidado no reinicie lo que el PUT no tocó."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('El bloque de membresías debe ser un objeto')
|
||||
out = {}
|
||||
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
|
||||
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
|
||||
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 'dias_aviso_renovacion' in raw:
|
||||
valor = raw['dias_aviso_renovacion']
|
||||
if not isinstance(valor, list) or not valor:
|
||||
raise ValueError('Los días de aviso de renovación deben ser una lista')
|
||||
dias = []
|
||||
for v in valor:
|
||||
if isinstance(v, bool) or not isinstance(v, int):
|
||||
raise ValueError('Cada día de aviso debe ser un número entero')
|
||||
if not (0 <= v <= MEMBRESIAS_AVISO_MAX):
|
||||
raise ValueError('Los días de aviso deben estar entre 0 y %d' % MEMBRESIAS_AVISO_MAX)
|
||||
if v not in dias:
|
||||
dias.append(v)
|
||||
out['dias_aviso_renovacion'] = sorted(set(dias))
|
||||
if 'dias_max_congelamiento_anio' in raw:
|
||||
if isinstance(raw['dias_max_congelamiento_anio'], bool) or \
|
||||
not isinstance(raw['dias_max_congelamiento_anio'], int):
|
||||
raise ValueError('El máximo de días de congelamiento debe ser un número entero')
|
||||
if not (0 <= raw['dias_max_congelamiento_anio'] <= MEMBRESIAS_DIAS_MAX):
|
||||
raise ValueError('El máximo de días de congelamiento debe estar entre 0 y %d'
|
||||
% MEMBRESIAS_DIAS_MAX)
|
||||
out['dias_max_congelamiento_anio'] = raw['dias_max_congelamiento_anio']
|
||||
if 'descuento_porcentaje_default' in raw:
|
||||
n = _num(raw['descuento_porcentaje_default'], None)
|
||||
if n is None:
|
||||
raise ValueError('El descuento por defecto debe ser un número')
|
||||
if not (0 <= n <= 100):
|
||||
raise ValueError('El descuento por defecto debe estar entre 0 y 100')
|
||||
out['descuento_porcentaje_default'] = n
|
||||
if 'dias_gracia' in raw:
|
||||
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
|
||||
raise ValueError('Los días de gracia deben ser un número entero')
|
||||
if not (0 <= raw['dias_gracia'] <= 365):
|
||||
raise ValueError('Los días de gracia deben estar entre 0 y 365')
|
||||
out['dias_gracia'] = raw['dias_gracia']
|
||||
return out
|
||||
|
||||
|
||||
def get_membresias(env):
|
||||
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
|
||||
return normalizar_membresias(get_settings(env).get('membresias'))
|
||||
|
||||
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
||||
AGENDA_START_DEFAULT = 8.0
|
||||
AGENDA_END_DEFAULT = 19.0
|
||||
@@ -526,6 +648,7 @@ def get_settings(env):
|
||||
'impuestos': dict(IMPUESTOS_DEFAULT),
|
||||
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
|
||||
'notificaciones': normalizar_notificaciones(None),
|
||||
'membresias': normalizar_membresias(None),
|
||||
}
|
||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||
if raw:
|
||||
@@ -540,6 +663,7 @@ def get_settings(env):
|
||||
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
|
||||
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
|
||||
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
|
||||
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
|
||||
return out
|
||||
|
||||
|
||||
@@ -564,6 +688,10 @@ def set_settings(env, vals):
|
||||
bloque = validar_notificaciones(vals['notificaciones'])
|
||||
if bloque is not None:
|
||||
actual['notificaciones'] = {**actual['notificaciones'], **bloque}
|
||||
if 'membresias' in vals:
|
||||
bloque = validar_membresias(vals['membresias'])
|
||||
if bloque is not None:
|
||||
actual['membresias'] = {**actual['membresias'], **bloque}
|
||||
env['ir.config_parameter'].sudo().set_param(
|
||||
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
||||
return get_settings(env)
|
||||
|
||||
3
odoo-addons/skeen_membresias/__init__.py
Normal file
3
odoo-addons/skeen_membresias/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
35
odoo-addons/skeen_membresias/__manifest__.py
Normal file
35
odoo-addons/skeen_membresias/__manifest__.py
Normal 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',
|
||||
}
|
||||
2
odoo-addons/skeen_membresias/controllers/__init__.py
Normal file
2
odoo-addons/skeen_membresias/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import membresias
|
||||
717
odoo-addons/skeen_membresias/controllers/membresias.py
Normal file
717
odoo-addons/skeen_membresias/controllers/membresias.py
Normal 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)
|
||||
21
odoo-addons/skeen_membresias/data/membresias_cron.xml
Normal file
21
odoo-addons/skeen_membresias/data/membresias_cron.xml
Normal 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>
|
||||
14
odoo-addons/skeen_membresias/data/sequences.xml
Normal file
14
odoo-addons/skeen_membresias/data/sequences.xml
Normal 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>
|
||||
3
odoo-addons/skeen_membresias/models/__init__.py
Normal file
3
odoo-addons/skeen_membresias/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import membresia
|
||||
from . import venta
|
||||
806
odoo-addons/skeen_membresias/models/membresia.py
Normal file
806
odoo-addons/skeen_membresias/models/membresia.py
Normal 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
|
||||
18
odoo-addons/skeen_membresias/models/venta.py
Normal file
18
odoo-addons/skeen_membresias/models/venta.py
Normal 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.')
|
||||
@@ -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
|
||||
|
163
odoo-addons/skeen_membresias/views/membresia_views.xml
Normal file
163
odoo-addons/skeen_membresias/views/membresia_views.xml
Normal 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>
|
||||
@@ -44,6 +44,7 @@ TIPOS = [
|
||||
('post_visita', 'Post-visita'),
|
||||
('recuperacion', 'Recuperación'),
|
||||
('formulario', 'Formulario'),
|
||||
('renovacion_membresia', 'Renovación de membresía'),
|
||||
('manual', 'Manual'),
|
||||
]
|
||||
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
||||
@@ -67,6 +68,8 @@ PLANTILLA_POR_TIPO = {
|
||||
'recuperacion': 'plantilla_recordatorio',
|
||||
# El aviso de un formulario digital usa su propia plantilla con {enlace}.
|
||||
'formulario': 'plantilla_formulario',
|
||||
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
|
||||
'renovacion_membresia': 'plantilla_renovacion_membresia',
|
||||
'manual': 'plantilla_recordatorio',
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,13 @@ PERMISSION_GROUPS = [
|
||||
('formularios.editar', 'Administrar plantillas de formularios'),
|
||||
('formularios.enviar', 'Enviar formularios a pacientes'),
|
||||
]),
|
||||
('membresias', 'Membresías y suscripciones', [
|
||||
('membresias.ver', 'Ver membresías, planes y adeudos'),
|
||||
('membresias.crear', 'Dar de alta membresías'),
|
||||
('membresias.cobrar', 'Cobrar y renovar membresías'),
|
||||
('membresias.editar', 'Editar planes y membresías'),
|
||||
('membresias.cancelar', 'Congelar y cancelar membresías'),
|
||||
]),
|
||||
('config', 'Usuarios y configuración', [
|
||||
('config.usuarios', 'Administrar usuarios'),
|
||||
('config.roles', 'Administrar roles y menús'),
|
||||
@@ -123,6 +130,7 @@ READ_PERM_KEYS = (
|
||||
'whatsapp.ver',
|
||||
'notificaciones.ver',
|
||||
'formularios.ver',
|
||||
'membresias.ver',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -210,6 +218,14 @@ DEFAULT_PERM_ROLES = {
|
||||
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
||||
'formularios.editar': AG,
|
||||
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
|
||||
# Membresías y suscripciones. Ver es de todos; dar de alta y cobrar es de
|
||||
# recepción/gerencia/admin; editar planes y cancelar/congelar es de
|
||||
# gerencia/admin (contra el dinero recurrente de la clínica).
|
||||
'membresias.ver': ALL,
|
||||
'membresias.crear': ('admin', 'recepcion', 'gerencia'),
|
||||
'membresias.cobrar': ('admin', 'recepcion', 'gerencia'),
|
||||
'membresias.editar': AG,
|
||||
'membresias.cancelar': AG,
|
||||
# Usuarios y configuración
|
||||
'config.usuarios': AG,
|
||||
'config.roles': AG,
|
||||
|
||||
@@ -160,6 +160,9 @@ DEFAULT_MENU_ROLES = {
|
||||
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
# Membresías: apagado por defecto (membresias.activo=false). La sección solo
|
||||
# se pinta cuando la instancia la enciende; aquí queda su visibilidad por rol.
|
||||
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
||||
'/tickets': ['admin', 'gerencia'],
|
||||
@@ -172,6 +175,7 @@ DEFAULT_MENU_ROLES = {
|
||||
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=membresias': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
|
||||
Reference in New Issue
Block a user