Files
SKEEN-Proyecto/odoo-addons/skeen_membresias/models/membresia.py

807 lines
36 KiB
Python

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