# -*- coding: utf-8 -*- """Paquetes de sesiones. El catálogo de paquetes vive en `skeen.paquete` (un paquete = N sesiones de un servicio a un precio cerrado). Cada vez que el POS vende uno se materializa una `skeen.paquete.compra` a nombre del paciente, que es la que lleva el saldo: sessions_total sesiones contratadas sessions_used sesiones ya consumidas (citas atendidas) -> restantes = sessions_total - sessions_used El consumo lo hace `SkeenCitaPaquete` (cita_package.py) al pasar la cita por 'arrived'/'done', y lo revierte si la cita se cancela. """ from datetime import timedelta from odoo import models, fields, api, _ from odoo.exceptions import ValidationError # Estados de cita que ya gastaron una sesión del paquete CONSUME_STATES = ('arrived', 'in_progress', 'done') class SkeenPaquete(models.Model): _name = 'skeen.paquete' _description = 'Paquete de Sesiones' _order = 'sequence, name' name = fields.Char(string='Nombre', required=True) code = fields.Char(string='Código') service_id = fields.Many2one('skeen.servicio', string='Servicio', required=True, help='Servicio cuyas sesiones incluye el paquete.') duration_min = fields.Integer(string='Duración sesión (min)', help='Solo informativo / para agendar. Si se deja ' 'en 0 se usa la del servicio.') sessions = fields.Integer(string='Sesiones', required=True, default=1) price = fields.Float(string='Precio paquete', required=True, default=0.0) validity_days = fields.Integer(string='Vigencia (días)', default=0, help='Días desde la compra para usar las sesiones. ' '0 = sin caducidad.') notes = fields.Char(string='Notas') color = fields.Char(string='Color', default='#1abc9c') genera_monedero = fields.Boolean(string='Genera monedero', default=True) active = fields.Boolean(string='Activo', default=True) sequence = fields.Integer(string='Secuencia', default=10) compra_ids = fields.One2many('skeen.paquete.compra', 'paquete_id', string='Compras') compras_count = fields.Integer(string='Compras', compute='_compute_compras_count') @api.depends('compra_ids') def _compute_compras_count(self): for p in self: p.compras_count = len(p.compra_ids) @api.constrains('sessions', 'price') def _check_sessions_price(self): for p in self: if p.sessions < 1: raise ValidationError(_('Un paquete debe tener al menos 1 sesión.')) if p.price < 0: raise ValidationError(_('El precio del paquete no puede ser negativo.')) @api.onchange('service_id') def _onchange_service(self): if self.service_id and not self.duration_min: self.duration_min = self.service_id.duration_min def name_get(self): return [(p.id, '%s (%d sesiones)' % (p.name, p.sessions)) for p in self] class SkeenPaqueteCompra(models.Model): _name = 'skeen.paquete.compra' _description = 'Paquete Comprado' _order = 'date desc, id desc' partner_id = fields.Many2one('res.partner', string='Paciente', required=True, domain=[('is_patient', '=', True)]) paquete_id = fields.Many2one('skeen.paquete', string='Paquete', required=True, ondelete='restrict') service_id = fields.Many2one(related='paquete_id.service_id', string='Servicio', store=True, readonly=True) venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null') sale_line_id = fields.Many2one('skeen.venta.line', string='Línea de venta', ondelete='set null') date = fields.Date(string='Fecha compra', required=True, default=fields.Date.today) sessions_total = fields.Integer(string='Sesiones', required=True, default=1) sessions_used = fields.Integer(string='Sesiones usadas', default=0) price = fields.Float(string='Precio pagado', default=0.0) user_login = fields.Char(string='Vendió', index=True) sessions_remaining = fields.Integer(string='Restantes', compute='_compute_remaining', store=True) expiry_date = fields.Date(string='Vence', compute='_compute_expiry_date', store=True) state = fields.Selection([ ('active', 'Vigente'), ('finished', 'Completado'), ('expired', 'Vencido'), ('cancelled', 'Anulado'), ], string='Estado', default='active', compute='_compute_state', store=True) line_ids = fields.One2many('skeen.paquete.consumo', 'compra_id', string='Consumos') consumo_count = fields.Integer(string='Consumos', compute='_compute_consumo_count') @api.depends('sessions_total', 'sessions_used') def _compute_remaining(self): for c in self: c.sessions_remaining = max(0, c.sessions_total - c.sessions_used) @api.depends('paquete_id.validity_days', 'date') def _compute_expiry_date(self): for c in self: days = c.paquete_id.validity_days or 0 c.expiry_date = (c.date + timedelta(days=days)) \ if (c.date and days > 0) else False @api.depends('sessions_remaining', 'expiry_date', 'date') def _compute_state(self): today = fields.Date.today() for c in self: if c.sessions_total <= 0: c.state = 'cancelled' elif c.sessions_remaining <= 0: c.state = 'finished' elif c.expiry_date and c.expiry_date < today: c.state = 'expired' else: c.state = 'active' @api.depends('line_ids') def _compute_consumo_count(self): for c in self: c.consumo_count = len(c.line_ids) @api.model_create_multi def create(self, vals_list): """sessions_total por defecto = las del paquete (el POS no siempre lo manda).""" for vals in vals_list: if not vals.get('sessions_total') and vals.get('paquete_id'): paquete = self.env['skeen.paquete'].browse(vals['paquete_id']) vals['sessions_total'] = paquete.sessions or 1 return super().create(vals_list) def action_cancel(self): """Anula el paquete: libera los consumos registrados.""" for c in self: c.line_ids.write({'state': 'cancelled'}) c.write({'sessions_used': 0}) def action_adjust_sessions(self, sessions_total=None, sessions_used=None): vals = {} if sessions_total is not None: vals['sessions_total'] = int(sessions_total) if sessions_used is not None: vals['sessions_used'] = int(sessions_used) if vals: self.write(vals) return True class SkeenPaqueteConsumo(models.Model): _name = 'skeen.paquete.consumo' _description = 'Consumo de Sesión de Paquete' _order = 'date desc, id desc' compra_id = fields.Many2one('skeen.paquete.compra', string='Paquete comprado', required=True, ondelete='cascade') cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null', index=True) partner_id = fields.Many2one(related='compra_id.partner_id', string='Paciente', store=True, readonly=True) date = fields.Datetime(string='Fecha', default=fields.Datetime.now) state = fields.Selection([ ('consumed', 'Consumida'), ('cancelled', 'Liberada'), ], string='Estado', default='consumed') _sql_constraints = [ ('unique_cita_compra', 'unique(cita_id, compra_id)', 'Una misma cita solo puede consumir una sesión de un paquete.') ] class SkeenVentaLinePaquete(models.Model): _inherit = 'skeen.venta.line' paquete_id = fields.Many2one('skeen.paquete', string='Paquete', help='Si la línea vende un paquete de sesiones, ' 'aquí se enlaza. Al confirmar la venta se crea ' 'el saldo del paciente.') is_package = fields.Boolean(string='Es paquete', compute='_compute_is_package', store=True) @api.depends('paquete_id') def _compute_is_package(self): for line in self: line.is_package = bool(line.paquete_id) @api.constrains('paquete_id', 'service_id') def _check_paquete_service(self): for line in self: if line.paquete_id and line.service_id and \ line.service_id != line.paquete_id.service_id: raise ValidationError(_( 'La línea mezcla un paquete con un servicio distinto al del paquete.' )) class SkeenCitaPaquete(models.Model): _inherit = 'skeen.cita' paquete_compra_id = fields.Many2one('skeen.paquete.compra', string='Paquete origen', ondelete='set null', index=True, help='Compra de paquete que pagó esta cita.') def _paquete_asignable(self): """Compra vigente del paciente para el servicio de esta cita.""" self.ensure_one() if not self.partner_id or not self.servicio_id: return None Compra = self.env['skeen.paquete.compra'].sudo() return Compra.search([ ('partner_id', '=', self.partner_id.id), ('service_id', '=', self.servicio_id.id), ('sessions_remaining', '>', 0), ('state', '=', 'active'), ], order='expiry_date asc, date asc', limit=1) def _registrar_consumo(self, compra): for cita in self: existe = self.env['skeen.paquete.consumo'].sudo().search([ ('compra_id', '=', compra.id), ('cita_id', '=', cita.id)], limit=1) if existe: if existe.state != 'consumed': existe.write({'state': 'consumed'}) compra.write({'sessions_used': compra.sessions_used + 1}) continue self.env['skeen.paquete.consumo'].sudo().create({ 'compra_id': compra.id, 'cita_id': cita.id, }) compra.write({'sessions_used': compra.sessions_used + 1}) if not cita.paquete_compra_id: cita.write({'paquete_compra_id': compra.id}) def _liberar_consumo(self): Consumos = self.env['skeen.paquete.consumo'].sudo() for cita in self: registro = Consumos.search([('cita_id', '=', cita.id), ('state', '=', 'consumed')], limit=1) if not registro: continue compra = registro.compra_id registro.write({'state': 'cancelled'}) compra.write({'sessions_used': max(0, compra.sessions_used - 1)}) def write(self, vals): cambio_estado = 'state' in vals antes = {c.id: c.state for c in self} if cambio_estado else {} res = super().write(vals) if not cambio_estado: return res Consumir, Liberar = self.browse(), self.browse() for c in self: previo = antes.get(c.id) if c.state in CONSUME_STATES and previo not in CONSUME_STATES: if not c.paquete_compra_id: compra = c._paquete_asignable() if compra: Consumir |= c elif c.state in ('cancelled', 'no_show', 'waitlist') and \ previo in CONSUME_STATES: Liberar |= c for c in Consumir: compra = c._paquete_asignable() if compra: c._registrar_consumo(compra) Liberar._liberar_consumo() return res