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

@@ -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,

View File

@@ -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>

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_skeen_service_tag_user Acceso a Etiquetas de Servicio SKEEN model_skeen_service_tag base.group_user 1 1 1 1
4 access_skeen_cita_user Acceso a Citas SKEEN model_skeen_cita base.group_user 1 1 1 1
5 access_skeen_cita_bloqueo_user Acceso a Bloqueos de Agenda SKEEN model_skeen_cita_bloqueo base.group_user 1 1 1 1
6 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
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -6,6 +6,7 @@ from odoo.http import request, Response
import base64
import json
import re
import uuid
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
@@ -25,6 +26,14 @@ def json_response(data, status=200):
)
# Orden del checklist de atención (espejo de STATE_FLOW en skeen.cita):
# Agendada -> Confirmada -> En cabina -> En consulta -> Completada
STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
# Estados que liberan el horario (no ocupan agenda)
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
def _parse_json_body():
"""Lee y parsea el body JSON de la petición"""
try:
@@ -517,7 +526,7 @@ class SkeenFrontendController(http.Controller):
# pending_confirm=1: citas activas SIN 1ra confirmación telefónica
if kw.get('pending_confirm') in ('1', 'true', 'True'):
domain.append(('phone_confirmed', '=', False))
domain.append(('state', 'not in', ('cancelled', 'no_show', 'done')))
domain.append(('state', 'not in', INACTIVE_STATES + ('done',)))
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Citas = request.env['skeen.cita'].sudo()
@@ -538,6 +547,14 @@ class SkeenFrontendController(http.Controller):
'status': 'success',
'appointments': dicts,
'bloqueos': self._bloqueos_rango(domain),
# La lista de espera viaja en la misma respuesta para que el
# auto refresco de la agenda sea un solo request por ciclo.
'lista_espera': [
self._lista_espera_to_dict(r)
for r in request.env['skeen.lista.espera'].sudo().search(
[('state', '=', 'esperando')],
order='priority_rank asc, create_date asc', limit=200)
],
'total': total,
'page': page,
'page_size': page_size,
@@ -572,6 +589,7 @@ class SkeenFrontendController(http.Controller):
'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
}
first_date = data.get('date')
first_time = data.get('time')
@@ -688,6 +706,7 @@ class SkeenFrontendController(http.Controller):
'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
})
return json_response({
'status': 'success',
@@ -730,7 +749,7 @@ class SkeenFrontendController(http.Controller):
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state', 'duration']
vals = {k: data[k] for k in allowed if k in data}
if 'time' in vals and not self._valid_agenda_time(vals['time']):
return json_response(
@@ -745,6 +764,7 @@ class SkeenFrontendController(http.Controller):
'time': vals.get('time', cita.time),
'doctor_id': vals.get('doctor_id', cita.doctor_id.id if cita.doctor_id else False),
'servicio_id': vals.get('servicio_id', cita.servicio_id.id),
'duration': vals.get('duration', cita.duration),
'state': vals.get('state', cita.state),
}
resp = {'status': 'error', 'message': str(e)}
@@ -771,27 +791,90 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/status', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def update_appointment_status(self, appointment_id, **kw):
"""Cambio de estado de la cita (checklist con retroceso).
Acciones:
confirm | arrive | start | done | cancel | no_show (flujo clásico)
next (avanza un paso)
back (retrocede un paso)
set_state + {state: '...'} (salto directo)
Todo cambio queda en la bitácora con el usuario y la hora.
"""
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
action = data.get('action')
user = request.frontend_user.login
note = (data.get('reason') or '').strip()
if action == 'confirm':
cita.action_confirm()
cita.set_state('confirmed', user=user)
elif action == 'arrive':
cita.action_arrive()
cita.set_state('arrived', user=user)
elif action == 'start':
cita.action_start()
cita.set_state('in_progress', user=user)
elif action == 'done':
cita.action_done()
cita.set_state('done', user=user)
elif action == 'cancel':
cita.write({'state': 'cancelled', 'cancellation_reason': data.get('reason', '')})
cita.set_state('cancelled', user=user, note=note)
if note:
cita.write({'cancellation_reason': note})
elif action == 'no_show':
cita.action_no_show()
cita.set_state('no_show', user=user, note=note)
elif action == 'waitlist':
cita.set_state('waitlist', user=user, note=note)
elif action == 'next':
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx < 0 or idx >= len(STATE_FLOW) - 1:
return json_response(
{'status': 'error', 'message': 'No hay un paso siguiente desde el estado actual'}, 400)
cita.set_state(STATE_FLOW[idx + 1], user=user)
elif action == 'back':
idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
if idx <= 0:
return json_response(
{'status': 'error', 'message': 'No hay un paso anterior desde el estado actual'}, 400)
cita.set_state(STATE_FLOW[idx - 1], user=user, note='Retroceso de estado')
elif action == 'set_state':
target = data.get('state')
if target not in STATE_FLOW + ['cancelled', 'no_show', 'waitlist']:
return json_response({'status': 'error', 'message': 'Estado no válido'}, 400)
cita.set_state(target, user=user, note='Cambio manual de estado')
else:
return json_response({'status': 'error', 'message': 'Acción no válida'}, 400)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/state_history', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def appointment_state_history(self, appointment_id, **kw):
"""Bitácora de cambios de estado de la cita (quién, cuándo y de qué a qué)."""
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
logs = request.env['skeen.cita.state.log'].sudo().search(
[('cita_id', '=', cita.id)], order='create_date desc, id desc', limit=100)
selection = dict(cita._fields['state'].selection)
return json_response({
'status': 'success',
'history': [{
'id': l.id,
'from_state': l.from_state,
'from_label': selection.get(l.from_state, l.from_state or ''),
'to_state': l.to_state,
'to_label': selection.get(l.to_state, l.to_state or ''),
'user': l.user_name or '',
'note': l.note or '',
'date': l.date_change.strftime('%Y-%m-%d %H:%M:%S') if l.date_change else '',
} for l in logs],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -869,8 +952,8 @@ class SkeenFrontendController(http.Controller):
citas = Citas.browse(ids).exists()
found = set(citas.ids)
not_found = [i for i in dict.fromkeys(ids) if i not in found]
elegibles = citas.filtered(lambda c: c.state not in ('cancelled', 'no_show', 'done'))
skipped_invalid_state = [c.id for c in citas if c.state in ('cancelled', 'no_show', 'done')]
elegibles = citas.filtered(lambda c: c.state not in INACTIVE_STATES + ('done',))
skipped_invalid_state = [c.id for c in citas if c.state in INACTIVE_STATES + ('done',)]
if confirmed:
elegibles.write({
'phone_confirmed': True,
@@ -892,18 +975,20 @@ class SkeenFrontendController(http.Controller):
def _agenda_conflict_payload(self, vals, exclude_id=None):
"""Detecta el origen del conflicto de agenda para los valores intentados.
Devuelve (payload_extra, http_status): {'conflicting_appointment': ...}
cuando hay solapamiento con otra cita, o {} si no se pudo identificar.
Replica la lógica de _check_availability de skeen.cita.
Devuelve {'conflicting_appointment': ...} cuando hay solapamiento con
otra cita, o {} si no se pudo identificar. Replica la lógica de
_check_availability de skeen.cita, incluida la duración real de la cita
(duración propia o, si es 0, la del servicio).
"""
try:
date_str = vals.get('date')
time_f = float(vals.get('time'))
state = vals.get('state') or 'pending'
if not date_str or state in ('cancelled', 'no_show'):
if not date_str or state in ('cancelled', 'no_show', 'waitlist'):
return {}
servicio = request.env['skeen.servicio'].sudo().browse(int(vals.get('servicio_id') or 0))
dur = (servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30
dur = int(vals.get('duration') or 0) or (
servicio.duration_min if servicio.exists() and servicio.duration_min else 30) or 30
hh, mm = int(time_f), int(round((float(time_f) % 1) * 60))
start_dt = datetime.strptime(f'{date_str} {hh:02d}:{mm:02d}', '%Y-%m-%d %H:%M')
end_dt = start_dt + timedelta(minutes=dur)
@@ -911,7 +996,7 @@ class SkeenFrontendController(http.Controller):
domain = [
('id', '!=', exclude_id),
('date', '=', date_str),
('state', 'not in', ('cancelled', 'no_show')),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
('doctor_id', '=', doctor_id),
('datetime_start', '<', end_dt),
('datetime_end', '>', start_dt),
@@ -930,14 +1015,22 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/appointments/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def available_slots(self, **kw):
"""Slots libres (cada 15 min) para una fecha y servicio.
Acepta `duration` para simular una duración distinta a la del servicio
(la agenda deja que recepción la ajuste). El hueco se descarta si el
rango completo inicio+duración choca con otra cita o con un bloqueo.
"""
try:
date_str = kw.get('date')
servicio_id = int(kw.get('servicio_id', 0))
doctor_id = int(kw.get('doctor_id', 0)) if kw.get('doctor_id') else None
duration = int(kw.get('duration', 0) or 0) or None
if not date_str or not servicio_id:
return json_response({'status': 'error', 'message': 'Falta date o servicio_id'}, 400)
d = datetime.strptime(date_str, '%Y-%m-%d').date()
slots = request.env['skeen.cita'].sudo().get_available_slots(d, servicio_id, doctor_id=doctor_id)
slots = request.env['skeen.cita'].sudo().get_available_slots(
d, servicio_id, doctor_id=doctor_id, duration=duration)
return json_response({'status': 'success', 'slots': slots})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -975,7 +1068,12 @@ class SkeenFrontendController(http.Controller):
'all_day': b.all_day,
'time_from': b.time_from,
'time_to': b.time_to,
'motivo_tipo': b.motivo_tipo or 'otro',
'motivo_tipo_label': dict(b._fields['motivo_tipo'].selection).get(b.motivo_tipo or '', ''),
'motivo': b.motivo or '',
'motivo_label': b.nombre_motivo(),
'grupo': b.grupo or '',
'created_by': b.created_by or '',
}
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@@ -989,7 +1087,9 @@ class SkeenFrontendController(http.Controller):
domain.append(('date', '<=', kw['date_to']))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc', limit=500)
if kw.get('grupo'):
domain.append(('grupo', '=', kw['grupo']))
bloqueos = request.env['skeen.cita.bloqueo'].sudo().search(domain, order='date desc, time_from', limit=500)
return json_response({
'status': 'success',
'bloqueos': [self._bloqueo_to_dict(b) for b in bloqueos],
@@ -1000,28 +1100,157 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_bloqueo(self, **kw):
"""Crea uno o varios bloqueos de agenda.
Body:
doctor_ids: [1, 2] (o `doctor_id` simple) — multi-selección
date: 'YYYY-MM-DD' (o date_from/date_to para un rango)
days: [0, 2] (opcional) repetir solo esos días de la semana
dentro del rango, p. ej. "todos los lunes"
all_day: bool
time_from / time_to: float de horas (si no es todo el día)
motivo_tipo: vacaciones|junta|comida|personal|otro
motivo: texto libre
force: true permite bloquear aunque haya citas agendadas
Todos los registros creados comparten un `grupo` para poder borrarlos
o localizarlos como serie.
"""
try:
data = _parse_json_body()
if not data.get('doctor_id') or not data.get('date'):
return json_response({'status': 'error', 'message': 'Médico y fecha son obligatorios'}, 400)
raw_doctors = data.get('doctor_ids') or ([data.get('doctor_id')] if data.get('doctor_id') else [])
doctor_ids = []
for d in raw_doctors:
try:
doctor_ids.append(int(d))
except (TypeError, ValueError):
continue
doctor_ids = list(dict.fromkeys(doctor_ids))
if not doctor_ids:
return json_response({'status': 'error', 'message': 'Selecciona al menos un médico'}, 400)
doctores = request.env['hr.employee'].sudo().browse(doctor_ids).exists()
if len(doctores) != len(doctor_ids):
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
date_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from
if not date_from:
return json_response({'status': 'error', 'message': 'La fecha es obligatoria'}, 400)
try:
ini = datetime.strptime(str(date_from)[:10], '%Y-%m-%d').date()
fin = datetime.strptime(str(date_to)[:10], '%Y-%m-%d').date()
except ValueError:
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if fin < ini:
return json_response({'status': 'error', 'message': 'El rango de fechas está invertido'}, 400)
if (fin - ini).days > 366:
return json_response({'status': 'error', 'message': 'El rango no puede exceder un año'}, 400)
days = data.get('days') or []
try:
days = sorted({int(d) for d in days})
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Días inválidos'}, 400)
all_day = bool(data.get('all_day'))
time_from = float(data.get('time_from', 9.0) or 9.0)
time_to = float(data.get('time_to', 10.0) or 10.0)
try:
time_from = float(data.get('time_from', 9.0) or 9.0)
time_to = float(data.get('time_to', 10.0) or 10.0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Horas inválidas'}, 400)
if not all_day and time_to <= time_from:
return json_response({'status': 'error', 'message': 'La hora final debe ser mayor a la inicial'}, 400)
doctor = request.env['hr.employee'].sudo().browse(int(data['doctor_id']))
if not doctor.exists():
return json_response({'status': 'error', 'message': 'Médico no encontrado'}, 404)
bloqueo = request.env['skeen.cita.bloqueo'].sudo().create({
'doctor_id': doctor.id,
'date': data['date'],
if not all_day and (time_from < self.AGENDA_START - 1e-6 or time_to > self.AGENDA_END + 1e-6):
return json_response(
{'status': 'error', 'message': 'El rango de horas debe estar entre 8:00 y 19:00'}, 400)
motivo_tipo = data.get('motivo_tipo') or 'otro'
if motivo_tipo not in dict(request.env['skeen.cita.bloqueo']._fields['motivo_tipo'].selection):
motivo_tipo = 'otro'
motivo = (data.get('motivo') or '').strip()
fechas = []
cur = ini
while cur <= fin:
if not days or cur.weekday() in days:
fechas.append(cur)
cur += timedelta(days=1)
if not fechas:
return json_response(
{'status': 'error', 'message': 'Ninguna fecha del rango cae en los días seleccionados'}, 400)
grupo = str(uuid.uuid4())[:8]
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
creados = Bloqueo.browse()
creados = creados.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).create([{
'doctor_id': doc.id,
'date': f.strftime('%Y-%m-%d'),
'all_day': all_day,
'time_from': time_from,
'time_to': time_to,
'motivo': data.get('motivo', ''),
})
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)}, 201)
'motivo_tipo': motivo_tipo,
'motivo': motivo,
'grupo': grupo,
'created_by': request.frontend_user.login,
} for doc in doctores for f in fechas])
self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name')))
return json_response({
'status': 'success',
'grupo': grupo,
'creados': len(creados),
'bloqueos': [self._bloqueo_to_dict(b) for b in creados],
}, 201)
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e), 'conflicto_citas': True}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_bloqueo(self, bloqueo_id, **kw):
"""Edita un bloqueo existente (fecha, horario, motivo, médico)."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'doctor_id' in data and data['doctor_id']:
vals['doctor_id'] = int(data['doctor_id'])
if data.get('date'):
vals['date'] = data['date']
if 'all_day' in data:
vals['all_day'] = bool(data['all_day'])
if 'time_from' in data and data['time_from'] is not None:
vals['time_from'] = float(data['time_from'])
if 'time_to' in data and data['time_to'] is not None:
vals['time_to'] = float(data['time_to'])
if 'motivo_tipo' in data:
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data:
vals['motivo'] = data['motivo'] or ''
if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400)
bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
).write(vals)
self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name,
new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e), 'conflicto_citas': True}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@@ -1031,11 +1260,203 @@ class SkeenFrontendController(http.Controller):
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
if not bloqueo.exists():
return json_response({'status': 'error', 'message': 'Bloqueo no encontrado'}, 404)
etiqueta = '%s · %s' % (bloqueo.doctor_id.name, bloqueo.date)
bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/bloqueos/grupo/<string:grupo>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_bloqueo_grupo(self, grupo, **kw):
"""Elimina la serie completa (mismo `grupo`) de bloqueos."""
try:
Bloqueo = request.env['skeen.cita.bloqueo'].sudo()
bloqueos = Bloqueo.search([('grupo', '=', grupo)])
if not bloqueos:
return json_response({'status': 'error', 'message': 'Serie no encontrada'}, 404)
n = len(bloqueos)
bloqueos.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar-serie',
entity_label=grupo, old_value='%s bloqueo(s)' % n)
return json_response({'status': 'success', 'eliminados': n})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Lista de espera de pacientes
# ============================================================
def _lista_espera_to_dict(self, r):
return {
'id': r.id,
'reference': r.name,
'patient_id': r.partner_id.id,
'patient': r.partner_id.name,
'phone': r.partner_id.phone or r.partner_id.mobile or '',
'doctor_id': r.doctor_id.id if r.doctor_id else None,
'doctor': r.doctor_id.name if r.doctor_id else None,
'service_id': r.servicio_id.id if r.servicio_id else None,
'service': r.servicio_id.name if r.servicio_id else None,
'priority': r.priority,
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'date_from': r.date_from.strftime('%Y-%m-%d') if r.date_from else None,
'date_to': r.date_to.strftime('%Y-%m-%d') if r.date_to else None,
'notes': r.notes or '',
'state': r.state,
'cita_id': r.cita_id.id if r.cita_id else None,
'created_by': r.created_by or '',
'create_date': r.create_date.strftime('%Y-%m-%d %H:%M') if r.create_date else None,
'resolved_at': r.resolved_at.strftime('%Y-%m-%d %H:%M') if r.resolved_at else None,
'espera_dias': max(0, (date.today() - r.create_date.date()).days) if r.create_date else 0,
}
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_lista_espera(self, **kw):
"""Pacientes en lista de espera (ordenados por prioridad y antigüedad)."""
try:
domain = []
state = kw.get('state')
domain.append(('state', '=', state if state else 'esperando'))
if kw.get('doctor_id'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
limit = min(max(int(kw.get('limit', 200) or 200), 1), 500)
records = request.env['skeen.lista.espera'].sudo().search(
domain, order='priority_rank asc, create_date asc', limit=limit)
return json_response({
'status': 'success',
'lista': [self._lista_espera_to_dict(r) for r in records],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/compatibles', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def lista_espera_compatibles(self, **kw):
"""Pacientes en espera compatibles con un hueco que se acaba de liberar.
Filtra por médico (o sin médico = cualquiera) y por el rango de fechas
deseado. Marca `match_servicio` cuando además coincide el servicio, para
que la UI pueda priorizar el aviso.
"""
try:
doctor_id = int(kw.get('doctor_id')) if kw.get('doctor_id') else None
servicio_id = int(kw.get('servicio_id')) if kw.get('servicio_id') else None
fecha = kw.get('date')
domain = [('state', '=', 'esperando')]
if doctor_id:
domain += ['|', ('doctor_id', '=', doctor_id), ('doctor_id', '=', False)]
records = request.env['skeen.lista.espera'].sudo().search(
domain, order='priority_rank asc, create_date asc', limit=50)
if fecha:
records = records.filtered(lambda r: (
(not r.date_from or r.date_from.strftime('%Y-%m-%d') <= fecha)
and (not r.date_to or r.date_to.strftime('%Y-%m-%d') >= fecha)
))
out = []
for r in records:
d = self._lista_espera_to_dict(r)
d['match_servicio'] = bool(servicio_id and r.servicio_id and r.servicio_id.id == servicio_id)
d['match_doctor'] = bool(doctor_id and r.doctor_id and r.doctor_id.id == doctor_id)
out.append(d)
out.sort(key=lambda d: (0 if d['match_doctor'] else 1, 0 if d['match_servicio'] else 1))
return json_response({'status': 'success', 'lista': out, 'total': len(out)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
def create_lista_espera(self, **kw):
try:
data = _parse_json_body()
partner_id = data.get('partner_id') or data.get('patient_id')
if not partner_id:
return json_response({'status': 'error', 'message': 'El paciente es obligatorio'}, 400)
if data.get('priority') and data['priority'] not in ('alta', 'normal', 'baja'):
return json_response({'status': 'error', 'message': 'Prioridad no válida'}, 400)
record = request.env['skeen.lista.espera'].sudo().create({
'partner_id': int(partner_id),
'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False,
'servicio_id': int(data['service_id'] or data['servicio_id']) if (data.get('service_id') or data.get('servicio_id')) else False,
'priority': data.get('priority') or 'normal',
'franja': data.get('franja') or '',
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
'created_by': request.frontend_user.login,
})
self._auditar('agenda', 'skeen.lista.espera', 'crear',
entity_label=record.partner_id.name,
new_value=record.name)
return json_response({'status': 'success', 'registro': self._lista_espera_to_dict(record)}, 201)
except ValidationError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 409)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def update_lista_espera(self, registro_id, **kw):
"""Edita la espera o la resuelve (state agendado/descartado, cita_id)."""
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
if not record.exists():
return json_response({'status': 'error', 'message': 'Registro no encontrado'}, 404)
data = _parse_json_body()
if data.get('state') not in (None, '', 'esperando', 'agendado', 'descartado'):
return json_response({'status': 'error', 'message': 'Estado no válido'}, 400)
vals = {}
for key, field in (
('doctor_id', 'doctor_id'), ('service_id', 'servicio_id'),
('servicio_id', 'servicio_id'), ('priority', 'priority'),
('franja', 'franja'), ('date_from', 'date_from'), ('date_to', 'date_to'),
('notes', 'notes'), ('partner_id', 'partner_id'),
):
if key in data:
vals[field] = data[key] or False
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
new_state = data.get('state')
if new_state:
vals['state'] = new_state
if new_state in ('agendado', 'descartado'):
vals['resolved_at'] = fields.Datetime.now()
else:
vals['resolved_at'] = False
if vals:
record.write(vals)
if new_state:
etiquetas = {'agendado': 'Agendado', 'descartado': 'Descartado', 'esperando': 'Reabierto'}
record._log(etiquetas.get(new_state, new_state), user=request.frontend_user.login)
self._auditar('agenda', 'skeen.lista.espera', 'estado',
entity_label=record.partner_id.name, new_value=new_state)
return json_response({'status': 'success', 'registro': self._lista_espera_to_dict(record)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
def delete_lista_espera(self, registro_id, **kw):
try:
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
if not record.exists():
return json_response({'status': 'error', 'message': 'Registro no encontrado'}, 404)
etiqueta = '%s · %s' % (record.name, record.partner_id.name)
record.unlink()
self._auditar('agenda', 'skeen.lista.espera', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Registro eliminado'})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _appointment_to_dict(self, cita):
return {
'id': cita.id,
@@ -1048,11 +1469,16 @@ class SkeenFrontendController(http.Controller):
'service_category': cita.servicio_id.category,
'date': cita.date.strftime('%Y-%m-%d') if cita.date else None,
'time': cita.time_str,
'time_float': cita.time,
'state': cita.state,
'state_label': dict(cita._fields['state'].selection).get(cita.state, cita.state),
'payment_state': cita.payment_state,
'price': cita.servicio_price,
'amount_paid': cita.amount_paid,
'duration': cita.servicio_duration,
# Duración efectiva (propia de la cita o la del servicio)
'duration': cita._duracion(),
'duration_override': cita.duration or 0,
'service_duration': cita.servicio_duration,
'doctor_id': cita.doctor_id.id if cita.doctor_id else None,
'doctor': cita.doctor_id.name if cita.doctor_id else None,
'branch': cita.branch,
@@ -1694,7 +2120,7 @@ class SkeenFrontendController(http.Controller):
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([
('partner_id', '=', patient_id),
('state', 'not in', ('cancelled', 'no_show')),
('state', 'not in', INACTIVE_STATES),
('servicio_id.package_sessions', '>', 0),
])
grupos = {}
@@ -2454,7 +2880,7 @@ class SkeenFrontendController(http.Controller):
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search(
[('date', '>=', date.today().strftime('%Y-%m-%d')),
('state', 'not in', ('cancelled', 'no_show')), '|',
('state', 'not in', INACTIVE_STATES), '|',
('partner_id.name', 'ilike', q), ('servicio_id.name', 'ilike', q)],
limit=5, order='date asc, time asc')
Servicios = request.env['skeen.servicio'].sudo()
@@ -4656,7 +5082,7 @@ class SkeenFrontendController(http.Controller):
end = kw.get('end', start)
citas = request.env['skeen.cita'].sudo().search([
('date', '>=', start), ('date', '<=', end),
('state', 'not in', ('cancelled', 'no_show')),
('state', 'not in', INACTIVE_STATES),
])
por_hora = {}
for c in citas:

View File

@@ -36,8 +36,13 @@ class SkeenWhatsAppController(http.Controller):
return {'status': 'error', 'message': str(e)}
@http.route('/skeen/api/v1/create_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
def create_appointment(self, phone, name, service_id, date, time, **kw):
"""Crear una nueva cita"""
def create_appointment(self, phone, name, service_id, date, time, doctor_id=None, duration=None, **kw):
"""Crear una nueva cita (flujo Hermes/WACRM).
`doctor_id` y `duration` son opcionales. Si se indica médico, se validan
su horario semanal y sus bloqueos de agenda; sin médico, la cita se
rechaza solo si la clínica completa está bloqueada a esa hora.
"""
try:
# Buscar o crear paciente
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
@@ -54,13 +59,18 @@ class SkeenWhatsAppController(http.Controller):
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
# Crear cita
cita = request.env['skeen.cita'].create({
vals = {
'partner_id': partner.id,
'servicio_id': int(service_id),
'date': date,
'time': time_float,
'state': 'confirmed',
})
}
if doctor_id:
vals['doctor_id'] = int(doctor_id)
if duration:
vals['duration'] = int(duration)
cita = request.env['skeen.cita'].create(vals)
return {
'status': 'success',