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>
|
||||
Reference in New Issue
Block a user