updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}>
diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx
index c7377d5..e6e138d 100644
--- a/frontend/src/pages/Reportes.tsx
+++ b/frontend/src/pages/Reportes.tsx
@@ -29,6 +29,7 @@ import TopClientesReport from '../components/reportes/TopClientesReport';
import ExportarReport from '../components/reportes/ExportarReport';
import HorasAgendaReport from '../components/reportes/HorasAgendaReport';
import PaquetesReport from '../components/reportes/PaquetesReport';
+import MembresiasReport from '../components/reportes/MembresiasReport';
import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
@@ -45,6 +46,7 @@ import {
useGlobalReportRange,
useReportFavorites,
} from '../lib/reportRange';
+import { useClinicSettings } from '../lib/clinic-settings';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
@@ -89,6 +91,7 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'pagos-clientes', label: 'Pagos · Clientes', desc: 'Cobros agrupados por paciente' },
{ key: 'recomendaciones', label: 'Recomendaciones', desc: 'Pacientes que llegan recomendados' },
{ key: 'paquetes', label: 'Paquetes', desc: 'Ventas y avance de paquetes' },
+ { key: 'membresias', label: 'Membresías', desc: 'Altas, ingresos recurrentes y sesiones consumidas' },
],
},
{
@@ -145,12 +148,17 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
+ const { membresias: membresiasCfg } = useClinicSettings();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
// '/reportes'. Asi, quien tiene el modulo completo ve todas y quien tiene una
// concedida suelta (Horas agenda al medico, Exportar a cosmetologia) solo esa.
- const puedeTab = useCallback((k: string) => canSee(`/reportes?tab=${k}`), [canSee]);
+ // La pestaña Membresías además requiere que la clínica tenga el módulo activo.
+ const puedeTab = useCallback((k: string) => {
+ if (k === 'membresias' && !membresiasCfg.activo) return false;
+ return canSee(`/reportes?tab=${k}`);
+ }, [canSee, membresiasCfg.activo]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
@@ -472,6 +480,8 @@ const ReportesContent: FC = () => {
) : tab === 'paquetes' ? (
+ ) : tab === 'membresias' ? (
+
) : tab === 'vendedores' ? (
) : tab === 'concentrado' ? (
diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts
index bc084a3..8bde689 100644
--- a/frontend/src/services/odoo.ts
+++ b/frontend/src/services/odoo.ts
@@ -1014,6 +1014,7 @@ export interface ClinicSettings {
impuestos: ImpuestosSettings;
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
+ membresias: MembresiasSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1108,6 +1109,194 @@ export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
{ horas: 168, label: '1 semana' },
];
+// ---- Membresías y suscripciones ----
+export interface MembresiasSettings {
+ /** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
+ activo: boolean;
+ /** Días antes del cobro en que se avisa (ordenados). */
+ dias_aviso_renovacion: number[];
+ permitir_congelamiento: boolean;
+ /** Tope de días congelados por año. */
+ dias_max_congelamiento_anio: number;
+ renovacion_automatica_default: boolean;
+ /** Descuento por defecto en servicios no incluidos (%). */
+ descuento_porcentaje_default: number;
+ permitir_pausa_pago: boolean;
+ /** El cron genera el adeudo del nuevo periodo. */
+ generar_cargo_al_renovar: boolean;
+ /** Días tras el vencimiento antes de marcar la membresía vencida. */
+ dias_gracia: number;
+}
+
+export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
+ 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,
+};
+
+export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual';
+export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada';
+export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado';
+
+export interface MembresiaPlan {
+ id: number;
+ name: string;
+ descripcion: string;
+ precio: number;
+ moneda: 'MXN' | 'USD';
+ periodicidad: PeriodicidadMembresia;
+ sesiones_incluidas: number;
+ servicios_incluidos: { id: number; name: string }[];
+ categorias_incluidas: { id: number; name: string; code: string }[];
+ descuento_porcentaje: number;
+ cargo_inscripcion: number;
+ meses_minimos: number;
+ permitir_congelamiento: boolean;
+ activo: boolean;
+ sequence: number;
+ sucursales: { id: number; name: string }[];
+ servicio_id: number | null;
+ servicio: string;
+ notas: string;
+ membresias_count: number;
+}
+
+/** Payload de alta/edición de un plan (el backend recibe ids, no objetos). */
+export interface MembresiaPlanInput {
+ name?: string;
+ descripcion?: string;
+ precio?: number;
+ moneda?: 'MXN' | 'USD';
+ periodicidad?: PeriodicidadMembresia;
+ sesiones_incluidas?: number;
+ servicios_incluidos?: number[];
+ categorias_incluidas?: number[];
+ descuento_porcentaje?: number;
+ cargo_inscripcion?: number;
+ meses_minimos?: number;
+ permitir_congelamiento?: boolean;
+ activo?: boolean;
+ sequence?: number;
+ sucursales?: number[];
+ servicio_id?: number | null;
+ notas?: string;
+}
+
+export interface Membresia {
+ id: number;
+ folio: string;
+ partner_id: number;
+ patient: string;
+ phone: string;
+ plan_id: number | null;
+ plan: string;
+ periodicidad: string;
+ precio: number;
+ moneda: string;
+ estado: EstadoMembresia;
+ estado_label: string;
+ fecha_inicio: string | null;
+ fecha_fin: string | null;
+ periodo_actual_inicio: string | null;
+ periodo_actual_fin: string | null;
+ proximo_cobro: string | null;
+ sesiones_incluidas_periodo: number;
+ sesiones_usadas_periodo: number;
+ sesiones_restantes: number;
+ ilimitadas: boolean;
+ renovacion_automatica: boolean;
+ descuento_porcentaje: number;
+ congelada_desde: string | null;
+ congelada_hasta: string | null;
+ dias_congelados_anio: number;
+ motivo_cancelacion: string;
+ notas: string;
+ adeudo: number;
+ periodos_pagados: number;
+ dias_para_cobro: number;
+ vigente: boolean;
+ servicios_incluidos: { id: number; name: string }[];
+}
+
+export interface MembresiaPeriodo {
+ id: number;
+ tipo: 'periodo' | 'inscripcion' | 'ajuste';
+ periodo_inicio: string | null;
+ periodo_fin: string | null;
+ monto: number;
+ moneda: string;
+ estado: EstadoPeriodoMembresia;
+ venta_id: number | null;
+ venta: string;
+ fecha_pago: string | null;
+ notas: string;
+}
+
+export interface MembresiaConsumo {
+ id: number;
+ fecha: string | null;
+ cita_id: number | null;
+ venta_id: number | null;
+ servicio_id: number | null;
+ servicio: string;
+ sesiones: number;
+ usuario: string;
+ notas: string;
+ state: 'consumed' | 'cancelled';
+}
+
+export interface MembresiaResumen {
+ activas: number;
+ por_vencer: number;
+ vencidas: number;
+ mrr_estimado: number;
+}
+
+export interface MembresiasResponse {
+ status: string;
+ membresias: Membresia[];
+ total: number;
+ page: number;
+ page_size: number;
+ total_pages: number;
+ resumen: MembresiaResumen;
+ message?: string;
+}
+
+export interface MembresiaDetalleResponse {
+ status: string;
+ membresia: Membresia;
+ periodos: MembresiaPeriodo[];
+ consumos: MembresiaConsumo[];
+ ventas: { id: number; name: string; date: string | null; total: number; state: string; payment_method: string }[];
+ saldos: { adeudo: number; sesiones_restantes: number; ilimitadas: boolean; periodos_pagados: number };
+ message?: string;
+}
+
+export interface MembresiasReporte {
+ status: string;
+ start: string;
+ end: string;
+ altas: number;
+ cancelaciones: number;
+ ingresos_por_mes: { mes: string; total: number }[];
+ recurrentes_por_mes: { mes: string; total: number }[];
+ total_ingresos: number;
+ total_recurrente: number;
+ proximas_renovaciones: { id: number; folio: string; patient: string; plan: string; proximo_cobro: string | null; precio: number; moneda: string }[];
+ sesiones_consumidas: number;
+ sesiones_incluidas_activas: number;
+ membresias_activas: number;
+ message?: string;
+}
+
+
export interface Notificacion {
id: number;
partner_id: number;
@@ -1351,6 +1540,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
+ membresias: MEMBRESIAS_DEFAULT,
};
export interface FrontendUser {
@@ -2011,6 +2201,96 @@ export const odooApi = {
return data;
},
+ // ---- Membresías y suscripciones ----
+ // Con `membresias.activo=false` (default) el backend responde 403 y estas
+ // llamadas nunca se hacen desde la UI (la sección no se monta).
+ async getMembresiaPlanes(opts?: { includeInactive?: boolean; q?: string }): Promise<{ status: string; planes: MembresiaPlan[]; message?: string }> {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes`, {
+ params: { ...(opts?.includeInactive ? { include_inactive: 1 } : {}), ...(opts?.q ? { q: opts.q } : {}) },
+ });
+ return data;
+ },
+
+ async createMembresiaPlan(payload: MembresiaPlanInput): Promise<{ status: string; plan: MembresiaPlan; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes`, payload);
+ return data;
+ },
+
+ async updateMembresiaPlan(id: number, payload: MembresiaPlanInput): Promise<{ status: string; plan: MembresiaPlan; message?: string }> {
+ const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes/${id}`, payload);
+ return data;
+ },
+
+ async deleteMembresiaPlan(id: number): Promise<{ status: string; message?: string }> {
+ const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes/${id}`);
+ return data;
+ },
+
+ async getMembresias(params?: { estado?: string; plan_id?: number; partner_id?: number; por_vencer?: boolean; q?: string; page?: number; page_size?: number }): Promise
{
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias`, {
+ params: {
+ ...(params?.estado ? { estado: params.estado } : {}),
+ ...(params?.plan_id ? { plan_id: params.plan_id } : {}),
+ ...(params?.partner_id ? { partner_id: params.partner_id } : {}),
+ ...(params?.por_vencer ? { por_vencer: 1 } : {}),
+ ...(params?.q ? { q: params.q } : {}),
+ ...(params?.page ? { page: params.page } : {}),
+ ...(params?.page_size ? { page_size: params.page_size } : {}),
+ },
+ });
+ return data;
+ },
+
+ async getMembresia(id: number): Promise {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}`);
+ return data;
+ },
+
+ async getMembresiasPorPaciente(partnerId: number): Promise<{ status: string; membresias: Membresia[]; message?: string }> {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/por_paciente`, { params: { partner_id: partnerId } });
+ return data;
+ },
+
+ async crearMembresia(payload: { partner_id: number; plan_id: number; fecha_inicio?: string; renovacion_automatica?: boolean; descuento_porcentaje?: number; notas?: string }): Promise<{ status: string; membresia: Membresia; inscripcion_cargo: number; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias`, payload);
+ return data;
+ },
+
+ async congelarMembresia(id: number, payload: { desde: string; hasta: string; motivo?: string }): Promise<{ status: string; membresia: Membresia; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/congelar`, payload);
+ return data;
+ },
+
+ async descongelarMembresia(id: number): Promise<{ status: string; membresia: Membresia; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/descongelar`, {});
+ return data;
+ },
+
+ async renovarMembresia(id: number, generarCargo?: boolean): Promise<{ status: string; membresia: Membresia; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/renovar`, { generar_cargo: generarCargo });
+ return data;
+ },
+
+ async cancelarMembresia(id: number, motivo: string): Promise<{ status: string; membresia: Membresia; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/cancelar`, { motivo });
+ return data;
+ },
+
+ async cobrarMembresia(id: number, payload: { metodo: string; monto?: number; moneda?: string; cobrar_inscripcion?: boolean }): Promise<{ status: string; membresia: Membresia; venta: { id: number; name: string; total: number; state: string }; pago: { id: number; name: string; amount: number; payment_method: string }; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/cobrar`, payload);
+ return data;
+ },
+
+ async consumirMembresia(id: number, payload: { sesiones?: number; cita_id?: number; venta_id?: number; servicio_id?: number; notas?: string }): Promise<{ status: string; consumo: MembresiaConsumo; membresia: Membresia; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/consumir`, payload);
+ return data;
+ },
+
+ async getMembresiasReporte(params: { start: string; end: string }): Promise {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/reporte`, { params });
+ return data;
+ },
+
// ---- Portal público de reservas en línea (sin autenticación) ----
async getBookingConfig(): Promise {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/config`);
diff --git a/odoo-addons/skeen_citas/clinic_settings.py b/odoo-addons/skeen_citas/clinic_settings.py
index 84a77b6..b771b9f 100644
--- a/odoo-addons/skeen_citas/clinic_settings.py
+++ b/odoo-addons/skeen_citas/clinic_settings.py
@@ -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)
diff --git a/odoo-addons/skeen_membresias/__init__.py b/odoo-addons/skeen_membresias/__init__.py
new file mode 100644
index 0000000..3b38916
--- /dev/null
+++ b/odoo-addons/skeen_membresias/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+from . import models
+from . import controllers
diff --git a/odoo-addons/skeen_membresias/__manifest__.py b/odoo-addons/skeen_membresias/__manifest__.py
new file mode 100644
index 0000000..7590836
--- /dev/null
+++ b/odoo-addons/skeen_membresias/__manifest__.py
@@ -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',
+}
diff --git a/odoo-addons/skeen_membresias/controllers/__init__.py b/odoo-addons/skeen_membresias/controllers/__init__.py
new file mode 100644
index 0000000..b3fbc74
--- /dev/null
+++ b/odoo-addons/skeen_membresias/controllers/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import membresias
diff --git a/odoo-addons/skeen_membresias/controllers/membresias.py b/odoo-addons/skeen_membresias/controllers/membresias.py
new file mode 100644
index 0000000..e32f03f
--- /dev/null
+++ b/odoo-addons/skeen_membresias/controllers/membresias.py
@@ -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/', 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)
diff --git a/odoo-addons/skeen_membresias/data/membresias_cron.xml b/odoo-addons/skeen_membresias/data/membresias_cron.xml
new file mode 100644
index 0000000..87b150a
--- /dev/null
+++ b/odoo-addons/skeen_membresias/data/membresias_cron.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+ SKEEN Membresías: vencimientos, avisos y cargos recurrentes
+
+ code
+ model._cron_tick()
+ 1
+ days
+ -1
+ True
+
+
+
diff --git a/odoo-addons/skeen_membresias/data/sequences.xml b/odoo-addons/skeen_membresias/data/sequences.xml
new file mode 100644
index 0000000..90820ac
--- /dev/null
+++ b/odoo-addons/skeen_membresias/data/sequences.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Membresía SKEEN
+ skeen.membresia
+ MEM-
+ 4
+ 1
+ 1
+
+
+
diff --git a/odoo-addons/skeen_membresias/models/__init__.py b/odoo-addons/skeen_membresias/models/__init__.py
new file mode 100644
index 0000000..21a67c2
--- /dev/null
+++ b/odoo-addons/skeen_membresias/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+from . import membresia
+from . import venta
diff --git a/odoo-addons/skeen_membresias/models/membresia.py b/odoo-addons/skeen_membresias/models/membresia.py
new file mode 100644
index 0000000..1f04ad6
--- /dev/null
+++ b/odoo-addons/skeen_membresias/models/membresia.py
@@ -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(
+ 'Membresía congelada Del %s al %s (%s días). Motivo: %s 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('Membresía reactivada 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(
+ 'Membresía cancelada Motivo: %s 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(
+ 'Membresía renovada Periodo %s a %s. Cargo generado: %s 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(
+ 'Periodo cobrado Venta %s por %s (%s) · método %s. 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:::`, 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
diff --git a/odoo-addons/skeen_membresias/models/venta.py b/odoo-addons/skeen_membresias/models/venta.py
new file mode 100644
index 0000000..5940667
--- /dev/null
+++ b/odoo-addons/skeen_membresias/models/venta.py
@@ -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.')
diff --git a/odoo-addons/skeen_membresias/security/ir.model.access.csv b/odoo-addons/skeen_membresias/security/ir.model.access.csv
new file mode 100644
index 0000000..cb5ffdd
--- /dev/null
+++ b/odoo-addons/skeen_membresias/security/ir.model.access.csv
@@ -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
diff --git a/odoo-addons/skeen_membresias/views/membresia_views.xml b/odoo-addons/skeen_membresias/views/membresia_views.xml
new file mode 100644
index 0000000..da7edd1
--- /dev/null
+++ b/odoo-addons/skeen_membresias/views/membresia_views.xml
@@ -0,0 +1,163 @@
+
+
+
+
+
+ skeen.membresia.plan.tree
+ skeen.membresia.plan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ skeen.membresia.plan.form
+ skeen.membresia.plan
+
+
+
+
+
+
+
+
+
+ skeen.membresia.tree
+ skeen.membresia
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ skeen.membresia.form
+ skeen.membresia
+
+
+
+
+
+
+
+
diff --git a/odoo-addons/skeen_whatsapp/models/notificacion.py b/odoo-addons/skeen_whatsapp/models/notificacion.py
index ed1cb32..2994d4d 100644
--- a/odoo-addons/skeen_whatsapp/models/notificacion.py
+++ b/odoo-addons/skeen_whatsapp/models/notificacion.py
@@ -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',
}
diff --git a/odoo-addons/skeen_whatsapp/permissions.py b/odoo-addons/skeen_whatsapp/permissions.py
index f95d5e4..8d2d05d 100644
--- a/odoo-addons/skeen_whatsapp/permissions.py
+++ b/odoo-addons/skeen_whatsapp/permissions.py
@@ -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,
diff --git a/odoo-addons/skeen_whatsapp/roles.py b/odoo-addons/skeen_whatsapp/roles.py
index f4715be..7807486 100644
--- a/odoo-addons/skeen_whatsapp/roles.py
+++ b/odoo-addons/skeen_whatsapp/roles.py
@@ -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'],