Agrego carpeta del source sql de la Base de Datos

This commit is contained in:
Pablo
2026-08-22 19:10:49 -03:00
parent 11e36bd6c2
commit 88d724fbcf
94 changed files with 7820 additions and 0 deletions
@@ -0,0 +1,164 @@
-- ============================================================================
-- fc_eliminar_dia_especial
-- ============================================================================
-- PROPÓSITO
-- Elimina la planificación especial de una fecha y restaura los turnos a
-- la plantilla regular vigente para esa fecha. Las reservas activas
-- sobre turnos especiales que no calzan con la plantilla regular se
-- rescatan como reservas huérfanas.
--
-- DOMINIO
-- Implementa la operación **"restablecer una fecha al horario regular"**
-- descrita en Documentation/DominioHorarios.md §7.4 ("Un día especial
-- también puede ser restablecido"). Es un cambio estructural §8/§11.
--
-- Como puede romper reservas existentes (los bloques especiales no
-- compatibles con la plantilla regular vigente quedan sin destino),
-- genera **reservas huérfanas** §7.8: el "match suave" del cuerpo elige
-- qué turnos sobreviven (los compatibles con la plantilla regular) y
-- cuáles caen junto con sus reservas. A partir de esas huérfanas, otro
-- flujo se encarga de la notificación al cliente afectado (§7.9 y §6).
--
-- PARÁMETROS
-- p_token UUID sesión del actor.
-- p_fecha DATE fecha del día especial a eliminar. Debe ser hoy o
-- posterior.
--
-- AUTORIZACIÓN
-- Requiere permiso 'modificar_horarios'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'No se pueden alterar horarios en fechas pasadas.'
-- - 'No existe un día especial para la fecha X.'
--
-- RETORNA
-- JSONB con el estado actualizado de la fecha (mismo formato que un día
-- en fc_obtener_horarios).
--
-- EFECTOS SECUNDARIOS
-- - INSERT en reservas_huerfanas para las reservas que no calzan con la
-- plantilla regular vigente para esa fecha.
-- - DELETE selectivo de turnos especiales que no calzan con la regular.
-- - UPDATE de los turnos que sí calzan: es_especial = false.
-- - INSERT (con ON CONFLICT DO NOTHING) de los turnos faltantes para
-- materializar la plantilla regular en la fecha.
-- - DELETE del registro en dias_especiales (CASCADE limpia
-- horario_actividad_especial asociado).
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_eliminar_dia_especial(
p_token UUID,
p_fecha DATE
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET timezone = 'America/Argentina/Buenos_Aires'
VOLATILE
AS $function$
DECLARE
v_dia_especial_id INT;
v_dia_semana SMALLINT;
v_bloque RECORD;
v_hora_iter TIME;
BEGIN
PERFORM internal.validate_permission(p_token, 'modificar_horarios');
IF p_fecha < CURRENT_DATE THEN
RAISE EXCEPTION 'No se pueden alterar horarios en fechas pasadas.';
END IF;
SELECT id INTO v_dia_especial_id
FROM dias_especiales
WHERE fecha = p_fecha;
IF v_dia_especial_id IS NULL THEN
RAISE EXCEPTION 'No existe un día especial para la fecha %.', p_fecha;
END IF;
v_dia_semana := EXTRACT(ISODOW FROM p_fecha)::SMALLINT;
------------------------------------------------------------
-- 1. Match suave contra la plantilla REGULAR vigente para esta fecha
-- (no contra el JSON: la plantilla regular es la referencia)
------------------------------------------------------------
-- 1.A Rescate de huérfanas: reservas activas sobre turnos que NO calzan
-- con ningún rango de la plantilla regular para esta fecha.
INSERT INTO reservas_huerfanas
(cliente_id, actividad_nombre, fecha_original, hora_inicio_original)
SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio
FROM reservas r
JOIN turnos t ON r.turno_id = t.id
JOIN actividades a ON t.actividad_id = a.id
WHERE t.fecha = p_fecha
AND r.cancelada = false
AND NOT EXISTS (
SELECT 1 FROM horario_actividad ha
WHERE ha.dia_semana = v_dia_semana
AND ha.actividad_id = t.actividad_id
AND ha.valido_desde <= p_fecha
AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= p_fecha)
AND t.hora_inicio >= ha.hora_inicio
AND t.hora_fin <= ha.hora_fin
);
-- 1.B Borrado selectivo de turnos que no calzan con la plantilla regular.
DELETE FROM turnos t
WHERE t.fecha = p_fecha
AND NOT EXISTS (
SELECT 1 FROM horario_actividad ha
WHERE ha.dia_semana = v_dia_semana
AND ha.actividad_id = t.actividad_id
AND ha.valido_desde <= p_fecha
AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= p_fecha)
AND t.hora_inicio >= ha.hora_inicio
AND t.hora_fin <= ha.hora_fin
);
-- 1.C Para los turnos que SÍ calzaron, marcar es_especial=false:
-- ya no estamos bajo régimen especial, son turnos regulares ahora.
UPDATE turnos
SET es_especial = false
WHERE fecha = p_fecha
AND es_especial = true;
-- 1.D Parcheo: materializar los turnos regulares que falten.
FOR v_bloque IN (
SELECT ha.actividad_id AS act_id,
ha.hora_inicio AS h_in,
ha.hora_fin AS h_out,
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 <= p_fecha
AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= p_fecha)
AND a.activo = true
) LOOP
v_hora_iter := v_bloque.h_in;
WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.h_out LOOP
INSERT INTO turnos
(actividad_id, fecha, hora_inicio, hora_fin,
capacidad_maxima, es_especial, dia_semana)
VALUES (
v_bloque.act_id, p_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;
------------------------------------------------------------
-- 2. Borrar el día especial (CASCADE limpia horario_actividad_especial)
------------------------------------------------------------
DELETE FROM dias_especiales WHERE id = v_dia_especial_id;
------------------------------------------------------------
-- 3. Devolver el estado actualizado de la fecha
------------------------------------------------------------
RETURN public.fc_obtener_horarios(p_token, p_fecha, 1::smallint);
END;
$function$;
@@ -0,0 +1,89 @@
-- ============================================================================
-- fc_insertar_horario_con_actividades
-- ============================================================================
-- PROPÓSITO
-- Facade unificada para insertar/actualizar horarios. El frontend llama a
-- esta función sin importar si quiere insertar un horario regular o un
-- horario especial de un día puntual; la función enruta al helper
-- internal correspondiente y devuelve el estado final consolidado.
--
-- DOMINIO
-- Edición de la estructura del horario: modifica el **horario regular**
-- (Documentation/DominioHorarios.md §7.1) o establece/edita un **día
-- especial** (§7.4). Es un **cambio estructural** en términos de §8 y
-- §11: potencialmente produce **reservas huérfanas** (§7.8) en cadena,
-- y por eso queda sujeto a trazabilidad obligada (§11).
--
-- El parámetro `alcance` materializa la decisión del operador sobre cómo
-- convive su edición con las planificaciones futuras ya programadas
-- (ver §7.1: el dominio admite que existan planificaciones programadas
-- para el futuro y la edición del horario debe poder pisarlas o
-- respetarlas). Los valores 'indefinido', 'hasta_proximo' y 'hasta'
-- son la implementación concreta de ese alcance.
--
-- PARÁMETROS
-- p_token UUID sesión del actor.
-- p_datos JSONB con el payload. Claves:
-- fecha DATE (obligatorio) fecha de referencia (la del
-- día especial, o un día representativo del
-- dia_semana para el regular).
-- es_especial BOOLEAN (opcional, default false) si true → especial,
-- si false → regular.
-- valido_desde DATE (sólo para regular, opcional). Default = fecha.
-- alcance OBJECT (sólo para regular). { tipo, fecha? } con
-- tipo en {'indefinido', 'hasta_proximo', 'hasta'}.
-- motivo TEXT (sólo para especial).
-- rangos ARRAY de {actividad_id, hora_inicio, hora_fin}.
-- Para especial: si no se manda o está vacío,
-- el día queda como 'cerrado'.
-- El parseo y las reglas específicas viven en los helpers internal.
--
-- AUTORIZACIÓN
-- Requiere permiso 'modificar_horarios'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'El campo fecha es obligatorio en el nodo raíz del JSON.'
-- - Más los errores propios de los helpers (ver internal.upsert_horario_*).
--
-- RETORNA
-- JSONB con el estado de la fecha tras la operación, en el mismo formato
-- que un único día de fc_obtener_horarios.
--
-- EFECTOS SECUNDARIOS
-- Delega a internal.upsert_horario_regular o internal.upsert_horario_especial.
-- ============================================================================
-- FACADE para que el frontend llame a la misma función sin importar qué carajo quiere insertar
CREATE OR REPLACE FUNCTION public.fc_insertar_horario_con_actividades(p_token uuid, p_datos jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
SET timezone = 'America/Argentina/Buenos_Aires'
AS $function$
DECLARE
v_fecha DATE;
v_es_especial BOOLEAN;
v_fecha_retorno DATE;
BEGIN
PERFORM internal.validate_permission(p_token, 'modificar_horarios');
IF (p_datos->>'fecha') IS NULL THEN
RAISE EXCEPTION 'El campo fecha es obligatorio en el nodo raíz del JSON.';
END IF;
v_fecha := (p_datos->>'fecha')::DATE;
v_es_especial := COALESCE((p_datos->>'es_especial')::BOOLEAN, false);
-- Enrutamiento interno
IF v_es_especial THEN
v_fecha_retorno := internal.upsert_horario_especial(p_datos);
ELSE
v_fecha_retorno := internal.upsert_horario_regular(p_datos);
END IF;
-- Retornamos el estado final usando la función unificada de lectura
RETURN public.fc_obtener_horarios(p_token, v_fecha_retorno, 1::smallint);
END;
$function$
;
@@ -0,0 +1,166 @@
-- ============================================================================
-- fc_obtener_horarios
-- ============================================================================
-- PROPÓSITO
-- Devuelve la "agenda" de un rango de días (1 a 31): para cada día
-- indica si es normal, día con horario diferente, o día cerrado, y trae
-- sus rangos horarios con la actividad asociada.
--
-- Para días normales, también incluye la vigencia (valido_desde,
-- valido_hasta) de la plantilla regular vigente, lo que permite al
-- frontend mostrar contexto temporal de la planificación.
--
-- DOMINIO
-- Lectura del estado de la oferta del gimnasio para un rango de días
-- (Documentation/DominioHorarios.md §7.1 horario regular + §7.4 día
-- especial). Es la fuente con la que se construye la "grilla del
-- gimnasio" que §13 le exige a la interfaz mostrar al operador. Los tres
-- estados ('normal', 'horario_diferente', 'cerrado') son la
-- materialización de los conceptos §7.1 y §7.4 en términos JSON. No
-- produce efectos; lee el estado actual.
--
-- 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 a incluir (1..31).
--
-- AUTORIZACIÓN
-- Requiere permiso 'ver_horarios'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'p_cantidad_dias debe estar entre 1 y 31.'
--
-- RETORNA
-- JSONB objeto con clave = fecha (YYYY-MM-DD), valor = objeto día.
-- Cada día tiene: dia_semana, tipo ('normal' | 'horario_diferente' |
-- 'cerrado'), motivo (sólo en especiales), horarios (array de rangos
-- con actividad), y valido_desde/valido_hasta (sólo en 'normal').
--
-- EFECTOS SECUNDARIOS
-- Ninguno (STABLE).
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_obtener_horarios(
p_token UUID,
p_fecha_inicio DATE,
p_cantidad_dias SMALLINT DEFAULT 7
)
RETURNS JSONB
SECURITY DEFINER
SET search_path = public
STABLE
LANGUAGE plpgsql
AS $$
DECLARE
v_fecha DATE;
v_dia_offset INT;
v_dia_semana SMALLINT;
v_resultado JSONB := '{}'::JSONB;
v_dia_json JSONB;
v_especial RECORD;
v_vigencia RECORD;
BEGIN
PERFORM internal.validate_permission(p_token, 'ver_horarios');
IF p_cantidad_dias < 1 OR p_cantidad_dias > 31 THEN
RAISE EXCEPTION 'p_cantidad_dias debe estar entre 1 y 31.';
END IF;
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;
SELECT id, tipo, motivo
INTO v_especial
FROM dias_especiales
WHERE fecha = v_fecha;
IF FOUND AND v_especial.tipo = 'cerrado' THEN
----------------------------------------------------
-- CASO 1: Día cerrado
----------------------------------------------------
v_dia_json := jsonb_build_object(
'dia_semana', v_dia_semana,
'tipo', 'cerrado',
'motivo', v_especial.motivo,
'horarios', '[]'::JSONB
);
ELSIF FOUND AND v_especial.tipo = 'horario_diferente' THEN
----------------------------------------------------
-- CASO 2: Día con horario especial (override puntual)
----------------------------------------------------
v_dia_json := jsonb_build_object(
'dia_semana', v_dia_semana,
'tipo', 'horario_diferente',
'motivo', v_especial.motivo,
'horarios', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', hae.id,
'hora_inicio', to_char(hae.hora_inicio, 'HH24:MI'),
'hora_fin', to_char(hae.hora_fin, 'HH24:MI'),
'actividad', jsonb_build_object(
'id', a.id,
'nombre', a.nombre,
'duracion', a.duracion,
'capacidad', a.capacidad_por_defecto
)
) ORDER BY hae.hora_inicio ASC
), '[]'::JSONB)
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
)
);
ELSE
----------------------------------------------------
-- CASO 3: Día normal: plantilla regular vigente esa fecha
-- Adicionalmente devolvemos la vigencia para que el
-- frontend pueda renderizar contexto temporal.
----------------------------------------------------
SELECT valido_desde, valido_hasta
INTO v_vigencia
FROM horario_actividad
WHERE dia_semana = v_dia_semana
AND valido_desde <= v_fecha
AND (valido_hasta IS NULL OR valido_hasta >= v_fecha)
LIMIT 1; -- Todas las filas de la plantilla vigente comparten la misma vigencia.
v_dia_json := jsonb_build_object(
'dia_semana', v_dia_semana,
'tipo', 'normal',
'valido_desde', v_vigencia.valido_desde::TEXT,
'valido_hasta', v_vigencia.valido_hasta::TEXT,
'horarios', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', ha.id,
'hora_inicio', to_char(ha.hora_inicio, 'HH24:MI'),
'hora_fin', to_char(ha.hora_fin, 'HH24:MI'),
'actividad', jsonb_build_object(
'id', a.id,
'nombre', a.nombre,
'duracion', a.duracion,
'capacidad', a.capacidad_por_defecto
)
) ORDER BY ha.hora_inicio ASC
), '[]'::JSONB)
FROM horario_actividad ha
JOIN actividades a ON ha.actividad_id = a.id
WHERE ha.dia_semana = v_dia_semana
AND a.activo = true
AND ha.valido_desde <= v_fecha
AND (ha.valido_hasta IS NULL OR ha.valido_hasta >= v_fecha)
)
);
END IF;
v_resultado := v_resultado || jsonb_build_object(v_fecha::TEXT, v_dia_json);
END LOOP;
RETURN v_resultado;
END;
$$;
@@ -0,0 +1,82 @@
-- ============================================================================
-- fc_obtener_horarios_especiales
-- ============================================================================
-- PROPÓSITO
-- Devuelve los días con horario especial (sea cerrado o con horario
-- diferente) configurados. Opcionalmente filtra por una fecha puntual.
-- Pensada para que el frontend liste y muestre el detalle de los días
-- especiales programados.
--
-- DOMINIO
-- Consulta del catálogo de **días especiales** programados
-- (Documentation/DominioHorarios.md §7.4). Sirve a la vista de
-- administración de excepciones del horario regular. No describe el
-- detalle del estado de cada fecha en su grilla diaria (eso es trabajo
-- de fc_obtener_horarios): acá lo que se trae es el listado de las
-- excepciones como tales.
--
-- PARÁMETROS
-- p_token UUID sesión del actor.
-- p_fecha DATE (opcional, default NULL) si se pasa, sólo retorna el
-- día especial para esa fecha. NULL = todos.
--
-- AUTORIZACIÓN
-- Requiere permiso 'ver_horarios'.
--
-- ERRORES (RAISE EXCEPTION)
-- Ninguno propio (sólo los del validador de permiso).
--
-- RETORNA
-- JSONB array (puede ser vacío) de días especiales. Cada ítem:
-- { fecha, tipo, motivo, rangos_actividades: [{id, hora_inicio,
-- hora_fin, actividad}] }. Ordenado por fecha descendente.
--
-- EFECTOS SECUNDARIOS
-- Ninguno (STABLE).
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_obtener_horarios_especiales(
p_token UUID,
p_fecha DATE DEFAULT NULL
)
RETURNS JSONB
SECURITY DEFINER
SET search_path = public
STABLE
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM internal.validate_permission(p_token, 'ver_horarios');
RETURN (
SELECT COALESCE(jsonb_agg(dia_completo ORDER BY dia_completo->>'fecha' DESC), '[]'::jsonb)
FROM (
SELECT
de.fecha,
de.tipo,
de.motivo,
-- Si el día es especial (no cerrado), adjuntamos sus actividades
COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', hae.id,
'hora_inicio', to_char(hae.hora_inicio, 'HH24:MI'),
'hora_fin', to_char(hae.hora_fin, 'HH24:MI'),
'actividad', jsonb_build_object(
'id', a.id,
'nombre', a.nombre,
'duracion', a.duracion
)
) ORDER BY hae.hora_inicio ASC
)
FROM public.horario_actividad_especial hae
JOIN public.actividades a ON hae.actividad_id = a.id
WHERE hae.dia_especial_id = de.id AND a.activo = true),
'[]'::jsonb
) as rangos_actividades
FROM public.dias_especiales de
WHERE (p_fecha IS NULL OR de.fecha = p_fecha)
) AS dia_completo
);
END;
$$;
@@ -0,0 +1,110 @@
-- ============================================================================
-- fc_obtener_planificaciones_futuras
-- ============================================================================
-- PROPÓSITO
-- Devuelve las plantillas horarias regulares programadas hacia el futuro
-- para un día de la semana específico, dentro de un horizonte temporal
-- (default 6 meses). Cada plantilla es un grupo de rangos horarios con
-- vigencia común (valido_desde, valido_hasta).
--
-- DOMINIO
-- Sirve a la noción del dominio de **cambio de horario programado**
-- (Documentation/DominioHorarios.md §7.1: "el operador puede dejar
-- programado un cambio para que entre en vigencia desde una fecha
-- futura"). La función habilita al frontend a mostrarle al operador el
-- mapa de planificaciones futuras antes de editar un horario, para que
-- pueda decidir el alcance de su edición (pisar las planificaciones
-- futuras, o respetar la más próxima como tope de la suya).
--
-- PARÁMETROS
-- p_token UUID sesión del actor.
-- p_dia_semana SMALLINT día de la semana ISO (1=Lunes, 7=Domingo).
-- p_desde DATE fecha de referencia; trae plantillas con
-- valido_desde estrictamente posterior a esta.
-- p_meses INT (default 6) horizonte en meses desde p_desde.
-- Rango válido 1..24.
--
-- AUTORIZACIÓN
-- Requiere permiso 'ver_horarios'.
--
-- ERRORES (RAISE EXCEPTION)
-- - 'p_dia_semana debe estar entre 1 y 7 (recibido: X).'
-- - 'p_meses debe estar entre 1 y 24 (recibido: X).'
--
-- RETORNA
-- JSONB array (puede ser vacío). Cada ítem: { valido_desde, valido_hasta,
-- rangos: [{id, hora_inicio, hora_fin, actividad}] }. Ordenado por
-- valido_desde ascendente.
--
-- EFECTOS SECUNDARIOS
-- Ninguno (STABLE).
-- ============================================================================
CREATE OR REPLACE FUNCTION public.fc_obtener_planificaciones_futuras(p_token uuid, p_dia_semana smallint, p_desde date, p_meses integer DEFAULT 6)
RETURNS jsonb
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_resultado JSONB;
v_hasta DATE;
BEGIN
PERFORM internal.validate_permission(p_token, 'ver_horarios');
IF p_dia_semana < 1 OR p_dia_semana > 7 THEN
RAISE EXCEPTION 'p_dia_semana debe estar entre 1 y 7 (recibido: %).', p_dia_semana;
END IF;
IF p_meses < 1 OR p_meses > 24 THEN
RAISE EXCEPTION 'p_meses debe estar entre 1 y 24 (recibido: %).', p_meses;
END IF;
v_hasta := p_desde + (p_meses || ' months')::INTERVAL;
-- Agrupamos por (valido_desde, valido_hasta): cada grupo es UNA plantilla
-- futura con sus N rangos horarios. Solo traemos plantillas cuyo
-- valido_desde es estrictamente posterior a p_desde y cae dentro del
-- horizonte (p_desde + p_meses).
WITH plantillas AS (
SELECT
ha.valido_desde,
ha.valido_hasta,
jsonb_agg(
jsonb_build_object(
'id', ha.id,
'hora_inicio', to_char(ha.hora_inicio, 'HH24:MI'),
'hora_fin', to_char(ha.hora_fin, 'HH24:MI'),
'actividad', jsonb_build_object(
'id', a.id,
'nombre', a.nombre,
'duracion', a.duracion,
'capacidad', a.capacidad_por_defecto
)
) ORDER BY ha.hora_inicio ASC, a.nombre ASC
) AS rangos
FROM horario_actividad ha
JOIN actividades a ON ha.actividad_id = a.id
WHERE ha.dia_semana = p_dia_semana
AND ha.valido_desde > p_desde
AND ha.valido_desde <= v_hasta
AND a.activo = true
GROUP BY ha.valido_desde, ha.valido_hasta
)
SELECT COALESCE(
jsonb_agg(
jsonb_build_object(
'valido_desde', valido_desde::TEXT,
'valido_hasta', valido_hasta::TEXT,
'rangos', rangos
) ORDER BY valido_desde ASC
),
'[]'::JSONB
)
INTO v_resultado
FROM plantillas;
RETURN v_resultado;
END;
$function$
;
@@ -0,0 +1,79 @@
-- ============================================================================
-- internal.limpiar_dias_especiales_antiguos
-- ============================================================================
-- PROPÓSITO
-- Higiene operativa: elimina filas de public.dias_especiales cuya
-- `fecha` esté fuera de la ventana de retención. Un día especial es
-- una excepción puntual al régimen regular (cerrado u horario
-- diferente); una vez pasado, no tiene valor operativo ni se archiva.
--
-- DOMINIO
-- Implementa la higiene de días especiales descrita en
-- Documentation/PoliticaRetencion.md §7. La ventana es corta (1 mes
-- por default) porque un día especial sólo importa mientras condiciona
-- la generación de turnos; vencido el mes, ya no afecta nada vigente.
--
-- PARÁMETROS
-- Ninguno. La ventana en meses se lee de `internal.app_config` con la
-- clave `retencion.dias_especiales_meses` (default 1). 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`).
-- 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, fecha_corte, meses_conservados,
-- dias_eliminados, mensaje}.
--
-- EFECTOS SECUNDARIOS
-- DELETE de dias_especiales con fecha < (CURRENT_DATE - ventana). Las
-- filas de horario_actividad_especial asociadas caen por CASCADE
-- (horario_actividad_especial_dia_fkey ON DELETE CASCADE). No registra
-- evento.
-- ============================================================================
CREATE OR REPLACE FUNCTION internal.limpiar_dias_especiales_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.dias_especiales_meses', 1);
v_fecha_limite := CURRENT_DATE - (v_meses * INTERVAL '1 month');
DELETE FROM dias_especiales
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,
'dias_eliminados', v_eliminados,
'mensaje', format(
'Se eliminaron %s días especiales anteriores a %s (rangos horarios asociados eliminados 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_dias_especiales_antiguos()
FROM PUBLIC, anon, authenticated;
@@ -0,0 +1,144 @@
-- ============================================================================
-- internal.upsert_horario_especial
-- ============================================================================
-- PROPÓSITO
-- Helper interno invocado por fc_insertar_horario_con_actividades cuando
-- el payload corresponde a un día con horario especial (override puntual
-- o cierre). Crea/actualiza el registro en dias_especiales y materializa
-- o limpia los turnos en consecuencia.
--
-- Si rangos[] viene vacío o no viene → el día queda como 'cerrado'.
-- Si trae rangos → el día queda como 'horario_diferente'.
--
-- PARÁMETROS
-- p_datos JSONB con: fecha (obligatorio), motivo?, rangos[]?.
--
-- AUTORIZACIÓN
-- No valida permiso propio (lo hizo la fachada antes de invocar).
--
-- ERRORES (RAISE EXCEPTION)
-- - 'No se pueden alterar horarios en fechas pasadas.'
--
-- RETORNA
-- DATE = la fecha del día especial.
--
-- EFECTOS SECUNDARIOS
-- - UPSERT en dias_especiales (ON CONFLICT (fecha) DO UPDATE).
-- - DELETE de horario_actividad_especial previo (reemplazo).
-- - Si tipo='horario_diferente': INSERT de los rangos nuevos en
-- horario_actividad_especial.
-- - Si la fecha tenía turnos materializados:
-- - tipo='cerrado': rescata TODAS las reservas activas como huérfanas
-- y borra todos los turnos del día.
-- - tipo='horario_diferente': rescate selectivo + delete selectivo +
-- parcheo JIT (mismo patrón que upsert_horario_regular).
-- ============================================================================
CREATE OR REPLACE FUNCTION internal.upsert_horario_especial(
p_datos JSONB
)
RETURNS DATE
LANGUAGE plpgsql
SET search_path = public
SET timezone = 'America/Argentina/Buenos_Aires'
VOLATILE
AS $function$
DECLARE
v_fecha DATE;
v_dia_semana SMALLINT;
v_tipo VARCHAR(25);
v_motivo TEXT;
v_dia_especial_id INT;
v_turnos_generados BOOLEAN;
v_bloque RECORD;
v_hora_iter TIME;
BEGIN
v_fecha := (p_datos->>'fecha')::DATE;
v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT;
IF v_fecha < (now() AT TIME ZONE 'America/Argentina/Buenos_Aires')::DATE THEN
RAISE EXCEPTION 'No se pueden alterar horarios en fechas pasadas.';
END IF;
v_motivo := p_datos->>'motivo';
IF (p_datos->'rangos') IS NULL OR jsonb_typeof(p_datos->'rangos') <> 'array' OR jsonb_array_length(p_datos->'rangos') = 0 THEN
v_tipo := 'cerrado';
ELSE
v_tipo := 'horario_diferente';
END IF;
-- 1. Upsert del día especial
INSERT INTO dias_especiales (fecha, tipo, motivo)
VALUES (v_fecha, v_tipo, v_motivo)
ON CONFLICT (fecha) DO UPDATE
SET tipo = EXCLUDED.tipo, motivo = EXCLUDED.motivo
RETURNING id INTO v_dia_especial_id;
DELETE FROM horario_actividad_especial WHERE dia_especial_id = v_dia_especial_id;
IF v_tipo = 'horario_diferente' THEN
INSERT INTO horario_actividad_especial (dia_especial_id, actividad_id, hora_inicio, hora_fin)
SELECT DISTINCT v_dia_especial_id, (rango->>'actividad_id')::INT, (rango->>'hora_inicio')::TIME, (rango->>'hora_fin')::TIME
FROM jsonb_array_elements(p_datos->'rangos') AS rango;
END IF;
-- 2. Análisis del estado JIT
SELECT EXISTS (SELECT 1 FROM turnos WHERE fecha = v_fecha) INTO v_turnos_generados;
IF v_turnos_generados THEN
IF v_tipo = 'cerrado' THEN
-- Rescate total
INSERT INTO reservas_huerfanas (cliente_id, actividad_nombre, fecha_original, hora_inicio_original)
SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio
FROM reservas r JOIN turnos t ON r.turno_id = t.id JOIN actividades a ON t.actividad_id = a.id
WHERE t.fecha = v_fecha AND r.cancelada = false;
-- Borrado total
DELETE FROM turnos WHERE fecha = v_fecha;
ELSE
-- Rescate parcial (Match Suave)
INSERT INTO reservas_huerfanas (cliente_id, actividad_nombre, fecha_original, hora_inicio_original)
SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio
FROM reservas r JOIN turnos t ON r.turno_id = t.id JOIN actividades a ON t.actividad_id = a.id
WHERE t.fecha = v_fecha
AND r.cancelada = false
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng
WHERE (rng->>'actividad_id')::INT = t.actividad_id
AND t.hora_inicio >= (rng->>'hora_inicio')::TIME
AND t.hora_fin <= (rng->>'hora_fin')::TIME
);
-- Borrado parcial
DELETE FROM turnos t
WHERE t.fecha = v_fecha
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng
WHERE (rng->>'actividad_id')::INT = t.actividad_id
AND t.hora_inicio >= (rng->>'hora_inicio')::TIME
AND t.hora_fin <= (rng->>'hora_fin')::TIME
);
-- Parcheo JIT
FOR v_bloque IN (
SELECT (r->>'actividad_id')::INT AS act_id, (r->>'hora_inicio')::TIME AS h_in, (r->>'hora_fin')::TIME AS h_out,
a.duracion, a.capacidad_por_defecto
FROM jsonb_array_elements(p_datos->'rangos') AS r
JOIN actividades a ON a.id = (r->>'actividad_id')::INT
) LOOP
v_hora_iter := v_bloque.h_in;
WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.h_out LOOP
INSERT INTO turnos (actividad_id, fecha, hora_inicio, hora_fin, capacidad_maxima, es_especial, dia_semana)
VALUES (v_bloque.act_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;
END IF;
END IF;
RETURN v_fecha;
END;
$function$;
@@ -0,0 +1,228 @@
-- ============================================================================
-- internal.upsert_horario_regular
-- ============================================================================
-- PROPÓSITO
-- Helper interno invocado por fc_insertar_horario_con_actividades cuando
-- el payload corresponde a un horario regular (semanal). Reemplaza /
-- crea la plantilla horaria del día de la semana para una vigencia
-- declarada, cierra plantillas previas que se solapan y sincroniza los
-- turnos JIT ya materializados.
--
-- PARÁMETROS
-- p_datos JSONB con (todos los campos parseados acá; ver fachada para
-- descripción completa): fecha, valido_desde?, alcance.{tipo,
-- fecha?}, rangos[].
--
-- AUTORIZACIÓN
-- No valida permiso propio (lo hizo la fachada antes de invocar).
--
-- ERRORES (RAISE EXCEPTION)
-- - 'valido_desde no puede ser una fecha pasada.'
-- - 'alcance.tipo inválido: X. Debe ser ''indefinido'', ''hasta_proximo''
-- o ''hasta''.'
-- - 'alcance.fecha es obligatorio cuando alcance.tipo = ''hasta''.'
-- - 'alcance.fecha (X) no puede ser anterior a valido_desde (Y).'
-- - 'Conflicto: existe una planificación con valido_desde = X dentro
-- del intervalo [Y, Z]. Modificá el alcance o eliminá la planificación
-- previamente.'
--
-- RETORNA
-- DATE = valido_desde efectivamente aplicado.
--
-- EFECTOS SECUNDARIOS
-- - Según el alcance: borra plantillas futuras del mismo día_semana, o
-- deja vigencia abierta, o cierra contra la próxima futura.
-- - DELETE de plantilla con valido_desde exactamente igual (reemplazo).
-- - UPDATE de plantillas anteriores que se solapan: valido_hasta =
-- valido_desde - 1.
-- - INSERT de los rangos nuevos en horario_actividad.
-- - Para las fechas afectadas con turnos ya materializados: rescate de
-- huérfanas + delete selectivo + parcheo JIT (mismo patrón que usan
-- fc_eliminar_dia_especial e internal.cerrar_actividad).
-- ============================================================================
CREATE OR REPLACE FUNCTION internal.upsert_horario_regular(
p_datos JSONB
)
RETURNS DATE
LANGUAGE plpgsql
SET search_path = public
SET timezone = 'America/Argentina/Buenos_Aires'
VOLATILE
AS $function$
DECLARE
v_fecha DATE;
v_dia_semana SMALLINT;
v_valido_desde DATE;
v_alcance_tipo TEXT;
v_alcance_fecha DATE;
v_valido_hasta_nuevo DATE;
v_fecha_conflicto DATE;
v_fechas_afectadas DATE[];
v_f DATE;
v_bloque RECORD;
v_hora_iter TIME;
BEGIN
------------------------------------------------------------
-- 0. Parseo y validación de entrada
------------------------------------------------------------
v_fecha := (p_datos->>'fecha')::DATE;
v_dia_semana := EXTRACT(ISODOW FROM v_fecha)::SMALLINT;
v_valido_desde := COALESCE((p_datos->>'valido_desde')::DATE, v_fecha);
IF v_valido_desde < (now() AT TIME ZONE 'America/Argentina/Buenos_Aires')::DATE THEN
RAISE EXCEPTION 'valido_desde no puede ser una fecha pasada.';
END IF;
v_alcance_tipo := COALESCE(p_datos->'alcance'->>'tipo', 'hasta_proximo');
IF v_alcance_tipo NOT IN ('indefinido', 'hasta_proximo', 'hasta') THEN
RAISE EXCEPTION
'alcance.tipo inválido: %. Debe ser ''indefinido'', ''hasta_proximo'' o ''hasta''.',
v_alcance_tipo;
END IF;
IF v_alcance_tipo = 'hasta' THEN
v_alcance_fecha := (p_datos->'alcance'->>'fecha')::DATE;
IF v_alcance_fecha IS NULL THEN
RAISE EXCEPTION 'alcance.fecha es obligatorio cuando alcance.tipo = ''hasta''.';
END IF;
IF v_alcance_fecha < v_valido_desde THEN
RAISE EXCEPTION
'alcance.fecha (%) no puede ser anterior a valido_desde (%).',
v_alcance_fecha, v_valido_desde;
END IF;
END IF;
------------------------------------------------------------
-- 1. Resolver el valido_hasta del nuevo intervalo según el alcance
------------------------------------------------------------
IF v_alcance_tipo = 'indefinido' THEN
-- Purga todas las plantillas futuras del mismo dia_semana
DELETE FROM horario_actividad
WHERE dia_semana = v_dia_semana
AND valido_desde > v_valido_desde;
v_valido_hasta_nuevo := NULL;
ELSIF v_alcance_tipo = 'hasta_proximo' THEN
-- Cerrar antes del próximo futuro, o quedar abierto si no hay
SELECT MIN(valido_desde) - 1
INTO v_valido_hasta_nuevo
FROM horario_actividad
WHERE dia_semana = v_dia_semana
AND valido_desde > v_valido_desde;
ELSE -- 'hasta'
-- Validar que no haya futuros dentro del intervalo solicitado
SELECT MIN(valido_desde)
INTO v_fecha_conflicto
FROM horario_actividad
WHERE dia_semana = v_dia_semana
AND valido_desde > v_valido_desde
AND valido_desde <= v_alcance_fecha;
IF v_fecha_conflicto IS NOT NULL THEN
RAISE EXCEPTION
'Conflicto: existe una planificación con valido_desde = % dentro del intervalo [%, %]. Modificá el alcance o eliminá la planificación previamente.',
v_fecha_conflicto, v_valido_desde, v_alcance_fecha;
END IF;
v_valido_hasta_nuevo := v_alcance_fecha;
END IF;
------------------------------------------------------------
-- 2. Limpieza estructural en horario_actividad
------------------------------------------------------------
-- 2.A Colisión exacta: si ya existía una plantilla con el mismo valido_desde,
-- la borramos para reemplazarla con los rangos nuevos.
DELETE FROM horario_actividad
WHERE dia_semana = v_dia_semana
AND valido_desde = v_valido_desde;
-- 2.B Cerrar la(s) plantilla(s) anterior(es) que se solapen con el nuevo desde.
UPDATE horario_actividad
SET valido_hasta = v_valido_desde - 1
WHERE dia_semana = v_dia_semana
AND valido_desde < v_valido_desde
AND (valido_hasta IS NULL OR valido_hasta >= v_valido_desde);
-- 2.C Insertar los rangos nuevos con la vigencia ya resuelta.
INSERT INTO horario_actividad
(dia_semana, actividad_id, hora_inicio, hora_fin, valido_desde, valido_hasta)
SELECT DISTINCT
v_dia_semana,
(rango->>'actividad_id')::INT,
(rango->>'hora_inicio')::TIME,
(rango->>'hora_fin')::TIME,
v_valido_desde,
v_valido_hasta_nuevo
FROM jsonb_array_elements(p_datos->'rangos') AS rango;
------------------------------------------------------------
-- 3. Match suave sobre turnos JIT ya materializados
-- Acotado al intervalo efectivo del nuevo (fix del bug viejo:
-- no tocar turnos que pertenezcan a futuros que se respetan).
------------------------------------------------------------
SELECT array_agg(DISTINCT fecha)
INTO v_fechas_afectadas
FROM turnos
WHERE dia_semana = v_dia_semana
AND fecha >= v_valido_desde
AND fecha <= COALESCE(v_valido_hasta_nuevo, 'infinity'::date);
IF v_fechas_afectadas IS NOT NULL THEN
-- 3.A Rescate de huérfanas
INSERT INTO reservas_huerfanas
(cliente_id, actividad_nombre, fecha_original, hora_inicio_original)
SELECT r.cliente_id, a.nombre, t.fecha, t.hora_inicio
FROM reservas r
JOIN turnos t ON r.turno_id = t.id
JOIN actividades a ON t.actividad_id = a.id
WHERE t.fecha = ANY(v_fechas_afectadas)
AND r.cancelada = false
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng
WHERE (rng->>'actividad_id')::INT = t.actividad_id
AND t.hora_inicio >= (rng->>'hora_inicio')::TIME
AND t.hora_fin <= (rng->>'hora_fin')::TIME
);
-- 3.B Borrado selectivo de turnos que ya no calzan con los rangos nuevos
DELETE FROM turnos t
WHERE t.fecha = ANY(v_fechas_afectadas)
AND NOT EXISTS (
SELECT 1 FROM jsonb_array_elements(p_datos->'rangos') AS rng
WHERE (rng->>'actividad_id')::INT = t.actividad_id
AND t.hora_inicio >= (rng->>'hora_inicio')::TIME
AND t.hora_fin <= (rng->>'hora_fin')::TIME
);
-- 3.C Parcheo JIT iterativo para esas fechas
FOREACH v_f IN ARRAY v_fechas_afectadas LOOP
FOR v_bloque IN (
SELECT (r->>'actividad_id')::INT AS act_id,
(r->>'hora_inicio')::TIME AS h_in,
(r->>'hora_fin')::TIME AS h_out,
a.duracion, a.capacidad_por_defecto
FROM jsonb_array_elements(p_datos->'rangos') AS r
JOIN actividades a ON a.id = (r->>'actividad_id')::INT
) LOOP
v_hora_iter := v_bloque.h_in;
WHILE v_hora_iter + (v_bloque.duracion || ' minutes')::INTERVAL <= v_bloque.h_out LOOP
INSERT INTO turnos
(actividad_id, fecha, hora_inicio, hora_fin,
capacidad_maxima, es_especial, dia_semana)
VALUES (
v_bloque.act_id, v_f, 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 LOOP;
END IF;
RETURN v_valido_desde;
END;
$function$;