Duración
{detailApt.duration ? `${detailApt.duration} min` : '-'}
diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx
index 278e885..2ab542f 100644
--- a/frontend/src/pages/Configuracion.tsx
+++ b/frontend/src/pages/Configuracion.tsx
@@ -12,6 +12,7 @@ import TicketDesigner from '../components/TicketDesigner';
import ImpactoModal from '../components/ImpactoModal';
import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
+import RecursosPanel from '../components/RecursosPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import NotificacionesPanel from '../components/NotificacionesPanel';
@@ -31,6 +32,7 @@ const TABS = [
{ key: 'formularios', label: 'Formularios' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
+ { key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
@@ -42,7 +44,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
-const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
+const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -67,6 +69,7 @@ const Configuracion: FC = () => {
notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver',
membresias: 'membresias.editar',
+ recursos: 'recursos.editar',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -382,6 +385,12 @@ const Configuracion: FC = () => {
+ ) : tab === 'recursos' ? (
+
+
+
+
+
) : tab === 'membresias' ? (
diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx
index e6e138d..6c192f8 100644
--- a/frontend/src/pages/Reportes.tsx
+++ b/frontend/src/pages/Reportes.tsx
@@ -34,6 +34,7 @@ import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
import KpisReport from '../components/reportes/KpisReport';
+import RecursosReport from '../components/reportes/RecursosReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
@@ -101,6 +102,7 @@ const FAMILIAS: ReportFamily[] = [
icon: Activity,
tabs: [
{ key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' },
+ { key: 'recursos', label: 'Recursos', desc: 'Ocupación de cabinas, salas y equipos' },
{ key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' },
{ key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' },
@@ -148,7 +150,7 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
- const { membresias: membresiasCfg } = useClinicSettings();
+ const { membresias: membresiasCfg, recursos: recursosCfg } = useClinicSettings();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -157,8 +159,9 @@ const ReportesContent: FC = () => {
// La pestaña Membresías además requiere que la clínica tenga el módulo activo.
const puedeTab = useCallback((k: string) => {
if (k === 'membresias' && !membresiasCfg.activo) return false;
+ if (k === 'recursos' && !recursosCfg.activo) return false;
return canSee(`/reportes?tab=${k}`);
- }, [canSee, membresiasCfg.activo]);
+ }, [canSee, membresiasCfg.activo, recursosCfg.activo]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
@@ -478,6 +481,8 @@ const ReportesContent: FC = () => {
) : tab === 'horas-agenda' ? (
+ ) : tab === 'recursos' ? (
+
) : tab === 'paquetes' ? (
) : tab === 'membresias' ? (
diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts
index 8bde689..3dadc9f 100644
--- a/frontend/src/services/odoo.ts
+++ b/frontend/src/services/odoo.ts
@@ -70,6 +70,9 @@ export interface Appointment {
is_first_visit?: boolean;
doctor_id?: number | null;
doctor?: string;
+ /** Recursos asignados (cabina, sala, equipo). Vacío si el módulo está apagado. */
+ recurso_ids?: number[];
+ recursos?: RecursoBrief[];
branch?: string;
medium?: string;
/** Origen de la reserva: 'internal' (default) o 'public_portal' (portal en línea) */
@@ -94,10 +97,60 @@ export interface Appointment {
/** Motivos predefinidos de un bloqueo de agenda */
export type BloqueoMotivo = 'vacaciones' | 'junta' | 'comida' | 'personal' | 'otro';
+// ---- Recursos de la agenda (cabinas, salas, consultorios, equipos) ----
+export interface RecursoBrief {
+ id: number;
+ name: string;
+ code: string;
+ color: string;
+}
+
+export interface Recurso {
+ id: number;
+ code: string;
+ name: string;
+ tipo: RecursoTipo;
+ tipo_label: string;
+ activo: boolean;
+ sequence: number;
+ sucursales: { id: number; code: string; name: string }[];
+ capacidad: number;
+ costo_hora: number;
+ color: string;
+ notas: string;
+}
+
+export type RecursoTipo = 'cabina' | 'sala' | 'consultorio' | 'equipo' | 'otro';
+
+export interface RecursoInput {
+ name: string;
+ code?: string;
+ tipo?: RecursoTipo;
+ activo?: boolean;
+ sequence?: number;
+ sucursal_ids?: number[];
+ capacidad?: number;
+ costo_hora?: number;
+ color?: string;
+ notas?: string;
+ motivo?: string;
+}
+
+export const RECURSO_TIPOS: { value: RecursoTipo; label: string }[] = [
+ { value: 'cabina', label: 'Cabina' },
+ { value: 'sala', label: 'Sala' },
+ { value: 'consultorio', label: 'Consultorio' },
+ { value: 'equipo', label: 'Equipo' },
+ { value: 'otro', label: 'Otro' },
+];
+
export interface Bloqueo {
id: number;
- doctor_id: number;
- doctor: string;
+ doctor_id: number | null;
+ doctor: string | null;
+ /** Recursos bloqueados (cabina, sala, equipo). Vacío si solo bloquea médico. */
+ recurso_ids?: number[];
+ recursos?: RecursoBrief[];
date: string;
all_day: boolean;
time_from: number;
@@ -124,8 +177,8 @@ export interface ListaEspera {
priority: 'alta' | 'normal' | 'baja';
priority_label: string;
franja: '' | 'manana' | 'tarde';
- // Dónde espera: consultoría (médico) o cabina (tratamiento)
- sala?: '' | 'consultoria' | 'cabina';
+ // Dónde espera: el código de un recurso de la agenda (cabina, sala...)
+ sala?: string;
sala_label?: string;
date_from: string | null;
date_to: string | null;
@@ -1015,6 +1068,7 @@ export interface ClinicSettings {
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
+ recursos: RecursosSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1049,6 +1103,46 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
};
// ---- Notificaciones y recordatorios de citas ----
+// ---- Recursos físicos de la agenda ----
+export interface RecursosSettings {
+ /** Enciende el módulo de recursos. Apagado = la agenda se ve igual que hoy. */
+ activo: boolean;
+ /** Impide dos citas/bloqueos en el mismo recurso a la misma hora. */
+ validar_solape: boolean;
+ /** Exige elegir al menos un recurso al crear la cita. */
+ obligatorio_al_agendar: boolean;
+ /** Permite asignar más de un recurso a una cita. */
+ permitir_multiples: boolean;
+ /** Muestra el recurso (chip/color) en la agenda. */
+ mostrar_en_agenda: boolean;
+ /** Ofrece el filtro por recurso junto al de doctores. */
+ filtrar_como_doctores: boolean;
+}
+
+export const RECURSOS_DEFAULT: RecursosSettings = {
+ activo: false,
+ validar_solape: true,
+ obligatorio_al_agendar: false,
+ permitir_multiples: true,
+ mostrar_en_agenda: true,
+ filtrar_como_doctores: true,
+};
+
+export interface RecursosReportRow {
+ recurso_id: number;
+ recurso: string;
+ code: string;
+ tipo: RecursoTipo;
+ tipo_label: string;
+ color: string;
+ citas: number;
+ horas_ocupadas: number;
+ horas_disponibles: number;
+ ocupacion_pct: number;
+ costo_hora: number;
+ ingresos_estimados: number;
+}
+
export type NotificacionCanal = 'whatsapp' | 'email';
export type NotificacionCanalConfig = NotificacionCanal | 'ambos';
export type NotificacionTipo = 'recordatorio' | 'confirmacion' | 'cumpleanos' | 'post_visita' | 'recuperacion' | 'manual';
@@ -1541,6 +1635,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
+ recursos: RECURSOS_DEFAULT,
};
export interface FrontendUser {
@@ -2190,6 +2285,40 @@ export const odooApi = {
return data;
},
+ // ---- Recursos de la agenda (cabinas, salas, consultorios, equipos) ----
+ async getRecursos(opts?: { includeInactive?: boolean }): Promise<{ status: string; recursos: Recurso[]; total: number; message?: string }> {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/recursos`, {
+ params: opts?.includeInactive ? { include_inactive: 1 } : undefined,
+ });
+ return data;
+ },
+
+ async createRecurso(payload: RecursoInput): Promise<{ status: string; recurso: Recurso; message?: string }> {
+ const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/recursos`, payload);
+ return data;
+ },
+
+ async updateRecurso(id: number, payload: Partial): Promise<{ status: string; recurso: Recurso; message?: string }> {
+ const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/recursos/${id}`, payload);
+ return data;
+ },
+
+ async deleteRecurso(id: number): Promise<{ status: string; recurso: Recurso; en_uso: number; message: string }> {
+ const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/recursos/${id}`);
+ return data;
+ },
+
+ async getRecursosReport(start: string, end: string): Promise<{
+ status: string; start: string; end: string; dias: number; horas_disponibles: number;
+ recursos: RecursosReportRow[]; totales: { citas: number; horas_ocupadas: number; ingresos_estimados: number };
+ message?: string;
+ }> {
+ const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recursos`, {
+ params: { start, end },
+ });
+ return data;
+ },
+
// ---- Ajustes de la clínica ----
async getClinicSettings(): Promise<{ status: string; settings: ClinicSettings; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/clinic-settings`);
@@ -2492,7 +2621,7 @@ export const odooApi = {
return data;
},
- async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: string }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
+ async createQuickAppointment(payload: { name: string; phone: string; date: string; time: number; doctor_id?: number; notes?: string; duration?: number; branch?: string; recurso_ids?: number[] }): Promise<{ status: string; appointment: Appointment; patient: Patient; patient_created: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/quick`, payload);
return data;
},
@@ -2596,6 +2725,7 @@ export const odooApi = {
async createBloqueo(payload: {
doctor_ids: number[];
+ recurso_ids?: number[];
date_from: string;
date_to?: string;
days?: number[];
@@ -2611,7 +2741,7 @@ export const odooApi = {
},
async updateBloqueo(id: number, payload: Partial<{
- doctor_id: number; date: string; all_day: boolean; time_from: number; time_to: number;
+ doctor_id: number; recurso_ids: number[]; date: string; all_day: boolean; time_from: number; time_to: number;
motivo_tipo: BloqueoMotivo; motivo: string; force: boolean;
}>): Promise<{ status: string; bloqueo: Bloqueo }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`, payload);
diff --git a/odoo-addons/skeen_citas/__manifest__.py b/odoo-addons/skeen_citas/__manifest__.py
index 9a26d4a..a8a742a 100644
--- a/odoo-addons/skeen_citas/__manifest__.py
+++ b/odoo-addons/skeen_citas/__manifest__.py
@@ -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,
diff --git a/odoo-addons/skeen_citas/clinic_settings.py b/odoo-addons/skeen_citas/clinic_settings.py
index b771b9f..6d9e8e1 100644
--- a/odoo-addons/skeen_citas/clinic_settings.py
+++ b/odoo-addons/skeen_citas/clinic_settings.py
@@ -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)
diff --git a/odoo-addons/skeen_citas/data/catalogos.xml b/odoo-addons/skeen_citas/data/catalogos.xml
index 929e34d..434c2a6 100644
--- a/odoo-addons/skeen_citas/data/catalogos.xml
+++ b/odoo-addons/skeen_citas/data/catalogos.xml
@@ -45,5 +45,24 @@
40
+
+
+
+ Cabina
+ cabina
+ cabina
+ 10
+
+
+
+ Consultoría
+ consultoria
+ consultorio
+ 20
+
+
diff --git a/odoo-addons/skeen_citas/models/__init__.py b/odoo-addons/skeen_citas/models/__init__.py
index 4b8f3d1..db8bd74 100644
--- a/odoo-addons/skeen_citas/models/__init__.py
+++ b/odoo-addons/skeen_citas/models/__init__.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
+from . import recurso
from . import cita
from . import bloqueo
from . import lista_espera
diff --git a/odoo-addons/skeen_citas/models/bloqueo.py b/odoo-addons/skeen_citas/models/bloqueo.py
index 2142b80..727537a 100644
--- a/odoo-addons/skeen_citas/models/bloqueo.py
+++ b/odoo-addons/skeen_citas/models/bloqueo.py
@@ -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()
diff --git a/odoo-addons/skeen_citas/models/cita.py b/odoo-addons/skeen_citas/models/cita.py
index 5a42e65..22859c2 100644
--- a/odoo-addons/skeen_citas/models/cita.py
+++ b/odoo-addons/skeen_citas/models/cita.py
@@ -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.
diff --git a/odoo-addons/skeen_citas/models/lista_espera.py b/odoo-addons/skeen_citas/models/lista_espera.py
index 0601846..7765585 100644
--- a/odoo-addons/skeen_citas/models/lista_espera.py
+++ b/odoo-addons/skeen_citas/models/lista_espera.py
@@ -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:
diff --git a/odoo-addons/skeen_citas/models/recurso.py b/odoo-addons/skeen_citas/models/recurso.py
new file mode 100644
index 0000000..8829d46
--- /dev/null
+++ b/odoo-addons/skeen_citas/models/recurso.py
@@ -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')]
diff --git a/odoo-addons/skeen_citas/security/ir.model.access.csv b/odoo-addons/skeen_citas/security/ir.model.access.csv
index 3eee021..6240b03 100644
--- a/odoo-addons/skeen_citas/security/ir.model.access.csv
+++ b/odoo-addons/skeen_citas/security/ir.model.access.csv
@@ -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
diff --git a/odoo-addons/skeen_citas/views/bloqueo_views.xml b/odoo-addons/skeen_citas/views/bloqueo_views.xml
index 4860a6f..4663e80 100644
--- a/odoo-addons/skeen_citas/views/bloqueo_views.xml
+++ b/odoo-addons/skeen_citas/views/bloqueo_views.xml
@@ -8,6 +8,7 @@
+
@@ -29,6 +30,8 @@
+
diff --git a/odoo-addons/skeen_citas/views/cita_views.xml b/odoo-addons/skeen_citas/views/cita_views.xml
index de06058..cd6d717 100644
--- a/odoo-addons/skeen_citas/views/cita_views.xml
+++ b/odoo-addons/skeen_citas/views/cita_views.xml
@@ -46,6 +46,8 @@
+
diff --git a/odoo-addons/skeen_citas/views/recurso_views.xml b/odoo-addons/skeen_citas/views/recurso_views.xml
new file mode 100644
index 0000000..d6eb16e
--- /dev/null
+++ b/odoo-addons/skeen_citas/views/recurso_views.xml
@@ -0,0 +1,63 @@
+
+
+
+
+ skeen.recurso.tree
+ skeen.recurso
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ skeen.recurso.form
+ skeen.recurso
+
+
+
+
+
+
+
+ Recursos
+ skeen.recurso
+ tree,form
+
+
+
+
+
diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py
index 1d5340f..4ecae98 100644
--- a/odoo-addons/skeen_whatsapp/controllers/frontend.py
+++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py
@@ -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/', 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/', 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)
# ============================================================
diff --git a/odoo-addons/skeen_whatsapp/controllers/public_booking.py b/odoo-addons/skeen_whatsapp/controllers/public_booking.py
index 5b7a3de..8eee347 100644
--- a/odoo-addons/skeen_whatsapp/controllers/public_booking.py
+++ b/odoo-addons/skeen_whatsapp/controllers/public_booking.py
@@ -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
diff --git a/odoo-addons/skeen_whatsapp/permissions.py b/odoo-addons/skeen_whatsapp/permissions.py
index 8d2d05d..35f9369 100644
--- a/odoo-addons/skeen_whatsapp/permissions.py
+++ b/odoo-addons/skeen_whatsapp/permissions.py
@@ -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,