Agenda: horario 8-19h, bloques de 15 min, cita rapida, vista restringida para rol medico y chips de pacientes por doctor

This commit is contained in:
2026-08-28 07:35:46 +00:00
parent f9fc66b343
commit 29225e848a
5 changed files with 446 additions and 81 deletions

View File

@@ -4,6 +4,7 @@ from odoo import http
from odoo.http import request, Response
import base64
import json
import re
from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
@@ -35,6 +36,43 @@ def _parse_json_body():
class SkeenFrontendController(http.Controller):
"""Controladores API públicos para el Frontend React de SKEEN"""
# Horario de agenda de la clínica (hora de inicio permitida de una cita)
AGENDA_START = 8.0 # 8:00 AM
AGENDA_END = 19.0 # 7:00 PM
def _valid_agenda_time(self, value):
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
try:
t = float(value)
except (TypeError, ValueError):
return False
return self.AGENDA_START <= t < self.AGENDA_END
def _linked_employee(self, user):
"""hr.employee vinculado a un usuario frontend (rol medico).
Vía principal: res.users con el mismo login -> employee_ids.
Fallback: empleado cuyo work_email coincide con el login o cuyo
nombre coincide con el nombre del usuario frontend.
"""
Emp = request.env['hr.employee'].sudo()
ru = request.env['res.users'].sudo().search([('login', '=', user.login)], limit=1)
emp = ru.employee_ids[:1] if ru else Emp.browse()
if not emp and user.login:
emp = Emp.search([('work_email', '=ilike', user.login)], limit=1)
if not emp and user.name:
emp = Emp.search([('name', '=ilike', user.name)], limit=1)
return emp
def _frontend_user_dict(self, user):
"""to_public_dict + datos del empleado vinculado (rol medico)."""
d = user.to_public_dict()
if user.role == 'medico':
emp = self._linked_employee(user)
d['employee_id'] = emp.id if emp else None
d['employee_name'] = emp.name if emp else None
return d
# ============================================================
# Health
# ============================================================
@@ -61,7 +99,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Credenciales inválidas'}, 401)
user.write({'last_login': fields.Datetime.now()})
token = sign_token(request.env, user)
return json_response({'status': 'success', 'token': token, 'user': user.to_public_dict()})
return json_response({'status': 'success', 'token': token, 'user': self._frontend_user_dict(user)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -69,7 +107,7 @@ class SkeenFrontendController(http.Controller):
@require_role('lectura')
def auth_me(self, **kw):
try:
return json_response({'status': 'success', 'user': request.frontend_user.to_public_dict()})
return json_response({'status': 'success', 'user': self._frontend_user_dict(request.frontend_user)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -274,9 +312,30 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_appointments(self, **kw):
"""Listar citas (opcionalmente filtradas por fecha, estado, paciente)"""
"""Listar citas (opcionalmente filtradas por fecha, estado, paciente)
Rol 'medico': solo ve las citas asignadas a su hr.employee vinculado
(filtro del lado del servidor, ignora cualquier doctor_id recibido).
"""
try:
domain = []
user = request.frontend_user
if user and user.role == 'medico':
emp = self._linked_employee(user)
if not emp:
# Sin empleado vinculado: agenda vacía (el frontend muestra aviso)
return json_response({
'status': 'success',
'appointments': [],
'bloqueos': [],
'total': 0,
'page': 1,
'page_size': int(kw.get('page_size', 50) or 50),
'total_pages': 0,
'medico_sin_employee': True,
})
domain = [('doctor_id', '=', emp.id)]
else:
domain = []
if kw.get('date'):
domain.append(('date', '=', kw.get('date')))
if kw.get('date_from'):
@@ -287,7 +346,7 @@ class SkeenFrontendController(http.Controller):
domain.append(('state', '=', kw.get('state')))
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw.get('patient_id'))))
if kw.get('doctor_id'):
if kw.get('doctor_id') and not (user and user.role == 'medico'):
domain.append(('doctor_id', '=', int(kw.get('doctor_id'))))
if kw.get('search'):
s = kw.get('search')
@@ -344,6 +403,9 @@ class SkeenFrontendController(http.Controller):
}
first_date = data.get('date')
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
created = []
# Para paquetes, generamos citas separadas con fechas consecutivas
current_date = datetime.strptime(first_date, '%Y-%m-%d').date() if first_date else date.today()
@@ -365,6 +427,94 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def create_quick_appointment(self, **kw):
"""Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto).
Busca el paciente por teléfono y lo reutiliza si existe; si no, lo crea
con datos mínimos. El servicio usa uno por defecto (primera 'consulta'
activa, o el primer servicio activo) salvo que se indique servicio_id.
"""
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
phone = (data.get('phone') or '').strip()
if not name or not phone:
return json_response({'status': 'error', 'message': 'Nombre y teléfono son obligatorios'}, 400)
apt_date = data.get('date') or date.today().strftime('%Y-%m-%d')
apt_time = data.get('time')
if apt_time is None or not self._valid_agenda_time(apt_time):
return json_response(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
Partner = request.env['res.partner'].sudo()
digits = re.sub(r'\D', '', phone)
patient = None
if len(digits) >= 7:
# Comparación sobre dígitos normalizados (ignora espacios, +52, etc.):
# coinciden si los últimos N dígitos (N = longitud del más corto) son iguales
match_id = None
candidates = Partner.search_read(
[('is_patient', '=', True), '|', ('phone', '!=', False), ('mobile', '!=', False)],
['phone', 'mobile'], limit=10000)
for c in candidates:
for f in ('phone', 'mobile'):
c_digits = re.sub(r'\D', '', c.get(f) or '')
n = min(len(c_digits), len(digits))
if n >= 7 and c_digits[-n:] == digits[-n:]:
match_id = c['id']
break
if match_id:
break
if match_id:
patient = Partner.browse(match_id)
if not patient:
patient = Partner.search([
('is_patient', '=', True), ('phone', '=', phone),
], limit=1)
patient_created = False
if not patient:
patient = Partner.create({
'name': name,
'phone': phone,
'is_patient': True,
'source': data.get('source', 'walkin'),
})
patient_created = True
Servicio = request.env['skeen.servicio'].sudo()
servicio = Servicio.browse(int(data.get('servicio_id') or 0))
if not servicio.exists():
servicio = Servicio.search([('category', '=', 'consulta'), ('active', '=', True)],
order='sequence, id', limit=1)
if not servicio:
servicio = Servicio.search([('active', '=', True)], order='sequence, id', limit=1)
if not servicio:
return json_response({'status': 'error', 'message': 'No hay servicios activos para agendar'}, 400)
cita = request.env['skeen.cita'].sudo().create({
'partner_id': patient.id,
'servicio_id': servicio.id,
'doctor_id': int(data['doctor_id']) if data.get('doctor_id') else False,
'date': apt_date,
'time': float(apt_time),
'branch': data.get('branch', 'rosarito'),
'medium': data.get('medium', 'onsite'),
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
})
return json_response({
'status': 'success',
'appointment': self._appointment_to_dict(cita),
'patient': self._patient_to_dict(patient),
'patient_created': patient_created,
}, 201)
except Exception as e:
# Rollback explícito: evita persistir paciente/cita con validación rechazada
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def get_appointment(self, appointment_id, **kw):
@@ -386,6 +536,9 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body()
allowed = ['partner_id', 'servicio_id', 'date', 'time', 'doctor_id', 'branch', 'medium', 'notes', 'state']
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(
{'status': 'error', 'message': 'La hora debe estar dentro del horario de agenda (8:00 a 19:00)'}, 400)
cita.write(vals)
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e: