Puntos 16 y 10: modulo de paquetes de sesiones (catalogo, saldo por paciente, descuento en agenda, venta en POS) + fix de auditoria silenciosa

This commit is contained in:
2026-09-24 17:17:29 +00:00
parent 09b4f25bb8
commit dcf2c367f1
18 changed files with 1892 additions and 17 deletions

View File

@@ -16,6 +16,7 @@
'data/sequences.xml',
'views/venta_views.xml',
'views/corte_views.xml',
'views/paquete_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -1,3 +1,4 @@
from . import tipo_cambio
from . import pago
from . import venta
from . import paquete

View File

@@ -0,0 +1,279 @@
# -*- 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

View File

@@ -3,3 +3,6 @@ access_skeen_venta_user,Acceso a Ventas SKEEN,model_skeen_venta,base.group_user,
access_skeen_venta_line_user,Acceso a Líneas de Venta SKEEN,model_skeen_venta_line,base.group_user,1,1,1,1
access_skeen_corte_caja_user,Acceso a Cortes de Caja SKEEN,model_skeen_corte_caja,base.group_user,1,1,1,1
access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cambio,base.group_user,1,1,1,1
access_skeen_paquete_user,Acceso a Paquetes SKEEN,model_skeen_paquete,base.group_user,1,1,1,1
access_skeen_paquete_compra_user,Acceso a Compras de Paquete SKEEN,model_skeen_paquete_compra,base.group_user,1,1,1,1
access_skeen_paquete_consumo_user,Acceso a Consumos de Paquete SKEEN,model_skeen_paquete_consumo,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_skeen_venta_line_user Acceso a Líneas de Venta SKEEN model_skeen_venta_line base.group_user 1 1 1 1
4 access_skeen_corte_caja_user Acceso a Cortes de Caja SKEEN model_skeen_corte_caja base.group_user 1 1 1 1
5 access_skeen_tipo_cambio_user Acceso a Tipo de Cambio SKEEN model_skeen_tipo_cambio base.group_user 1 1 1 1
6 access_skeen_paquete_user Acceso a Paquetes SKEEN model_skeen_paquete base.group_user 1 1 1 1
7 access_skeen_paquete_compra_user Acceso a Compras de Paquete SKEEN model_skeen_paquete_compra base.group_user 1 1 1 1
8 access_skeen_paquete_consumo_user Acceso a Consumos de Paquete SKEEN model_skeen_paquete_consumo base.group_user 1 1 1 1

View File

@@ -4,5 +4,7 @@
<menuitem id="menu_skeen_ventas_root" name="Ventas" parent="skeen_citas.menu_skeen_root" sequence="30" />
<menuitem id="menu_skeen_venta" name="Ventas" parent="menu_skeen_ventas_root" action="action_skeen_venta" sequence="10" />
<menuitem id="menu_skeen_corte_caja" name="Cortes de Caja" parent="menu_skeen_ventas_root" action="action_skeen_corte_caja" sequence="20" />
<menuitem id="menu_skeen_paquete" name="Paquetes" parent="menu_skeen_ventas_root" action="action_skeen_paquete" sequence="30" />
<menuitem id="menu_skeen_paquete_compra" name="Paquetes de Pacientes" parent="menu_skeen_ventas_root" action="action_skeen_paquete_compra" sequence="40" />
</data>
</odoo>

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ============ Paquetes (catálogo) ============ -->
<record id="view_skeen_paquete_tree" model="ir.ui.view">
<field name="name">skeen.paquete.tree</field>
<field name="model">skeen.paquete</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle" />
<field name="name" />
<field name="service_id" />
<field name="sessions" />
<field name="price" />
<field name="validity_days" />
<field name="compras_count" />
<field name="active" />
</tree>
</field>
</record>
<record id="view_skeen_paquete_form" model="ir.ui.view">
<field name="name">skeen.paquete.form</field>
<field name="model">skeen.paquete</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Ej. Lumecca paquete 3 sesiones" /></h1>
</div>
<group>
<group>
<field name="service_id" />
<field name="sessions" />
<field name="price" />
<field name="duration_min" />
</group>
<group>
<field name="code" />
<field name="validity_days" />
<field name="color" widget="color" />
<field name="genera_monedero" />
<field name="active" />
<field name="sequence" />
</group>
</group>
<field name="notes" placeholder="Notas del paquete" />
<separator string="Compras" />
<field name="compra_ids">
<tree>
<field name="date" />
<field name="partner_id" />
<field name="sessions_total" />
<field name="sessions_used" />
<field name="sessions_remaining" />
<field name="expiry_date" />
<field name="state" />
</tree>
</field>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_paquete" model="ir.actions.act_window">
<field name="name">Paquetes</field>
<field name="res_model">skeen.paquete</field>
<field name="view_mode">tree,form</field>
</record>
<!-- ============ Paquetes comprados (saldos) ============ -->
<record id="view_skeen_paquete_compra_tree" model="ir.ui.view">
<field name="name">skeen.paquete.compra.tree</field>
<field name="model">skeen.paquete.compra</field>
<field name="arch" type="xml">
<tree decoration-success="state=='active'" decoration-muted="state=='cancelled'"
decoration-danger="state=='expired'" decoration-warning="state=='finished'">
<field name="date" />
<field name="partner_id" />
<field name="paquete_id" />
<field name="service_id" />
<field name="sessions_total" />
<field name="sessions_used" />
<field name="sessions_remaining" />
<field name="expiry_date" />
<field name="price" />
<field name="state" />
</tree>
</field>
</record>
<record id="view_skeen_paquete_compra_form" model="ir.ui.view">
<field name="name">skeen.paquete.compra.form</field>
<field name="model">skeen.paquete.compra</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="partner_id" />
<field name="paquete_id" />
<field name="service_id" />
<field name="date" />
</group>
<group>
<field name="sessions_total" />
<field name="sessions_used" />
<field name="sessions_remaining" />
<field name="expiry_date" />
<field name="state" />
<field name="venta_id" />
<field name="user_login" />
</group>
</group>
<separator string="Sesiones consumidas" />
<field name="line_ids">
<tree>
<field name="date" />
<field name="cita_id" />
<field name="state" />
</tree>
</field>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_paquete_compra" model="ir.actions.act_window">
<field name="name">Paquetes de Pacientes</field>
<field name="res_model">skeen.paquete.compra</field>
<field name="view_mode">tree,form</field>
</record>
</data>
</odoo>

View File

@@ -2114,13 +2114,52 @@ class SkeenFrontendController(http.Controller):
def patient_packages(self, patient_id, **kw):
"""Paquetes del paciente con sesiones restantes.
El sistema no guarda un contador de sesiones: el paquete se materializa
como las citas creadas para un servicio con `package_sessions > 0`
(create_appointment genera una cita por sesión). Por eso:
total = citas no canceladas del paciente con ese servicio
usadas = citas ya atendidas (llegó / en curso / completada)
Fuente principal: `skeen.paquete.compra`, el saldo que crea el POS al
vender un paquete y que la cita descuenta al atenderse.
De respaldo se mantiene la derivación antigua para pacientes migrados
del sistema legacy, donde el paquete nunca se registró como compra y
solo existe como las citas del servicio:
total = citas no canceladas del paciente con ese servicio
usadas = citas ya atendidas (llegó / en curso / completada)
restantes = total - usadas
"""
try:
Compras = request.env['skeen.paquete.compra'].sudo()
compras = Compras.search([
('partner_id', '=', patient_id),
('state', 'in', ('active', 'finished', 'expired')),
], order='date asc')
reales = []
servicios_con_compra = set()
for c in compras:
servicios_con_compra.add(c.service_id.id if c.service_id else None)
reales.append({
'id': c.id,
'compra': True,
'service_id': c.service_id.id if c.service_id else None,
'name': c.paquete_id.name if c.paquete_id else (c.service_id.name if c.service_id else ''),
'package_sessions': c.sessions_total,
'total': c.sessions_total,
'used': c.sessions_used,
'remaining': c.sessions_remaining,
'next_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
'state': c.state,
})
if reales:
heredados = self._paquetes_heredados(patient_id, servicios_con_compra)
return json_response({
'status': 'success',
'packages': sorted(reales + heredados,
key=lambda r: (-r['remaining'], r['name'])),
})
return json_response({'status': 'success',
'packages': self._paquetes_heredados(patient_id, set())})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _paquetes_heredados(self, patient_id, excluir_servicios):
"""Saldos de paquete derivados de las citas (pacientes del legacy)."""
try:
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([
@@ -2130,6 +2169,8 @@ class SkeenFrontendController(http.Controller):
])
grupos = {}
for c in citas:
if excluir_servicios and c.servicio_id.id in excluir_servicios:
continue
g = grupos.setdefault(c.servicio_id.id, {
'service_id': c.servicio_id.id,
'name': c.servicio_id.name,
@@ -2146,12 +2187,288 @@ class SkeenFrontendController(http.Controller):
rows = []
for g in grupos.values():
g['remaining'] = max(0, g['total'] - g['used'])
g['compra'] = False
g['id'] = None
g['state'] = 'active' if g['remaining'] else 'finished'
rows.append(g)
rows.sort(key=lambda r: (-r['remaining'], r['name']))
return json_response({'status': 'success', 'packages': rows})
return rows
except Exception:
return []
# ============================================================
# Paquetes de sesiones
# ============================================================
def _paquete_to_dict(self, p):
servicio = p.service_id
return {
'id': p.id,
'name': p.name,
'code': p.code or '',
'service_id': servicio.id if servicio else None,
'service': servicio.name if servicio else '',
'sessions': p.sessions,
'price': p.price,
'price_per_session': round(p.price / p.sessions, 2) if p.sessions else 0.0,
'session_price': servicio.price if servicio else 0.0,
'duration_min': p.duration_min or (servicio.duration_min if servicio else 0),
'validity_days': p.validity_days or 0,
'notes': p.notes or '',
'color': p.color or '#1abc9c',
'genera_monedero': p.genera_monedero,
'active': p.active,
'sequence': p.sequence or 0,
'compras_count': p.compras_count,
}
def _paquete_compra_to_dict(self, c):
return {
'id': c.id,
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
'patient_id': c.partner_id.id,
'patient': c.partner_id.name or '',
'phone': c.partner_id.phone or '',
'paquete_id': c.paquete_id.id if c.paquete_id else None,
'paquete': c.paquete_id.name if c.paquete_id else '',
'service_id': c.service_id.id if c.service_id else None,
'service': c.service_id.name if c.service_id else '',
'sessions_total': c.sessions_total,
'sessions_used': c.sessions_used,
'sessions_remaining': c.sessions_remaining,
'price': c.price,
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
'state': c.state,
'venta_id': c.venta_id.id if c.venta_id else None,
'venta': c.venta_id.name if c.venta_id else '',
'user_login': c.user_login or '',
}
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_paquetes(self, **kw):
"""Catálogo de paquetes. `include_inactive=1` para ver los dados de baja."""
try:
domain = []
if not (kw.get('include_inactive') or '').strip():
domain.append(('active', '=', True))
if kw.get('search'):
s = kw['search']
domain += ['|', ('name', 'ilike', s), ('code', 'ilike', s)]
if kw.get('service_id'):
domain.append(('service_id', '=', int(kw['service_id'])))
rows = request.env['skeen.paquete'].sudo().with_context(
active_test=False).search(domain, order='sequence asc, name asc')
return json_response({'status': 'success', 'paquetes': [
self._paquete_to_dict(p) for p in rows]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('admin')
def create_paquete(self, **kw):
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
if not data.get('service_id'):
return json_response({'status': 'error',
'message': 'El servicio es obligatorio'}, 400)
Paq = request.env['skeen.paquete'].sudo()
if Paq.search([('name', '=ilike', name)], limit=1):
return json_response({'status': 'error',
'message': f'Ya existe un paquete "{name}".'}, 409)
vals = {
'name': name,
'code': (data.get('code') or '').strip(),
'service_id': int(data['service_id']),
'sessions': int(data.get('sessions') or 1),
'price': float(data.get('price') or 0.0),
'duration_min': int(data.get('duration_min') or 0),
'validity_days': int(data.get('validity_days') or 0),
'notes': data.get('notes') or '',
'color': data.get('color') or '#1abc9c',
'genera_monedero': bool(data.get('genera_monedero', True)),
}
try:
p = Paq.create(vals)
except ValidationError as e:
return json_response({'status': 'error',
'message': e.args[0] if e.args else str(e)}, 400)
self._auditar('paquetes', 'skeen.paquete', 'create',
new_value=p.name, entity_label=p.name,
notes=f'{p.sessions} sesiones · {p.price}')
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/<int:paquete_id>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('admin')
def update_paquete(self, paquete_id, **kw):
try:
p = request.env['skeen.paquete'].sudo().with_context(active_test=False).browse(paquete_id)
if not p.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
mapping = {
'name': 'name', 'code': 'code', 'notes': 'notes', 'color': 'color',
}
vals = {}
for key, field in mapping.items():
if key in data:
vals[field] = (data[key] or '').strip() if field != 'notes' else (data[key] or '')
if 'service_id' in data and data['service_id']:
vals['service_id'] = int(data['service_id'])
for key in ('sessions', 'duration_min', 'validity_days', 'sequence'):
if key in data:
vals[key] = int(data[key] or 0)
for key in ('price',):
if key in data:
vals[key] = float(data[key] or 0.0)
for key in ('genera_monedero', 'active'):
if key in data:
vals[key] = bool(data[key])
if 'active' in vals and vals['active'] != p.active:
self._auditar('paquetes', 'skeen.paquete', 'active',
old_value='activo' if p.active else 'inactivo',
new_value='activo' if vals['active'] else 'inactivo',
entity_label=p.name,
notes='Alta de baja' if not vals['active'] else 'Reactivación')
try:
p.write(vals)
except ValidationError as e:
return json_response({'status': 'error',
'message': e.args[0] if e.args else str(e)}, 400)
self._auditar('paquetes', 'skeen.paquete', 'edit',
new_value=', '.join(vals.keys()), entity_label=p.name)
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_paquetes_compras(self, **kw):
"""Paquetes vendidos con su saldo.
Filtros: patient_id, state (active|finished|expired|cancelled),
doctor_id, search (paciente o paquete), start/end por fecha de compra.
"""
try:
domain = []
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw['patient_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
elif not (kw.get('include_all') or '').strip():
domain.append(('state', '!=', 'cancelled'))
if kw.get('search'):
s = f"%{kw['search']}%"
domain += ['|', ('partner_id.name', 'ilike', s),
('paquete_id.name', 'ilike', s)]
if kw.get('start'):
domain.append(('date', '>=', kw['start']))
if kw.get('end'):
domain.append(('date', '<=', kw['end']))
if kw.get('doctor_id'):
domain.append(('line_ids.cita_id.doctor_id', '=', int(kw['doctor_id'])))
limit = min(max(int(kw.get('limit', 300) or 300), 1), 1000)
rows = request.env['skeen.paquete.compra'].sudo().search(
domain, order='date desc, id desc', limit=limit)
totales = {
'vigentes': sum(1 for c in rows if c.state == 'active'),
'completados': sum(1 for c in rows if c.state == 'finished'),
'vencidos': sum(1 for c in rows if c.state == 'expired'),
'sesiones_restantes': sum(c.sessions_remaining for c in rows
if c.state == 'active'),
'importe': sum(c.price for c in rows),
}
return json_response({'status': 'success',
'compras': [self._paquete_compra_to_dict(c) for c in rows],
'totales': totales})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/consumos',
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def paquete_compra_consumos(self, compra_id, **kw):
"""Bitácora de sesiones consumidas por un paquete (con su cita)."""
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error',
'message': 'Paquete no encontrado'}, 404)
filas = []
for mov in c.line_ids:
cita = mov.cita_id
filas.append({
'id': mov.id,
'date': mov.date.strftime('%Y-%m-%d %H:%M') if mov.date else None,
'state': mov.state,
'cita_id': cita.id if cita else None,
'cita_fecha': cita.date.strftime('%Y-%m-%d') if (cita and cita.date) else None,
'cita_hora': cita.time_str if cita else '',
'doctor': cita.doctor_id.name if (cita and cita.doctor_id) else '',
'estado_cita': cita.state if cita else '',
})
filas.sort(key=lambda f: (f['cita_fecha'] or '', f['cita_hora'] or ''))
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c),
'consumos': filas})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>', type='http',
auth='none', methods=['PUT', 'PATCH'], csrf=False)
@require_role('recepcion')
def update_paquete_compra(self, compra_id, **kw):
"""Ajuste manual del saldo (sesiones compradas / usadas)."""
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
before = f"{c.sessions_total}/{c.sessions_used}"
try:
c.action_adjust_sessions(data.get('sessions_total'), data.get('sessions_used'))
except (ValueError, TypeError):
return json_response({'status': 'error',
'message': 'Las sesiones deben ser números enteros'}, 400)
self._auditar('paquetes', 'skeen.paquete.compra', 'sessions',
old_value=before,
new_value=f"{c.sessions_total}/{c.sessions_used}",
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
notes='Ajuste manual de saldo')
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/anular',
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def anular_paquete_compra(self, compra_id, **kw):
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
motivo = (data.get('reason') or '').strip()
if not motivo:
return json_response({'status': 'error',
'message': 'Indica el motivo de la anulación'}, 400)
c.action_cancel()
c.write({'sessions_total': 0})
self._auditar('paquetes', 'skeen.paquete.compra', 'state',
old_value='activo', new_value='anulado',
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
notes=motivo)
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Adeudos: cobro con recibo y plan de pagos
# ============================================================
@@ -4270,6 +4587,7 @@ class SkeenFrontendController(http.Controller):
discount = float(data.get('discount', 0) or 0)
line_vals = []
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
stock_warnings = []
subtotal = 0.0
for l in lines:
@@ -4279,6 +4597,12 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
service_id = int(l.get('service_id', 0) or 0)
item_id = int(l.get('item_id', 0) or 0)
paquete_id = int(l.get('paquete_id', 0) or 0)
if paquete_id and not service_id:
# Una línea de paquete también consume servicio (el del paquete)
# para que el reporte de comisiones no se rompa.
service_id = int(request.env['skeen.paquete'].sudo().browse(
paquete_id).service_id.id or 0)
if bool(service_id) == bool(item_id):
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
subtotal += qty * price
@@ -4308,7 +4632,10 @@ class SkeenFrontendController(http.Controller):
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
'paquete_id': paquete_id or False,
}))
if paquete_id:
paquete_lines.append((paquete_id, qty, price))
total_est = max(0.0, subtotal - discount)
# Moneda y tipo de cambio: validar ANTES de crear nada
@@ -4343,6 +4670,25 @@ class SkeenFrontendController(http.Controller):
})
venta.action_confirm()
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
# las compras a las líneas reales de la venta para poder revertirlas.
compras_creadas = []
if paquete_lines:
Compra = request.env['skeen.paquete.compra'].sudo()
lineas_venta = venta.line_ids.filtered(lambda ln: ln.paquete_id)
for line in lineas_venta:
for _i in range(int(line.quantity or 1)):
compras_creadas.append(Compra.create({
'partner_id': partner.id,
'paquete_id': line.paquete_id.id,
'venta_id': venta.id,
'sale_line_id': line.id,
'date': venta.date or fields.Date.today(),
'sessions_total': line.paquete_id.sessions,
'price': line.price_unit,
'user_login': cobrador,
}))
# Descargo de inventario por cada artículo (el create del move ajusta el stock)
if item_lines:
Move = request.env['skeen.inventario.move'].sudo()
@@ -4425,6 +4771,14 @@ class SkeenFrontendController(http.Controller):
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points,
'stock_warnings': stock_warnings,
'packages': [{
'id': c.id,
'paquete': c.paquete_id.name,
'service_id': c.service_id.id if c.service_id else None,
'sessions_total': c.sessions_total,
'sessions_remaining': c.sessions_remaining,
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
} for c in compras_creadas],
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)

View File

@@ -29,6 +29,12 @@ class SkeenConfigAudit(models.Model):
('pacientes', 'Expedientes de pacientes'),
('catalogos', 'Catálogos'),
('clinica', 'Clínica'),
# 'agenda' (bloqueos y lista de espera) y 'paquetes' se escriben desde
# los endpoints del frontend; al no estar en la lista, Odoo rechazaba el
# create y el _auditar() lo silenciaba, perdiendo esos registros.
('agenda', 'Agenda'),
('paquetes', 'Paquetes'),
('monedero', 'Monedero'),
], string='Módulo', required=True, index=True)
entity = fields.Char(string='Entidad', index=True,

View File

@@ -58,6 +58,7 @@ DEFAULT_MENU_ROLES = {
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],
@@ -115,12 +116,20 @@ def default_menu_roles():
def get_menu_roles(env):
"""Matriz vigente: la guardada en ir.config_parameter o los defaults."""
"""Matriz vigente: la guardada en ir.config_parameter o los defaults.
La matriz guardada manda sobre los defaults, PERO se completan en memoria
las rutas que no existan en ella. Sin eso, una ruta nueva (p. ej.
'/paquetes') no aparecería en la matriz guardada y el frontend la trataría
como "visible para todos" por no matchear ninguna llave.
"""
raw = env['ir.config_parameter'].sudo().get_param(MENU_ROLES_KEY)
if raw:
try:
val = json.loads(raw)
if isinstance(val, dict):
for href, roles in default_menu_roles().items():
val.setdefault(href, roles)
return val
except Exception:
pass