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:
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import recurso
|
||||
from . import cita
|
||||
from . import bloqueo
|
||||
from . import lista_espera
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
59
odoo-addons/skeen_citas/models/recurso.py
Normal file
59
odoo-addons/skeen_citas/models/recurso.py
Normal 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')]
|
||||
Reference in New Issue
Block a user