fase 3.1: recursos fisicos en agenda (modelo skeen.recurso con semilla cabina/consultoria, asignacion multiple a citas con validacion de solape, bloqueos por recurso, lista de espera desde recursos, vista/filtro por recurso, reporte de ocupacion); apagado por defecto

This commit is contained in:
2026-10-11 11:33:59 +00:00
parent 8ff13b9c2a
commit ee8a844d96
29 changed files with 1986 additions and 78 deletions

View File

@@ -20,6 +20,7 @@
'views/cita_views.xml',
'views/bloqueo_views.xml',
'views/lista_espera_views.xml',
'views/recurso_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -606,6 +606,66 @@ def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
# ---------------------------------------------------------------------------
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la agenda — no aparece la
# sección en Configuración ni el selector en el modal de cita, la agenda se ve
# igual que hoy y no corre ninguna validación de solape de recursos. Por eso el
# default es apagado.
#
# activo: enciende todo el módulo de recursos
# validar_solape: impide dos citas/bloqueos en el mismo recurso a la
# misma hora (solo aplica si activo)
# obligatorio_al_agendar: exige elegir al menos un recurso al crear la cita
# permitir_multiples: permite asignar más de un recurso a una cita
# mostrar_en_agenda: muestra el recurso (chips/color) en las tarjetas
# filtrar_como_doctores: ofrece el filtro por recurso junto al de doctores
# ---------------------------------------------------------------------------
RECURSOS_DEFAULT = {
'activo': False,
'validar_solape': True,
'obligatorio_al_agendar': False,
'permitir_multiples': True,
'mostrar_en_agenda': True,
'filtrar_como_doctores': True,
}
RECURSOS_BOOLEANOS = tuple(RECURSOS_DEFAULT)
def normalizar_recursos(raw):
"""Bloque `recursos` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RECURSOS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in RECURSOS_BOOLEANOS:
out[k] = _bool(raw.get(k), RECURSOS_DEFAULT[k])
return out
def validar_recursos(raw):
"""Valida el bloque `recursos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de recursos debe ser un objeto')
out = {}
for k in RECURSOS_BOOLEANOS:
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
return out
def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -649,6 +709,7 @@ def get_settings(env):
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -664,6 +725,7 @@ def get_settings(env):
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
return out
@@ -692,6 +754,10 @@ def set_settings(env, vals):
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque}
if 'recursos' in vals:
bloque = validar_recursos(vals['recursos'])
if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -45,5 +45,24 @@
<field name="sequence">40</field>
<field name="is_system" eval="True"/>
</record>
<!-- Recursos de la agenda (cabinas, salas, consultorios, equipos).
Los códigos 'cabina'/'consultoria' conservan los valores que antes
estaban hardcodeados en la lista de espera, para que los registros
históricos sigan mostrándose igual. -->
<record id="recurso_cabina" model="skeen.recurso">
<field name="name">Cabina</field>
<field name="code">cabina</field>
<field name="tipo">cabina</field>
<field name="sequence">10</field>
<field name="activo" eval="True"/>
</record>
<record id="recurso_consultoria" model="skeen.recurso">
<field name="name">Consultoría</field>
<field name="code">consultoria</field>
<field name="tipo">consultorio</field>
<field name="sequence">20</field>
<field name="activo" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import recurso
from . import cita
from . import bloqueo
from . import lista_espera

View File

@@ -3,6 +3,8 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..clinic_settings import get_recursos
# Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida)
MOTIVO_SELECTION = [
('vacaciones', 'Vacaciones'),
@@ -14,17 +16,24 @@ MOTIVO_SELECTION = [
class SkeenCitaBloqueo(models.Model):
"""Bloqueo de agenda de un médico para una fecha concreta.
"""Bloqueo de agenda: de un médico y/o de un recurso físico, en una fecha.
Un registro = un médico × una fecha. Los bloqueos creados juntos (varios
médicos y/o un rango de fechas con repetición semanal) comparten el campo
`grupo`, lo que permite editarlos o eliminarlos como serie.
Un registro = una fecha, con un médico (opcional) y/o recursos (opcional).
Los bloqueos creados juntos (varios médicos/recursos y/o un rango de fechas
con repetición semanal) comparten el campo `grupo`, lo que permite editarlos
o eliminarlos como serie.
`doctor_id` dejó de ser obligatorio para poder bloquear una cabina, sala o
equipo sin médico (módulo de recursos). Debe haber al menos uno de los dos.
"""
_name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico'
_description = 'Bloqueo de Agenda'
_order = 'date desc, time_from'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
doctor_id = fields.Many2one('hr.employee', string='Médico')
recurso_ids = fields.Many2many(
'skeen.recurso', 'skeen_bloqueo_recurso_rel', 'bloqueo_id', 'recurso_id',
string='Recursos')
date = fields.Date(string='Fecha', required=True, index=True)
all_day = fields.Boolean(string='Todo el día', default=False)
time_from = fields.Float(string='Desde', default=9.0)
@@ -32,7 +41,7 @@ class SkeenCitaBloqueo(models.Model):
motivo_tipo = fields.Selection(MOTIVO_SELECTION, string='Motivo', default='otro')
motivo = fields.Char(string='Detalle del motivo')
grupo = fields.Char(string='Grupo', index=True, copy=False,
help='Identificador de la serie (médicos y fechas creados juntos).')
help='Identificador de la serie (médicos/recursos y fechas creados juntos).')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
@api.constrains('time_from', 'time_to', 'all_day')
@@ -44,9 +53,18 @@ class SkeenCitaBloqueo(models.Model):
if b.time_from < 0 or b.time_to > 24:
raise ValidationError(_('Las horas deben estar dentro del día'))
@api.constrains('doctor_id', 'recurso_ids')
def _check_destino(self):
"""Un bloqueo se hace para un médico y/o un recurso: sin destino no
tiene sentido (y no bloquearía nada)."""
for b in self:
if not b.doctor_id and not b.recurso_ids:
raise ValidationError(_(
'Selecciona al menos un médico o un recurso para bloquear.'))
@api.constrains('doctor_id', 'date', 'all_day', 'time_from', 'time_to')
def _check_sin_citas(self):
"""No se puede bloquear un rango que ya tiene citas activas.
"""No se puede bloquear un rango que ya tiene citas activas del médico.
Evita "cerrar la agenda" dejando citas dentro del bloqueo. Al editar un
bloqueo se ignora a sí mismo (no tiene citas asociadas, pero el dominio
@@ -74,6 +92,56 @@ class SkeenCitaBloqueo(models.Model):
'Reprograma o cancela esas citas antes de bloquear.'
) % {'n': len(citas), 'doc': b.doctor_id.name})
@api.constrains('recurso_ids', 'date', 'all_day', 'time_from', 'time_to')
def _check_recursos(self):
"""Un recurso no se puede bloquear si tiene citas activas dentro, ni
solaparse con otro bloqueo del mismo recurso.
Sólo corre con `recursos.activo` (default apagado → sin cambios). El
solape con otro bloqueo respeta además `validar_solape`.
"""
if self.env.context.get('skeen_skip_recursos_solape'):
return
cfg = get_recursos(self.env)
if not cfg['activo']:
return
Citas = self.env['skeen.cita']
for b in self:
if not b.date or not b.recurso_ids:
continue
if b.all_day:
ini_c, fin_c = 0.0, 24.0
else:
ini_c, fin_c = b.time_from, b.time_to
for recurso in b.recurso_ids:
citas = Citas.search([
('date', '=', b.date),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
('recurso_ids', 'in', recurso.id),
]).filtered(
lambda c: b.all_day or (
c.time < fin_c and c.time + c._duracion() / 60.0 > ini_c)
)
if citas:
raise ValidationError(_(
'%(rec)s tiene %(n)s cita(s) en ese horario. Reprograma o '
'cancela esas citas antes de bloquear.'
) % {'rec': recurso.name, 'n': len(citas)})
if not cfg['validar_solape']:
continue
otros = self.search([
('id', '!=', b.id),
('date', '=', b.date),
('recurso_ids', 'in', recurso.id),
]).filtered(
lambda o: (o.all_day or b.all_day)
or (ini_c < o.time_to and fin_c > o.time_from)
)
if otros:
raise ValidationError(_(
'%(rec)s ya tiene un bloqueo en ese horario (%(motivo)s).'
) % {'rec': recurso.name, 'motivo': otros[0].nombre_motivo()})
def nombre_motivo(self):
"""Texto legible del motivo para la UI y los mensajes de validación."""
self.ensure_one()

View File

@@ -4,7 +4,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from datetime import datetime, timedelta
from ..clinic_settings import agenda_bounds
from ..clinic_settings import agenda_bounds, get_recursos
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
@@ -134,6 +134,11 @@ class SkeenCita(models.Model):
string='Sucursal',
default=lambda self: self.env['skeen.sucursal'].default_code())
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
# Recursos físicos asignados (cabina, sala, equipo). Vacío mientras la
# configuración de la clínica no active el módulo de recursos.
recurso_ids = fields.Many2many(
'skeen.recurso', 'skeen_cita_recurso_rel', 'cita_id', 'recurso_id',
string='Recursos')
medium = fields.Selection([
('videocall', 'Videollamada'),
('onsite', 'Presencial'),
@@ -432,6 +437,52 @@ class SkeenCita(models.Model):
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % b.nombre_motivo())
@api.constrains('date', 'time', 'servicio_id', 'duration', 'recurso_ids', 'state')
def _check_recursos_solape(self):
"""Rechaza citas que pisen un recurso ya ocupado.
Solo corre cuando la clínica tiene `recursos.activo` y `validar_solape`;
con el módulo apagado (default) no cambia nada. Un recurso está ocupado
por otra cita activa (excluye cancelled/no_show/waitlist) o por un
bloqueo de agenda del recurso. El mensaje indica el recurso y con quién
choca, para que la UI lo muestre legible.
"""
if self.env.context.get('skeen_skip_recursos_solape'):
return
cfg = get_recursos(self.env)
if not (cfg['activo'] and cfg['validar_solape']):
return
Bloqueo = self.env['skeen.cita.bloqueo']
for cita in self:
if cita.state in INACTIVE_STATES or not cita.date or not cita.recurso_ids:
continue
ini = cita.time
fin = ini + cita._duracion() / 60.0
for recurso in cita.recurso_ids:
otra = self.search([
('id', '!=', cita.id),
('date', '=', cita.date),
('state', 'not in', INACTIVE_STATES),
('recurso_ids', 'in', recurso.id),
('datetime_start', '<', cita.datetime_end),
('datetime_end', '>', cita.datetime_start),
], limit=1)
if otra:
raise ValidationError(_(
'%(rec)s está ocupado por %(pac)s a las %(hora)s. '
'Elige otro horario o desasigna el recurso.'
) % {'rec': recurso.name, 'pac': otra.partner_id.name or 'otra cita',
'hora': otra.time_str or ''})
bloqueos = Bloqueo.search([
('date', '=', cita.date),
('recurso_ids', 'in', recurso.id),
])
for b in bloqueos:
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_(
'%(rec)s tiene la agenda bloqueada (%(motivo)s).'
) % {'rec': recurso.name, 'motivo': b.nombre_motivo()})
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_horario_medico(self):
"""Rechaza citas fuera del horario semanal del médico.

View File

@@ -45,13 +45,13 @@ class SkeenListaEspera(models.Model):
('tarde', 'Tarde'),
], string='Preferencia', default='')
# Dónde espera el paciente: consultoría (consulta con el médico) o cabina
# (tratamiento/valoración). Recepción lo mueve con un click en la etiqueta.
sala = fields.Selection([
('', 'Sin asignar'),
('consultoria', 'Consultoría'),
('cabina', 'Cabina'),
], string='Sala de espera', default='', index=True)
# Dónde espera el paciente: sale del catálogo de recursos (cabina, sala,
# consultorio...) para que el white-label pueda renombrarlos. Los códigos
# semilla 'cabina'/'consultoria' conservan el comportamiento y los datos
# históricos; sin recursos configurados cae a los valores de siempre.
sala = fields.Selection(
selection='_get_sala_selection',
string='Sala de espera', default='', index=True)
date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta')
@@ -88,6 +88,19 @@ class SkeenListaEspera(models.Model):
) % {'nombre': rec.partner_id.name,
'doc': rec.doctor_id.name or 'sin médico asignado'})
@api.model
def _get_sala_selection(self):
"""Opciones de sala: los recursos activos (o los valores de siempre si
aún no hay ninguno). El '' vacío = sin asignar se agrega en la UI."""
return self.env['skeen.recurso']._seleccion_activos()
@api.model
def sala_label(self, value):
"""Nombre legible de una sala ('' o desconocida → '')."""
if not value:
return ''
return dict(self._get_sala_selection()).get(value, value)
@api.depends('priority')
def _compute_priority_rank(self):
for rec in self:

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
# Tipos de recurso físico de la agenda. Los códigos son estables: la lógica
# interna solo usa `code`/`tipo` como etiqueta, nunca para decidir solapes.
RECURSO_TIPOS = [
('cabina', 'Cabina'),
('sala', 'Sala'),
('consultorio', 'Consultorio'),
('equipo', 'Equipo'),
('otro', 'Otro'),
]
class SkeenRecurso(models.Model):
"""Recurso físico que se puede asignar a las citas (cabina, sala, equipo).
Sustituye a las dos opciones que estaban hardcodeadas en la lista de
espera ('cabina'/'consultoria'): los recursos semilla conservan esos
códigos para que los registros históricos sigan mostrándose igual.
`sucursales` vacío = el recurso aplica a todas las sucursales.
Todo el módulo queda apagado con `recursos.activo=False` (por defecto):
mientras no se active, este catálogo no cambia nada en la agenda.
"""
_name = 'skeen.recurso'
_description = 'Recurso de la agenda (cabina, sala, consultorio, equipo)'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(string='Código', required=True, copy=False,
help='Identificador estable que se guarda en citas y bloqueos.')
tipo = fields.Selection(RECURSO_TIPOS, string='Tipo', default='cabina', required=True)
activo = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)
sucursales = fields.Many2many(
'skeen.sucursal', 'skeen_recurso_sucursal_rel', 'recurso_id', 'sucursal_id',
string='Sucursales',
help='Sucursales donde está disponible. Vacío = todas.')
capacidad = fields.Integer(string='Capacidad', default=0)
costo_hora = fields.Float(string='Costo por hora', default=0.0,
help='Costo operativo del recurso por hora, para estimar ingresos en reportes.')
color = fields.Char(string='Color', help='Color hex para distinguirlo en la agenda (p. ej. #1abc9c).')
notas = fields.Text(string='Notas')
cita_ids = fields.Many2many('skeen.cita', string='Citas')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código del recurso debe ser único!')
]
@api.model
def _seleccion_activos(self):
"""[(code, name)] de los recursos activos. Fallback a los valores de
siempre cuando todavía no hay recursos (instancia sin migrar)."""
recs = self.sudo().search([('activo', '=', True)], order='sequence, name')
seleccion = [(r.code, r.name) for r in recs if r.code]
return seleccion or [('consultoria', 'Consultoría'), ('cabina', 'Cabina')]

View File

@@ -7,3 +7,4 @@ access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
access_skeen_sucursal_user,Acceso a Sucursales SKEEN,model_skeen_sucursal,base.group_user,1,1,1,1
access_skeen_service_category_user,Acceso a Categorias de Servicio SKEEN,model_skeen_service_category,base.group_user,1,1,1,1
access_skeen_recurso_user,Acceso a Recursos SKEEN,model_skeen_recurso,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1
8 access_skeen_sucursal_user Acceso a Sucursales SKEEN model_skeen_sucursal base.group_user 1 1 1 1
9 access_skeen_service_category_user Acceso a Categorias de Servicio SKEEN model_skeen_service_category base.group_user 1 1 1 1
10 access_skeen_recurso_user Acceso a Recursos SKEEN model_skeen_recurso base.group_user 1 1 1 1

View File

@@ -8,6 +8,7 @@
<tree>
<field name="date"/>
<field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"/>
<field name="all_day"/>
<field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/>
@@ -29,6 +30,8 @@
<group>
<group>
<field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="date"/>
<field name="motivo_tipo"/>
<field name="motivo"/>

View File

@@ -46,6 +46,8 @@
<field name="servicio_id"/>
<field name="servicio_price" readonly="1"/>
<field name="servicio_duration" readonly="1"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="duration"/>
<field name="date"/>
<field name="time" widget="float_time"/>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de recursos -->
<record id="view_skeen_recurso_tree" model="ir.ui.view">
<field name="name">skeen.recurso.tree</field>
<field name="model">skeen.recurso</field>
<field name="arch" type="xml">
<tree decoration-muted="not activo">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="tipo"/>
<field name="capacidad"/>
<field name="costo_hora"/>
<field name="activo"/>
</tree>
</field>
</record>
<!-- Vista de formulario de recursos -->
<record id="view_skeen_recurso_form" model="ir.ui.view">
<field name="name">skeen.recurso.form</field>
<field name="model">skeen.recurso</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="tipo"/>
<field name="activo"/>
</group>
<group>
<field name="sequence"/>
<field name="capacidad"/>
<field name="costo_hora"/>
<field name="color" placeholder="#1abc9c"/>
</group>
</group>
<group string="Sucursales">
<field name="sucursales" widget="many2many_tags"
options="{'no_create': True}"/>
</group>
<group string="Notas">
<field name="notas" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Acción de recursos -->
<record id="action_skeen_recurso_tree" model="ir.actions.act_window">
<field name="name">Recursos</field>
<field name="res_model">skeen.recurso</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Recursos (bajo el menú Citas) -->
<menuitem id="menu_skeen_citas_recursos" name="Recursos" parent="menu_skeen_citas"
action="action_skeen_recurso_tree" sequence="40"/>
</odoo>