Agrego carpeta del source sql de la Base de Datos
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
-- ============================================================================
|
||||
-- fc_cancelar_reserva
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Cancela (soft-delete) una reserva. Aplica la regla de anticipación
|
||||
-- mínima: por defecto el cliente debe cancelar con al menos 24 horas
|
||||
-- de anticipación. Sólo el propio cliente puede cancelar su reserva
|
||||
-- por esta vía.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación **"cancelar"** ejecutada por el cliente
|
||||
-- (Documentation/DominioHorarios.md §7.5 reserva, §9.2 reglas para
|
||||
-- cancelar). El parámetro `p_horas_anticipacion` materializa la
|
||||
-- **ventana de cancelación** del dominio (§9.2: "un valor global, único
|
||||
-- para todo el gimnasio, decidido por el operador"). El default 24h se
|
||||
-- asume cuando el caller no lo pasa; el dominio espera que el operador
|
||||
-- decida el valor real y se aplique consistentemente.
|
||||
--
|
||||
-- Pasada la ventana, el dominio describe la reserva como "firme desde
|
||||
-- el lado del cliente": esta función rechaza la cancelación. El
|
||||
-- bypasseo para el operador vive en `fc_cancelar_reserva_admin` (§9.6).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor (cliente).
|
||||
-- p_cliente_id UUID ID del cliente dueño de la reserva.
|
||||
-- p_reserva_id UUID ID de la reserva a cancelar.
|
||||
-- p_horas_anticipacion INT (default 24) horas mínimas de anticipación.
|
||||
-- Parametrizable porque es regla de negocio,
|
||||
-- no crítica.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'reservar_turno' (mismo que reserva: el cliente
|
||||
-- gestiona sus propias reservas).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'La reserva no existe, no te pertenece o ya fue cancelada.'
|
||||
-- - 'No podés cancelar la reserva con menos de X horas de anticipación.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status: 'success', mensaje}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE en reservas: setea cancelada=true y cancelada_en=now().
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_cancelar_reserva(
|
||||
p_token UUID,
|
||||
p_cliente_id UUID,
|
||||
p_reserva_id UUID,
|
||||
p_horas_anticipacion INT DEFAULT 24 -- Horas límite para cancelar, está como parámetro porque no es crítico
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_turno_timestamp TIMESTAMP;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Autenticación
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'reservar_turno');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Validar Existencia y Propiedad
|
||||
------------------------------------------------------------
|
||||
-- Buscamos la reserva y calculamos el Timestamp exacto del turno
|
||||
SELECT (t.fecha + t.hora_inicio)::TIMESTAMP
|
||||
INTO v_turno_timestamp
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
WHERE r.id = p_reserva_id
|
||||
AND r.cliente_id = p_cliente_id -- ¡Vital para que no cancelen reservas ajenas!
|
||||
AND r.cancelada = false;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'La reserva no existe, no te pertenece o ya fue cancelada.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Validar Anticipación (La regla de las X horas)
|
||||
------------------------------------------------------------
|
||||
-- LOCALTIMESTAMP nos da la fecha y hora actual del servidor
|
||||
IF v_turno_timestamp - LOCALTIMESTAMP < (p_horas_anticipacion || ' hours')::INTERVAL THEN
|
||||
RAISE EXCEPTION 'No podés cancelar la reserva con menos de % horas de anticipación.', p_horas_anticipacion;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 4. Ejecutar Cancelación (Soft Delete)
|
||||
------------------------------------------------------------
|
||||
UPDATE reservas
|
||||
SET cancelada = true,
|
||||
cancelada_en = now()
|
||||
WHERE id = p_reserva_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'mensaje', 'Tu reserva fue cancelada con éxito.'
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,76 @@
|
||||
-- ============================================================================
|
||||
-- fc_cancelar_reserva_admin
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Versión administrativa de cancelar reserva. Bypassea las validaciones
|
||||
-- de propiedad y de horas de anticipación; sólo verifica que la reserva
|
||||
-- exista y no esté ya cancelada.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación **"cancelar"** cuando la ejecuta el operador
|
||||
-- (Documentation/DominioHorarios.md §4, §9.2, §9.6). Materializa
|
||||
-- directamente la regla del dominio: "el operador puede cancelar
|
||||
-- cualquier reserva, en cualquier momento, sin restricciones".
|
||||
-- Bypassea pertenencia (no exige ser el cliente dueño) y ventana de
|
||||
-- cancelación.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor (admin).
|
||||
-- p_reserva_id UUID ID de la reserva a cancelar.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'gestionar_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'La reserva no existe o ya se encuentra cancelada.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status: 'success', mensaje}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE en reservas: setea cancelada=true y cancelada_en=now().
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_cancelar_reserva_admin(
|
||||
p_token UUID,
|
||||
p_reserva_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $function$
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Autenticación Elevada
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'gestionar_reservas');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Validar Existencia
|
||||
------------------------------------------------------------
|
||||
IF NOT EXISTS (SELECT 1 FROM reservas WHERE id = p_reserva_id AND cancelada = false) THEN
|
||||
RAISE EXCEPTION 'La reserva no existe o ya se encuentra cancelada.';
|
||||
END IF;
|
||||
|
||||
-- OMITIDAS intencionalmente (Bypass Administrativo):
|
||||
-- ✖ Validación de Propiedad (cliente_id)
|
||||
-- ✖ Validación de Horas de Anticipación
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Ejecutar Cancelación (Soft Delete)
|
||||
------------------------------------------------------------
|
||||
UPDATE reservas
|
||||
SET cancelada = true,
|
||||
cancelada_en = now()
|
||||
WHERE id = p_reserva_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'mensaje', 'La reserva fue cancelada administrativamente.'
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,157 @@
|
||||
-- ============================================================================
|
||||
-- fc_mover_reserva_huerfana
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Reubica una reserva huérfana en un turno nuevo: crea la reserva real
|
||||
-- y marca la huérfana como 'reubicado'. Atómica: bloquea la huérfana
|
||||
-- y el turno para evitar dobles reubicaciones y carreras concurrentes.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa una de las dos resoluciones que el dominio admite para una
|
||||
-- reserva huérfana (Documentation/DominioHorarios.md §7.8): la
|
||||
-- **reasignación por el operador**, donde el operador le crea al
|
||||
-- cliente la nueva reserva en lugar de esperar a que el cliente la haga
|
||||
-- por su cuenta. El dominio §7.8 lo describe como "opcional caso a
|
||||
-- caso, no es una obligación".
|
||||
--
|
||||
-- El correlato de notificación al cliente (§7.9) cambia según el
|
||||
-- resultado: si la huérfana sigue pendiente, el aviso es "tu reserva se
|
||||
-- cayó, podés reservar de nuevo"; si la reasignaste con esta función,
|
||||
-- el aviso pasa a ser "te reasignamos tu turno a esto otro". Esa
|
||||
-- distinción la maneja el caller; esta función sólo mueve la huérfana
|
||||
-- a estado 'reubicado'.
|
||||
--
|
||||
-- NOTA DE COHERENCIA. Reasignar es uno de los actos del operador que
|
||||
-- §9.6 cubre ("al reservar, cancelar o **asignar** en nombre de un
|
||||
-- cliente"). Por eso esta función no bloquea por cupo del bloque: si
|
||||
-- el destino está lleno, la ocupación queda por encima de
|
||||
-- `capacidad_maxima` y se ve naturalmente en la grilla. La UI muestra
|
||||
-- la advertencia antes de invocar. El chequeo de "mismo cliente, mismo
|
||||
-- turno" sí se mantiene — eso es integridad, no regla operativa.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_huerfana_id UUID ID de la reserva huérfana (debe estar 'pendiente').
|
||||
-- p_turno_id UUID ID del turno destino (debe estar activo).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'gestionar_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'La reserva huérfana no existe.'
|
||||
-- - 'La reserva huérfana ya fue resuelta (estado: X).'
|
||||
-- - 'El turno no existe, fue cancelado o no está disponible.'
|
||||
-- - 'El cliente ya tiene una reserva activa en este turno.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status: 'success', mensaje, reserva_id, fecha, hora_inicio}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - INSERT en reservas (cancelada=false, reservada_en=now()). Puede
|
||||
-- dejar el turno con ocupacion > capacidad_maxima si el operador
|
||||
-- reubica a un bloque lleno.
|
||||
-- - UPDATE en reservas_huerfanas: estado_resolucion='reubicado',
|
||||
-- resuelto_en=now() (arranca la ventana de gracia previa a purga;
|
||||
-- ver internal.limpiar_huerfanas_resueltas y la clave
|
||||
-- `retencion.huerfanas_resueltas_dias`).
|
||||
-- ============================================================================
|
||||
|
||||
-- Para hacer una nueva reserva en base a una reserva huérfana
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_mover_reserva_huerfana(
|
||||
p_token UUID,
|
||||
p_huerfana_id UUID,
|
||||
p_turno_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_cliente_id UUID;
|
||||
v_estado_actual VARCHAR(20);
|
||||
v_fecha_turno DATE;
|
||||
v_hora_inicio TIME;
|
||||
v_nueva_reserva_id UUID;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Autenticación
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'gestionar_reservas');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Verificar que la huérfana existe y sigue pendiente
|
||||
------------------------------------------------------------
|
||||
SELECT cliente_id, estado_resolucion
|
||||
INTO v_cliente_id, v_estado_actual
|
||||
FROM reservas_huerfanas
|
||||
WHERE id = p_huerfana_id
|
||||
FOR UPDATE; -- evita doble reubicación concurrente
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'La reserva huérfana no existe.';
|
||||
END IF;
|
||||
|
||||
IF v_estado_actual <> 'pendiente' THEN
|
||||
RAISE EXCEPTION 'La reserva huérfana ya fue resuelta (estado: %).', v_estado_actual;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Bloquear turno (integridad: existe y está activo)
|
||||
------------------------------------------------------------
|
||||
-- El lock evita carreras concurrentes en la huérfana/turno aunque
|
||||
-- ya no chequeamos cupo (§9.6: reasignar es un acto del operador
|
||||
-- y el cupo del bloque es advertencia, no bloqueo; la UI lo muestra
|
||||
-- antes de invocar).
|
||||
SELECT t.fecha, t.hora_inicio
|
||||
INTO v_fecha_turno, v_hora_inicio
|
||||
FROM turnos t
|
||||
WHERE t.id = p_turno_id AND t.activo = true
|
||||
FOR NO KEY UPDATE OF t;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El turno no existe, fue cancelado o no está disponible.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 4. Evitar reserva duplicada del mismo cliente en ese turno
|
||||
-- (integridad, no regla operativa)
|
||||
------------------------------------------------------------
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM reservas
|
||||
WHERE turno_id = p_turno_id
|
||||
AND cliente_id = v_cliente_id
|
||||
AND cancelada = false
|
||||
) THEN
|
||||
RAISE EXCEPTION 'El cliente ya tiene una reserva activa en este turno.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 5. Insertar reserva
|
||||
------------------------------------------------------------
|
||||
INSERT INTO reservas (turno_id, cliente_id, reservada_en, cancelada)
|
||||
VALUES (p_turno_id, v_cliente_id, now(), false)
|
||||
RETURNING id INTO v_nueva_reserva_id;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 6. Marcar huérfana como reubicada
|
||||
------------------------------------------------------------
|
||||
UPDATE reservas_huerfanas
|
||||
SET estado_resolucion = 'reubicado',
|
||||
resuelto_en = now()
|
||||
WHERE id = p_huerfana_id;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 7. Retorno
|
||||
------------------------------------------------------------
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'mensaje', 'Reserva huérfana reubicada con éxito.',
|
||||
'reserva_id', v_nueva_reserva_id,
|
||||
'fecha', v_fecha_turno,
|
||||
'hora_inicio', to_char(v_hora_inicio, 'HH24:MI')
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_estado_cupo
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve el estado del cupo semanal de un cliente para la semana en
|
||||
-- la que cae la fecha dada: cuántas reservas activas usó y cuántas le
|
||||
-- quedan según el límite de su plan.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Materializa la consulta del **cupo semanal del plan**
|
||||
-- (Documentation/DominioHorarios.md §7.7) de un cliente para la semana
|
||||
-- en que cae una fecha. Es la fuente que sostiene la condición §9.1
|
||||
-- "el cliente no debe haber agotado su cupo semanal del plan".
|
||||
--
|
||||
-- El cupo se contabiliza sobre **todas** las reservas activas del
|
||||
-- cliente en la semana, sin distinguir su origen — eso es coherente con
|
||||
-- §7.5: "una reserva es indistinguible por su origen; ocupa cupo... de
|
||||
-- la misma manera".
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_cliente_id UUID ID del cliente.
|
||||
-- p_fecha DATE fecha dentro de la semana a evaluar.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {usados, disponibles, limite_total, tiene_plan}.
|
||||
-- - Si el cliente no tiene plan activo: {usados: 0, disponibles: 0,
|
||||
-- limite_total: 0, tiene_plan: false}.
|
||||
-- - Si tiene plan: valores reales basados en tipos_cuota.dias_semana.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_estado_cupo(
|
||||
p_token UUID,
|
||||
p_cliente_id UUID,
|
||||
p_fecha DATE
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_limite_semanal INT;
|
||||
v_reservas_usadas INT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_reservas');
|
||||
|
||||
-- 1. Identificar el límite del plan del cliente
|
||||
SELECT tc.dias_semana INTO v_limite_semanal
|
||||
FROM usuarios u
|
||||
LEFT JOIN tipos_cuota tc ON u.tipo_cuota = tc.id
|
||||
WHERE u.id = p_cliente_id AND u.isactive = true;
|
||||
|
||||
IF v_limite_semanal IS NULL THEN
|
||||
-- El cliente no tiene un plan activo asignado
|
||||
RETURN jsonb_build_object(
|
||||
'usados', 0,
|
||||
'disponibles', 0,
|
||||
'limite_total', 0,
|
||||
'tiene_plan', false
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- 2. Contar instancias de reserva activa en el bloque semanal
|
||||
SELECT COUNT(*)::INT INTO v_reservas_usadas
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
WHERE r.cliente_id = p_cliente_id
|
||||
AND r.cancelada = false
|
||||
AND date_trunc('week', t.fecha) = date_trunc('week', p_fecha);
|
||||
|
||||
-- 3. Retornar el cálculo
|
||||
RETURN jsonb_build_object(
|
||||
'usados', v_reservas_usadas,
|
||||
'disponibles', GREATEST(0, v_limite_semanal - v_reservas_usadas),
|
||||
'limite_total', v_limite_semanal,
|
||||
'tiene_plan', true
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,95 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_reservas_cliente
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve las reservas de un cliente con datos de turno y actividad.
|
||||
-- Por defecto excluye canceladas; opcionalmente las incluye. Permite
|
||||
-- filtrar por fecha mínima.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Consulta de las **reservas** de un cliente
|
||||
-- (Documentation/DominioHorarios.md §7.5). Es la fuente para la vista
|
||||
-- que §13 le exige a la interfaz: "que el cliente vea sus reservas
|
||||
-- activas". También sirve al operador como vista del compromiso vigente
|
||||
-- de un cliente concreto.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_cliente_id UUID ID del cliente.
|
||||
-- p_incluir_canceladas BOOLEAN (default false) si true, también trae
|
||||
-- las reservas canceladas.
|
||||
-- p_fecha_desde DATE (opcional) trae sólo reservas cuyo turno
|
||||
-- tiene fecha >= a esta. NULL = todas.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_reservas_propias'. Se asume que el sistema de
|
||||
-- permisos maneja la dualidad admin / cliente sobre sí mismo; si no,
|
||||
-- conviene reforzar acá con un IF explícito (ver comentario en la
|
||||
-- función).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: { reserva_id, turno_id,
|
||||
-- fecha, hora_inicio, hora_fin, cancelada, reservada_en, actividad:
|
||||
-- {id, nombre, libre} }. Ordenado por fecha y hora descendentes.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_reservas_cliente(
|
||||
p_token UUID,
|
||||
p_cliente_id UUID,
|
||||
p_incluir_canceladas BOOLEAN DEFAULT false,
|
||||
p_fecha_desde DATE DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Autenticación y Permisos
|
||||
------------------------------------------------------------
|
||||
-- Aquí debe validarse que el p_token corresponda a un admin (Juani)
|
||||
-- o que el p_token corresponda exactamente al mismo p_cliente_id (el usuario viendo su propia app).
|
||||
-- Asumiremos que internal.validate_permission ya maneja esta dualidad,
|
||||
-- o bien se puede agregar un bloque IF explícito si tu lógica de tokens lo requiere.
|
||||
PERFORM internal.validate_permission(p_token, 'ver_reservas_propias');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Consulta y armado del JSON
|
||||
------------------------------------------------------------
|
||||
RETURN COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'reserva_id', r.id,
|
||||
'turno_id', t.id,
|
||||
'fecha', t.fecha,
|
||||
'hora_inicio', to_char(t.hora_inicio, 'HH24:MI'),
|
||||
'hora_fin', to_char(t.hora_fin, 'HH24:MI'),
|
||||
'cancelada', r.cancelada,
|
||||
'reservada_en', r.reservada_en,
|
||||
'actividad', jsonb_build_object(
|
||||
'id', a.id,
|
||||
'nombre', a.nombre,
|
||||
'libre', a.libre
|
||||
)
|
||||
) ORDER BY t.fecha DESC, t.hora_inicio DESC
|
||||
)
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
JOIN actividades a ON t.actividad_id = a.id
|
||||
WHERE r.cliente_id = p_cliente_id
|
||||
-- Filtro condicional para canceladas
|
||||
AND (p_incluir_canceladas = true OR r.cancelada = false)
|
||||
-- Filtro condicional de fecha
|
||||
AND (p_fecha_desde IS NULL OR t.fecha >= p_fecha_desde)
|
||||
), '[]'::jsonb);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_reservas_huerfanas
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve las reservas huérfanas: aquellas que fueron rescatadas
|
||||
-- automáticamente cuando un turno dejó de existir (por desactivación
|
||||
-- de actividad, eliminación de día especial, cambio de plantilla
|
||||
-- horaria, etc.). Permite filtrar por estado y por fecha de creación.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Consulta del catálogo de **reservas huérfanas**
|
||||
-- (Documentation/DominioHorarios.md §7.8). Sirve a lo que §13 le pide a
|
||||
-- la interfaz: "que el operador vea las reservas huérfanas pendientes y
|
||||
-- las resuelva si lo desea (reasignándolas), o las deje para que el
|
||||
-- cliente las resuelva por su cuenta".
|
||||
--
|
||||
-- El parámetro `p_creada_desde` habilita el caso "ver las huérfanas
|
||||
-- que se generaron a raíz de la operación que acabo de hacer": al
|
||||
-- pasarlo el timestamp previo a un cambio estructural, el caller
|
||||
-- obtiene la cosecha de huérfanas producida por su edición. Sirve para
|
||||
-- el aviso post-operación que §13 espera ("la interfaz le muestra al
|
||||
-- operador qué clientes quedaron afectados").
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_estado VARCHAR (opcional, default 'pendiente') filtra
|
||||
-- por estado de resolución. NULL = todos.
|
||||
-- Valores válidos: 'pendiente',
|
||||
-- 'reubicado', 'resuelta'.
|
||||
-- p_creada_desde TIMESTAMPTZ (opcional) trae sólo huérfanas creadas
|
||||
-- en o después de esta fecha. NULL = todas.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'gestionar_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: { huerfana_id, cliente_id,
|
||||
-- nombre, apellido, telefono, actividad_nombre, fecha_original,
|
||||
-- hora_inicio_original, estado_resolucion, creada_en }. Ordenado por
|
||||
-- creada_en descendente.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_reservas_huerfanas(
|
||||
p_token UUID,
|
||||
p_estado VARCHAR DEFAULT 'pendiente',
|
||||
p_creada_desde TIMESTAMPTZ DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'gestionar_reservas');
|
||||
|
||||
RETURN COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'huerfana_id', rh.id,
|
||||
'cliente_id', u.id,
|
||||
'nombre', u.nombre,
|
||||
'apellido', u.apellido,
|
||||
'telefono', u.telefono,
|
||||
'actividad_nombre', rh.actividad_nombre,
|
||||
'fecha_original', rh.fecha_original,
|
||||
'hora_inicio_original', to_char(rh.hora_inicio_original, 'HH24:MI'),
|
||||
'estado_resolucion', rh.estado_resolucion,
|
||||
'creada_en', rh.creada_en
|
||||
) ORDER BY rh.creada_en DESC
|
||||
)
|
||||
FROM reservas_huerfanas rh
|
||||
JOIN usuarios u ON rh.cliente_id = u.id
|
||||
WHERE (rh.estado_resolucion = p_estado OR p_estado IS NULL)
|
||||
AND (p_creada_desde IS NULL OR rh.creada_en >= p_creada_desde)
|
||||
), '[]'::jsonb);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_reservas_turno
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve la lista de reservas asociadas a un turno, incluyendo
|
||||
-- canceladas, con datos básicos del cliente.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Lectura del estado de un **bloque** desde el lado del operador
|
||||
-- (Documentation/DominioHorarios.md §7.3 + §7.5). Es la fuente con la
|
||||
-- que se materializa lo que §13 le pide a la interfaz: "que el operador
|
||||
-- vea quién está anotado a qué bloque".
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_turno_id UUID ID del turno.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: { reserva_id, cliente_id,
|
||||
-- nombre, apellido, cancelada, reservada_en }. Ordenado por reservada_en
|
||||
-- ascendente.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_reservas_turno(
|
||||
p_token UUID,
|
||||
p_turno_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_reservas');
|
||||
|
||||
RETURN COALESCE((
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'reserva_id', r.id,
|
||||
'cliente_id', u.id,
|
||||
'nombre', u.nombre,
|
||||
'apellido', u.apellido,
|
||||
'cancelada', r.cancelada,
|
||||
'reservada_en', r.reservada_en
|
||||
) ORDER BY r.reservada_en ASC
|
||||
)
|
||||
FROM reservas r
|
||||
JOIN usuarios u ON r.cliente_id = u.id
|
||||
WHERE r.turno_id = p_turno_id
|
||||
), '[]'::jsonb);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,209 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_turnos
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Facade para obtener los turnos de un rango de días. Si los turnos no
|
||||
-- están materializados todavía (estrategia JIT), los genera primero a
|
||||
-- partir de la plantilla horaria (regular o especial) y después devuelve
|
||||
-- el estado. Para días marcados como 'cerrado' no genera turnos.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Devuelve los **bloques reservables** (Documentation/DominioHorarios.md
|
||||
-- §7.3) para un rango de días, con su ocupación. Es lo que la grilla del
|
||||
-- gimnasio (§13) muestra al operador en términos de "qué turnos hay y
|
||||
-- cuántos lugares quedan".
|
||||
--
|
||||
-- La materialización JIT — generar turnos a partir de la plantilla la
|
||||
-- primera vez que se consulta una fecha — es un **detalle de
|
||||
-- implementación**. El dominio sólo conoce "bloques ofrecidos según la
|
||||
-- plantilla vigente para esa fecha"; cuándo se persisten en la tabla no
|
||||
-- le importa. La consecuencia práctica para el caller: esta función
|
||||
-- está marcada VOLATILE aunque conceptualmente sea de lectura.
|
||||
--
|
||||
-- Para días marcados como 'cerrado' (§7.4) no genera bloques: el día
|
||||
-- especial sobrescribe la oferta a vacío.
|
||||
--
|
||||
-- RETENCIÓN (US-R13)
|
||||
-- La generación JIT se inhibe para fechas anteriores a la ventana de
|
||||
-- purga de turnos (`retencion.turnos_meses`, default 2). Así una
|
||||
-- consulta a una fecha vieja no re-materializa turnos que el pipeline
|
||||
-- ya purgó (Documentation/PoliticaRetencion.md §7). La lectura no se
|
||||
-- filtra: turnos viejos aún no purgados se siguen devolviendo.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_fecha_inicio DATE primer día del rango.
|
||||
-- p_cantidad_dias SMALLINT (default 7) cantidad de días (1..31).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_turnos'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'p_cantidad_dias debe estar entre 1 y 31.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB objeto con clave = fecha (YYYY-MM-DD), valor = { dia_semana,
|
||||
-- estado: 'normal' | 'horario_diferente' | 'cerrado', turnos: [{id,
|
||||
-- hora_inicio, hora_fin, capacidad_maxima, ocupacion, actividad}] }.
|
||||
-- Si el día está cerrado, turnos = [].
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Marcada VOLATILE porque puede generar turnos JIT (INSERTs en turnos)
|
||||
-- en fechas que aún no tienen materializados.
|
||||
-- ============================================================================
|
||||
|
||||
-- FACADE para que el frontend obtenga los turnos, si hace falta los genera
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_turnos(
|
||||
p_token UUID,
|
||||
p_fecha_inicio DATE,
|
||||
p_cantidad_dias SMALLINT DEFAULT 7
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_fecha DATE;
|
||||
v_dia_offset INT;
|
||||
v_dia_semana SMALLINT;
|
||||
v_resultado JSONB := '{}'::JSONB;
|
||||
v_turnos_json JSONB;
|
||||
v_especial RECORD;
|
||||
v_bloque RECORD;
|
||||
v_hora_iter TIME;
|
||||
v_cutoff DATE;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_turnos');
|
||||
|
||||
IF p_cantidad_dias < 1 OR p_cantidad_dias > 31 THEN
|
||||
RAISE EXCEPTION 'p_cantidad_dias debe estar entre 1 y 31.';
|
||||
END IF;
|
||||
|
||||
-- Auditoría de retención (US-R13): no re-materializar turnos en fechas
|
||||
-- ya fuera de la ventana de purga. Sin este corte, consultar una fecha
|
||||
-- vieja regeneraría JIT turnos que internal.limpiar_turnos_antiguos
|
||||
-- acaba de borrar — fabricando datos que el sistema considera purgables
|
||||
-- (Documentation/PoliticaRetencion.md §7). Usamos la MISMA clave que el
|
||||
-- purge (`retencion.turnos_meses`) para que el corte de lectura y el de
|
||||
-- borrado nunca diverjan. La fase de LECTURA no se toca: si quedaran
|
||||
-- turnos viejos sin purgar, se siguen mostrando; sólo no se generan.
|
||||
v_cutoff := CURRENT_DATE - (internal.get_config_int('retencion.turnos_meses', 2) * INTERVAL '1 month');
|
||||
|
||||
FOR v_dia_offset IN 0..(p_cantidad_dias - 1) LOOP
|
||||
v_fecha := p_fecha_inicio + v_dia_offset;
|
||||
v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT;
|
||||
|
||||
-- Leemos el estado del día especial UNA sola vez.
|
||||
SELECT id, tipo INTO v_especial
|
||||
FROM dias_especiales
|
||||
WHERE fecha = v_fecha;
|
||||
-- A partir de acá NO chequeamos FOUND: usamos v_especial.id IS NULL/NOT NULL.
|
||||
|
||||
------------------------------------------------------------
|
||||
-- FASE A: GENERACIÓN JIT
|
||||
-- Bajo concurrencia, dos invocaciones simultáneas a la misma
|
||||
-- fecha pueden pasar ambas el NOT EXISTS (READ COMMITTED no ve
|
||||
-- inserts no commiteados) y entrar al bloque de generación. La
|
||||
-- UNIQUE constraint `turnos_actividad_id_fecha_hora_inicio_key`
|
||||
-- garantiza que el segundo INSERT colisione, y el ON CONFLICT
|
||||
-- DO NOTHING lo descarta silenciosamente. Resultado: una sola
|
||||
-- fila por turno, sin error visible al caller.
|
||||
------------------------------------------------------------
|
||||
IF v_fecha >= v_cutoff AND NOT EXISTS (SELECT 1 FROM turnos WHERE fecha = v_fecha) THEN
|
||||
|
||||
IF v_especial.id IS NOT NULL AND v_especial.tipo = 'cerrado' THEN
|
||||
-- Día explícitamente cerrado: no-op
|
||||
NULL;
|
||||
|
||||
ELSIF v_especial.id IS NOT NULL AND v_especial.tipo = 'horario_diferente' THEN
|
||||
FOR v_bloque IN (
|
||||
SELECT hae.actividad_id, hae.hora_inicio, hae.hora_fin,
|
||||
a.duracion, a.capacidad_por_defecto
|
||||
FROM horario_actividad_especial hae
|
||||
JOIN actividades a ON hae.actividad_id = a.id
|
||||
WHERE hae.dia_especial_id = v_especial.id AND a.activo = true
|
||||
) LOOP
|
||||
v_hora_iter := v_bloque.hora_inicio;
|
||||
WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.hora_fin LOOP
|
||||
INSERT INTO turnos (actividad_id, fecha, hora_inicio, hora_fin,
|
||||
capacidad_maxima, es_especial, dia_semana)
|
||||
VALUES (
|
||||
v_bloque.actividad_id, v_fecha, v_hora_iter,
|
||||
v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL,
|
||||
v_bloque.capacidad_por_defecto, true, v_dia_semana
|
||||
) ON CONFLICT DO NOTHING;
|
||||
v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
ELSE
|
||||
-- Día normal: plantilla regular vigente
|
||||
FOR v_bloque IN (
|
||||
SELECT ha.actividad_id, ha.hora_inicio, ha.hora_fin,
|
||||
a.duracion, a.capacidad_por_defecto
|
||||
FROM horario_actividad ha
|
||||
JOIN actividades a ON ha.actividad_id = a.id
|
||||
WHERE ha.dia_semana = v_dia_semana
|
||||
AND ha.valido_desde <= v_fecha
|
||||
AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= v_fecha)
|
||||
AND a.activo = true
|
||||
) LOOP
|
||||
v_hora_iter := v_bloque.hora_inicio;
|
||||
WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.hora_fin LOOP
|
||||
INSERT INTO turnos (actividad_id, fecha, hora_inicio, hora_fin,
|
||||
capacidad_maxima, es_especial, dia_semana)
|
||||
VALUES (
|
||||
v_bloque.actividad_id, v_fecha, v_hora_iter,
|
||||
v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL,
|
||||
v_bloque.capacidad_por_defecto, false, v_dia_semana
|
||||
) ON CONFLICT DO NOTHING;
|
||||
v_hora_iter := v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- FASE B: LECTURA
|
||||
------------------------------------------------------------
|
||||
IF v_especial.id IS NOT NULL AND v_especial.tipo = 'cerrado' THEN
|
||||
v_turnos_json := jsonb_build_object(
|
||||
'dia_semana', v_dia_semana,
|
||||
'estado', 'cerrado',
|
||||
'turnos', '[]'::JSONB
|
||||
);
|
||||
ELSE
|
||||
v_turnos_json := jsonb_build_object(
|
||||
'dia_semana', v_dia_semana,
|
||||
'estado', COALESCE(v_especial.tipo, 'normal'),
|
||||
'turnos', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'hora_inicio', to_char(t.hora_inicio, 'HH24:MI'),
|
||||
'hora_fin', to_char(t.hora_fin, 'HH24:MI'),
|
||||
'capacidad_maxima', t.capacidad_maxima,
|
||||
'ocupacion', (SELECT COUNT(*)::INT FROM reservas r
|
||||
WHERE r.turno_id = t.id AND r.cancelada = false),
|
||||
'actividad', jsonb_build_object(
|
||||
'id', a.id,
|
||||
'nombre', a.nombre,
|
||||
'libre', a.libre
|
||||
)
|
||||
) ORDER BY t.hora_inicio ASC
|
||||
), '[]'::JSONB)
|
||||
FROM turnos t
|
||||
JOIN actividades a ON t.actividad_id = a.id
|
||||
WHERE t.fecha = v_fecha AND t.activo = true
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_resultado := v_resultado || jsonb_build_object(v_fecha::TEXT, v_turnos_json);
|
||||
END LOOP;
|
||||
|
||||
RETURN v_resultado;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,251 @@
|
||||
-- ============================================================================
|
||||
-- fc_reservar_turno
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Reserva un turno a nombre de un cliente. Aplica todas las validaciones
|
||||
-- de negocio: el cliente debe estar al día con los pagos, el turno debe
|
||||
-- pertenecer a una actividad incluida en su plan (salvo que sea libre),
|
||||
-- debe respetar el límite semanal, no debe solaparse con otra reserva
|
||||
-- suya, y el turno debe tener cupo. Pensada para que el cliente la
|
||||
-- invoque desde la app.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación **"reservar"** ejecutada por el cliente
|
||||
-- (Documentation/DominioHorarios.md §7.5 reserva, §8 gestión de
|
||||
-- reservas). Aplica las reglas operativas de §9.1: plan vigente,
|
||||
-- actividad habilitada por el plan, cupo del bloque, morosidad, cupo
|
||||
-- semanal, no solape horario con otra reserva del cliente.
|
||||
--
|
||||
-- El chequeo de no solape se hace sobre la misma fecha + intervalos
|
||||
-- semiabiertos: dos reservas chocan si `existente.hora_inicio <
|
||||
-- nuevo.hora_fin AND nuevo.hora_inicio < existente.hora_fin`. Esto
|
||||
-- permite que el cliente reserve dos actividades distintas el mismo
|
||||
-- día siempre que no se pisen físicamente (caso típico: Pilates a la
|
||||
-- mañana, Funcional a la tarde).
|
||||
--
|
||||
-- NOTAS DE COHERENCIA frente al dominio:
|
||||
-- - El umbral de **morosidad** §9.3 es decisión del operador: se lee
|
||||
-- desde internal.app_config con la clave 'pagos.umbral_morosidad_meses'
|
||||
-- (cantidad mínima de meses impagos para considerar moroso). Default 2
|
||||
-- si la clave no está cargada todavía. Cuando exista la UI para que
|
||||
-- Juani lo configure, va a escribir esa misma key.
|
||||
-- - La medición del impago se hace como "meses transcurridos desde el
|
||||
-- último anio_mes_pagado de un pago de tipo cuota_mensual no anulado,
|
||||
-- o desde la fecha de alta del cliente si nunca pagó".
|
||||
-- - El sistema no implementa todavía el concepto de **turno fijo**
|
||||
-- (§7.6); esta función trata cada reserva como individual.
|
||||
-- - Para el flujo del operador anotando en nombre del cliente con
|
||||
-- libertad de saltar reglas (§9.6), ver fc_reservar_turno_admin.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor (típicamente el cliente).
|
||||
-- p_cliente_id UUID ID del cliente para el que se reserva.
|
||||
-- p_turno_id UUID ID del turno.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'reservar_turno'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El turno no existe, fue cancelado o no está disponible.'
|
||||
-- - 'No podés reservar un turno de una fecha pasada.'
|
||||
-- - 'El cliente no existe o se encuentra inactivo.'
|
||||
-- - 'Tenés una deuda de X meses. Regularizá tu situación de pago para
|
||||
-- poder reservar.'
|
||||
-- - 'Ya tenés una reserva ese día en un horario que se superpone con
|
||||
-- este (HH:MM - HH:MM).'
|
||||
-- - 'No tenés un plan activo asignado para reservar esta actividad.'
|
||||
-- - 'Tu plan actual no incluye esta actividad.'
|
||||
-- - 'Ya alcanzaste tu límite de X reservas para esta semana.'
|
||||
-- - 'Lo sentimos, este turno ya no tiene lugares disponibles.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status: 'success', mensaje, reserva_id, fecha, hora_inicio}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- INSERT en reservas con cancelada=false y reservada_en=now().
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_reservar_turno(
|
||||
p_token UUID,
|
||||
p_cliente_id UUID,
|
||||
p_turno_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_fecha_turno DATE;
|
||||
v_hora_inicio TIME;
|
||||
v_hora_fin TIME;
|
||||
v_actividad_id INT;
|
||||
v_capacidad_maxima SMALLINT;
|
||||
v_actividad_libre BOOLEAN;
|
||||
|
||||
v_conflicto_hora_inicio TIME;
|
||||
v_conflicto_hora_fin TIME;
|
||||
|
||||
v_tipo_cuota_id UUID;
|
||||
v_limite_semanal INT;
|
||||
v_fecha_creacion DATE;
|
||||
|
||||
v_ultimo_mes_pagado DATE;
|
||||
v_meses_deuda INT;
|
||||
v_umbral_morosidad INT;
|
||||
|
||||
v_reservas_semana INT;
|
||||
v_ocupacion_actual INT;
|
||||
|
||||
v_nueva_reserva_id UUID;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Autenticación y Permisos
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'reservar_turno');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Bloqueo Transaccional del Turno (Anti-Overbooking)
|
||||
------------------------------------------------------------
|
||||
SELECT t.fecha, t.hora_inicio, t.hora_fin, t.actividad_id, t.capacidad_maxima, a.libre
|
||||
INTO v_fecha_turno, v_hora_inicio, v_hora_fin, v_actividad_id, v_capacidad_maxima, v_actividad_libre
|
||||
FROM turnos t
|
||||
JOIN actividades a ON t.actividad_id = a.id
|
||||
WHERE t.id = p_turno_id AND t.activo = true
|
||||
FOR NO KEY UPDATE OF t;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El turno no existe, fue cancelado o no está disponible.';
|
||||
END IF;
|
||||
|
||||
IF v_fecha_turno < CURRENT_DATE THEN
|
||||
RAISE EXCEPTION 'No podés reservar un turno de una fecha pasada.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Obtener Datos del Cliente y su Cuota
|
||||
------------------------------------------------------------
|
||||
SELECT u.tipo_cuota, u.fecha_creacion::DATE, tc.dias_semana
|
||||
INTO v_tipo_cuota_id, v_fecha_creacion, v_limite_semanal
|
||||
FROM usuarios u
|
||||
LEFT JOIN tipos_cuota tc ON u.tipo_cuota = tc.id
|
||||
WHERE u.id = p_cliente_id AND u.isactive = true;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El cliente no existe o se encuentra inactivo.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 4. Validación de Deuda (umbral configurable, §9.3)
|
||||
------------------------------------------------------------
|
||||
-- Filtros:
|
||||
-- tipo = 'cuota_mensual' → ignora correctivos (no representan meses pagados).
|
||||
-- anulado_at IS NULL → un pago anulado no cuenta como pagado.
|
||||
SELECT MAX(anio_mes_pagado) INTO v_ultimo_mes_pagado
|
||||
FROM pagos
|
||||
WHERE cliente_id = p_cliente_id
|
||||
AND tipo = 'cuota_mensual'
|
||||
AND anulado_at IS NULL;
|
||||
|
||||
-- Si nunca pagó (o todos sus cuota_mensual están anulados), calculamos
|
||||
-- la deuda desde el mes en que se creó el usuario.
|
||||
v_ultimo_mes_pagado := COALESCE(v_ultimo_mes_pagado, date_trunc('month', v_fecha_creacion)::DATE);
|
||||
|
||||
v_meses_deuda := (EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM v_ultimo_mes_pagado)) * 12
|
||||
+ (EXTRACT(MONTH FROM CURRENT_DATE) - EXTRACT(MONTH FROM v_ultimo_mes_pagado));
|
||||
|
||||
v_umbral_morosidad := internal.get_config_int('pagos.umbral_morosidad_meses', 2);
|
||||
|
||||
IF v_meses_deuda >= v_umbral_morosidad THEN
|
||||
RAISE EXCEPTION 'Tenés una deuda de % meses. Regularizá tu situación de pago para poder reservar.', v_meses_deuda;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 5. Validación: No Solape Horario con Otra Reserva (§9.1)
|
||||
------------------------------------------------------------
|
||||
-- Misma fecha + intervalos semiabiertos solapados.
|
||||
-- Dos turnos consecutivos (hora_fin de uno == hora_inicio del otro)
|
||||
-- NO se consideran solapados.
|
||||
SELECT t.hora_inicio, t.hora_fin
|
||||
INTO v_conflicto_hora_inicio, v_conflicto_hora_fin
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
WHERE r.cliente_id = p_cliente_id
|
||||
AND r.cancelada = false
|
||||
AND t.fecha = v_fecha_turno
|
||||
AND t.hora_inicio < v_hora_fin
|
||||
AND v_hora_inicio < t.hora_fin
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
RAISE EXCEPTION 'Ya tenés una reserva ese día en un horario que se superpone con este (% - %).',
|
||||
to_char(v_conflicto_hora_inicio, 'HH24:MI'),
|
||||
to_char(v_conflicto_hora_fin, 'HH24:MI');
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 6. Validación de Plan y Actividad Permitida
|
||||
------------------------------------------------------------
|
||||
IF NOT v_actividad_libre THEN
|
||||
IF v_tipo_cuota_id IS NULL THEN
|
||||
RAISE EXCEPTION 'No tenés un plan activo asignado para reservar esta actividad.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM actividades_tipos_cuota
|
||||
WHERE tipo_cuota_id = v_tipo_cuota_id AND actividad_id = v_actividad_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Tu plan actual no incluye esta actividad.';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 7. Validación: Límite Semanal de la Cuota
|
||||
------------------------------------------------------------
|
||||
IF NOT v_actividad_libre THEN
|
||||
SELECT COUNT(*)::INT INTO v_reservas_semana
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
WHERE r.cliente_id = p_cliente_id
|
||||
AND r.cancelada = false
|
||||
AND date_trunc('week', t.fecha) = date_trunc('week', v_fecha_turno);
|
||||
|
||||
IF v_reservas_semana >= COALESCE(v_limite_semanal, 0) THEN
|
||||
RAISE EXCEPTION 'Ya alcanzaste tu límite de % reservas para esta semana.', v_limite_semanal;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 8. Validación de Cupo / Capacidad del Turno
|
||||
------------------------------------------------------------
|
||||
SELECT COUNT(*)::INT INTO v_ocupacion_actual
|
||||
FROM reservas
|
||||
WHERE turno_id = p_turno_id AND cancelada = false;
|
||||
|
||||
IF v_ocupacion_actual >= v_capacidad_maxima THEN
|
||||
RAISE EXCEPTION 'Lo sentimos, este turno ya no tiene lugares disponibles.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 9. Inserción de la Reserva
|
||||
------------------------------------------------------------
|
||||
INSERT INTO reservas (turno_id, cliente_id, reservada_en, cancelada)
|
||||
VALUES (p_turno_id, p_cliente_id, now(), false)
|
||||
RETURNING id INTO v_nueva_reserva_id;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 10. Retorno Exitoso
|
||||
------------------------------------------------------------
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'mensaje', 'Turno reservado con éxito.',
|
||||
'reserva_id', v_nueva_reserva_id,
|
||||
'fecha', v_fecha_turno,
|
||||
'hora_inicio', to_char(v_hora_inicio, 'HH24:MI')
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,131 @@
|
||||
-- ============================================================================
|
||||
-- fc_reservar_turno_admin
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Versión administrativa de reservar: Juani u otro admin reserva un
|
||||
-- turno a nombre de un cliente. Bypassea todas las validaciones de
|
||||
-- negocio (deuda, plan, límite semanal, no-solape-horario, cupo del
|
||||
-- bloque) pero mantiene las de integridad (turno existe y activo,
|
||||
-- cliente existe).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación **"reservar"** cuando es el operador quien
|
||||
-- anota a un cliente (Documentation/DominioHorarios.md §4 operador, §8
|
||||
-- gestión de reservas, §9.6 libertad del operador). Es la
|
||||
-- materialización del principio del dominio "el operador puede saltarse
|
||||
-- cualquier regla operativa al reservar en nombre de un cliente":
|
||||
-- bypassea morosidad, plan, cupo semanal, regla de no solape horario
|
||||
-- y cupo del bloque.
|
||||
--
|
||||
-- NOTA DE COHERENCIA. El dominio §9.6 dice que las advertencias son
|
||||
-- "informativas, no bloqueantes". Esta función va un paso más allá: ni
|
||||
-- siquiera devuelve advertencias (la UI las muestra antes de invocar).
|
||||
-- Las validaciones de **integridad** que sí mantiene (turno existe y
|
||||
-- activo, cliente existe) no son reglas operativas sino consistencia
|
||||
-- de la base.
|
||||
--
|
||||
-- El cupo del bloque se trata como advertencia, no como bloqueo: si el
|
||||
-- admin sobrecupa, la `ocupacion` queda por encima de `capacidad_maxima`
|
||||
-- en el turno. Eso es consistente con §9.6 ("este bloque está lleno"
|
||||
-- está listado como advertencia informativa) y deja `capacidad_maxima`
|
||||
-- como dato estable de configuración, no como output mutado por la
|
||||
-- actividad operativa. Para crear bloques ad-hoc o ajustar capacidad
|
||||
-- deliberadamente, sigue existiendo `fc_upsert_turno`.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor (admin).
|
||||
-- p_cliente_id UUID ID del cliente para el que se reserva.
|
||||
-- p_turno_id UUID ID del turno.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'gestionar_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El turno no existe, fue cancelado o no está disponible.'
|
||||
-- - 'El cliente seleccionado no existe en la base de datos.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status: 'success', mensaje, reserva_id, fecha, hora_inicio}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- INSERT en reservas con cancelada=false y reservada_en=now(). Puede
|
||||
-- dejar el turno con ocupacion > capacidad_maxima si el admin sobrecupa.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_reservar_turno_admin(
|
||||
p_token UUID,
|
||||
p_cliente_id UUID,
|
||||
p_turno_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_fecha_turno DATE;
|
||||
v_hora_inicio TIME;
|
||||
v_nueva_reserva_id UUID;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Autenticación (Permiso elevado)
|
||||
------------------------------------------------------------
|
||||
-- A diferencia del cliente, exigimos un rol administrativo
|
||||
PERFORM internal.validate_permission(p_token, 'gestionar_reservas');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Bloqueo Transaccional del Turno (Integridad)
|
||||
------------------------------------------------------------
|
||||
-- Bloqueamos la fila para evitar carreras con otras reservas
|
||||
-- simultáneas (aunque acá no chequeemos cupo, mantener el lock
|
||||
-- evita inconsistencias en lecturas concurrentes de ocupación).
|
||||
SELECT t.fecha, t.hora_inicio
|
||||
INTO v_fecha_turno, v_hora_inicio
|
||||
FROM turnos t
|
||||
WHERE t.id = p_turno_id AND t.activo = true
|
||||
FOR NO KEY UPDATE OF t;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El turno no existe, fue cancelado o no está disponible.';
|
||||
END IF;
|
||||
|
||||
-- A Juani le dejamos reservar en el pasado si necesita cargar un histórico que se olvidó
|
||||
-- Así que NO validamos si v_fecha_turno < CURRENT_DATE
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Validación Mínima de Cliente
|
||||
------------------------------------------------------------
|
||||
IF NOT EXISTS (SELECT 1 FROM usuarios WHERE id = p_cliente_id) THEN
|
||||
RAISE EXCEPTION 'El cliente seleccionado no existe en la base de datos.';
|
||||
END IF;
|
||||
|
||||
-- OMITIDAS intencionalmente (Bypass Administrativo §9.6):
|
||||
-- ✖ Validación de Deuda
|
||||
-- ✖ Validación de Tipo de Cuota (Plan)
|
||||
-- ✖ Validación de Límite Semanal
|
||||
-- ✖ Validación de No Solape Horario
|
||||
-- ✖ Validación de Cupo del Bloque
|
||||
-- (la UI muestra todas estas como advertencias antes de invocar)
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 4. Inserción de la Reserva
|
||||
------------------------------------------------------------
|
||||
INSERT INTO reservas (turno_id, cliente_id, reservada_en, cancelada)
|
||||
VALUES (p_turno_id, p_cliente_id, now(), false)
|
||||
RETURNING id INTO v_nueva_reserva_id;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 5. Retorno Exitoso
|
||||
------------------------------------------------------------
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'mensaje', 'Reserva administrativa creada con éxito.',
|
||||
'reserva_id', v_nueva_reserva_id,
|
||||
'fecha', v_fecha_turno,
|
||||
'hora_inicio', to_char(v_hora_inicio, 'HH24:MI')
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,96 @@
|
||||
-- ============================================================================
|
||||
-- fc_resolver_huerfana
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Cambia el estado de una reserva huérfana a uno distinto de
|
||||
-- 'pendiente'. Útil para que el operador la dé por cerrada sin
|
||||
-- reubicarla (típicamente después de notificar al cliente y delegarle
|
||||
-- la re-reserva).
|
||||
--
|
||||
-- Para reubicar una huérfana en un turno nuevo, usar
|
||||
-- fc_mover_reserva_huerfana.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Cambio puntual de estado de una **reserva huérfana**
|
||||
-- (Documentation/DominioHorarios.md §7.8). El dominio reconoce dos
|
||||
-- resoluciones conceptuales: re-reserva por el cliente (transparente,
|
||||
-- no la dispara el operador) y reasignación por el operador. La
|
||||
-- implementación operativa usa tres estados:
|
||||
-- - 'pendiente': estado inicial, no resuelta. Requiere atención.
|
||||
-- - 'reubicado': la reasignación por operador ya ocurrió (la setea
|
||||
-- fc_mover_reserva_huerfana, no esta función).
|
||||
-- - 'resuelta': el operador la dio por cerrada sin reubicarla.
|
||||
-- Típicamente después de notificar al cliente para que se ocupe él;
|
||||
-- también cubre cierres manuales por alta-por-error u otros motivos.
|
||||
-- Sostenido por §9.6 (libertad operativa del operador): cerrar
|
||||
-- manualmente una huérfana es legítimo aunque el dominio no lo
|
||||
-- describa como una de sus dos resoluciones.
|
||||
--
|
||||
-- Esta función acepta los tres valores como nuevo estado, incluyendo
|
||||
-- transiciones "hacia atrás" (p.ej. 'resuelta' → 'pendiente' como
|
||||
-- deshacer). La libertad operativa del operador aplica.
|
||||
--
|
||||
-- NOTA. El tracking de "se le notificó al cliente" no vive en este
|
||||
-- estado: ese registro vivirá en el módulo de notificaciones (tabla
|
||||
-- notificaciones) cuando se implemente la integración para huérfanas.
|
||||
-- 'resuelta' es agnóstica al cómo se llegó.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_huerfana_id UUID ID de la reserva huérfana.
|
||||
-- p_nuevo_estado VARCHAR nuevo estado. Debe ser 'pendiente',
|
||||
-- 'reubicado' o 'resuelta'.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'gestionar_reservas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Estado no válido. Debe ser pendiente, reubicado o resuelta.'
|
||||
-- - 'No se encontró el registro de reserva huérfana.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status: 'success', mensaje}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE en reservas_huerfanas: setea estado_resolucion y resuelto_en.
|
||||
-- Toda transición a estado terminal (reubicado/resuelta) setea
|
||||
-- resuelto_en=now() y arranca la ventana de gracia previa a purga
|
||||
-- (ver internal.limpiar_huerfanas_resueltas y la clave
|
||||
-- `retencion.huerfanas_resueltas_dias`). Volver a 'pendiente' resetea
|
||||
-- resuelto_en a NULL, devolviéndole la inmunidad.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_resolver_huerfana(
|
||||
p_token UUID,
|
||||
p_huerfana_id UUID,
|
||||
p_nuevo_estado VARCHAR
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE
|
||||
AS $function$
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'gestionar_reservas');
|
||||
|
||||
-- Validación estricta del dominio de estados permitidos
|
||||
IF p_nuevo_estado NOT IN ('pendiente', 'reubicado', 'resuelta') THEN
|
||||
RAISE EXCEPTION 'Estado no válido. Debe ser pendiente, reubicado o resuelta.';
|
||||
END IF;
|
||||
|
||||
UPDATE reservas_huerfanas
|
||||
SET estado_resolucion = p_nuevo_estado,
|
||||
resuelto_en = CASE WHEN p_nuevo_estado = 'pendiente' THEN NULL ELSE now() END
|
||||
WHERE id = p_huerfana_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No se encontró el registro de reserva huérfana.';
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'mensaje', 'Estado actualizado a ' || p_nuevo_estado
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,119 @@
|
||||
-- ============================================================================
|
||||
-- fc_upsert_turno
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Crea un turno nuevo o actualiza uno existente (UPSERT). Pensada como
|
||||
-- workaround para que Juani pueda meter un turno sobre la marcha
|
||||
-- (sumar uno extra, ampliar capacidad, etc.) sin pasar por la
|
||||
-- plantilla horaria. Por default marca el turno como especial.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Vía de escape del operador sobre la oferta de **bloques**
|
||||
-- (Documentation/DominioHorarios.md §7.3) sin pasar por la plantilla.
|
||||
-- Es la materialización de la libertad operativa §9.6 aplicada al
|
||||
-- catálogo de turnos: el operador puede meter un bloque ad hoc, sumar
|
||||
-- capacidad a uno existente, reactivar uno borrado lógicamente.
|
||||
--
|
||||
-- La única regla que esta función NO permite saltar: reducir la
|
||||
-- capacidad por debajo de la ocupación actual. Eso preservaría
|
||||
-- coherencia con el principio rector §6: bajar el cupo por debajo de
|
||||
-- los ya anotados convertiría reservas vivas en huérfanas sin que el
|
||||
-- operador haya manifestado intención de hacerlo. Para esos casos, el
|
||||
-- operador primero cancela reservas (fc_cancelar_reserva_admin) y
|
||||
-- recién después achica el cupo.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_actividad_id INT ID de la actividad.
|
||||
-- p_fecha DATE día del turno.
|
||||
-- p_hora_inicio TIME hora de inicio.
|
||||
-- p_hora_fin TIME hora de fin.
|
||||
-- p_capacidad_maxima SMALLINT capacidad del turno.
|
||||
-- p_es_especial BOOLEAN (default true) si false, el turno se trata
|
||||
-- como regular (forma parte de la plantilla).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_turnos'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'La hora de inicio debe ser anterior a la hora de fin.'
|
||||
-- - 'No podés reducir la capacidad máxima a X porque ya hay Y clientes
|
||||
-- anotados.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- UUID del turno (nuevo o existente actualizado).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - Si existe turno con (actividad_id, fecha, hora_inicio): UPDATE de
|
||||
-- hora_fin, capacidad_maxima, es_especial y reactivación
|
||||
-- (activo=true) por si estaba en borrado lógico.
|
||||
-- - Si no existe: INSERT con dia_semana derivado de la fecha.
|
||||
-- ============================================================================
|
||||
|
||||
-- Insertar o actualizar un turno
|
||||
-- Esta función es una herramienta para que Juani tenga un workaround de agregar un turno sobre la marcha
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_upsert_turno(
|
||||
p_token UUID,
|
||||
p_actividad_id INT,
|
||||
p_fecha DATE,
|
||||
p_hora_inicio TIME,
|
||||
p_hora_fin TIME,
|
||||
p_capacidad_maxima SMALLINT,
|
||||
p_es_especial BOOLEAN DEFAULT true
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_turno_id UUID;
|
||||
v_ocupacion INT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'modificar_turnos');
|
||||
|
||||
-- Validar coherencia de horas
|
||||
IF p_hora_inicio >= p_hora_fin THEN
|
||||
RAISE EXCEPTION 'La hora de inicio debe ser anterior a la hora de fin.';
|
||||
END IF;
|
||||
|
||||
-- Buscamos si el turno ya existe físicamente
|
||||
SELECT id INTO v_turno_id
|
||||
FROM turnos
|
||||
WHERE actividad_id = p_actividad_id AND fecha = p_fecha AND hora_inicio = p_hora_inicio;
|
||||
|
||||
IF FOUND THEN
|
||||
-- Validación de negocio: no achicar la capacidad por debajo de los anotados
|
||||
SELECT count(*)::INT INTO v_ocupacion
|
||||
FROM reservas
|
||||
WHERE turno_id = v_turno_id AND cancelada = false;
|
||||
|
||||
IF p_capacidad_maxima < v_ocupacion THEN
|
||||
RAISE EXCEPTION 'No podés reducir la capacidad máxima a % porque ya hay % clientes anotados.', p_capacidad_maxima, v_ocupacion;
|
||||
END IF;
|
||||
|
||||
UPDATE turnos SET
|
||||
hora_fin = p_hora_fin,
|
||||
capacidad_maxima = p_capacidad_maxima,
|
||||
es_especial = p_es_especial,
|
||||
activo = true -- Reactivamos por si estaba en borrado lógico
|
||||
WHERE id = v_turno_id;
|
||||
|
||||
ELSE
|
||||
-- Es un turno nuevo, lo insertamos
|
||||
INSERT INTO turnos (
|
||||
actividad_id, fecha, hora_inicio, hora_fin,
|
||||
capacidad_maxima, es_especial, dia_semana, activo
|
||||
)
|
||||
VALUES (
|
||||
p_actividad_id, p_fecha, p_hora_inicio, p_hora_fin,
|
||||
p_capacidad_maxima, p_es_especial, EXTRACT(ISODOW FROM p_fecha)::SMALLINT, true
|
||||
)
|
||||
RETURNING id INTO v_turno_id;
|
||||
END IF;
|
||||
|
||||
RETURN v_turno_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,101 @@
|
||||
-- ============================================================================
|
||||
-- internal.limpiar_huerfanas_resueltas
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Higiene operativa: elimina filas de public.reservas_huerfanas que ya
|
||||
-- pasaron a estado terminal (reubicado / resuelta) Y cumplieron la
|
||||
-- ventana de gracia configurada. Una huérfana es un snapshot
|
||||
-- desconectado que existe sólo para que el operador (o el cliente)
|
||||
-- reubique o cierre la reserva rescatada; una vez resuelta y pasada la
|
||||
-- gracia, cumplió su función y se descarta.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la higiene de huérfanas descrita en
|
||||
-- Documentation/PoliticaRetencion.md §7. El criterio combina ESTADO y
|
||||
-- ANTIGÜEDAD del cambio de estado:
|
||||
-- - `pendiente` nunca se purga (sigue siendo trabajo no hecho), por
|
||||
-- más vieja que sea.
|
||||
-- - Una terminal recién es elegible cuando pasaron al menos
|
||||
-- `retencion.huerfanas_resueltas_dias` (default 14) desde su
|
||||
-- `resuelto_en`.
|
||||
-- La ventana es el piso de seguridad para que el operador pueda
|
||||
-- revertir un cambio de estado por error: cambiar la fila otra vez a
|
||||
-- `pendiente` resetea resuelto_en a NULL y le devuelve la inmunidad.
|
||||
--
|
||||
-- CRITERIO DE PURGA
|
||||
-- Se elimina toda fila con `estado_resolucion <> 'pendiente'` Y
|
||||
-- `resuelto_en < now() - INTERVAL N días`. Se expresa por negación
|
||||
-- de `pendiente` (no enumerando los terminales) para que cualquier
|
||||
-- estado terminal que se agregue en el futuro herede la purga
|
||||
-- automáticamente, mientras `pendiente` siga siendo el único estado
|
||||
-- protegido. El `IS NOT NULL` extra en resuelto_en es defensa en
|
||||
-- profundidad: el CHECK de la tabla ya garantiza el invariante, pero
|
||||
-- si una migración o un fix de datos dejara una fila inconsistente
|
||||
-- no la queremos arrastrar.
|
||||
--
|
||||
-- INTERACCIÓN CON LA CADENCIA MENSUAL
|
||||
-- Esta función la dispara el pipeline mensual (US-R17) el día 1 a
|
||||
-- las 03:00 AR, junto con el resto de las purgas. La ventana de N
|
||||
-- días es el PISO de vida; el techo lo pone la cadencia del cron.
|
||||
-- En la práctica, una terminal vive entre ~N+1 y ~N+31 días según
|
||||
-- en qué momento del mes se haya marcado. La ventana cubre el caso
|
||||
-- patológico de "resuelta a las 23:59 del 31, purgada a las 03:00
|
||||
-- del 1" que existía cuando el criterio era sólo de estado.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- Ninguno. Lee `retencion.huerfanas_resueltas_dias` (default 14).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No valida permiso propio. El schema `internal` no es alcanzable por
|
||||
-- roles cliente (ver hardening en `database/schema/00_schemas.sql`).
|
||||
-- Callers legítimos: pg_cron y fachadas `public.fc_*` SECURITY DEFINER
|
||||
-- que orquesten el pipeline mensual (US-R17).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status, huerfanas_eliminadas, dias_gracia, mensaje}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- DELETE de reservas_huerfanas elegibles. No registra evento.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.limpiar_huerfanas_resueltas()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_dias_gracia INT;
|
||||
v_eliminadas INT;
|
||||
BEGIN
|
||||
v_dias_gracia := internal.get_config_int('retencion.huerfanas_resueltas_dias', 14);
|
||||
|
||||
DELETE FROM reservas_huerfanas
|
||||
WHERE estado_resolucion <> 'pendiente'
|
||||
AND resuelto_en IS NOT NULL
|
||||
AND resuelto_en < now() - (v_dias_gracia * INTERVAL '1 day');
|
||||
|
||||
GET DIAGNOSTICS v_eliminadas = ROW_COUNT;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'huerfanas_eliminadas', v_eliminadas,
|
||||
'dias_gracia', v_dias_gracia,
|
||||
'mensaje', format(
|
||||
'Se eliminaron %s huérfanas resueltas/reubicadas con más de %s días desde su cambio de estado (las pendientes y las en gracia se conservan).',
|
||||
v_eliminadas, v_dias_gracia
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql)
|
||||
-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá
|
||||
-- por si en algún futuro alguien afloja las defensas a nivel schema.
|
||||
REVOKE ALL ON FUNCTION internal.limpiar_huerfanas_resueltas()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- ============================================================================
|
||||
-- internal.limpiar_turnos_antiguos
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Higiene operativa: elimina turnos cuya `fecha` esté fuera de la
|
||||
-- ventana de retención. La purga es incondicional: borra todos los
|
||||
-- turnos pasados la ventana, hayan tenido reservas o no. Las reservas
|
||||
-- vinculadas caen por CASCADE (reservas_turno_id_fkey ON DELETE
|
||||
-- CASCADE).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la higiene de turnos descrita en
|
||||
-- Documentation/PoliticaRetencion.md §7. El sistema es operativo, no
|
||||
-- archivo histórico: lo que pasó la ventana se descarta. La
|
||||
-- concurrencia mensual que sí vale conservar se archiva agregada en
|
||||
-- el XLSX (§3, §4.1), no en la base.
|
||||
--
|
||||
-- El diseño anterior preservaba turnos con reservas como "histórico".
|
||||
-- Ese trade-off quedó obsoleto: con la política operativa + archivo,
|
||||
-- el histórico vive en el XLSX agregado y la base no carga ese rol.
|
||||
-- La excepción se retira; los turnos pasados se purgan parejo.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- Ninguno. La ventana en meses se lee de `internal.app_config` con la
|
||||
-- clave `retencion.turnos_meses` (default 2). El ajuste operativo se
|
||||
-- hace en la tabla, sin redeploy.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No valida permiso propio. El schema `internal` no es alcanzable por
|
||||
-- roles cliente (ver hardening en `database/schema/00_schemas.sql`).
|
||||
-- Los callers legítimos son `pg_cron` (corre como postgres) y, si en
|
||||
-- algún momento aplica, una fachada pública `SECURITY DEFINER` que
|
||||
-- orqueste el pipeline mensual (US-R17). Ninguna ruta de cliente
|
||||
-- llega acá.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {status, fecha_corte, meses_conservados, turnos_eliminados,
|
||||
-- mensaje}. Las reservas eliminadas por CASCADE no se cuentan: la
|
||||
-- métrica que importa para el archivo (totales y cancelaciones por
|
||||
-- actividad/mes) se calcula en otra función antes del DELETE.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- DELETE de turnos con fecha < (CURRENT_DATE - ventana). Las reservas
|
||||
-- asociadas caen por CASCADE. No registra evento.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.limpiar_turnos_antiguos()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_meses INT;
|
||||
v_fecha_limite DATE;
|
||||
v_eliminados INT;
|
||||
BEGIN
|
||||
v_meses := internal.get_config_int('retencion.turnos_meses', 2);
|
||||
v_fecha_limite := CURRENT_DATE - (v_meses * INTERVAL '1 month');
|
||||
|
||||
DELETE FROM turnos
|
||||
WHERE fecha < v_fecha_limite;
|
||||
|
||||
GET DIAGNOSTICS v_eliminados = ROW_COUNT;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'fecha_corte', v_fecha_limite,
|
||||
'meses_conservados', v_meses,
|
||||
'turnos_eliminados', v_eliminados,
|
||||
'mensaje', format(
|
||||
'Se eliminaron %s turnos anteriores a %s (reservas asociadas eliminadas por CASCADE).',
|
||||
v_eliminados, v_fecha_limite
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Defensa en profundidad: aunque el hardening del schema (00_schemas.sql)
|
||||
-- ya bloquee USAGE y EXECUTE por default, revocamos explícitamente acá
|
||||
-- por si en algún futuro alguien afloja las defensas a nivel schema.
|
||||
REVOKE ALL ON FUNCTION internal.limpiar_turnos_antiguos()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user