718 lines
32 KiB
Python
718 lines
32 KiB
Python
# -*- 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)
|