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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import recurso
|
||||
from . import cita
|
||||
from . import bloqueo
|
||||
from . import lista_espera
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
59
odoo-addons/skeen_citas/models/recurso.py
Normal file
59
odoo-addons/skeen_citas/models/recurso.py
Normal 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')]
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
63
odoo-addons/skeen_citas/views/recurso_views.xml
Normal file
63
odoo-addons/skeen_citas/views/recurso_views.xml
Normal 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>
|
||||
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user