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