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,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$;