# -*- 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/', 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/', 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 + '/', 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 + '//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 + '//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 + '//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 + '//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 + '//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 + '//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)