fase 3.1: recursos fisicos en agenda (modelo skeen.recurso con semilla cabina/consultoria, asignacion multiple a citas con validacion de solape, bloqueos por recurso, lista de espera desde recursos, vista/filtro por recurso, reporte de ocupacion); apagado por defecto

This commit is contained in:
2026-10-11 11:33:59 +00:00
parent 8ff13b9c2a
commit ee8a844d96
29 changed files with 1986 additions and 78 deletions

View File

@@ -20,6 +20,7 @@
'views/cita_views.xml',
'views/bloqueo_views.xml',
'views/lista_espera_views.xml',
'views/recurso_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -606,6 +606,66 @@ def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
# ---------------------------------------------------------------------------
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la agenda — no aparece la
# sección en Configuración ni el selector en el modal de cita, la agenda se ve
# igual que hoy y no corre ninguna validación de solape de recursos. Por eso el
# default es apagado.
#
# activo: enciende todo el módulo de recursos
# validar_solape: impide dos citas/bloqueos en el mismo recurso a la
# misma hora (solo aplica si activo)
# obligatorio_al_agendar: exige elegir al menos un recurso al crear la cita
# permitir_multiples: permite asignar más de un recurso a una cita
# mostrar_en_agenda: muestra el recurso (chips/color) en las tarjetas
# filtrar_como_doctores: ofrece el filtro por recurso junto al de doctores
# ---------------------------------------------------------------------------
RECURSOS_DEFAULT = {
'activo': False,
'validar_solape': True,
'obligatorio_al_agendar': False,
'permitir_multiples': True,
'mostrar_en_agenda': True,
'filtrar_como_doctores': True,
}
RECURSOS_BOOLEANOS = tuple(RECURSOS_DEFAULT)
def normalizar_recursos(raw):
"""Bloque `recursos` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RECURSOS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in RECURSOS_BOOLEANOS:
out[k] = _bool(raw.get(k), RECURSOS_DEFAULT[k])
return out
def validar_recursos(raw):
"""Valida el bloque `recursos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de recursos debe ser un objeto')
out = {}
for k in RECURSOS_BOOLEANOS:
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
return out
def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -649,6 +709,7 @@ def get_settings(env):
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -664,6 +725,7 @@ def get_settings(env):
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
return out
@@ -692,6 +754,10 @@ def set_settings(env, vals):
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque}
if 'recursos' in vals:
bloque = validar_recursos(vals['recursos'])
if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -45,5 +45,24 @@
<field name="sequence">40</field>
<field name="is_system" eval="True"/>
</record>
<!-- Recursos de la agenda (cabinas, salas, consultorios, equipos).
Los códigos 'cabina'/'consultoria' conservan los valores que antes
estaban hardcodeados en la lista de espera, para que los registros
históricos sigan mostrándose igual. -->
<record id="recurso_cabina" model="skeen.recurso">
<field name="name">Cabina</field>
<field name="code">cabina</field>
<field name="tipo">cabina</field>
<field name="sequence">10</field>
<field name="activo" eval="True"/>
</record>
<record id="recurso_consultoria" model="skeen.recurso">
<field name="name">Consultoría</field>
<field name="code">consultoria</field>
<field name="tipo">consultorio</field>
<field name="sequence">20</field>
<field name="activo" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from . import recurso
from . import cita
from . import bloqueo
from . import lista_espera

View File

@@ -3,6 +3,8 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..clinic_settings import get_recursos
# Motivos predefinidos (el legacy usaba 7 = Vacaciones y 8 = Horario de comida)
MOTIVO_SELECTION = [
('vacaciones', 'Vacaciones'),
@@ -14,17 +16,24 @@ MOTIVO_SELECTION = [
class SkeenCitaBloqueo(models.Model):
"""Bloqueo de agenda de un médico para una fecha concreta.
"""Bloqueo de agenda: de un médico y/o de un recurso físico, en una fecha.
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.
Un registro = una fecha, con un médico (opcional) y/o recursos (opcional).
Los bloqueos creados juntos (varios médicos/recursos y/o un rango de fechas
con repetición semanal) comparten el campo `grupo`, lo que permite editarlos
o eliminarlos como serie.
`doctor_id` dejó de ser obligatorio para poder bloquear una cabina, sala o
equipo sin médico (módulo de recursos). Debe haber al menos uno de los dos.
"""
_name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico'
_description = 'Bloqueo de Agenda'
_order = 'date desc, time_from'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
doctor_id = fields.Many2one('hr.employee', string='Médico')
recurso_ids = fields.Many2many(
'skeen.recurso', 'skeen_bloqueo_recurso_rel', 'bloqueo_id', 'recurso_id',
string='Recursos')
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)
@@ -32,7 +41,7 @@ class SkeenCitaBloqueo(models.Model):
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).')
help='Identificador de la serie (médicos/recursos y fechas creados juntos).')
created_by = fields.Char(string='Creado por', readonly=True, copy=False)
@api.constrains('time_from', 'time_to', 'all_day')
@@ -44,9 +53,18 @@ class SkeenCitaBloqueo(models.Model):
if b.time_from < 0 or b.time_to > 24:
raise ValidationError(_('Las horas deben estar dentro del día'))
@api.constrains('doctor_id', 'recurso_ids')
def _check_destino(self):
"""Un bloqueo se hace para un médico y/o un recurso: sin destino no
tiene sentido (y no bloquearía nada)."""
for b in self:
if not b.doctor_id and not b.recurso_ids:
raise ValidationError(_(
'Selecciona al menos un médico o un recurso para bloquear.'))
@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.
"""No se puede bloquear un rango que ya tiene citas activas del médico.
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
@@ -74,6 +92,56 @@ class SkeenCitaBloqueo(models.Model):
'Reprograma o cancela esas citas antes de bloquear.'
) % {'n': len(citas), 'doc': b.doctor_id.name})
@api.constrains('recurso_ids', 'date', 'all_day', 'time_from', 'time_to')
def _check_recursos(self):
"""Un recurso no se puede bloquear si tiene citas activas dentro, ni
solaparse con otro bloqueo del mismo recurso.
Sólo corre con `recursos.activo` (default apagado → sin cambios). El
solape con otro bloqueo respeta además `validar_solape`.
"""
if self.env.context.get('skeen_skip_recursos_solape'):
return
cfg = get_recursos(self.env)
if not cfg['activo']:
return
Citas = self.env['skeen.cita']
for b in self:
if not b.date or not b.recurso_ids:
continue
if b.all_day:
ini_c, fin_c = 0.0, 24.0
else:
ini_c, fin_c = b.time_from, b.time_to
for recurso in b.recurso_ids:
citas = Citas.search([
('date', '=', b.date),
('state', 'not in', ('cancelled', 'no_show', 'waitlist')),
('recurso_ids', 'in', recurso.id),
]).filtered(
lambda c: b.all_day or (
c.time < fin_c and c.time + c._duracion() / 60.0 > ini_c)
)
if citas:
raise ValidationError(_(
'%(rec)s tiene %(n)s cita(s) en ese horario. Reprograma o '
'cancela esas citas antes de bloquear.'
) % {'rec': recurso.name, 'n': len(citas)})
if not cfg['validar_solape']:
continue
otros = self.search([
('id', '!=', b.id),
('date', '=', b.date),
('recurso_ids', 'in', recurso.id),
]).filtered(
lambda o: (o.all_day or b.all_day)
or (ini_c < o.time_to and fin_c > o.time_from)
)
if otros:
raise ValidationError(_(
'%(rec)s ya tiene un bloqueo en ese horario (%(motivo)s).'
) % {'rec': recurso.name, 'motivo': otros[0].nombre_motivo()})
def nombre_motivo(self):
"""Texto legible del motivo para la UI y los mensajes de validación."""
self.ensure_one()

View File

@@ -4,7 +4,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from datetime import datetime, timedelta
from ..clinic_settings import agenda_bounds
from ..clinic_settings import agenda_bounds, get_recursos
# Estados que liberan el horario: no ocupan agenda ni bloquean solapes.
@@ -134,6 +134,11 @@ class SkeenCita(models.Model):
string='Sucursal',
default=lambda self: self.env['skeen.sucursal'].default_code())
doctor_id = fields.Many2one('hr.employee', string='Médico / Especialista')
# Recursos físicos asignados (cabina, sala, equipo). Vacío mientras la
# configuración de la clínica no active el módulo de recursos.
recurso_ids = fields.Many2many(
'skeen.recurso', 'skeen_cita_recurso_rel', 'cita_id', 'recurso_id',
string='Recursos')
medium = fields.Selection([
('videocall', 'Videollamada'),
('onsite', 'Presencial'),
@@ -432,6 +437,52 @@ class SkeenCita(models.Model):
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % b.nombre_motivo())
@api.constrains('date', 'time', 'servicio_id', 'duration', 'recurso_ids', 'state')
def _check_recursos_solape(self):
"""Rechaza citas que pisen un recurso ya ocupado.
Solo corre cuando la clínica tiene `recursos.activo` y `validar_solape`;
con el módulo apagado (default) no cambia nada. Un recurso está ocupado
por otra cita activa (excluye cancelled/no_show/waitlist) o por un
bloqueo de agenda del recurso. El mensaje indica el recurso y con quién
choca, para que la UI lo muestre legible.
"""
if self.env.context.get('skeen_skip_recursos_solape'):
return
cfg = get_recursos(self.env)
if not (cfg['activo'] and cfg['validar_solape']):
return
Bloqueo = self.env['skeen.cita.bloqueo']
for cita in self:
if cita.state in INACTIVE_STATES or not cita.date or not cita.recurso_ids:
continue
ini = cita.time
fin = ini + cita._duracion() / 60.0
for recurso in cita.recurso_ids:
otra = self.search([
('id', '!=', cita.id),
('date', '=', cita.date),
('state', 'not in', INACTIVE_STATES),
('recurso_ids', 'in', recurso.id),
('datetime_start', '<', cita.datetime_end),
('datetime_end', '>', cita.datetime_start),
], limit=1)
if otra:
raise ValidationError(_(
'%(rec)s está ocupado por %(pac)s a las %(hora)s. '
'Elige otro horario o desasigna el recurso.'
) % {'rec': recurso.name, 'pac': otra.partner_id.name or 'otra cita',
'hora': otra.time_str or ''})
bloqueos = Bloqueo.search([
('date', '=', cita.date),
('recurso_ids', 'in', recurso.id),
])
for b in bloqueos:
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_(
'%(rec)s tiene la agenda bloqueada (%(motivo)s).'
) % {'rec': recurso.name, 'motivo': 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.

View File

@@ -45,13 +45,13 @@ class SkeenListaEspera(models.Model):
('tarde', 'Tarde'),
], string='Preferencia', default='')
# Dónde espera el paciente: consultoría (consulta con el médico) o cabina
# (tratamiento/valoración). Recepción lo mueve con un click en la etiqueta.
sala = fields.Selection([
('', 'Sin asignar'),
('consultoria', 'Consultoría'),
('cabina', 'Cabina'),
], string='Sala de espera', default='', index=True)
# Dónde espera el paciente: sale del catálogo de recursos (cabina, sala,
# consultorio...) para que el white-label pueda renombrarlos. Los códigos
# semilla 'cabina'/'consultoria' conservan el comportamiento y los datos
# históricos; sin recursos configurados cae a los valores de siempre.
sala = fields.Selection(
selection='_get_sala_selection',
string='Sala de espera', default='', index=True)
date_from = fields.Date(string='Desde')
date_to = fields.Date(string='Hasta')
@@ -88,6 +88,19 @@ class SkeenListaEspera(models.Model):
) % {'nombre': rec.partner_id.name,
'doc': rec.doctor_id.name or 'sin médico asignado'})
@api.model
def _get_sala_selection(self):
"""Opciones de sala: los recursos activos (o los valores de siempre si
aún no hay ninguno). El '' vacío = sin asignar se agrega en la UI."""
return self.env['skeen.recurso']._seleccion_activos()
@api.model
def sala_label(self, value):
"""Nombre legible de una sala ('' o desconocida → '')."""
if not value:
return ''
return dict(self._get_sala_selection()).get(value, value)
@api.depends('priority')
def _compute_priority_rank(self):
for rec in self:

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
# Tipos de recurso físico de la agenda. Los códigos son estables: la lógica
# interna solo usa `code`/`tipo` como etiqueta, nunca para decidir solapes.
RECURSO_TIPOS = [
('cabina', 'Cabina'),
('sala', 'Sala'),
('consultorio', 'Consultorio'),
('equipo', 'Equipo'),
('otro', 'Otro'),
]
class SkeenRecurso(models.Model):
"""Recurso físico que se puede asignar a las citas (cabina, sala, equipo).
Sustituye a las dos opciones que estaban hardcodeadas en la lista de
espera ('cabina'/'consultoria'): los recursos semilla conservan esos
códigos para que los registros históricos sigan mostrándose igual.
`sucursales` vacío = el recurso aplica a todas las sucursales.
Todo el módulo queda apagado con `recursos.activo=False` (por defecto):
mientras no se active, este catálogo no cambia nada en la agenda.
"""
_name = 'skeen.recurso'
_description = 'Recurso de la agenda (cabina, sala, consultorio, equipo)'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(string='Código', required=True, copy=False,
help='Identificador estable que se guarda en citas y bloqueos.')
tipo = fields.Selection(RECURSO_TIPOS, string='Tipo', default='cabina', required=True)
activo = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)
sucursales = fields.Many2many(
'skeen.sucursal', 'skeen_recurso_sucursal_rel', 'recurso_id', 'sucursal_id',
string='Sucursales',
help='Sucursales donde está disponible. Vacío = todas.')
capacidad = fields.Integer(string='Capacidad', default=0)
costo_hora = fields.Float(string='Costo por hora', default=0.0,
help='Costo operativo del recurso por hora, para estimar ingresos en reportes.')
color = fields.Char(string='Color', help='Color hex para distinguirlo en la agenda (p. ej. #1abc9c).')
notas = fields.Text(string='Notas')
cita_ids = fields.Many2many('skeen.cita', string='Citas')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código del recurso debe ser único!')
]
@api.model
def _seleccion_activos(self):
"""[(code, name)] de los recursos activos. Fallback a los valores de
siempre cuando todavía no hay recursos (instancia sin migrar)."""
recs = self.sudo().search([('activo', '=', True)], order='sequence, name')
seleccion = [(r.code, r.name) for r in recs if r.code]
return seleccion or [('consultoria', 'Consultoría'), ('cabina', 'Cabina')]

View File

@@ -7,3 +7,4 @@ access_skeen_cita_state_log_user,Acceso a Bitácora de Estados SKEEN,model_skeen
access_skeen_lista_espera_user,Acceso a Lista de Espera SKEEN,model_skeen_lista_espera,base.group_user,1,1,1,1
access_skeen_sucursal_user,Acceso a Sucursales SKEEN,model_skeen_sucursal,base.group_user,1,1,1,1
access_skeen_service_category_user,Acceso a Categorias de Servicio SKEEN,model_skeen_service_category,base.group_user,1,1,1,1
access_skeen_recurso_user,Acceso a Recursos SKEEN,model_skeen_recurso,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_skeen_lista_espera_user Acceso a Lista de Espera SKEEN model_skeen_lista_espera base.group_user 1 1 1 1
8 access_skeen_sucursal_user Acceso a Sucursales SKEEN model_skeen_sucursal base.group_user 1 1 1 1
9 access_skeen_service_category_user Acceso a Categorias de Servicio SKEEN model_skeen_service_category base.group_user 1 1 1 1
10 access_skeen_recurso_user Acceso a Recursos SKEEN model_skeen_recurso base.group_user 1 1 1 1

View File

@@ -8,6 +8,7 @@
<tree>
<field name="date"/>
<field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"/>
<field name="all_day"/>
<field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/>
@@ -29,6 +30,8 @@
<group>
<group>
<field name="doctor_id"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="date"/>
<field name="motivo_tipo"/>
<field name="motivo"/>

View File

@@ -46,6 +46,8 @@
<field name="servicio_id"/>
<field name="servicio_price" readonly="1"/>
<field name="servicio_duration" readonly="1"/>
<field name="recurso_ids" widget="many2many_tags"
options="{'no_create': True}"/>
<field name="duration"/>
<field name="date"/>
<field name="time" widget="float_time"/>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de recursos -->
<record id="view_skeen_recurso_tree" model="ir.ui.view">
<field name="name">skeen.recurso.tree</field>
<field name="model">skeen.recurso</field>
<field name="arch" type="xml">
<tree decoration-muted="not activo">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="tipo"/>
<field name="capacidad"/>
<field name="costo_hora"/>
<field name="activo"/>
</tree>
</field>
</record>
<!-- Vista de formulario de recursos -->
<record id="view_skeen_recurso_form" model="ir.ui.view">
<field name="name">skeen.recurso.form</field>
<field name="model">skeen.recurso</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="tipo"/>
<field name="activo"/>
</group>
<group>
<field name="sequence"/>
<field name="capacidad"/>
<field name="costo_hora"/>
<field name="color" placeholder="#1abc9c"/>
</group>
</group>
<group string="Sucursales">
<field name="sucursales" widget="many2many_tags"
options="{'no_create': True}"/>
</group>
<group string="Notas">
<field name="notas" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Acción de recursos -->
<record id="action_skeen_recurso_tree" model="ir.actions.act_window">
<field name="name">Recursos</field>
<field name="res_model">skeen.recurso</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Recursos (bajo el menú Citas) -->
<menuitem id="menu_skeen_citas_recursos" name="Recursos" parent="menu_skeen_citas"
action="action_skeen_recurso_tree" sequence="40"/>
</odoo>

View File

@@ -20,6 +20,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
get_impuestos,
get_recursos,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -930,6 +931,19 @@ class SkeenFrontendController(http.Controller):
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
}
# Recursos físicos (cabina, sala, equipo). Solo se atienden cuando la
# clínica tiene el módulo activo; con recursos.activo=False se ignora.
cfg_recursos = get_recursos(request.env)
recurso_ids = self._recursos_payload(data) if cfg_recursos['activo'] else []
if cfg_recursos['activo']:
if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1:
return json_response({'status': 'error',
'message': 'Solo se permite un recurso por cita'}, 400)
if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids:
return json_response({
'status': 'error',
'message': 'Selecciona al menos un recurso para agendar la cita'}, 400)
base_vals['recurso_ids'] = [(6, 0, recurso_ids)]
first_date = data.get('date')
first_time = data.get('time')
if first_time is not None and not self._valid_agenda_time(first_time):
@@ -1047,6 +1061,7 @@ class SkeenFrontendController(http.Controller):
'notes': data.get('notes', ''),
'state': data.get('state', 'pending'),
'duration': int(data.get('duration') or 0),
**self._recurso_vals_cita(data),
})
return json_response({
'status': 'success',
@@ -1092,6 +1107,17 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body()
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}
cfg_recursos = get_recursos(request.env)
if cfg_recursos['activo'] and 'recurso_ids' in data:
recurso_ids = self._recursos_payload(data)
if not cfg_recursos['permitir_multiples'] and len(recurso_ids) > 1:
return json_response({'status': 'error',
'message': 'Solo se permite un recurso por cita'}, 400)
if cfg_recursos['obligatorio_al_agendar'] and not recurso_ids:
return json_response({
'status': 'error',
'message': 'Selecciona al menos un recurso para agendar la cita'}, 400)
vals['recurso_ids'] = [(6, 0, recurso_ids)]
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 (%s)' % self._agenda_horario_txt()}, 400)
@@ -1420,8 +1446,11 @@ class SkeenFrontendController(http.Controller):
def _bloqueo_to_dict(self, b):
return {
'id': b.id,
'doctor_id': b.doctor_id.id,
'doctor': b.doctor_id.name,
'doctor_id': b.doctor_id.id if b.doctor_id else None,
'doctor': b.doctor_id.name if b.doctor_id else None,
'recurso_ids': b.recurso_ids.ids,
'recursos': [{'id': r.id, 'name': r.name, 'code': r.code,
'color': r.color or ''} for r in b.recurso_ids],
'date': b.date.strftime('%Y-%m-%d') if b.date else None,
'all_day': b.all_day,
'time_from': b.time_from,
@@ -1485,11 +1514,15 @@ class SkeenFrontendController(http.Controller):
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)
# Recursos (opcional): bloquear una cabina/sala/equipo sin médico.
recurso_ids = self._recursos_payload(data) if get_recursos(request.env)['activo'] else []
recursos = request.env['skeen.recurso'].sudo().browse(recurso_ids).exists()
if not doctores and not recursos:
return json_response(
{'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400)
date_from = data.get('date_from') or data.get('date')
date_to = data.get('date_to') or date_from
@@ -1542,12 +1575,7 @@ class SkeenFrontendController(http.Controller):
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'),
base = {
'all_day': all_day,
'time_from': time_from,
'time_to': time_to,
@@ -1555,10 +1583,25 @@ class SkeenFrontendController(http.Controller):
'motivo': motivo,
'grupo': grupo,
'created_by': request.frontend_user.login,
} for doc in doctores for f in fechas])
}
filas = []
# Un bloqueo por médico (sin recurso) y uno por recurso (sin médico):
# seleccionar médicos y recursos bloquea la unión de ambos.
for doc in doctores:
for f in fechas:
filas.append({**base, 'date': f.strftime('%Y-%m-%d'), 'doctor_id': doc.id})
for rec in recursos:
for f in fechas:
filas.append({**base, 'date': f.strftime('%Y-%m-%d'),
'recurso_ids': [(6, 0, [rec.id])]})
creados = Bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).create(filas)
etiqueta = ', '.join(doctores.mapped('name') + recursos.mapped('name'))
self._auditar('agenda', 'skeen.cita.bloqueo', 'crear',
new_value='%s bloqueo(s)' % len(creados),
entity_label=', '.join(doctores.mapped('name')))
entity_label=etiqueta)
return json_response({
'status': 'success',
'grupo': grupo,
@@ -1585,8 +1628,8 @@ class SkeenFrontendController(http.Controller):
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 'doctor_id' in data:
vals['doctor_id'] = int(data['doctor_id']) if data.get('doctor_id') else False
if data.get('date'):
vals['date'] = data['date']
if 'all_day' in data:
@@ -1599,13 +1642,21 @@ class SkeenFrontendController(http.Controller):
vals['motivo_tipo'] = data['motivo_tipo'] or 'otro'
if 'motivo' in data:
vals['motivo'] = data['motivo'] or ''
if 'recurso_ids' in data and get_recursos(request.env)['activo']:
vals['recurso_ids'] = [(6, 0, self._recursos_payload(data))]
if not vals:
return json_response({'status': 'error', 'message': 'Nada que actualizar'}, 400)
if not vals.get('doctor_id') and 'recurso_ids' not in vals and not bloqueo.recurso_ids:
return json_response(
{'status': 'error', 'message': 'Selecciona al menos un médico o un recurso'}, 400)
bloqueo.with_context(
skeen_skip_bloqueo_citas=bool(data.get('force')),
skeen_skip_recursos_solape=bool(data.get('force')),
).write(vals)
etiqueta = bloqueo.doctor_id.name if bloqueo.doctor_id else \
', '.join(bloqueo.recurso_ids.mapped('name'))
self._auditar('agenda', 'skeen.cita.bloqueo', 'editar',
entity_label=bloqueo.doctor_id.name,
entity_label=etiqueta,
new_value=str(vals))
return json_response({'status': 'success', 'bloqueo': self._bloqueo_to_dict(bloqueo)})
except ValidationError as e:
@@ -1623,7 +1674,10 @@ 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)
etiqueta = '%s · %s' % (
bloqueo.doctor_id.name if bloqueo.doctor_id
else ', '.join(bloqueo.recurso_ids.mapped('name')) or 'bloqueo',
bloqueo.date)
bloqueo.unlink()
self._auditar('agenda', 'skeen.cita.bloqueo', 'eliminar', entity_label=etiqueta)
return json_response({'status': 'success', 'message': 'Bloqueo eliminado'})
@@ -1651,6 +1705,14 @@ class SkeenFrontendController(http.Controller):
# ============================================================
# Lista de espera de pacientes
# ============================================================
def _sala_codigo(self, data):
"""Código de sala válido (de los recursos) o False si viene vacío/ajeno."""
value = data.get('sala')
if not value:
return False
validos = [c for c, _l in request.env['skeen.lista.espera']._get_sala_selection()]
return value if value in validos else False
def _lista_espera_to_dict(self, r):
return {
'id': r.id,
@@ -1666,7 +1728,7 @@ class SkeenFrontendController(http.Controller):
'priority_label': dict(r._fields['priority'].selection).get(r.priority, ''),
'franja': r.franja or '',
'sala': r.sala or '',
'sala_label': dict(r._fields['sala'].selection).get(r.sala, '') if r.sala else '',
'sala_label': r.sala_label(r.sala) if r.sala else '',
'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 '',
@@ -1751,7 +1813,7 @@ class SkeenFrontendController(http.Controller):
'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 '',
'sala': data.get('sala') if data.get('sala') in ('consultoria', 'cabina') else False,
'sala': self._sala_codigo(data),
'date_from': data.get('date_from') or False,
'date_to': data.get('date_to') or False,
'notes': data.get('notes') or '',
@@ -1793,9 +1855,9 @@ class SkeenFrontendController(http.Controller):
vals[field] = data[key] or False
if 'sala' in data:
# Toggle de sala desde la etiqueta: '' vuelve a "Sin asignar"
vals['sala'] = data['sala'] if data['sala'] in ('consultoria', 'cabina') else False
vals['sala'] = self._sala_codigo(data)
if vals['sala']:
record._log('Sala: %s' % dict(record._fields['sala'].selection).get(vals['sala'], vals['sala']),
record._log('Sala: %s' % record.sala_label(vals['sala']),
user=request.frontend_user.login)
if data.get('cita_id'):
vals['cita_id'] = int(data['cita_id'])
@@ -1857,6 +1919,8 @@ class SkeenFrontendController(http.Controller):
'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,
'recurso_ids': cita.recurso_ids.ids,
'recursos': [self._recurso_to_dict(r) for r in cita.recurso_ids],
'branch': cita.branch,
'medium': cita.medium,
'booking_source': cita.booking_source or 'internal',
@@ -5854,7 +5918,7 @@ class SkeenFrontendController(http.Controller):
])
bloqueo_horas = {}
for b in bloqueos:
if b.date.weekday() == 6:
if not b.doctor_id or b.date.weekday() == 6:
continue
horas = 8.0 if b.all_day else max(0.0, b.time_to - b.time_from)
bloqueo_horas[b.doctor_id.name] = bloqueo_horas.get(b.doctor_id.name, 0.0) + horas
@@ -7113,6 +7177,301 @@ class SkeenFrontendController(http.Controller):
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Recursos de la agenda (configurables: cabinas, salas, equipos)
# ============================================================
def _recursos_payload(self, data):
"""IDs de recurso de un body (lista `recurso_ids` o CSV `recurso_ids`).
Descarta valores no numéricos o inexistentes para no romper el guardado.
"""
raw = data.get('recurso_ids')
if raw is None:
raw = data.get('recurso_ids_csv')
ids = []
if isinstance(raw, (list, tuple)):
for v in raw:
try:
ids.append(int(v))
except (TypeError, ValueError):
continue
elif raw not in (None, ''):
for v in str(raw).split(','):
v = v.strip()
if v.isdigit():
ids.append(int(v))
ids = list(dict.fromkeys(ids))
if not ids:
return []
existentes = request.env['skeen.recurso'].sudo().search([('id', 'in', ids)]).ids
return [i for i in ids if i in existentes]
def _recurso_vals_cita(self, data):
"""`{recurso_ids: [(6,0,ids)]}` si el módulo está activo y hay recursos."""
cfg = get_recursos(request.env)
if not cfg['activo']:
return {}
ids = self._recursos_payload(data)
if not cfg['permitir_multiples']:
ids = ids[:1]
return {'recurso_ids': [(6, 0, ids)]} if ids else {}
def _recurso_to_dict(self, r):
return {
'id': r.id,
'code': r.code,
'name': r.name,
'tipo': r.tipo,
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''),
'activo': r.activo,
'sequence': r.sequence,
'sucursales': [{'id': s.id, 'code': s.code, 'name': s.name} for s in r.sucursales],
'capacidad': r.capacidad or 0,
'costo_hora': r.costo_hora or 0.0,
'color': r.color or '',
'notas': r.notas or '',
}
@http.route('/skeen/frontend/v1/recursos', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('recursos.ver')
def list_recursos(self, **kw):
"""Recursos. Por defecto solo activos; ?include_inactive=1 trae todos."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
domain = [] if incluir else [('activo', '=', True)]
recs = request.env['skeen.recurso'].sudo().search(domain, order='sequence, name')
return json_response({'status': 'success',
'recursos': [self._recurso_to_dict(r) for r in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recursos', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def create_recurso(self, **kw):
"""Crea un recurso. Sin `code` se genera un slug del nombre."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
Recurso = request.env['skeen.recurso'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Recurso.search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe un recurso con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Recurso, name)
vals = self._recurso_vals(data)
vals.update({'name': name, 'code': codigo})
recurso = Recurso.create(vals)
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'crear',
new_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes=(data.get('motivo') or 'creado').strip())
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
def _recurso_vals(self, data):
"""Campos comunes de alta/edición de un recurso (sin name/code)."""
vals = {}
if 'tipo' in data:
tipos = dict(request.env['skeen.recurso']._fields['tipo'].selection)
vals['tipo'] = data['tipo'] if data.get('tipo') in tipos else 'otro'
for campo in ('notas', 'color'):
if campo in data:
vals[campo] = str(data.get(campo) or '').strip()
if 'sequence' in data:
try:
vals['sequence'] = int(data['sequence'])
except (TypeError, ValueError):
raise ValueError('La secuencia debe ser numérica')
if 'capacidad' in data:
try:
vals['capacidad'] = max(0, int(data['capacidad'] or 0))
except (TypeError, ValueError):
raise ValueError('La capacidad debe ser un número entero')
if 'costo_hora' in data:
try:
vals['costo_hora'] = max(0.0, float(data['costo_hora'] or 0))
except (TypeError, ValueError):
raise ValueError('El costo por hora debe ser un número')
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'sucursal_ids' in data:
ids = data.get('sucursal_ids') or []
if not isinstance(ids, list):
raise ValueError('sucursal_ids debe ser una lista')
vals['sucursales'] = [(6, 0, [int(i) for i in ids if str(i).isdigit()])]
return vals
@http.route('/skeen/frontend/v1/recursos/<int:recurso_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def update_recurso(self, recurso_id, **kw):
try:
if request.httprequest.method == 'OPTIONS':
return json_response({}, 200)
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
recurso = request.env['skeen.recurso'].sudo().browse(recurso_id)
if not recurso.exists():
return json_response({'status': 'error',
'message': 'Recurso no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'code' in data:
codigo = catalog_slugify(data.get('code')) or str(data.get('code') or '').strip()
if codigo and codigo != recurso.code:
dup = request.env['skeen.recurso'].sudo().search_count(
[('code', '=', codigo), ('id', '!=', recurso.id)])
if dup:
return json_response({
'status': 'error',
'message': 'Ya existe un recurso con el código %s' % codigo}, 409)
vals['code'] = codigo
try:
vals.update(self._recurso_vals(data))
except ValueError as e:
return json_response({'status': 'error', 'message': str(e)}, 400)
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
recurso.write(vals)
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'editar',
new_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/recursos/<int:recurso_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('recursos.editar')
def delete_recurso(self, recurso_id, **kw):
"""Desactiva el recurso (nunca se borra: las citas lo referencian)."""
try:
if not self._puede_configurar_catalogo():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
recurso = request.env['skeen.recurso'].sudo().browse(recurso_id)
if not recurso.exists():
return json_response({'status': 'error',
'message': 'Recurso no encontrado'}, 404)
en_uso = request.env['skeen.cita'].sudo().search_count(
[('recurso_ids', 'in', recurso.id)])
recurso.write({'activo': False})
self._auditar('catalogos', 'recurso:%s' % recurso.id, 'desactivar',
old_value=json.dumps(self._recurso_to_dict(recurso),
ensure_ascii=False)[:500],
entity_label='Recurso %s' % recurso.name,
notes='desactivado (%d citas)' % en_uso)
mensaje = 'Recurso desactivado.'
if en_uso:
mensaje = ('El recurso tiene %d cita(s) registradas y no se puede '
'eliminar; quedó desactivado.' % en_uso)
return json_response({'status': 'success',
'recurso': self._recurso_to_dict(recurso),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/recursos', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_recursos(self, **kw):
"""Ocupación por recurso: citas, horas ocupadas, % y costo estimado.
Horas disponibles = días del rango × jornada de la clínica. El % de
ocupación es horas ocupadas / disponibles. `ingresos_estimados` usa el
`costo_hora` del recurso cuando está definido (0 si no).
"""
try:
start = kw.get('start', date.today().strftime('%Y-%m-%d'))
end = kw.get('end', start)
try:
d_ini = datetime.strptime(str(start)[:10], '%Y-%m-%d').date()
d_fin = datetime.strptime(str(end)[:10], '%Y-%m-%d').date()
except ValueError:
return json_response({'status': 'error', 'message': 'Fecha inválida'}, 400)
if d_fin < d_ini:
d_ini, d_fin = d_fin, d_ini
dias = (d_fin - d_ini).days + 1
ini_h, fin_h = self._agenda_bounds()
horas_dia = max(0.0, fin_h - ini_h)
disponibles = horas_dia * dias
citas = request.env['skeen.cita'].sudo().search([
('date', '>=', d_ini.strftime('%Y-%m-%d')),
('date', '<=', d_fin.strftime('%Y-%m-%d')),
('state', 'not in', INACTIVE_STATES),
])
acumulado = {}
for c in citas:
for r in c.recurso_ids:
acc = acumulado.setdefault(r.id, {'citas': 0, 'minutos': 0})
acc['citas'] += 1
acc['minutos'] += c._duracion()
recursos = request.env['skeen.recurso'].sudo().search(
[('activo', '=', True)], order='sequence, name')
filas = []
for r in recursos:
acc = acumulado.get(r.id, {'citas': 0, 'minutos': 0})
horas = round(acc['minutos'] / 60.0, 2)
ocupacion = round((horas / disponibles * 100.0), 1) if disponibles else 0.0
filas.append({
'recurso_id': r.id,
'recurso': r.name,
'code': r.code,
'tipo': r.tipo,
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo or '', ''),
'color': r.color or '',
'citas': acc['citas'],
'horas_ocupadas': horas,
'horas_disponibles': round(disponibles, 2),
'ocupacion_pct': min(ocupacion, 100.0),
'costo_hora': r.costo_hora or 0.0,
'ingresos_estimados': round(horas * (r.costo_hora or 0.0), 2),
})
total_horas = round(sum(f['horas_ocupadas'] for f in filas), 2)
total_citas = sum(f['citas'] for f in filas)
total_ingresos = round(sum(f['ingresos_estimados'] for f in filas), 2)
return json_response({
'status': 'success',
'start': d_ini.strftime('%Y-%m-%d'),
'end': d_fin.strftime('%Y-%m-%d'),
'dias': dias,
'horas_disponibles': round(disponibles, 2),
'recursos': filas,
'totales': {'citas': total_citas, 'horas_ocupadas': total_horas,
'ingresos_estimados': total_ingresos},
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Sucursales (configurables)
# ============================================================

View File

@@ -283,7 +283,9 @@ class SkeenPublicBookingController(http.Controller):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not self._activo():
return self._deshabilitado()
# 200 con activo=false (en vez de 404): el frontend lo trata como
# "no configurado" y no deja un 404 en la consola del login.
return _json({'status': 'success', 'activo': False})
cfg = self._config()
settings = self._settings()
env = request.env

View File

@@ -38,6 +38,10 @@ PERMISSION_GROUPS = [
('agenda.todas', 'Ver la agenda de todos (no solo la propia)'),
('agenda.bloquear', 'Bloquear / cerrar agenda'),
]),
('recursos', 'Recursos de la agenda', [
('recursos.ver', 'Ver recursos (cabinas, salas, equipos)'),
('recursos.editar', 'Administrar recursos y su configuración'),
]),
('pacientes', 'Pacientes', [
('pacientes.ver', 'Ver pacientes'),
('pacientes.expediente', 'Ver expediente completo'),
@@ -121,6 +125,7 @@ PERM_LABELS = {
# Permisos que sólo son lectura: son los que conserva el rol 'lectura'.
READ_PERM_KEYS = (
'agenda.ver', 'agenda.todas',
'recursos.ver',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver',
'inventario.ver',
@@ -168,6 +173,9 @@ DEFAULT_PERM_ROLES = {
'agenda.visitas': W,
'agenda.todas': W,
'agenda.bloquear': W,
# Recursos de la agenda: ver es de todos; administrarlos es de admin/gerencia.
'recursos.ver': ALL,
'recursos.editar': AG,
# Pacientes
'pacientes.ver': ALL,
'pacientes.expediente': ALL,