Agenda: cierre rápido de agendas por médico, lista de espera, duraciones 30/45/60, checklist de estados con bitácora y auto-refresh 30s

- BloqueosModal: alta multi-médico, rango repetible por día de la semana,
  motivos predefinidos, edición/borrado y cierre rápido desde la cabecera
  de cada columna (todo el día / resto del día / rango).
- skeen.lista.espera + panel y modal en la agenda: prioridad, franja, rango
  de fechas, agendar precargado, avisar por WhatsApp y aviso cuando se
  libera un hueco compatible.
- duration en skeen.cita (0 = la del servicio): la cita ocupa sus bloques de
  15 min reales en las 4 vistas, con celdas de continuación.
- Checklist de estados (Agendada → Confirmada → En cabina → En consulta →
  Completada) con avance/retroceso, cancelación y no-show con confirmación,
  y bitácora de quién/cuándo en skeen.cita.state.log.
- Etiquetas del sistema legacy alineadas (Canceló / Lista de espera / En
  cabina) en badges de agenda y dashboard.
- Auto refresco silencioso cada 30 s (pausable, se pospone con modales,
  escritura o drag) en citas + bloqueos + lista de espera en un solo request.
This commit is contained in:
Kimi
2026-09-24 15:46:38 +00:00
parent e32a79084a
commit d0eac00a40
19 changed files with 2964 additions and 328 deletions

View File

@@ -2,3 +2,4 @@
from . import cita
from . import bloqueo
from . import lista_espera

View File

@@ -3,21 +3,81 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
# Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida)
MOTIVO_SELECTION = [
('vacaciones', 'Vacaciones'),
('junta', 'Junta'),
('comida', 'Comida'),
('personal', 'Personal'),
('otro', 'Otro'),
]
class SkeenCitaBloqueo(models.Model):
"""Bloqueo de agenda de un médico para una fecha concreta.
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.
"""
_name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico'
_order = 'date desc'
_order = 'date desc, time_from'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
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)
time_to = fields.Float(string='Hasta', default=10.0)
motivo = fields.Char(string='Motivo')
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).')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
@api.constrains('time_from', 'time_to', 'all_day')
def _check_horas(self):
for b in self:
if not b.all_day and b.time_to <= b.time_from:
raise ValidationError(_('La hora final debe ser mayor a la inicial'))
if not b.all_day:
if b.time_to <= b.time_from:
raise ValidationError(_('La hora final debe ser mayor a la inicial'))
if b.time_from < 0 or b.time_to > 24:
raise ValidationError(_('Las horas deben estar dentro del día'))
@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.
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
es el mismo que usa la validación de citas).
"""
Citas = self.env['skeen.cita']
if self.env.context.get('skeen_skip_bloqueo_citas'):
return
for b in self:
if not b.doctor_id or not b.date:
continue
domain = [
('doctor_id', '=', b.doctor_id.id),
('date', '=', b.date),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
]
if not b.all_day:
domain += [('time', '<', b.time_to)]
citas = Citas.search(domain).filtered(
lambda c: b.all_day or (c.time + c._duracion() / 60.0) > b.time_from
)
if citas:
raise ValidationError(_(
'Hay %(n)s cita(s) agendadas en ese horario con %(doc)s. '
'Reprograma o cancela esas citas antes de bloquear.'
) % {'n': len(citas), 'doc': b.doctor_id.name})
def nombre_motivo(self):
"""Texto legible del motivo para la UI y los mensajes de validación."""
self.ensure_one()
etiqueta = dict(MOTIVO_SELECTION).get(self.motivo_tipo or '', '')
if self.motivo and etiqueta:
return '%s — %s' % (etiqueta, self.motivo)
return self.motivo or etiqueta or 'agenda bloqueada'

View File

@@ -5,6 +5,17 @@ from odoo.exceptions import ValidationError
from datetime import datetime, timedelta
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
# 'waitlist' (Lista de espera) es una cita tentativa sin hueco reservado.
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
# Orden del checklist de atención (permite avanzar y retroceder paso a paso).
STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
# Duración de respaldo cuando ni la cita ni el servicio la definen
DEFAULT_DURATION = 30
class SkeenServicio(models.Model):
_name = 'skeen.servicio'
_description = 'Servicio Dermatológico'
@@ -107,16 +118,28 @@ class SkeenCita(models.Model):
('scheduled', 'Agendar'),
], string='Estado de Flujo', default='scheduled')
# Estado
# Estado (rubros alineados al vocabulario de la clínica; 'waitlist' viene
# de la leyenda del sistema legacy: 4/5 = Lista de espera)
state = fields.Selection([
('pending', 'Pendiente'),
('pending', 'Agendada'),
('confirmed', 'Confirmada'),
('arrived', 'Llegó'),
('in_progress', 'En Progreso'),
('arrived', 'En cabina'),
('in_progress', 'En consulta'),
('done', 'Completada'),
('cancelled', 'Cancelada'),
('no_show', 'No Show'),
('cancelled', 'Canceló'),
('no_show', 'No se presentó'),
('waitlist', 'Lista de espera'),
], string='Estado', default='pending', tracking=True)
# Duración propia de la cita en minutos. 0 = usar la del servicio.
# Permite que una cita de 60 min ocupe 4 bloques de 15 min en la agenda.
duration = fields.Integer(
string='Duración (min)', default=0,
help='Duración de esta cita. 0 = usar la duración del servicio.')
# Bitácora de cambios de estado (avance y retroceso del checklist)
state_log_ids = fields.One2many(
'skeen.cita.state.log', 'cita_id', string='Bitácora de estados')
# Notas
notes = fields.Text(string='Notas')
@@ -151,17 +174,14 @@ class SkeenCita(models.Model):
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
@api.depends('date', 'time', 'servicio_id')
@api.depends('date', 'time', 'servicio_id', 'duration')
def _compute_datetime(self):
for cita in self:
if cita.date and cita.time is not False:
hours = int(cita.time)
minutes = int((cita.time - hours) * 60)
cita.datetime_start = datetime.combine(cita.date, datetime.min.time().replace(hour=hours, minute=minutes))
if cita.servicio_id:
cita.datetime_end = cita.datetime_start + timedelta(minutes=cita.servicio_id.duration_min)
else:
cita.datetime_end = cita.datetime_start + timedelta(minutes=30)
cita.datetime_end = cita.datetime_start + timedelta(minutes=cita._duracion())
@api.depends('time')
def _compute_time_str(self):
@@ -180,20 +200,97 @@ class SkeenCita(models.Model):
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.cita') or 'CITA-001'
return super(SkeenCita, self).create(vals_list)
def _duracion(self):
"""Duración efectiva de la cita en minutos.
Prioridad: `duration` propio de la cita -> `duration_min` del servicio
-> 30 min. Es la base de los solapes, los bloqueos, el horario del
médico y el pintado del grid de agenda.
"""
self.ensure_one()
return int(self.duration or (self.servicio_id.duration_min if self.servicio_id else 0) or DEFAULT_DURATION)
def _log_state(self, from_state, to_state, user=None, note=''):
"""Registra el cambio de estado en la bitácora. Nunca tumba la operación."""
try:
self.env['skeen.cita.state.log'].sudo().create({
'cita_id': self.id,
'from_state': from_state or '',
'to_state': to_state or '',
'user_name': user or '',
'note': note or '',
})
except Exception:
import logging
logging.getLogger(__name__).exception('No se pudo registrar la bitácora de estado')
def set_state(self, new_state, user=None, note=''):
"""Cambia el estado de la cita y registra quién/cuándo en la bitácora.
A diferencia de las acciones de flujo (que solo avanzan), este método
permite retroceder de un paso a otro. Mantiene sincronizados los campos
de doble confirmación:
- al volver a 'pending' se retira la confirmación telefónica;
- al confirmar se sella phone_confirmed/at/by si aún no estaba;
- al llegar (o pasar a en consulta/completada) se sella arrived_at.
"""
if not new_state:
raise ValidationError(_('Falta el estado destino'))
if new_state not in dict(self._fields['state'].selection):
raise ValidationError(_('Estado no válido: %s') % new_state)
for cita in self:
old_state = cita.state
if old_state == new_state:
continue
vals = {'state': new_state}
if new_state == 'pending':
vals.update({
'phone_confirmed': False,
'phone_confirmed_at': False,
'phone_confirmed_by': False,
})
elif new_state == 'confirmed' and not cita.phone_confirmed:
vals.update({
'phone_confirmed': True,
'phone_confirmed_at': fields.Datetime.now(),
'phone_confirmed_by': user or '',
})
if new_state in ('arrived', 'in_progress', 'done') and not cita.arrived_at:
vals['arrived_at'] = fields.Datetime.now()
cita.write(vals)
if new_state == 'done' and cita.partner_id:
cita.partner_id.write({'last_visit': fields.Date.today()})
cita._log_state(old_state, new_state, user=user, note=note)
return True
def action_next(self):
"""Avanza un paso en el checklist (Agendada -> ... -> Completada)."""
for cita in self:
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx < 0 or idx >= len(STATE_FLOW) - 1:
raise ValidationError(_('No hay un paso siguiente desde el estado actual'))
cita.set_state(STATE_FLOW[idx + 1])
def action_back(self):
"""Retrocede un paso en el checklist (p. ej. Completada -> En consulta)."""
for cita in self:
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx <= 0:
raise ValidationError(_('No hay un paso anterior desde el estado actual'))
cita.set_state(STATE_FLOW[idx - 1])
def action_confirm(self):
self.write({'state': 'confirmed'})
self.set_state('confirmed')
def action_arrive(self):
self.write({'state': 'arrived', 'arrived_at': fields.Datetime.now()})
self.set_state('arrived')
def action_start(self):
self.write({'state': 'in_progress'})
self.set_state('in_progress')
def action_done(self):
self.write({'state': 'done'})
# Actualizar última visita del paciente
self.partner_id.write({'last_visit': fields.Date.today()})
self.set_state('done')
def action_cancel(self):
return {
'type': 'ir.actions.act_window',
@@ -203,9 +300,9 @@ class SkeenCita(models.Model):
'target': 'new',
'context': {'default_cita_id': self.id}
}
def action_no_show(self):
self.write({'state': 'no_show'})
self.set_state('no_show')
def action_package_finished(self):
"""Marca la cita como paquete terminado (servicios de categoría 'paquete').
@@ -221,6 +318,8 @@ class SkeenCita(models.Model):
if cita.state not in ('done', 'cancelled', 'no_show'):
vals['state'] = 'done'
cita.write(vals)
if vals.get('state'):
cita._log_state('', 'done', note='Paquete terminado')
if cita.partner_id:
cita.partner_id.write({'last_visit': fields.Date.today()})
return True
@@ -228,18 +327,19 @@ class SkeenCita(models.Model):
def action_send_reminder(self):
self.write({'reminder_sent': True, 'reminder_date': fields.Datetime.now()})
@api.constrains('date', 'time', 'doctor_id', 'servicio_id')
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_availability(self):
for cita in self:
if cita.state in ('cancelled', 'no_show'):
if cita.state in INACTIVE_STATES:
continue
# Verificar solapamiento SOLO dentro del mismo médico:
# la clínica permite citas simultáneas con doctores distintos.
# Criterio para citas sin médico: solo traslapan con otras sin médico.
# datetime_end ya incluye la duración real de la cita.
domain = [
('id', '!=', cita.id),
('date', '=', cita.date),
('state', 'not in', ('cancelled', 'no_show')),
('state', 'not in', INACTIVE_STATES),
('doctor_id', '=', cita.doctor_id.id if cita.doctor_id else False),
('datetime_start', '<', cita.datetime_end),
('datetime_end', '>', cita.datetime_start),
@@ -248,47 +348,82 @@ class SkeenCita(models.Model):
if overlapping:
raise ValidationError(_('Ya existe una cita en ese horario para ese médico!'))
@api.constrains('date', 'time', 'doctor_id', 'servicio_id')
@api.constrains('date', 'time', 'doctor_id', 'servicio_id', 'duration')
def _check_bloqueos(self):
"""Rechaza citas que se solapan con un bloqueo de agenda del médico"""
"""Rechaza citas que se solapan con un bloqueo de agenda del médico.
Sin médico asignado (citas que entran por WACRM/Hermes) solo se rechaza
si el horario está bloqueado para TODOS los médicos: es decir, la
clínica está cerrada a esa hora.
"""
Bloqueo = self.env['skeen.cita.bloqueo']
for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date:
if cita.state in INACTIVE_STATES or not cita.date:
continue
dur = ((cita.servicio_id.duration_min if cita.servicio_id else 30) or 30) / 60.0
dur = cita._duracion() / 60.0
ini = cita.time
fin = ini + dur
bloqueos = self.env['skeen.cita.bloqueo'].search([
if not cita.doctor_id:
doctores = self.env['hr.employee'].search([
('staff_type', 'in', ('doctor', 'cosmetologa')),
])
if not doctores:
continue
bloqueos_dia = Bloqueo.search([('date', '=', cita.date)])
bloqueados = bloqueos_dia.filtered(
lambda b: b.all_day or (ini < b.time_to and fin > b.time_from)).mapped('doctor_id')
if set(doctores.ids).issubset(set(bloqueados.ids)):
raise ValidationError(_('La clínica tiene la agenda bloqueada (%s)') % (
bloqueos_dia[0].nombre_motivo() if bloqueos_dia else 'agenda bloqueada'))
continue
bloqueos = Bloqueo.search([
('doctor_id', '=', cita.doctor_id.id),
('date', '=', cita.date),
])
for b in bloqueos:
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
@api.constrains('date', 'time', 'doctor_id')
raise ValidationError(_('El médico tiene un bloqueo (%s)') % 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.
Sólo aplica a médicos CON horario configurado y sólo al crear/editar la
fecha, la hora o el médico: las citas históricas fuera de horario se
conservan intactas (cambios de estado u otros campos no las revalidan).
fecha, la hora, el médico o la duración: las citas históricas fuera de
horario se conservan intactas (cambios de estado u otros campos no las
revalidan).
"""
for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date:
if cita.state in INACTIVE_STATES or not cita.doctor_id or not cita.date:
continue
dur = (cita.servicio_id.duration_min if cita.servicio_id else 30) or 30
ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, dur)
ok, mensaje = cita.doctor_id._valida_cita_en_horario(cita.date, cita.time, cita._duracion())
if not ok:
raise ValidationError(_(mensaje))
def get_available_slots(self, date, servicio_id, doctor_id=None):
@api.constrains('duration')
def _check_duration(self):
for cita in self:
if cita.duration and (cita.duration < 5 or cita.duration > 480):
raise ValidationError(_('La duración debe estar entre 5 y 480 minutos'))
def get_available_slots(self, date, servicio_id, doctor_id=None, duration=None):
"""Retorna slots disponibles para una fecha y servicio.
Con doctor_id, además excluye los horarios bloqueados de ese médico
(el usuario no debe poder elegir una hora bloqueada) y respeta su
horario semanal cuando lo tiene configurado (sin horario configurado
el comportamiento es el de siempre: 8:00 a 19:00)."""
servicio = self.env['skeen.servicio'].browse(servicio_id)
duration = servicio.duration_min if servicio else 30
el comportamiento es el de siempre: 8:00 a 19:00).
`duration` (min) permite simular una duración distinta a la del
servicio, p. ej. cuando recepción la ajusta en el modal de la cita.
El cálculo de ocupación usa inicio + duración real de cada cita.
"""
servicio = self.env['skeen.servicio'].browse(int(servicio_id)) if servicio_id else self.env['skeen.servicio']
try:
duration = int(duration or 0) or (servicio.duration_min if servicio else DEFAULT_DURATION)
except (TypeError, ValueError):
duration = servicio.duration_min if servicio else DEFAULT_DURATION
duration = duration or DEFAULT_DURATION
# Bloqueos del médico para la fecha (día completo y por rango)
bloqueos = self.env['skeen.cita.bloqueo'].search([
@@ -320,7 +455,13 @@ class SkeenCita(models.Model):
domain = [
('date', '=', date),
('state', 'not in', ('cancelled', 'no_show')),
('state', 'not in', INACTIVE_STATES),
# Con médico explícito el solape es por médico (la clínica
# permite citas simultáneas de médicos distintos) y una cita
# de 60 min descarta los 4 bloques de 15 min que cubre. Sin
# médico (flujo externo WACRM/Hermes) se conserva el criterio
# conservador: cualquier cita del día ocupa el hueco.
] + ([('doctor_id', '=', doctor_id)] if doctor_id else []) + [
('datetime_start', '<', slot_end),
('datetime_end', '>', slot_start),
]
@@ -336,3 +477,27 @@ class SkeenCita(models.Model):
current += 0.25 # Slots cada 15 min
return slots
class SkeenCitaStateLog(models.Model):
"""Bitácora de cambios de estado de una cita (avance y retroceso).
Registra el estado anterior, el nuevo, quién lo cambió (login del usuario
del frontend o del usuario de Odoo) y cuándo. Es la evidencia de auditoría
del checklist de atención.
"""
_name = 'skeen.cita.state.log'
_description = 'Bitácora de estados de la cita'
_order = 'create_date desc, id desc'
cita_id = fields.Many2one('skeen.cita', string='Cita', required=True,
ondelete='cascade', index=True)
from_state = fields.Char(string='Estado anterior')
to_state = fields.Char(string='Estado nuevo')
user_name = fields.Char(string='Usuario')
note = fields.Char(string='Nota')
date_change = fields.Datetime(string='Fecha', default=fields.Datetime.now)
def _state_label(self, state):
selection = dict(self.env['skeen.cita']._fields['state'].selection)
return selection.get(state, state or '')

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
# Rango de prioridad (menor = se atiende primero). El orden del _order usa este
# rango entero porque ordenar por el Selection lo haría alfabéticamente.
PRIORITY_RANK = {'alta': 0, 'normal': 1, 'baja': 2}
class SkeenListaEspera(models.Model):
"""Paciente en lista de espera cuando no hay disponibilidad.
Es un registro liviano: no reserva horario en la agenda. Recepción lo crea
desde el modal de nueva cita o desde el detalle de una cita sin hueco, y lo
va resolviendo cuando se libera un espacio (se agenda) o cuando el paciente
ya no lo necesita (se descarta).
Auditoría básica: `historial` guarda cada cambio con fecha y usuario, y los
campos create_uid/write_uid/date de Odoo quedan como respaldo.
"""
_name = 'skeen.lista.espera'
_description = 'Lista de espera de pacientes'
_order = 'priority_rank, create_date, id'
name = fields.Char(string='Referencia', readonly=True, copy=False, default='Nuevo')
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
domain=[('is_patient', '=', True)])
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono', readonly=True)
doctor_id = fields.Many2one('hr.employee', string='Médico deseado')
servicio_id = fields.Many2one('skeen.servicio', string='Servicio deseado')
priority = fields.Selection([
('alta', 'Alta'),
('normal', 'Normal'),
('baja', 'Baja'),
], string='Prioridad', default='normal', required=True)
priority_rank = fields.Integer(string='Rango prioridad',
compute='_compute_priority_rank', store=True)
franja = fields.Selection([
('', 'Cualquiera'),
('manana', 'Mañana'),
('tarde', 'Tarde'),
], string='Preferencia', default='')
date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta')
notes = fields.Text(string='Nota')
state = fields.Selection([
('esperando', 'Esperando'),
('agendado', 'Agendado'),
('descartado', 'Descartado'),
], string='Estado', default='esperando', required=True, index=True)
cita_id = fields.Many2one('skeen.cita', string='Cita asignada', ondelete='set null')
resolved_at = fields.Datetime(string='Resuelto el')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
historial = fields.Text(string='Historial', readonly=True, copy=False)
_sql_constraints = []
@api.constrains('partner_id', 'state', 'doctor_id')
def _check_unico_esperando(self):
"""Un paciente no puede estar dos veces esperando al mismo médico."""
for rec in self:
if rec.state != 'esperando':
continue
duplicado = self.search([
('id', '!=', rec.id),
('partner_id', '=', rec.partner_id.id),
('state', '=', 'esperando'),
('doctor_id', '=', rec.doctor_id.id),
], limit=1)
if duplicado:
raise ValidationError(_(
'%(nombre)s ya está en la lista de espera para %(doc)s.'
) % {'nombre': rec.partner_id.name,
'doc': rec.doctor_id.name or 'sin médico asignado'})
@api.depends('priority')
def _compute_priority_rank(self):
for rec in self:
rec.priority_rank = PRIORITY_RANK.get(rec.priority or 'normal', 1)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == 'Nuevo':
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.lista.espera') or 'LE-001'
records = super().create(vals_list)
for rec in records:
rec._log('Alta en lista de espera')
return records
def _log(self, mensaje, user=None):
"""Añade una línea al historial (fecha · usuario · mensaje)."""
for rec in self:
marca = fields.Datetime.now().strftime('%Y-%m-%d %H:%M')
autor = user or rec.created_by or rec.env.user.name or ''
linea = '%s · %s%s' % (marca, ('%s: ' % autor) if autor else '', mensaje)
rec.sudo().write({'historial': ((rec.historial or '').strip() + '\n' + linea).strip()})
return True
def action_agendar(self, cita=None):
for rec in self:
rec.write({
'state': 'agendado',
'cita_id': cita.id if cita else rec.cita_id.id,
'resolved_at': fields.Datetime.now(),
})
rec._log('Agendado%s' % ((' · cita %s' % cita.name) if cita else ''))
return True
def action_descartar(self):
for rec in self:
rec.write({'state': 'descartado', 'resolved_at': fields.Datetime.now()})
rec._log('Descartado')
return True