# -*- 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