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

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