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

@@ -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')]