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:
@@ -16,6 +16,7 @@
|
||||
'data/sequences.xml',
|
||||
'views/cita_views.xml',
|
||||
'views/bloqueo_views.xml',
|
||||
'views/lista_espera_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
@@ -20,5 +20,15 @@
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
|
||||
<!-- Secuencia para la lista de espera -->
|
||||
<record id="seq_skeen_lista_espera" model="ir.sequence">
|
||||
<field name="name">Lista de espera SKEEN</field>
|
||||
<field name="code">skeen.lista.espera</field>
|
||||
<field name="prefix">LE-</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
from . import cita
|
||||
from . import bloqueo
|
||||
from . import lista_espera
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 '')
|
||||
|
||||
121
odoo-addons/skeen_citas/models/lista_espera.py
Normal file
121
odoo-addons/skeen_citas/models/lista_espera.py
Normal 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
|
||||
@@ -3,3 +3,5 @@ access_skeen_servicio_user,Acceso a Servicios SKEEN,model_skeen_servicio,base.gr
|
||||
access_skeen_service_tag_user,Acceso a Etiquetas de Servicio SKEEN,model_skeen_service_tag,base.group_user,1,1,1,1
|
||||
access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1,1,1
|
||||
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
|
||||
access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen_cita_state_log,base.group_user,1,1,1,1
|
||||
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -11,7 +11,10 @@
|
||||
<field name="all_day"/>
|
||||
<field name="time_from" widget="float_time"/>
|
||||
<field name="time_to" widget="float_time"/>
|
||||
<field name="motivo_tipo"/>
|
||||
<field name="motivo"/>
|
||||
<field name="grupo" optional="hide"/>
|
||||
<field name="created_by"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -27,12 +30,15 @@
|
||||
<group>
|
||||
<field name="doctor_id"/>
|
||||
<field name="date"/>
|
||||
<field name="motivo_tipo"/>
|
||||
<field name="motivo"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="all_day"/>
|
||||
<field name="time_from" widget="float_time" invisible="all_day"/>
|
||||
<field name="time_to" widget="float_time" invisible="all_day"/>
|
||||
<field name="grupo" readonly="1"/>
|
||||
<field name="created_by" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<button name="action_arrive" string="Llegó" type="object" invisible="state != 'confirmed'" class="oe_highlight"/>
|
||||
<button name="action_start" string="Iniciar" type="object" invisible="state != 'arrived'" class="oe_highlight"/>
|
||||
<button name="action_done" string="Completar" type="object" invisible="state != 'in_progress'" class="oe_highlight"/>
|
||||
<button name="action_back" string="Retroceder" type="object" invisible="state not in ('confirmed', 'arrived', 'in_progress', 'done')"/>
|
||||
<button name="action_cancel" string="Cancelar" type="object" invisible="state not in ('pending', 'confirmed', 'arrived', 'in_progress')"/>
|
||||
<button name="action_no_show" string="No Show" type="object" invisible="state not in ('confirmed', 'arrived')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,confirmed,arrived,in_progress,done"/>
|
||||
@@ -45,6 +46,7 @@
|
||||
<field name="servicio_id"/>
|
||||
<field name="servicio_price" readonly="1"/>
|
||||
<field name="servicio_duration" readonly="1"/>
|
||||
<field name="duration"/>
|
||||
<field name="date"/>
|
||||
<field name="time" widget="float_time"/>
|
||||
</group>
|
||||
@@ -59,6 +61,17 @@
|
||||
<field name="amount_paid"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Bitácora de estados">
|
||||
<field name="state_log_ids" readonly="1">
|
||||
<tree>
|
||||
<field name="date_change"/>
|
||||
<field name="from_state"/>
|
||||
<field name="to_state"/>
|
||||
<field name="user_name"/>
|
||||
<field name="note"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
|
||||
79
odoo-addons/skeen_citas/views/lista_espera_views.xml
Normal file
79
odoo-addons/skeen_citas/views/lista_espera_views.xml
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vista de lista de lista de espera -->
|
||||
<record id="view_skeen_lista_espera_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.lista.espera.tree</field>
|
||||
<field name="model">skeen.lista.espera</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-danger="priority=='alta' and state=='esperando'"
|
||||
decoration-muted="state!='esperando'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="partner_phone"/>
|
||||
<field name="doctor_id"/>
|
||||
<field name="servicio_id"/>
|
||||
<field name="priority" widget="badge"
|
||||
decoration-danger="priority=='alta'"
|
||||
decoration-warning="priority=='normal'"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state=='agendado'"
|
||||
decoration-info="state=='esperando'"/>
|
||||
<field name="create_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vista de formulario de lista de espera -->
|
||||
<record id="view_skeen_lista_espera_form" model="ir.ui.view">
|
||||
<field name="name">skeen.lista.espera.form</field>
|
||||
<field name="model">skeen.lista.espera</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_descartar" string="Descartar" type="object"
|
||||
invisible="state != 'esperando'" class="oe_highlight"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="partner_phone" readonly="1"/>
|
||||
<field name="priority"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="doctor_id"/>
|
||||
<field name="servicio_id"/>
|
||||
<field name="franja"/>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="cita_id" readonly="1"/>
|
||||
<field name="resolved_at" readonly="1"/>
|
||||
<field name="created_by" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Nota">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<group string="Historial">
|
||||
<field name="historial" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Acción de lista de espera -->
|
||||
<record id="action_skeen_lista_espera_tree" model="ir.actions.act_window">
|
||||
<field name="name">Lista de Espera</field>
|
||||
<field name="res_model">skeen.lista.espera</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Menú Lista de Espera (bajo el menú Citas) -->
|
||||
<menuitem id="menu_skeen_citas_lista_espera" name="Lista de Espera" parent="menu_skeen_citas"
|
||||
action="action_skeen_lista_espera_tree" sequence="30"/>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user