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:
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from . import tipo_cambio
|
||||
from . import pago
|
||||
from . import venta
|
||||
from . import paquete
|
||||
|
||||
279
odoo-addons/skeen_ventas/models/paquete.py
Normal file
279
odoo-addons/skeen_ventas/models/paquete.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
|
||||
135
odoo-addons/skeen_ventas/views/paquete_views.xml
Normal file
135
odoo-addons/skeen_ventas/views/paquete_views.xml
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user