Agrego carpeta del source sql de la Base de Datos
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
-- ============================================================================
|
||||
-- fc_eliminar_actividad
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Borrado físico de una actividad. Operación **destructiva en cascada**:
|
||||
-- borra plantillas horarias (regulares y de días especiales), todos los
|
||||
-- turnos pasados y futuros de la actividad, y todas las reservas
|
||||
-- históricas y futuras asociadas a esos turnos. La pérdida es irreversible.
|
||||
--
|
||||
-- Si la actividad está incluida en uno o más planes (tipos_cuota), el
|
||||
-- borrado se rechaza — esas FK no cascadean. El operador debe primero
|
||||
-- sacar la actividad de los planes (o desactivarla si quiere conservar
|
||||
-- las asociaciones).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- El dominio de horarios (Documentation/DominioHorarios.md §7.2) describe
|
||||
-- sólo dos operaciones de baja para una actividad: **suspender** y
|
||||
-- **restablecer**. La eliminación física no está en §7.2. Vive acá
|
||||
-- como vía operativa del operador, sostenida por §9.6 (libertad
|
||||
-- operativa con advertencia): la UI muestra la advertencia previa, el
|
||||
-- operador asume la consecuencia y la función ejecuta.
|
||||
--
|
||||
-- §11 exige que los cambios estructurales sean reconstruibles. Esta
|
||||
-- función lo cubre vía evento `actividad_eliminada` con un payload
|
||||
-- **minimalista** alineado con la política del sistema: los eventos
|
||||
-- guardan lo que puede volver a ser necesario operativamente, no
|
||||
-- estadística histórica. Por eso el snapshot es sólo `{id, nombre}` (lo
|
||||
-- mínimo para identificar qué se borró) y los conteos cuentan sólo
|
||||
-- compromisos vivos rotos (`turnos_futuros`, `reservas_vivas`). Los
|
||||
-- pasados no se cuentan: la política de retención los iba a descartar
|
||||
-- de todas formas. Los detalles de la actividad (duración, capacidad,
|
||||
-- etc.) no se conservan: el dominio no admite "deshacer eliminación";
|
||||
-- si vuelve a ser necesaria, el operador la crea de cero.
|
||||
--
|
||||
-- No mirar a esta función como la contraparte de "suspender" — son dos
|
||||
-- cosas distintas. Suspender es reversible y preserva historia;
|
||||
-- eliminar es terminal y la destruye. Para suspender, usar
|
||||
-- `fc_modificar_actividad` con `{activo: false}`.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_actividad_id INT ID de la actividad a eliminar.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'eliminar_actividad'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Sesión inválida o usuario no encontrado.'
|
||||
-- - 'No se puede eliminar la actividad X porque está incluida en uno o
|
||||
-- más planes. Sacala de los planes primero, o desactivala si querés
|
||||
-- conservarla en el sistema.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- BOOL TRUE si eliminó la actividad, FALSE si no encontró ninguna fila
|
||||
-- con ese ID. El frontend arma el preview previo a la confirmación con
|
||||
-- las consultas existentes (turnos/reservas filtrados por actividad).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - DELETE en actividades. Cascadea a horario_actividad,
|
||||
-- horario_actividad_especial, turnos, y de ahí a reservas.
|
||||
-- - INSERT en eventos: tipo 'actividad_eliminada', valor_anterior con
|
||||
-- `{actividad: {id, nombre}, destruido: {turnos_futuros, reservas_vivas}}`,
|
||||
-- valor_actual NULL. referencia_id queda NULL porque actividades.id
|
||||
-- es INT y eventos.referencia_id es UUID; el id va embebido en el
|
||||
-- snapshot.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_eliminar_actividad(
|
||||
p_token UUID,
|
||||
p_actividad_id INT
|
||||
)
|
||||
RETURNS BOOL
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor_id UUID;
|
||||
v_actividad_id INT;
|
||||
v_actividad_nombre TEXT;
|
||||
v_count_turnos_futuros INT;
|
||||
v_count_reservas_vivas INT;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- 1. Permisos + actor
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'eliminar_actividad');
|
||||
|
||||
SELECT s.usuario_id
|
||||
INTO v_actor_id
|
||||
FROM internal.sesiones s
|
||||
WHERE s.token = p_token;
|
||||
|
||||
IF v_actor_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Identidad mínima de la actividad (debe existir para continuar)
|
||||
------------------------------------------------------------
|
||||
SELECT a.id, a.nombre
|
||||
INTO v_actividad_id, v_actividad_nombre
|
||||
FROM actividades a
|
||||
WHERE a.id = p_actividad_id;
|
||||
|
||||
IF v_actividad_id IS NULL THEN
|
||||
-- No existe: nada que borrar, nada que loggear.
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Pre-conteo de compromisos vivos (lo único que importa
|
||||
-- para reconstrucción operativa; los pasados igual iban
|
||||
-- a desaparecer por retención).
|
||||
------------------------------------------------------------
|
||||
SELECT COUNT(*)::INT INTO v_count_turnos_futuros
|
||||
FROM turnos
|
||||
WHERE actividad_id = p_actividad_id
|
||||
AND fecha >= CURRENT_DATE;
|
||||
|
||||
SELECT COUNT(*)::INT INTO v_count_reservas_vivas
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
WHERE t.actividad_id = p_actividad_id
|
||||
AND t.fecha >= CURRENT_DATE
|
||||
AND r.cancelada = false;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 4. Borrado físico (cascadea por FK)
|
||||
------------------------------------------------------------
|
||||
BEGIN
|
||||
DELETE FROM actividades a WHERE a.id = p_actividad_id;
|
||||
EXCEPTION
|
||||
WHEN foreign_key_violation THEN
|
||||
-- El único origen esperado es actividades_tipos_cuota (no cascadea).
|
||||
RAISE EXCEPTION 'No se puede eliminar la actividad % porque está incluida en uno o más planes. Sacala de los planes primero, o desactivala si querés conservarla en el sistema.', p_actividad_id;
|
||||
END;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 5. Trazabilidad del cambio estructural destructivo (§11)
|
||||
-- Si log_evento fallara, la transacción entera revierte
|
||||
-- (el DELETE no queda firme sin trazabilidad).
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.log_evento(
|
||||
p_tipo => 'actividad_eliminada',
|
||||
p_tabla => 'actividades',
|
||||
p_referencia_id => NULL,
|
||||
p_cliente_id => NULL,
|
||||
p_actor_id => v_actor_id,
|
||||
p_valor_anterior => jsonb_build_object(
|
||||
'actividad', jsonb_build_object(
|
||||
'id', v_actividad_id,
|
||||
'nombre', v_actividad_nombre
|
||||
),
|
||||
'destruido', jsonb_build_object(
|
||||
'turnos_futuros', v_count_turnos_futuros,
|
||||
'reservas_vivas', v_count_reservas_vivas
|
||||
)
|
||||
),
|
||||
p_valor_actual => NULL,
|
||||
p_descripcion => NULL
|
||||
);
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,110 @@
|
||||
-- ============================================================================
|
||||
-- fc_insertar_actividad
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Crea una actividad nueva (entidad reservable: yoga, funcional, etc.).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Alta de una actividad (Documentation/DominioHorarios.md §7.2 — el
|
||||
-- agrupamiento operativo, no la disciplina física). Es un acto del
|
||||
-- operador sobre el catálogo: no produce reservas huérfanas por sí
|
||||
-- misma, pero ingresa al alcance de los **cambios estructurales** (§8,
|
||||
-- §11) — abre nuevas opciones reservables que después la plantilla
|
||||
-- horaria podrá referenciar.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_datos JSONB con el payload. Claves:
|
||||
-- nombre TEXT (obligatorio) no vacío.
|
||||
-- duracion SMALLINT (obligatorio) en minutos, > 0.
|
||||
-- capacidad_por_defecto SMALLINT (obligatorio) > 0.
|
||||
-- libre BOOLEAN (opcional, default FALSE) si la
|
||||
-- actividad es "libre" (sin turnos).
|
||||
-- activo BOOLEAN (opcional, default TRUE).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'crear_actividad'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El nombre de la actividad es obligatorio.'
|
||||
-- - 'La duración es obligatoria y debe ser mayor a 0.'
|
||||
-- - 'La capacidad por defecto es obligatoria y debe ser mayor a 0.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con la actividad recién creada: {id, nombre, duracion,
|
||||
-- capacidad_por_defecto, libre, activo, status: 'success'}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- INSERT en actividades.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_insertar_actividad(
|
||||
p_token UUID,
|
||||
p_datos JSONB
|
||||
)
|
||||
RETURNS JSONB -- Cambiado a JSONB para evitar errores de posición de columnas
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE -- Checklist #2: Modifica datos
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actividad_insertada RECORD;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos (Centralizado)
|
||||
------------------------------------------------------------
|
||||
-- Acción sugerida: 'crear_actividad'
|
||||
PERFORM internal.validate_permission(p_token, 'crear_actividad');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 1. Validaciones de Entrada
|
||||
------------------------------------------------------------
|
||||
-- Validamos que los campos obligatorios vengan y NO sean nulos
|
||||
IF (p_datos->>'nombre') IS NULL OR length(p_datos->>'nombre') = 0 THEN
|
||||
RAISE EXCEPTION 'El nombre de la actividad es obligatorio.';
|
||||
END IF;
|
||||
|
||||
IF (p_datos->>'duracion') IS NULL OR (p_datos->>'duracion')::INT <= 0 THEN
|
||||
RAISE EXCEPTION 'La duración es obligatoria y debe ser mayor a 0.';
|
||||
END IF;
|
||||
|
||||
IF (p_datos->>'capacidad_por_defecto') IS NULL OR (p_datos->>'capacidad_por_defecto')::INT <= 0 THEN
|
||||
RAISE EXCEPTION 'La capacidad por defecto es obligatoria y debe ser mayor a 0.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Insertar directo (Sin variables intermedias)
|
||||
------------------------------------------------------------
|
||||
INSERT INTO actividades (
|
||||
nombre,
|
||||
duracion,
|
||||
capacidad_por_defecto,
|
||||
libre,
|
||||
activo
|
||||
)
|
||||
VALUES (
|
||||
p_datos->>'nombre',
|
||||
(p_datos->>'duracion')::SMALLINT,
|
||||
(p_datos->>'capacidad_por_defecto')::SMALLINT,
|
||||
COALESCE((p_datos->>'libre')::BOOLEAN, FALSE), -- Default FALSE si no viene
|
||||
COALESCE((p_datos->>'activo')::BOOLEAN, TRUE) -- Default TRUE si no viene
|
||||
)
|
||||
RETURNING * INTO v_actividad_insertada; -- Capturamos todo el registro
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Retorno JSONB Seguro
|
||||
------------------------------------------------------------
|
||||
-- Al usar keys explícitas, nunca más tendrás el error de columnas intercambiadas
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_actividad_insertada.id,
|
||||
'nombre', v_actividad_insertada.nombre,
|
||||
'duracion', v_actividad_insertada.duracion,
|
||||
'capacidad_por_defecto', v_actividad_insertada.capacidad_por_defecto,
|
||||
'libre', v_actividad_insertada.libre,
|
||||
'activo', v_actividad_insertada.activo,
|
||||
'status', 'success'
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,180 @@
|
||||
-- ============================================================================
|
||||
-- fc_modificar_actividad
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Modifica los campos editables de una actividad existente. Semántica
|
||||
-- PATCH: sólo se actualizan las claves presentes en p_datos.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Modifica una actividad (Documentation/DominioHorarios.md §7.2). Esta
|
||||
-- función es el único punto de entrada para las dos operaciones nombradas
|
||||
-- del dominio sobre el ciclo de vida de una actividad:
|
||||
--
|
||||
-- - **Suspender** (`activo: true → false`). Cambio estructural (§11). Las
|
||||
-- consecuencias materializadas — borrar turnos futuros, rescatar
|
||||
-- reservas vivas como huérfanas, cerrar plantillas horarias — las
|
||||
-- ejecuta el trigger trg_desactivar_actividad / internal.cerrar_actividad,
|
||||
-- no esta función. Acá sólo flippeamos el flag y registramos el evento
|
||||
-- de trazabilidad.
|
||||
--
|
||||
-- - **Restablecer** (`activo: false → true`). Cambio estructural (§11). El
|
||||
-- dominio adopta la semántica "el operador define el horario nuevo": el
|
||||
-- restablecimiento no recrea plantillas ni resucita las que se cerraron
|
||||
-- al suspender. La actividad vuelve a ser ofrecible, y el operador
|
||||
-- crea las plantillas que correspondan (típicamente anunciándolas con
|
||||
-- anticipación). Las asociaciones a tipos_cuota nunca se tocan al
|
||||
-- suspender, así que vuelven a habilitar acceso automáticamente.
|
||||
--
|
||||
-- Cualquier otro cambio (nombre, duración, capacidad, libre) es metadata
|
||||
-- operativa, no produce evento ni dispara trigger.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_actividad_id INT (obligatorio) ID de la actividad.
|
||||
-- p_datos JSONB con los campos a actualizar (todos opcionales):
|
||||
-- nombre, duracion, capacidad_por_defecto, libre, activo.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_actividades'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'No existe la actividad con id X'
|
||||
-- - 'Sesión inválida o usuario no encontrado.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con la actividad ya actualizada: {id, nombre, duracion,
|
||||
-- capacidad_por_defecto, libre, activo, status: 'success'}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - UPDATE en actividades.
|
||||
-- - Si `activo` cambió, dispara trg_desactivar_actividad en el caso
|
||||
-- TRUE→FALSE (efectos materializados; ver internal.cerrar_actividad).
|
||||
-- - Si `activo` cambió, INSERT en eventos con tipo 'actividad_suspendida'
|
||||
-- o 'actividad_restablecida' (trazabilidad §11). referencia_id queda
|
||||
-- NULL porque actividades.id es INT y eventos.referencia_id es UUID;
|
||||
-- el id va embebido en los snapshots.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_modificar_actividad(
|
||||
p_token UUID,
|
||||
p_actividad_id INT, -- ID explícito
|
||||
p_datos JSONB
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE -- Checklist #2: Modifica datos
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor_id UUID;
|
||||
v_activo_anterior BOOLEAN;
|
||||
v_nombre_anterior TEXT;
|
||||
v_actividad_actualizada RECORD;
|
||||
v_tipo_evento TEXT;
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos (Centralizado)
|
||||
------------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'modificar_actividades');
|
||||
|
||||
SELECT s.usuario_id
|
||||
INTO v_actor_id
|
||||
FROM internal.sesiones s
|
||||
WHERE s.token = p_token;
|
||||
|
||||
IF v_actor_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Snapshot previo (necesario para detectar flip de `activo`
|
||||
-- y para el valor_anterior del evento de trazabilidad).
|
||||
------------------------------------------------------------
|
||||
SELECT a.activo, a.nombre
|
||||
INTO v_activo_anterior, v_nombre_anterior
|
||||
FROM actividades a
|
||||
WHERE a.id = p_actividad_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No existe la actividad con id %', p_actividad_id;
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 1. UPDATE Dinámico y Seguro
|
||||
------------------------------------------------------------
|
||||
-- Patrón CASE WHEN ? para permitir actualizaciones parciales reales.
|
||||
UPDATE actividades
|
||||
SET
|
||||
nombre = CASE
|
||||
WHEN p_datos ? 'nombre' THEN p_datos->>'nombre'
|
||||
ELSE nombre
|
||||
END,
|
||||
|
||||
duracion = CASE
|
||||
WHEN p_datos ? 'duracion' THEN (p_datos->>'duracion')::SMALLINT
|
||||
ELSE duracion
|
||||
END,
|
||||
|
||||
capacidad_por_defecto = CASE
|
||||
WHEN p_datos ? 'capacidad_por_defecto' THEN (p_datos->>'capacidad_por_defecto')::SMALLINT
|
||||
ELSE capacidad_por_defecto
|
||||
END,
|
||||
|
||||
libre = CASE
|
||||
WHEN p_datos ? 'libre' THEN (p_datos->>'libre')::BOOLEAN
|
||||
ELSE libre
|
||||
END,
|
||||
|
||||
activo = CASE
|
||||
WHEN p_datos ? 'activo' THEN (p_datos->>'activo')::BOOLEAN
|
||||
ELSE activo
|
||||
END
|
||||
WHERE id = p_actividad_id
|
||||
RETURNING * INTO v_actividad_actualizada;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Trazabilidad del cambio estructural (§11)
|
||||
-- Sólo si el flag `activo` efectivamente cambió.
|
||||
------------------------------------------------------------
|
||||
IF v_activo_anterior IS DISTINCT FROM v_actividad_actualizada.activo THEN
|
||||
v_tipo_evento := CASE
|
||||
WHEN v_actividad_actualizada.activo = FALSE THEN 'actividad_suspendida'
|
||||
ELSE 'actividad_restablecida'
|
||||
END;
|
||||
|
||||
PERFORM internal.log_evento(
|
||||
p_tipo => v_tipo_evento,
|
||||
p_tabla => 'actividades',
|
||||
p_referencia_id => NULL,
|
||||
p_cliente_id => NULL,
|
||||
p_actor_id => v_actor_id,
|
||||
p_valor_anterior => jsonb_build_object(
|
||||
'actividad_id', p_actividad_id,
|
||||
'nombre', v_nombre_anterior,
|
||||
'activo', v_activo_anterior
|
||||
),
|
||||
p_valor_actual => jsonb_build_object(
|
||||
'actividad_id', v_actividad_actualizada.id,
|
||||
'nombre', v_actividad_actualizada.nombre,
|
||||
'activo', v_actividad_actualizada.activo
|
||||
),
|
||||
p_descripcion => NULL
|
||||
);
|
||||
END IF;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Retorno JSONB
|
||||
------------------------------------------------------------
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_actividad_actualizada.id,
|
||||
'nombre', v_actividad_actualizada.nombre,
|
||||
'duracion', v_actividad_actualizada.duracion,
|
||||
'capacidad_por_defecto', v_actividad_actualizada.capacidad_por_defecto,
|
||||
'libre', v_actividad_actualizada.libre,
|
||||
'activo', v_actividad_actualizada.activo,
|
||||
'status', 'success'
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_actividades
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve las actividades configuradas. Permite filtrar por estado activo.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Lista el catálogo de actividades (Documentation/DominioHorarios.md §7.2).
|
||||
-- La actividad es el agrupamiento operativo del uso del gimnasio (no la
|
||||
-- disciplina física). El filtro `activo` se corresponde con la noción de
|
||||
-- **suspensión / restablecimiento** del dominio: una actividad inactiva
|
||||
-- "sigue existiendo conceptualmente, simplemente deja de ofrecerse".
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_solo_activas BOOLEAN (opcional, default NULL). Si NULL trae todas; si
|
||||
-- TRUE sólo las activas; si FALSE sólo las inactivas.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_actividades'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío) con las filas completas de actividades
|
||||
-- ordenadas por nombre.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_actividades(
|
||||
p_token UUID,
|
||||
p_solo_activas BOOLEAN DEFAULT NULL -- Opcional: útil para dropdowns
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE -- Checklist #2: Solo lee datos
|
||||
AS $$
|
||||
DECLARE
|
||||
-- Sin variables extra
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos
|
||||
------------------------------------------------------------
|
||||
-- Acción sugerida: 'ver_actividades'
|
||||
PERFORM internal.validate_permission(p_token, 'ver_actividades');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Retornar JSONB estandarizado
|
||||
------------------------------------------------------------
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.nombre), '[]'::jsonb)
|
||||
FROM actividades a
|
||||
WHERE
|
||||
-- Filtro opcional: Si p_solo_activas es NULL, trae todo.
|
||||
-- Si es TRUE, solo trae las activas = true.
|
||||
(p_solo_activas IS NULL OR a.activo = p_solo_activas)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_actividades_tipo_cuota
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve las actividades incluidas en un tipo de cuota (plan). Útil para
|
||||
-- mostrar el detalle de un plan o para validar si el cliente que tiene
|
||||
-- plan X puede reservar un turno de actividad Y.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Sirve a la consulta "qué actividades habilita un plan"
|
||||
-- (Documentation/DominioHorarios.md §7.2 actividad + §7.7 plan). Es la
|
||||
-- fuente con la que se materializa la primera condición de §9.1: "su plan
|
||||
-- está vigente y habilita la actividad del bloque".
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_tipo_cuota_id UUID ID del plan a consultar.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_actividades'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío) de actividades asociadas: cada ítem
|
||||
-- trae {id, nombre, duracion, activo}, ordenado por nombre.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_actividades_tipo_cuota(
|
||||
p_token UUID,
|
||||
p_tipo_cuota_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_actividades');
|
||||
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', a.id,
|
||||
'nombre', a.nombre,
|
||||
'duracion', a.duracion,
|
||||
'activo', a.activo
|
||||
)
|
||||
ORDER BY a.nombre ASC
|
||||
), '[]'::jsonb)
|
||||
FROM public.actividades_tipos_cuota atc
|
||||
JOIN public.actividades a ON atc.actividad_id = a.id
|
||||
WHERE atc.tipo_cuota_id = p_tipo_cuota_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- ============================================================================
|
||||
-- internal.cerrar_actividad
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Trigger function: cuando una actividad pasa de activo=true a activo=false,
|
||||
-- limpia el estado derivado: rescata reservas vivas como reservas huérfanas,
|
||||
-- borra los turnos futuros y cierra las plantillas horarias asociadas.
|
||||
--
|
||||
-- El trigger que la invoca (trg_desactivar_actividad, AFTER UPDATE OF activo
|
||||
-- ON actividades, WHEN OLD.activo = TRUE AND NEW.activo = FALSE) NO se
|
||||
-- versiona en este archivo. Vive como parte del DDL (por ahora en
|
||||
-- shift_functions/utility_functions.sql).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- Implícitos del trigger (OLD.id, OLD.nombre, NEW).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No aplica (trigger function).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- trigger (la fila NEW).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - INSERT en reservas_huerfanas para cada reserva activa sobre un turno
|
||||
-- futuro de la actividad desactivada.
|
||||
-- - DELETE de turnos futuros de la actividad.
|
||||
-- - DELETE de plantillas horarias (horario_actividad) cuya vigencia
|
||||
-- empieza hoy o en el futuro.
|
||||
-- - UPDATE de plantillas horarias vigentes: cierra su vigencia ayer
|
||||
-- (valido_hasta = CURRENT_DATE - 1).
|
||||
-- - Las plantillas históricas (ya vencidas) no se tocan.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.cerrar_actividad()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
AS $$
|
||||
BEGIN
|
||||
-- El trigger se filtra con WHEN, así que cuando entra acá ya sabemos que
|
||||
-- la actividad pasó de activo=true a activo=false.
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 1. Rescatar reservas activas sobre turnos futuros
|
||||
------------------------------------------------------------
|
||||
INSERT INTO reservas_huerfanas
|
||||
(cliente_id, actividad_nombre, fecha_original, hora_inicio_original)
|
||||
SELECT r.cliente_id, OLD.nombre, t.fecha, t.hora_inicio
|
||||
FROM reservas r
|
||||
JOIN turnos t ON r.turno_id = t.id
|
||||
WHERE t.actividad_id = OLD.id
|
||||
AND t.fecha >= CURRENT_DATE
|
||||
AND r.cancelada = false;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 2. Borrar turnos futuros (los pasados quedan para histórico/reportes)
|
||||
------------------------------------------------------------
|
||||
DELETE FROM turnos
|
||||
WHERE actividad_id = OLD.id
|
||||
AND fecha >= CURRENT_DATE;
|
||||
|
||||
------------------------------------------------------------
|
||||
-- 3. Limpiar plantillas según su posición temporal
|
||||
------------------------------------------------------------
|
||||
|
||||
-- 3.A Plantillas que empiezan hoy o en el futuro: borrarlas.
|
||||
-- Si la cerráramos en CURRENT_DATE-1 quedaría un intervalo vacío
|
||||
-- o invertido (valido_desde > valido_hasta).
|
||||
DELETE FROM horario_actividad
|
||||
WHERE actividad_id = OLD.id
|
||||
AND valido_desde >= CURRENT_DATE;
|
||||
|
||||
-- 3.B Plantillas que ya están rigiendo (valido_desde en el pasado y
|
||||
-- vigencia abierta o futura): cerrarlas en CURRENT_DATE - 1.
|
||||
UPDATE horario_actividad
|
||||
SET valido_hasta = CURRENT_DATE - 1
|
||||
WHERE actividad_id = OLD.id
|
||||
AND valido_desde < CURRENT_DATE
|
||||
AND (valido_hasta IS NULL OR valido_hasta >= CURRENT_DATE);
|
||||
|
||||
-- 3.C Plantillas históricas (valido_hasta < CURRENT_DATE): NO se tocan.
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- ============================================================================
|
||||
-- fc_eliminar_sesion
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Cierra una sesión activa eliminando su registro de internal.sesiones.
|
||||
-- Equivalente a "logout".
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID token de la sesión a cerrar.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No valida permiso (cualquiera con un token válido puede cerrarlo).
|
||||
-- Si p_token no existe, no hace nada (no rebota).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno.
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- DELETE en internal.sesiones de la fila con ese token.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_eliminar_sesion(p_token UUID)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM internal.sesiones s WHERE s.token = p_token;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- ============================================================================
|
||||
-- fc_ingresar
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Inicia sesión validando credenciales DNI + contraseña. Si las
|
||||
-- credenciales son correctas, crea una nueva sesión con vencimiento a
|
||||
-- 10 días y devuelve el token y el rol del usuario.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- dni_input TEXT DNI del usuario.
|
||||
-- password_plain_input TEXT contraseña en texto plano.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Pública: no requiere token previo (es la entrada al sistema).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Usuario o contraseña inválidos.' (mismo mensaje para usuario
|
||||
-- inexistente y para contraseña mal — evita oracles de existencia).
|
||||
--
|
||||
-- RETORNA
|
||||
-- TABLE (token UUID, rol TEXT) con la sesión creada y el rol del usuario.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - INSERT en internal.sesiones (token + expires_at = now() + 10 días).
|
||||
-- - El trigger trg_clean_old_sessions borra otras sesiones del mismo
|
||||
-- usuario al insertar (cada usuario tiene una sola sesión activa).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_ingresar(dni_input TEXT, password_plain_input TEXT)
|
||||
RETURNS TABLE (token UUID, rol TEXT)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = extensions, public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_user_rol TEXT;
|
||||
v_session_token UUID;
|
||||
v_stored_password_hash TEXT;
|
||||
BEGIN
|
||||
SELECT u.id, u.rol, u.password_hash
|
||||
INTO v_user_id, v_user_rol, v_stored_password_hash
|
||||
FROM public.usuarios u
|
||||
WHERE u.dni = dni_input;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Usuario o contraseña inválidos.';
|
||||
END IF;
|
||||
|
||||
IF v_stored_password_hash IS NULL OR v_stored_password_hash <> crypt(password_plain_input, v_stored_password_hash) THEN
|
||||
RAISE EXCEPTION 'Usuario o contraseña inválidos.';
|
||||
END IF;
|
||||
|
||||
v_session_token := gen_random_uuid();
|
||||
INSERT INTO internal.sesiones (usuario_id, token, expires_at)
|
||||
VALUES(v_user_id, v_session_token, NOW() + INTERVAL '10 days');
|
||||
|
||||
RETURN QUERY SELECT v_session_token, v_user_rol;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- ============================================================================
|
||||
-- fc_iniciar_sesion_por_token
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Reanuda una sesión existente a partir del token guardado localmente.
|
||||
-- Si el token es válido (existe y no expiró), extiende su vencimiento
|
||||
-- otros 10 días desde ahora y devuelve el rol del usuario.
|
||||
-- Es el equivalente de fc_ingresar pero usando el token persistido en
|
||||
-- lugar de credenciales — reemplaza a fc_validar_token en el flujo de
|
||||
-- startup de la app, renovando el TTL en lugar de solo validarlo.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID token de sesión guardado localmente.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Pública: recibe el token como única prueba de identidad.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Sesión inválida o expirada.' si el token no existe o ya venció.
|
||||
--
|
||||
-- RETORNA
|
||||
-- TEXT con el rol del usuario (misma forma que fc_validar_token).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - UPDATE en internal.sesiones: extends expires_at = now() + 10 días.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_iniciar_sesion_por_token(p_token UUID)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_rol TEXT;
|
||||
BEGIN
|
||||
-- Validar token, extender el TTL y obtener el rol en una sola sentencia.
|
||||
-- El UPDATE solo afecta filas cuyo token existe y no venció; el JOIN con
|
||||
-- usuarios provee el rol vía RETURNING. Si no matchea nada (token
|
||||
-- inexistente, expirado o sesión sin usuario), NOT FOUND dispara el error.
|
||||
UPDATE internal.sesiones s
|
||||
SET expires_at = NOW() + INTERVAL '10 days'
|
||||
FROM public.usuarios u
|
||||
WHERE s.token = p_token
|
||||
AND s.expires_at > NOW()
|
||||
AND u.id = s.usuario_id
|
||||
RETURNING u.rol INTO v_rol;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o expirada.';
|
||||
END IF;
|
||||
|
||||
RETURN v_rol;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- ============================================================================
|
||||
-- internal.clean_expired_sessions
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Elimina todas las sesiones cuya fecha de expiración ya pasó. Pensada
|
||||
-- para ejecutarse periódicamente por cron.
|
||||
--
|
||||
-- El job de cron que la dispara (ten_day_session_cleanup_job, días
|
||||
-- 1, 11 y 21 de cada mes a las 00:00) se versiona en
|
||||
-- cron/auth/ten_day_session_cleanup_job.sql.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- Ninguno.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No aplica (función interna invocada por cron).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno.
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- DELETE en internal.sesiones de las filas con expires_at < NOW().
|
||||
-- ============================================================================
|
||||
|
||||
create OR REPLACE function internal.clean_expired_sessions() returns void
|
||||
security definer
|
||||
SET search_path = internal, pg_catalog
|
||||
language plpgsql
|
||||
as
|
||||
$$
|
||||
BEGIN
|
||||
-- Elimina todas las sesiones cuya fecha de expiración es anterior a la actual.
|
||||
DELETE FROM internal.sesiones
|
||||
WHERE expires_at < NOW();
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- ============================================================================
|
||||
-- internal.clean_old_sessions_on_new_login
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Trigger function: al insertar una nueva sesión, elimina cualquier otra
|
||||
-- sesión previa del mismo usuario. Garantiza que cada usuario tiene a lo
|
||||
-- sumo una sesión activa.
|
||||
--
|
||||
-- El trigger que la invoca (trg_clean_old_sessions, BEFORE INSERT on
|
||||
-- internal.sesiones) se versiona en
|
||||
-- triggers/auth/trg_clean_old_sessions.sql.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- Implícitos del trigger (NEW.usuario_id, NEW.token).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No aplica (es trigger).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno.
|
||||
--
|
||||
-- RETORNA
|
||||
-- trigger (la fila NEW para continuar el INSERT).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- DELETE en internal.sesiones de cualquier sesión del mismo usuario con
|
||||
-- token distinto al recién insertado.
|
||||
-- ============================================================================
|
||||
|
||||
create OR REPLACE function internal.clean_old_sessions_on_new_login() returns trigger
|
||||
security definer
|
||||
set search_path = internal, pg_catalog
|
||||
language plpgsql
|
||||
as
|
||||
$$
|
||||
BEGIN
|
||||
-- Esto asegura que solo la sesión actual (la que se está creando) permanezca.
|
||||
DELETE FROM internal.sesiones
|
||||
WHERE usuario_id = NEW.usuario_id
|
||||
AND token IS DISTINCT FROM NEW.token;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- ============================================================================
|
||||
-- internal.create_password_hash
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve el hash bcrypt de una contraseña en texto plano. Usa
|
||||
-- crypt() + gen_salt('bf') del módulo pgcrypto, que vive en el schema
|
||||
-- extensions.
|
||||
--
|
||||
-- Helper interno usado por internal.fc_insertar_usuario (vía
|
||||
-- public.fc_insertar_usuario) y por funciones futuras de cambio de
|
||||
-- contraseña.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_password_plain TEXT contraseña en claro.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Función interna, sin validación propia. Pensada para ser llamada por
|
||||
-- otras funciones server-side, no por el cliente directamente.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- TEXT con el hash bcrypt.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.create_password_hash(p_password_plain TEXT)
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = extensions, public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN crypt(p_password_plain, gen_salt('bf'::text));
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- ============================================================================
|
||||
-- internal.validar_token
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Dado un token de sesión, devuelve el rol del usuario asociado si la
|
||||
-- sesión está vigente. Si el token no existe o ya expiró, devuelve NULL.
|
||||
-- Helper interno usado por internal.validate_permission y por funciones
|
||||
-- públicas que necesitan saber el rol del actor.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID token de sesión.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Función interna, sin validación propia.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno.
|
||||
--
|
||||
-- RETORNA
|
||||
-- TEXT con el rol del usuario, o NULL si el token es inválido o expiró.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.validar_token(p_token uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_rol TEXT;
|
||||
BEGIN
|
||||
SELECT u.rol
|
||||
FROM public.usuarios u
|
||||
JOIN internal.sesiones s ON s.usuario_id = u.id
|
||||
AND s.token = p_token
|
||||
AND s.expires_at > NOW()
|
||||
INTO v_rol;
|
||||
-- Retorna el rol si el token es válido, de lo contrario, retorna NULL
|
||||
RETURN v_rol;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- ============================================================================
|
||||
-- internal.validate_permission
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Valida que el portador del token tenga permiso para ejecutar una acción
|
||||
-- dada. Rebota con excepción si el token es inválido, si la acción no
|
||||
-- está registrada, o si el rol del usuario no está autorizado.
|
||||
--
|
||||
-- Es la pieza central del control de acceso: toda función pública (fc_*)
|
||||
-- la invoca al inicio.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID token de sesión del actor.
|
||||
-- p_accion TEXT nombre de la acción a validar (clave en internal.permisos).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Función interna; ella misma es el validador de autorización.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Token de sesión inválido o expirado'
|
||||
-- - 'Error interno: acción no reconocida: X'
|
||||
-- - 'No tienes internal.permisos para realizar esta acción'
|
||||
-- (NOTA: el texto del error contiene "internal.permisos" por un error
|
||||
-- histórico de redacción; se refiere a permisos en general.)
|
||||
--
|
||||
-- RETORNA
|
||||
-- void si el actor tiene permiso. Si no, rebota antes de retornar.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.validate_permission(p_token UUID, p_accion TEXT)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_rol TEXT;
|
||||
v_roles_permitidos TEXT[];
|
||||
BEGIN
|
||||
SELECT internal.validar_token(p_token) INTO v_rol;
|
||||
|
||||
IF v_rol IS NULL THEN
|
||||
RAISE EXCEPTION 'Token de sesión inválido o expirado';
|
||||
END IF;
|
||||
|
||||
SELECT roles_permitidos INTO v_roles_permitidos
|
||||
FROM internal.permisos
|
||||
WHERE accion = p_accion;
|
||||
|
||||
IF v_roles_permitidos IS NULL THEN
|
||||
RAISE EXCEPTION 'Error interno: acción no reconocida: %', p_accion;
|
||||
END IF;
|
||||
|
||||
IF v_rol <> ALL(v_roles_permitidos) THEN
|
||||
RAISE EXCEPTION 'No tienes internal.permisos para realizar esta acción';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_config
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve la configuración runtime del sistema, como objeto JSON con
|
||||
-- todas las parejas clave→valor de internal.app_config.
|
||||
--
|
||||
-- Ejemplo de uso: el frontend la consulta al iniciar sesión para
|
||||
-- conocer parámetros como la ventana de edición de pagos, defaults
|
||||
-- visuales, etc.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID (opcional, default NULL) sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_configuraciones_generales'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB objeto con todas las pares clave→valor de internal.app_config.
|
||||
-- Si la tabla está vacía, retorna '{}' (no NULL).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_config(p_token uuid DEFAULT NULL)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_resultado jsonb;
|
||||
BEGIN
|
||||
|
||||
PERFORM internal.validate_permission(p_token, 'ver_configuraciones_generales');
|
||||
|
||||
-- Agrupamos todas las filas en un único objeto JSON
|
||||
SELECT jsonb_object_agg(clave, valor)
|
||||
INTO v_resultado
|
||||
FROM internal.app_config;
|
||||
|
||||
-- COALESCE garantiza que si la tabla está vacía, devolvemos un JSON vacío '{}'
|
||||
-- en lugar de NULL, evitando que el frontend lance excepciones (NullReference).
|
||||
RETURN COALESCE(v_resultado, '{}'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- ============================================================================
|
||||
-- internal.get_config_int
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Lee un valor entero de internal.app_config por clave. Si la clave no
|
||||
-- existe, devuelve el default provisto. Helper para que las funciones
|
||||
-- públicas tomen parámetros operacionales (ventanas, límites, etc.)
|
||||
-- desde la tabla de configuración runtime sin tener que hardcodear.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_clave TEXT nombre de la clave en internal.app_config.
|
||||
-- p_default INTEGER valor a devolver si la clave no existe.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Función interna sin validación propia.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio. Si el valor en la tabla no es casteable a int, falla
|
||||
-- por error de cast de PostgreSQL.
|
||||
--
|
||||
-- RETORNA
|
||||
-- INTEGER con el valor de la clave o p_default si no existe.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.get_config_int(p_clave text, p_default integer)
|
||||
RETURNS integer
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
(SELECT valor::int FROM internal.app_config WHERE clave = p_clave),
|
||||
p_default
|
||||
)
|
||||
$$;
|
||||
@@ -0,0 +1,113 @@
|
||||
-- ============================================================================
|
||||
-- internal.limpiar_eventos_antiguos
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Higiene operativa: elimina filas de public.eventos cuya fecha
|
||||
-- esté fuera de la ventana de retención correspondiente a su tipo.
|
||||
-- No archiva: los eventos no son información que valga conservar
|
||||
-- más allá del operativo (ver Documentation/PoliticaRetencion.md
|
||||
-- §4.2 — el archivo registra cobros, no su historia de correcciones).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la higiene de eventos descrita en
|
||||
-- Documentation/PoliticaRetencion.md §7. Dos ventanas distintas
|
||||
-- porque los eventos de pagos viven más tiempo en la base que el
|
||||
-- resto: una corrección o anulación de pago suele auditarse más
|
||||
-- tarde que el cambio que la motivó. Tres meses es el horizonte
|
||||
-- suficiente para ese seguimiento; el resto (acciones admin sobre
|
||||
-- actividades, horarios, etc.) no requiere esa cola.
|
||||
--
|
||||
-- REGLA DE DISCRIMINACIÓN
|
||||
-- Evento de pagos sii `tipo LIKE 'pago_%'`. Lo determina el
|
||||
-- prefijo, no una tabla aparte ni un campo nuevo. Quien inserta
|
||||
-- en public.eventos elige el `tipo` y respeta el prefijo cuando
|
||||
-- corresponda; hoy lo cumplen `pago_editado` y `pago_anulado`
|
||||
-- (insertados por fc_editar_pago y fc_anular_pago vía
|
||||
-- internal.log_evento). Cualquier evento futuro relacionado a
|
||||
-- pagos debe seguir esta convención para heredar la ventana más
|
||||
-- larga.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- Ninguno. Las dos ventanas en meses se leen de internal.app_config:
|
||||
-- - `retencion.eventos_pagos_meses` (default 3).
|
||||
-- - `retencion.eventos_otros_meses` (default 2).
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con corte y eliminados desglosados por grupo (pagos / otros)
|
||||
-- más un total agregado, útil para inspección de operación.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - DELETE de eventos `pago_*` con `fecha_evento < now() - ventana_pagos`.
|
||||
-- - DELETE de eventos no-`pago_*` con `fecha_evento < now() - ventana_otros`.
|
||||
-- - **No** registra evento propio (vía internal.log_evento o de otro
|
||||
-- modo). Hacerlo crearía un loop trivial: cada corrida generaría
|
||||
-- un evento que la próxima corrida vería viejo y volvería a
|
||||
-- producir otro. La purga es deliberadamente silenciosa; la
|
||||
-- visibilidad se garantiza por el contador devuelto en el JSONB
|
||||
-- y, eventualmente, por la vista admin del pipeline (US-R21).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.limpiar_eventos_antiguos()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_meses_pagos INT;
|
||||
v_meses_otros INT;
|
||||
v_corte_pagos TIMESTAMPTZ;
|
||||
v_corte_otros TIMESTAMPTZ;
|
||||
v_eliminados_pagos INT;
|
||||
v_eliminados_otros INT;
|
||||
BEGIN
|
||||
v_meses_pagos := internal.get_config_int('retencion.eventos_pagos_meses', 3);
|
||||
v_meses_otros := internal.get_config_int('retencion.eventos_otros_meses', 2);
|
||||
|
||||
v_corte_pagos := now() - (v_meses_pagos * INTERVAL '1 month');
|
||||
v_corte_otros := now() - (v_meses_otros * INTERVAL '1 month');
|
||||
|
||||
DELETE FROM eventos
|
||||
WHERE tipo LIKE 'pago_%'
|
||||
AND fecha_evento < v_corte_pagos;
|
||||
GET DIAGNOSTICS v_eliminados_pagos = ROW_COUNT;
|
||||
|
||||
DELETE FROM eventos
|
||||
WHERE tipo NOT LIKE 'pago_%'
|
||||
AND fecha_evento < v_corte_otros;
|
||||
GET DIAGNOSTICS v_eliminados_otros = ROW_COUNT;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'corte_pagos', v_corte_pagos,
|
||||
'corte_otros', v_corte_otros,
|
||||
'meses_pagos', v_meses_pagos,
|
||||
'meses_otros', v_meses_otros,
|
||||
'eliminados_pagos', v_eliminados_pagos,
|
||||
'eliminados_otros', v_eliminados_otros,
|
||||
'eliminados_total', v_eliminados_pagos + v_eliminados_otros,
|
||||
'mensaje', format(
|
||||
'Eliminados %s eventos pago_* anteriores a %s y %s eventos no-pago anteriores a %s.',
|
||||
v_eliminados_pagos, v_corte_pagos,
|
||||
v_eliminados_otros, v_corte_otros
|
||||
)
|
||||
);
|
||||
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_eventos_antiguos()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- ============================================================================
|
||||
-- internal.log_evento
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Inserta una fila en public.eventos. Single entry point para que
|
||||
-- cualquier función pública registre un evento auditable (ediciones,
|
||||
-- anulaciones, acciones administrativas) sin tocar la tabla
|
||||
-- directamente.
|
||||
--
|
||||
-- Hoy lo usan fc_editar_pago y fc_anular_pago para registrar
|
||||
-- pago_editado y pago_anulado.
|
||||
--
|
||||
-- CAP DE SEGURIDAD (US-R12)
|
||||
-- Antes de insertar, chequea el conteo vigente de public.eventos
|
||||
-- contra `retencion.eventos_cap` (default 50000). Si registrar el
|
||||
-- evento haría que la tabla alcance o supere el cap, RECHAZA con
|
||||
-- excepción explícita en vez de insertar. No es FIFO: el rechazo
|
||||
-- hace fallar la función que dispara el evento y corta la cascada,
|
||||
-- de modo que un bug que dispare eventos en loop se vuelve ruidoso
|
||||
-- en vez de comerse la cuota de la base en silencio. El cap es un
|
||||
-- freno de emergencia, no la vía normal de control de tamaño (eso
|
||||
-- lo hace la purga mensual, internal.limpiar_eventos_antiguos).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_tipo TEXT discriminador del evento (ej. 'pago_editado').
|
||||
-- p_tabla TEXT nombre de la tabla referenciada (ej. 'pagos').
|
||||
-- p_referencia_id UUID ID de la fila referenciada en esa tabla.
|
||||
-- p_cliente_id UUID cliente afectado (para eventos sobre clientes).
|
||||
-- p_actor_id UUID usuario que ejecutó la acción.
|
||||
-- p_valor_anterior JSONB snapshot del estado anterior (opcional).
|
||||
-- p_valor_actual JSONB snapshot del estado nuevo (opcional).
|
||||
-- p_descripcion TEXT (opcional, default NULL) descripción libre /
|
||||
-- motivo.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Función interna sin validación propia.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'No se puede registrar el evento ...: public.eventos alcanzó su
|
||||
-- cap de seguridad ...' (US-R12).
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- INSERT en public.eventos.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.log_evento(
|
||||
p_tipo text,
|
||||
p_tabla text,
|
||||
p_referencia_id uuid,
|
||||
p_cliente_id uuid,
|
||||
p_actor_id uuid,
|
||||
p_valor_anterior jsonb,
|
||||
p_valor_actual jsonb,
|
||||
p_descripcion text DEFAULT NULL::text
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_cap INT;
|
||||
v_count INT;
|
||||
BEGIN
|
||||
-- Cap de seguridad (US-R12): freno anti-loop. Rechazamos el insert
|
||||
-- cuyo conteo resultante igualaría o superaría el cap, de modo que el
|
||||
-- cap es el valor que el conteo no debe alcanzar. Bug ruidoso > bug
|
||||
-- silencioso: que falle la cascada y nos enteremos.
|
||||
v_cap := internal.get_config_int('retencion.eventos_cap', 50000);
|
||||
SELECT count(*) INTO v_count FROM public.eventos;
|
||||
IF v_count + 1 >= v_cap THEN
|
||||
RAISE EXCEPTION
|
||||
'No se puede registrar el evento "%": public.eventos alcanzó su cap de seguridad (% filas). Suele indicar un bug que dispara eventos en loop; revisar la causa antes de subir retencion.eventos_cap en internal.app_config.',
|
||||
p_tipo, v_cap;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.eventos (
|
||||
tipo, tabla_referencia, referencia_id,
|
||||
cliente_id, actor_id,
|
||||
valor_anterior, valor_actual, descripcion
|
||||
) VALUES (
|
||||
p_tipo, p_tabla, p_referencia_id,
|
||||
p_cliente_id, p_actor_id,
|
||||
p_valor_anterior, p_valor_actual, p_descripcion
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -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$;
|
||||
@@ -0,0 +1,179 @@
|
||||
-- ============================================================================
|
||||
-- fc_anular_pago
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Anula un pago previamente registrado (soft-delete). El pago sigue
|
||||
-- existiendo y consultable, marcado con quién y cuándo lo anuló, y un
|
||||
-- motivo opcional. Genera un evento auditable.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación "anular" descrita en
|
||||
-- Documentation/DominioPagos.md §5.4 y §8. La anulación es terminal:
|
||||
-- un pago anulado no se desanula ni se vuelve a corregir; si la
|
||||
-- situación cambia se registra un pago nuevo (§5.4). Por ser
|
||||
-- transparente (visible + auditable), el rol con autoridad plena puede
|
||||
-- anular incluso fuera de la ventana de corrección (§9).
|
||||
--
|
||||
-- ARCHIVADO (US-R11)
|
||||
-- Un pago cuyo mes ya fue archivado al XLSX histórico es inmutable
|
||||
-- (DominioPagos §14): se rechaza la anulación antes de cualquier otra
|
||||
-- regla, incluso para la autoridad plena. El criterio lo resuelve
|
||||
-- internal.pagos_mes_archivado (consulta internal.archivo_exports).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_id UUID del pago a anular.
|
||||
-- p_motivo TEXT motivo libre. NULL, cadena vacía o sólo whitespace se
|
||||
-- persisten como NULL para no contaminar el campo.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'agregar_pagos'.
|
||||
-- - Sin 'gestionar_cualquier_pago': debe ser el creador del pago y
|
||||
-- estar dentro de la ventana de corrección.
|
||||
-- - Con 'gestionar_cualquier_pago': sin restricción de ownership ni
|
||||
-- ventana.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Sesión inválida o usuario no encontrado.'
|
||||
-- - 'No se encontró el pago.'
|
||||
-- - 'Pago archivado. No se puede modificar.' (US-R11)
|
||||
-- - 'Este pago ya estaba anulado.'
|
||||
-- - 'No tenés permiso para anular este pago.'
|
||||
-- - 'La ventana de anulación de este pago ya venció.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con el pago ya anulado, mismo shape que un ítem de
|
||||
-- fc_obtener_pagos.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - Setea anulado_at, anulado_por y motivo_anulacion del pago.
|
||||
-- - Registra evento 'pago_anulado' en public.eventos con un snapshot
|
||||
-- mínimo del pago (tipo, monto, mes, método, plan) como valor_anterior
|
||||
-- y el motivo como descripción.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_anular_pago(
|
||||
p_id UUID,
|
||||
p_motivo TEXT,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor_id UUID;
|
||||
v_actor_rol TEXT;
|
||||
v_has_gestion BOOLEAN;
|
||||
v_ventana_minutos INT;
|
||||
v_pago public.pagos%ROWTYPE;
|
||||
v_motivo_limpio TEXT;
|
||||
v_snapshot_min JSONB;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'agregar_pagos');
|
||||
|
||||
SELECT s.usuario_id, u.rol
|
||||
INTO v_actor_id, v_actor_rol
|
||||
FROM internal.sesiones s
|
||||
JOIN usuarios u ON u.id = s.usuario_id
|
||||
WHERE s.token = p_token;
|
||||
|
||||
IF v_actor_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM internal.permisos
|
||||
WHERE accion = 'gestionar_cualquier_pago'
|
||||
AND v_actor_rol = ANY(roles_permitidos)
|
||||
) INTO v_has_gestion;
|
||||
|
||||
SELECT * INTO v_pago FROM public.pagos WHERE id = p_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No se encontró el pago.';
|
||||
END IF;
|
||||
|
||||
-- Pago archivado: inmutable (US-R11, DominioPagos §14). Bloquea incluso a
|
||||
-- la autoridad plena, que normalmente puede anular fuera de ventana: una
|
||||
-- vez en el archivo, anular descoordinaría base y XLSX. Se chequea antes
|
||||
-- que el resto porque es la condición de inmutabilidad más fuerte.
|
||||
IF internal.pagos_mes_archivado(v_pago.anio_mes_pagado) THEN
|
||||
RAISE EXCEPTION 'Pago archivado. No se puede modificar.';
|
||||
END IF;
|
||||
|
||||
IF v_pago.anulado_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Este pago ya estaba anulado.';
|
||||
END IF;
|
||||
|
||||
IF NOT v_has_gestion THEN
|
||||
IF v_pago.created_by IS DISTINCT FROM v_actor_id THEN
|
||||
RAISE EXCEPTION 'No tenés permiso para anular este pago.';
|
||||
END IF;
|
||||
v_ventana_minutos := internal.get_config_int('pagos.ventana_edicion_minutos', 30);
|
||||
IF now() > v_pago.created_at + (v_ventana_minutos || ' minutes')::interval THEN
|
||||
RAISE EXCEPTION 'La ventana de anulación de este pago ya venció.';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_motivo_limpio := NULLIF(trim(p_motivo), '');
|
||||
|
||||
UPDATE public.pagos
|
||||
SET anulado_at = now(),
|
||||
anulado_por = v_actor_id,
|
||||
motivo_anulacion = v_motivo_limpio
|
||||
WHERE id = p_id;
|
||||
|
||||
v_snapshot_min := jsonb_build_object(
|
||||
'tipo', v_pago.tipo,
|
||||
'monto_total', v_pago.monto_total,
|
||||
'anio_mes_pagado', v_pago.anio_mes_pagado,
|
||||
'metodo_id', v_pago.metodo_id,
|
||||
'tipo_cuota_id', v_pago.tipo_cuota_id
|
||||
);
|
||||
|
||||
PERFORM internal.log_evento(
|
||||
p_tipo => 'pago_anulado',
|
||||
p_tabla => 'pagos',
|
||||
p_referencia_id => p_id,
|
||||
p_cliente_id => v_pago.cliente_id,
|
||||
p_actor_id => v_actor_id,
|
||||
p_valor_anterior => v_snapshot_min,
|
||||
p_valor_actual => NULL,
|
||||
p_descripcion => v_motivo_limpio
|
||||
);
|
||||
|
||||
RETURN (
|
||||
SELECT jsonb_build_object(
|
||||
'id', p.id,
|
||||
'tipo', p.tipo,
|
||||
'anio_mes_pagado', p.anio_mes_pagado,
|
||||
'fecha_pago', p.fecha_pago,
|
||||
'monto_total', p.monto_total,
|
||||
'detalle', p.detalle,
|
||||
'metodo', m.descripcion,
|
||||
'created_at', p.created_at,
|
||||
'created_by', p.created_by,
|
||||
'created_by_nombre', cu.nombre,
|
||||
'updated_at', p.updated_at,
|
||||
'updated_by_nombre', uu.nombre,
|
||||
'anulado_at', p.anulado_at,
|
||||
'anulado_por_nombre', au.nombre,
|
||||
'motivo_anulacion', p.motivo_anulacion,
|
||||
'cliente', jsonb_build_object(
|
||||
'nombre', cl.nombre,
|
||||
'apellido', cl.apellido,
|
||||
'dni', cl.dni
|
||||
)
|
||||
)
|
||||
FROM public.pagos p
|
||||
JOIN public.usuarios cl ON p.cliente_id = cl.id
|
||||
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
|
||||
LEFT JOIN public.usuarios cu ON p.created_by = cu.id
|
||||
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
|
||||
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
|
||||
WHERE p.id = p_id
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,272 @@
|
||||
-- ============================================================================
|
||||
-- fc_editar_pago
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Corrige un pago previamente registrado, dentro de la ventana de
|
||||
-- corrección. Genera un evento auditable con los cambios reales producidos.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación "corregir" descrita en
|
||||
-- Documentation/DominioPagos.md §5.3 y §8. La ventana aplica a TODOS los
|
||||
-- actores (incluso al rol con autoridad plena): pasada la ventana, la
|
||||
-- única vía para enmendar un pago es anular y registrar uno nuevo (§9).
|
||||
--
|
||||
-- ARCHIVADO (US-R11)
|
||||
-- Un pago cuyo mes ya fue archivado al XLSX histórico es inmutable
|
||||
-- (DominioPagos §14): se rechaza la edición antes de cualquier otra
|
||||
-- regla. El criterio de "archivado" lo resuelve
|
||||
-- internal.pagos_mes_archivado (consulta internal.archivo_exports).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_id UUID del pago a editar.
|
||||
-- p_datos JSONB con los campos a actualizar. Semántica PATCH: sólo se
|
||||
-- modifican las claves presentes en el JSON. Editables:
|
||||
-- metodo_id, anio_mes_pagado, fecha_pago, monto_total,
|
||||
-- detalle, tipo_cuota_id. cliente_id y tipo NO son editables.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'agregar_pagos'.
|
||||
-- - Sin 'gestionar_cualquier_pago': debe ser el creador del pago y estar
|
||||
-- dentro de la ventana de corrección.
|
||||
-- - Con 'gestionar_cualquier_pago': sin restricción de ownership, pero
|
||||
-- igualmente dentro de la ventana.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Sesión inválida o usuario no encontrado.'
|
||||
-- - 'No se encontró el pago.'
|
||||
-- - 'Pago archivado. No se puede modificar.' (US-R11)
|
||||
-- - 'No se puede editar un pago anulado.'
|
||||
-- - 'Sólo se pueden editar pagos de tipo cuota_mensual'
|
||||
-- - 'No tenés permiso para editar este pago.'
|
||||
-- - 'La ventana de edición de este pago ya venció.'
|
||||
-- - 'cliente_id no es editable. Anulá el pago y cargá uno nuevo.'
|
||||
-- - 'tipo no es editable. Anulá el pago y cargá uno nuevo.'
|
||||
-- - 'metodo_id no puede ser nulo en un pago de cuota_mensual.'
|
||||
-- - 'El método de pago ID X no existe o no está activo.'
|
||||
-- - 'anio_mes_pagado no puede ser nulo en un pago de cuota_mensual.'
|
||||
-- - 'El monto total es inválido para un pago de cuota_mensual.'
|
||||
-- - 'El tipo de cuota ID X no existe.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con el pago actualizado, mismo shape que un ítem de
|
||||
-- fc_obtener_pagos.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - UPDATE de la fila en public.pagos, setea updated_at = now() y
|
||||
-- updated_by = actor.
|
||||
-- - Registra evento 'pago_editado' en public.eventos con el diff campo
|
||||
-- a campo, SÓLO si hubo cambios reales. Las ediciones no-op no generan
|
||||
-- evento.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_editar_pago(
|
||||
p_id UUID,
|
||||
p_datos JSONB,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor_id UUID;
|
||||
v_actor_rol TEXT;
|
||||
v_has_gestion BOOLEAN;
|
||||
v_ventana_minutos INT;
|
||||
v_pago public.pagos%ROWTYPE;
|
||||
v_after public.pagos%ROWTYPE;
|
||||
v_diff_anterior JSONB := '{}'::jsonb;
|
||||
v_diff_actual JSONB := '{}'::jsonb;
|
||||
v_new_metodo_id SMALLINT;
|
||||
v_new_anio_mes DATE;
|
||||
v_new_tipo_cuota UUID;
|
||||
v_new_monto NUMERIC;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'agregar_pagos');
|
||||
|
||||
SELECT s.usuario_id, u.rol
|
||||
INTO v_actor_id, v_actor_rol
|
||||
FROM internal.sesiones s
|
||||
JOIN usuarios u ON u.id = s.usuario_id
|
||||
WHERE s.token = p_token;
|
||||
|
||||
IF v_actor_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM internal.permisos
|
||||
WHERE accion = 'gestionar_cualquier_pago'
|
||||
AND v_actor_rol = ANY(roles_permitidos)
|
||||
) INTO v_has_gestion;
|
||||
|
||||
SELECT * INTO v_pago FROM public.pagos WHERE id = p_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No se encontró el pago.';
|
||||
END IF;
|
||||
|
||||
-- Pago archivado: inmutable (US-R11, DominioPagos §14). Se chequea antes
|
||||
-- que cualquier otra regla porque es la condición de inmutabilidad más
|
||||
-- fuerte: si el mes ya viajó al archivo, no hay edición posible para
|
||||
-- nadie. (Normalmente el pipeline ya borró la fila, pero el guard cubre
|
||||
-- la ventana entre marcar 'ok' y el DELETE, y deja la regla explícita.)
|
||||
IF internal.pagos_mes_archivado(v_pago.anio_mes_pagado) THEN
|
||||
RAISE EXCEPTION 'Pago archivado. No se puede modificar.';
|
||||
END IF;
|
||||
|
||||
IF v_pago.anulado_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'No se puede editar un pago anulado.';
|
||||
END IF;
|
||||
|
||||
IF v_pago.tipo <> 'cuota_mensual' THEN
|
||||
RAISE EXCEPTION 'Sólo se pueden editar pagos de tipo cuota_mensual';
|
||||
END IF;
|
||||
|
||||
IF NOT v_has_gestion THEN
|
||||
IF v_pago.created_by IS DISTINCT FROM v_actor_id THEN
|
||||
RAISE EXCEPTION 'No tenés permiso para editar este pago.';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_ventana_minutos := internal.get_config_int('pagos.ventana_edicion_minutos', 30);
|
||||
IF now() > v_pago.created_at + (v_ventana_minutos || ' minutes')::interval THEN
|
||||
RAISE EXCEPTION 'La ventana de edición de este pago ya venció.';
|
||||
END IF;
|
||||
|
||||
IF p_datos ? 'cliente_id' THEN
|
||||
RAISE EXCEPTION 'cliente_id no es editable. Anulá el pago y cargá uno nuevo.';
|
||||
END IF;
|
||||
IF p_datos ? 'tipo' THEN
|
||||
RAISE EXCEPTION 'tipo no es editable. Anulá el pago y cargá uno nuevo.';
|
||||
END IF;
|
||||
|
||||
IF p_datos ? 'metodo_id' THEN
|
||||
v_new_metodo_id := (p_datos->>'metodo_id')::SMALLINT;
|
||||
IF v_new_metodo_id IS NULL THEN
|
||||
RAISE EXCEPTION 'metodo_id no puede ser nulo en un pago de cuota_mensual.';
|
||||
END IF;
|
||||
PERFORM 1 FROM metodos_pago WHERE id = v_new_metodo_id AND activo = true;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El método de pago ID % no existe o no está activo.', v_new_metodo_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF p_datos ? 'anio_mes_pagado' THEN
|
||||
v_new_anio_mes := date_trunc('month', (p_datos->>'anio_mes_pagado')::DATE)::DATE;
|
||||
IF v_new_anio_mes IS NULL THEN
|
||||
RAISE EXCEPTION 'anio_mes_pagado no puede ser nulo en un pago de cuota_mensual.';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF p_datos ? 'monto_total' THEN
|
||||
v_new_monto := (p_datos->>'monto_total')::NUMERIC;
|
||||
IF v_new_monto IS NULL OR v_new_monto < 0 THEN
|
||||
RAISE EXCEPTION 'El monto total es inválido para un pago de cuota_mensual.';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF p_datos ? 'tipo_cuota_id' THEN
|
||||
IF (p_datos->>'tipo_cuota_id') IS NOT NULL THEN
|
||||
v_new_tipo_cuota := (p_datos->>'tipo_cuota_id')::UUID;
|
||||
PERFORM 1 FROM tipos_cuota WHERE id = v_new_tipo_cuota;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El tipo de cuota ID % no existe.', v_new_tipo_cuota;
|
||||
END IF;
|
||||
ELSE
|
||||
v_new_tipo_cuota := NULL;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE public.pagos p
|
||||
SET metodo_id = CASE WHEN p_datos ? 'metodo_id'
|
||||
THEN v_new_metodo_id ELSE p.metodo_id END,
|
||||
anio_mes_pagado = CASE WHEN p_datos ? 'anio_mes_pagado'
|
||||
THEN v_new_anio_mes ELSE p.anio_mes_pagado END,
|
||||
fecha_pago = CASE WHEN p_datos ? 'fecha_pago'
|
||||
THEN (p_datos->>'fecha_pago')::TIMESTAMPTZ
|
||||
ELSE p.fecha_pago END,
|
||||
monto_total = CASE WHEN p_datos ? 'monto_total'
|
||||
THEN v_new_monto ELSE p.monto_total END,
|
||||
detalle = CASE WHEN p_datos ? 'detalle'
|
||||
THEN p_datos->'detalle' ELSE p.detalle END,
|
||||
tipo_cuota_id = CASE WHEN p_datos ? 'tipo_cuota_id'
|
||||
THEN v_new_tipo_cuota ELSE p.tipo_cuota_id END,
|
||||
updated_at = now(),
|
||||
updated_by = v_actor_id
|
||||
WHERE p.id = p_id
|
||||
RETURNING * INTO v_after;
|
||||
|
||||
IF v_pago.metodo_id IS DISTINCT FROM v_after.metodo_id THEN
|
||||
v_diff_anterior := v_diff_anterior || jsonb_build_object('metodo_id', v_pago.metodo_id);
|
||||
v_diff_actual := v_diff_actual || jsonb_build_object('metodo_id', v_after.metodo_id);
|
||||
END IF;
|
||||
IF v_pago.anio_mes_pagado IS DISTINCT FROM v_after.anio_mes_pagado THEN
|
||||
v_diff_anterior := v_diff_anterior || jsonb_build_object('anio_mes_pagado', v_pago.anio_mes_pagado);
|
||||
v_diff_actual := v_diff_actual || jsonb_build_object('anio_mes_pagado', v_after.anio_mes_pagado);
|
||||
END IF;
|
||||
IF v_pago.fecha_pago IS DISTINCT FROM v_after.fecha_pago THEN
|
||||
v_diff_anterior := v_diff_anterior || jsonb_build_object('fecha_pago', v_pago.fecha_pago);
|
||||
v_diff_actual := v_diff_actual || jsonb_build_object('fecha_pago', v_after.fecha_pago);
|
||||
END IF;
|
||||
IF v_pago.monto_total IS DISTINCT FROM v_after.monto_total THEN
|
||||
v_diff_anterior := v_diff_anterior || jsonb_build_object('monto_total', v_pago.monto_total);
|
||||
v_diff_actual := v_diff_actual || jsonb_build_object('monto_total', v_after.monto_total);
|
||||
END IF;
|
||||
IF v_pago.detalle IS DISTINCT FROM v_after.detalle THEN
|
||||
v_diff_anterior := v_diff_anterior || jsonb_build_object('detalle', v_pago.detalle);
|
||||
v_diff_actual := v_diff_actual || jsonb_build_object('detalle', v_after.detalle);
|
||||
END IF;
|
||||
IF v_pago.tipo_cuota_id IS DISTINCT FROM v_after.tipo_cuota_id THEN
|
||||
v_diff_anterior := v_diff_anterior || jsonb_build_object('tipo_cuota_id', v_pago.tipo_cuota_id);
|
||||
v_diff_actual := v_diff_actual || jsonb_build_object('tipo_cuota_id', v_after.tipo_cuota_id);
|
||||
END IF;
|
||||
|
||||
IF v_diff_anterior <> '{}'::jsonb THEN
|
||||
PERFORM internal.log_evento(
|
||||
p_tipo => 'pago_editado',
|
||||
p_tabla => 'pagos',
|
||||
p_referencia_id => p_id,
|
||||
p_cliente_id => v_after.cliente_id,
|
||||
p_actor_id => v_actor_id,
|
||||
p_valor_anterior => v_diff_anterior,
|
||||
p_valor_actual => v_diff_actual,
|
||||
p_descripcion => NULL
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN (
|
||||
SELECT jsonb_build_object(
|
||||
'id', p.id,
|
||||
'tipo', p.tipo,
|
||||
'anio_mes_pagado', p.anio_mes_pagado,
|
||||
'fecha_pago', p.fecha_pago,
|
||||
'monto_total', p.monto_total,
|
||||
'detalle', p.detalle,
|
||||
'metodo', m.descripcion,
|
||||
'created_at', p.created_at,
|
||||
'created_by', p.created_by,
|
||||
'created_by_nombre', cu.nombre,
|
||||
'updated_at', p.updated_at,
|
||||
'updated_by_nombre', uu.nombre,
|
||||
'anulado_at', p.anulado_at,
|
||||
'anulado_por_nombre', au.nombre,
|
||||
'motivo_anulacion', p.motivo_anulacion,
|
||||
'cliente', jsonb_build_object(
|
||||
'nombre', cl.nombre,
|
||||
'apellido', cl.apellido,
|
||||
'dni', cl.dni
|
||||
)
|
||||
)
|
||||
FROM public.pagos p
|
||||
JOIN public.usuarios cl ON p.cliente_id = cl.id
|
||||
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
|
||||
LEFT JOIN public.usuarios cu ON p.created_by = cu.id
|
||||
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
|
||||
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
|
||||
WHERE p.id = p_id
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,210 @@
|
||||
-- ============================================================================
|
||||
-- fc_insertar_pago
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Registra un pago nuevo de tipo cuota_mensual para un cliente identificado
|
||||
-- por DNI. Opcionalmente actualiza el plan por defecto del cliente. Como
|
||||
-- efecto colateral, activa al cliente.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la operación "registrar" descrita en
|
||||
-- Documentation/DominioPagos.md §7. Hoy sólo soporta el concepto
|
||||
-- cuota_mensual (§5.1); el dominio admite otros conceptos en el futuro,
|
||||
-- pero la función los rechaza explícitamente por ahora.
|
||||
--
|
||||
-- RETENCIÓN (US-R10)
|
||||
-- Rechaza pagos cuyo `anio_mes_pagado` cae por debajo de la ventana
|
||||
-- operativa de pagos (`retencion.pagos_meses`, default 12). Sin esto
|
||||
-- se podrían crear pagos que el pipeline mensual archivaría y
|
||||
-- purgaría a los pocos días (Documentation/PoliticaRetencion.md
|
||||
-- §4, §7). La validación es sólo del borde inferior; los meses
|
||||
-- futuros no se restringen acá.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_datos JSONB con el payload del pago. Claves:
|
||||
-- dni TEXT (obligatorio) DNI del cliente.
|
||||
-- metodo_id SMALLINT (obligatorio) método de pago activo.
|
||||
-- anio_mes_pagado DATE (obligatorio) mes que cubre el pago.
|
||||
-- Se trunca al día 1 del mes.
|
||||
-- fecha_pago TIMESTAMPTZ (opcional) fecha declarada del
|
||||
-- cobro. Default: now() en zona
|
||||
-- America/Argentina/Buenos_Aires.
|
||||
-- monto_total NUMERIC (obligatorio) ≥ 0.
|
||||
-- tipo_cuota_id UUID (opcional) plan que paga el cliente.
|
||||
-- set_default_tipo_cuota BOOLEAN (opcional, default false). Si true y
|
||||
-- tipo_cuota_id no nulo, actualiza el
|
||||
-- plan por defecto del cliente.
|
||||
-- detalle JSONB (opcional, default '{}'). Si se pasa
|
||||
-- tipo_cuota_id, se enriquece con
|
||||
-- cuota_nombre, cuota_precio,
|
||||
-- cuota_dias_semana (snapshot del plan
|
||||
-- al momento del cobro).
|
||||
-- tipo TEXT (opcional) debe ser 'cuota_mensual'
|
||||
-- si se pasa; otros valores se rechazan.
|
||||
-- p_token UUID de sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'agregar_pagos'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Tipo de pago no soportado'
|
||||
-- - 'Sesión inválida o usuario no encontrado.'
|
||||
-- - 'No existe un usuario con el DNI X'
|
||||
-- - 'El campo metodo_id es obligatorio'
|
||||
-- - 'El método de pago ID X no existe o no está activo'
|
||||
-- - 'El tipo de cuota ID X no existe'
|
||||
-- - 'El monto total es inválido.'
|
||||
-- - 'No se puede registrar un pago para el mes X: está fuera de la
|
||||
-- ventana de retención de pagos (N meses). ...' (US-R10)
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con {id, cliente_id, anio_mes_pagado, fecha_pago, monto_total,
|
||||
-- status: 'success'}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - INSERT en public.pagos con created_by = actor, tipo = 'cuota_mensual'.
|
||||
-- - SIEMPRE activa al cliente (usuarios.isactive = true), independiente
|
||||
-- de set_default_tipo_cuota.
|
||||
-- - Si set_default_tipo_cuota = true Y tipo_cuota_id no nulo: actualiza
|
||||
-- usuarios.tipo_cuota del cliente.
|
||||
-- - NO genera evento en public.eventos. La trazabilidad de creación está
|
||||
-- cubierta por created_at + created_by en la propia fila.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_insertar_pago(
|
||||
p_datos JSONB,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
VOLATILE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_actor_id UUID;
|
||||
v_cliente_id UUID;
|
||||
v_metodo_id SMALLINT;
|
||||
v_anio_mes DATE;
|
||||
v_fecha_pago TIMESTAMPTZ;
|
||||
v_monto NUMERIC;
|
||||
v_detalle JSONB;
|
||||
v_tipo_cuota_id UUID;
|
||||
v_set_default BOOLEAN;
|
||||
v_info_cuota JSONB;
|
||||
v_pago_insertado RECORD;
|
||||
v_meses_retencion INT;
|
||||
v_mes_limite DATE;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'agregar_pagos');
|
||||
|
||||
-- Actualmente la app sólo crea pagos de tipo 'cuota_mensual'.
|
||||
-- En un futuro, se podrían registrar pagos de otras cosas (nutricionista, pago anualizado, o cosas así)
|
||||
-- y las tablas ya lo soportan, sólo se cambiaría esta función.
|
||||
|
||||
IF p_datos ? 'tipo' AND (p_datos->>'tipo') IS DISTINCT FROM 'cuota_mensual' THEN
|
||||
RAISE EXCEPTION 'Tipo de pago no soportado';
|
||||
END IF;
|
||||
|
||||
-- Autor del registro de pago, lo queremos para auditoría (created_by).
|
||||
SELECT usuario_id INTO v_actor_id FROM internal.sesiones WHERE token = p_token;
|
||||
IF v_actor_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_cliente_id FROM usuarios WHERE dni = (p_datos->>'dni');
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No existe un usuario con el DNI %', p_datos->>'dni';
|
||||
END IF;
|
||||
|
||||
v_metodo_id := (p_datos->>'metodo_id')::SMALLINT;
|
||||
IF v_metodo_id IS NULL THEN
|
||||
RAISE EXCEPTION 'El campo metodo_id es obligatorio';
|
||||
END IF;
|
||||
|
||||
PERFORM 1 FROM metodos_pago WHERE id = v_metodo_id AND activo = true;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El método de pago ID % no existe o no está activo', v_metodo_id;
|
||||
END IF;
|
||||
|
||||
v_tipo_cuota_id := (p_datos->>'tipo_cuota_id')::UUID;
|
||||
v_set_default := COALESCE((p_datos->>'set_default_tipo_cuota')::BOOLEAN, false);
|
||||
v_detalle := COALESCE(p_datos->'detalle', '{}'::jsonb);
|
||||
|
||||
IF v_tipo_cuota_id IS NOT NULL THEN
|
||||
SELECT jsonb_build_object(
|
||||
'cuota_nombre', nombre,
|
||||
'cuota_precio', precio,
|
||||
'cuota_dias_semana', dias_semana
|
||||
) INTO v_info_cuota
|
||||
FROM tipos_cuota
|
||||
WHERE id = v_tipo_cuota_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El tipo de cuota ID % no existe', v_tipo_cuota_id;
|
||||
END IF;
|
||||
|
||||
v_detalle := v_detalle || v_info_cuota;
|
||||
END IF;
|
||||
|
||||
v_anio_mes := date_trunc('month', (p_datos->>'anio_mes_pagado')::DATE)::DATE;
|
||||
v_fecha_pago := COALESCE((p_datos->>'fecha_pago')::TIMESTAMPTZ, NOW());
|
||||
v_monto := (p_datos->>'monto_total')::NUMERIC;
|
||||
|
||||
-- cuota_mensual: monto positivo o cero. Los correctivos (que aún no se
|
||||
-- pueden crear acá) podrán llevar negativo cuando tengan sus funciones.
|
||||
IF v_monto IS NULL OR v_monto < 0 THEN
|
||||
RAISE EXCEPTION 'El monto total es inválido.';
|
||||
END IF;
|
||||
|
||||
-- Ventana de retención (US-R10): no permitir registrar un pago cuyo mes
|
||||
-- objetivo ya quedó fuera de la ventana operativa de pagos. De aceptarse,
|
||||
-- el pipeline mensual lo archivaría y purgaría casi de inmediato (ver
|
||||
-- Documentation/PoliticaRetencion.md §4, §7). Sólo se valida el borde
|
||||
-- inferior: los meses futuros (pago adelantado) no son un problema de
|
||||
-- retención y quedan deliberadamente fuera de esta validación.
|
||||
v_meses_retencion := internal.get_config_int('retencion.pagos_meses', 12);
|
||||
v_mes_limite := date_trunc('month', CURRENT_DATE)::DATE
|
||||
- (v_meses_retencion * INTERVAL '1 month');
|
||||
IF v_anio_mes < v_mes_limite THEN
|
||||
RAISE EXCEPTION
|
||||
'No se puede registrar un pago para el mes %: está fuera de la ventana de retención de pagos (% meses). El mes más antiguo admitido es %.',
|
||||
to_char(v_anio_mes, 'YYYY-MM'), v_meses_retencion, to_char(v_mes_limite, 'YYYY-MM');
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.pagos (
|
||||
id, cliente_id, anio_mes_pagado, fecha_pago, monto_total,
|
||||
metodo_id, detalle, tipo_cuota_id, tipo, created_by
|
||||
) VALUES (
|
||||
gen_random_uuid(), v_cliente_id, v_anio_mes, v_fecha_pago, v_monto,
|
||||
v_metodo_id, v_detalle, v_tipo_cuota_id, 'cuota_mensual', v_actor_id
|
||||
)
|
||||
RETURNING id, anio_mes_pagado, fecha_pago, monto_total, detalle
|
||||
INTO v_pago_insertado;
|
||||
|
||||
|
||||
IF v_set_default AND v_tipo_cuota_id IS NOT NULL THEN
|
||||
UPDATE usuarios
|
||||
SET tipo_cuota = v_tipo_cuota_id,
|
||||
isactive = true
|
||||
WHERE id = v_cliente_id;
|
||||
ELSE
|
||||
UPDATE usuarios
|
||||
SET isactive = true
|
||||
WHERE id = v_cliente_id;
|
||||
END IF;
|
||||
|
||||
-- No loggeamos creación en eventos (decisión de scope): created_at/created_by
|
||||
-- ya cubren la trazabilidad de la inserción.
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_pago_insertado.id,
|
||||
'cliente_id', v_cliente_id,
|
||||
'anio_mes_pagado', v_pago_insertado.anio_mes_pagado,
|
||||
'fecha_pago', v_pago_insertado.fecha_pago,
|
||||
'monto_total', v_pago_insertado.monto_total,
|
||||
'status', 'success'
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_mis_pagos
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve los pagos del cliente autenticado, paginados.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Versión "self-service" de la consulta descrita en
|
||||
-- Documentation/DominioPagos.md §7. Incluye los pagos anulados del propio
|
||||
-- cliente: él pudo haberlos visto cuando estaban vigentes, así que también
|
||||
-- debe poder consultar el rastro de la anulación
|
||||
-- (Documentation/DominioPagos.md §12).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del cliente.
|
||||
-- p_pagina INT (default 1) página, base 1.
|
||||
-- p_cantidad INT (default 20) ítems por página.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_mis_pagos'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Sesión inválida o usuario no encontrado.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: id, tipo, anio_mes_pagado,
|
||||
-- fecha_pago, monto_total, detalle, metodo (descripción), metadata de
|
||||
-- auditoría reducida (created_at, created_by, updated_at,
|
||||
-- updated_by_nombre, anulado_at, anulado_por_nombre) y motivo_anulacion.
|
||||
-- NO incluye el objeto cliente porque es siempre el del token.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_mis_pagos(
|
||||
p_token UUID,
|
||||
p_pagina INT DEFAULT 1,
|
||||
p_cantidad INT DEFAULT 20
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_offset INT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_mis_pagos');
|
||||
|
||||
SELECT usuario_id INTO v_user_id FROM internal.sesiones WHERE token = p_token;
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Sesión inválida o usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
v_offset := (p_pagina - 1) * p_cantidad;
|
||||
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(pago_json), '[]'::jsonb)
|
||||
FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', p.id,
|
||||
'tipo', p.tipo,
|
||||
'anio_mes_pagado', p.anio_mes_pagado,
|
||||
'fecha_pago', p.fecha_pago,
|
||||
'monto_total', p.monto_total,
|
||||
'detalle', p.detalle,
|
||||
'metodo', m.descripcion,
|
||||
'created_at', p.created_at,
|
||||
'created_by', p.created_by,
|
||||
'updated_at', p.updated_at,
|
||||
'updated_by_nombre', uu.nombre,
|
||||
'anulado_at', p.anulado_at,
|
||||
'anulado_por_nombre', au.nombre,
|
||||
'motivo_anulacion', p.motivo_anulacion
|
||||
) AS pago_json
|
||||
FROM public.pagos p
|
||||
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
|
||||
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
|
||||
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
|
||||
WHERE p.cliente_id = v_user_id
|
||||
ORDER BY p.fecha_pago DESC
|
||||
LIMIT p_cantidad
|
||||
OFFSET v_offset
|
||||
) t
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,106 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_pagos
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve un lote paginado de pagos, filtrable por cliente.
|
||||
-- Opcionalmente incluye los pagos anulados.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Sirve a la operación "consultar pagos" descrita en
|
||||
-- Documentation/DominioPagos.md §7. Cada ítem trae la metadata necesaria
|
||||
-- para que la UI pueda distinguir pagos vigentes de anulados, identificar
|
||||
-- autoría de registro / corrección / anulación y mostrar motivo (§12).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_dni TEXT (opcional) DNI del cliente para filtrar.
|
||||
-- NULL = todos los clientes.
|
||||
-- p_pagina INT (default 1) página, base 1.
|
||||
-- p_cantidad INT (default 50) ítems por página.
|
||||
-- p_incluir_anulados BOOLEAN (default FALSE) si TRUE, incluye pagos
|
||||
-- anulados en el resultado.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_pagos'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'No existe un usuario con el DNI X' cuando p_dni se pasa y no
|
||||
-- coincide con ningún cliente.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: id, tipo, anio_mes_pagado,
|
||||
-- fecha_pago, monto_total, detalle, metodo (descripción), metadata de
|
||||
-- auditoría (created/updated/anulado: at + by_nombre), motivo_anulacion
|
||||
-- y un objeto cliente {nombre, apellido, dni}.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_pagos(
|
||||
p_token UUID,
|
||||
p_dni TEXT DEFAULT NULL,
|
||||
p_pagina INT DEFAULT 1,
|
||||
p_cantidad INT DEFAULT 50,
|
||||
p_incluir_anulados BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_cliente_id UUID := NULL;
|
||||
v_offset INT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_pagos');
|
||||
v_offset := (p_pagina - 1) * p_cantidad;
|
||||
|
||||
IF p_dni IS NOT NULL THEN
|
||||
SELECT id INTO v_cliente_id FROM usuarios WHERE dni = p_dni;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(pago_json), '[]'::jsonb)
|
||||
FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', p.id,
|
||||
'tipo', p.tipo,
|
||||
'anio_mes_pagado', p.anio_mes_pagado,
|
||||
'fecha_pago', p.fecha_pago,
|
||||
'monto_total', p.monto_total,
|
||||
'detalle', p.detalle,
|
||||
'metodo', m.descripcion,
|
||||
'created_at', p.created_at,
|
||||
'created_by', p.created_by,
|
||||
'created_by_nombre', cu.nombre,
|
||||
'updated_at', p.updated_at,
|
||||
'updated_by_nombre', uu.nombre,
|
||||
'anulado_at', p.anulado_at,
|
||||
'anulado_por_nombre', au.nombre,
|
||||
'motivo_anulacion', p.motivo_anulacion,
|
||||
'cliente', jsonb_build_object(
|
||||
'nombre', cl.nombre,
|
||||
'apellido', cl.apellido,
|
||||
'dni', cl.dni
|
||||
)
|
||||
) AS pago_json
|
||||
FROM public.pagos p
|
||||
JOIN public.usuarios cl ON p.cliente_id = cl.id
|
||||
LEFT JOIN public.metodos_pago m ON p.metodo_id = m.id
|
||||
LEFT JOIN public.usuarios cu ON p.created_by = cu.id
|
||||
LEFT JOIN public.usuarios uu ON p.updated_by = uu.id
|
||||
LEFT JOIN public.usuarios au ON p.anulado_por = au.id
|
||||
WHERE (v_cliente_id IS NULL OR p.cliente_id = v_cliente_id)
|
||||
AND (p_incluir_anulados OR p.anulado_at IS NULL)
|
||||
ORDER BY p.fecha_pago DESC
|
||||
LIMIT p_cantidad
|
||||
OFFSET v_offset
|
||||
) t
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,96 @@
|
||||
-- ============================================================================
|
||||
-- fc_modificar_metodos_pago
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Modifica los campos editables de un método de pago existente. Semántica
|
||||
-- PATCH: sólo se actualizan las claves presentes en p_datos.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Mantenimiento de los métodos de pago como configuración del módulo.
|
||||
-- No crea ni elimina; el alta lógica/baja lógica se maneja con el flag
|
||||
-- `activo` del propio método.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_datos JSONB con:
|
||||
-- id INTEGER (obligatorio) ID del método a modificar.
|
||||
-- descripcion TEXT (opcional) nuevo nombre / etiqueta.
|
||||
-- activo BOOLEAN (opcional) baja lógica (false) o reactivación
|
||||
-- (true).
|
||||
-- icono TEXT (opcional) identificador del ícono asociado.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_metodos_pago'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Debe indicar el ID del tipo de cuota a modificar.'
|
||||
-- (NOTA: el mensaje refiere a "tipo de cuota" por un error histórico;
|
||||
-- en realidad valida que se pase el ID del método de pago.)
|
||||
-- - 'El método de pago con ID X no existe.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB con la fila completa del método de pago tras la modificación.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE sobre metodos_pago. No genera evento.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_modificar_metodos_pago(
|
||||
p_datos JSONB,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE -- Checklist #2: Modifica datos
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_resultado JSONB;
|
||||
v_id INTEGER;
|
||||
BEGIN
|
||||
-- -----------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos
|
||||
-- -----------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'modificar_metodos_pago');
|
||||
|
||||
|
||||
-- 3. Validar que venga el ID para saber qué modificar
|
||||
IF NOT p_datos ? 'id' OR p_datos->>'id' IS NULL THEN
|
||||
RAISE EXCEPTION 'Debe indicar el ID del tipo de cuota a modificar.';
|
||||
END IF;
|
||||
|
||||
v_id := (p_datos->>'id')::INTEGER;
|
||||
-- -----------------------------------------------------------
|
||||
-- 1. UPDATE Dinámico y Seguro
|
||||
-- -----------------------------------------------------------
|
||||
UPDATE metodos_pago
|
||||
SET
|
||||
descripcion = CASE
|
||||
WHEN p_datos ? 'descripcion' THEN p_datos->>'descripcion'
|
||||
ELSE descripcion
|
||||
END,
|
||||
|
||||
activo = CASE
|
||||
WHEN p_datos ? 'activo' THEN (p_datos->>'activo')::BOOL
|
||||
ELSE activo
|
||||
END,
|
||||
|
||||
-- BUG CORREGIDO: Ahora si no viene el icono, no lo tocamos.
|
||||
icono = CASE
|
||||
WHEN p_datos ? 'icono' THEN p_datos->>'icono'
|
||||
ELSE icono
|
||||
END
|
||||
WHERE id = v_id
|
||||
RETURNING to_jsonb(metodos_pago.*) INTO v_resultado; -- Retorno atómico
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 2. Verificar si se actualizó algo
|
||||
-- -----------------------------------------------------------
|
||||
IF v_resultado IS NULL THEN
|
||||
RAISE EXCEPTION 'El método de pago con ID % no existe.', v_id;
|
||||
END IF;
|
||||
|
||||
RETURN v_resultado;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_metodos_pago
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve todos los métodos de pago configurados (activos e inactivos).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Los métodos de pago (efectivo, transferencia, billetera digital, etc.)
|
||||
-- son entidades de configuración del módulo de pagos. Se consultan al
|
||||
-- registrar o corregir un pago para que el responsable elija cómo se cobró
|
||||
-- (Documentation/DominioPagos.md §5.1).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_metodos_pago'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array de filas completas de metodos_pago, ordenadas por id.
|
||||
-- Cada ítem: id, descripcion, activo, icono. Array vacío si no hay
|
||||
-- registros.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_obtener_metodos_pago(p_token UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $function$
|
||||
DECLARE
|
||||
-- No necesitamos variables extra si usamos internal.validate_permission
|
||||
BEGIN
|
||||
-- 1. Validar permisos (Checklist #3)
|
||||
-- Asumimos acción 'ver_configuracion' o 'ver_metodos_pago'
|
||||
-- Esto te permite mañana darle permiso al 'cliente' sin tocar este código.
|
||||
PERFORM internal.validate_permission(p_token, 'ver_metodos_pago');
|
||||
|
||||
-- 2. Retorno directo y sin vueltas
|
||||
RETURN (
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(to_jsonb(mp) ORDER BY mp.id),
|
||||
'[]'::jsonb
|
||||
)
|
||||
FROM public.metodos_pago mp
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,91 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_estado_archivo
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve el estado del pipeline mensual de archivado leyendo
|
||||
-- internal.archivo_exports, en un formato apto para la vista admin
|
||||
-- (US-R21). Es la fachada de lectura de la bitácora de control: el
|
||||
-- operador no escribe ni dispara el pipeline, sólo lo observa.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa la "visibilidad" de Documentation/PoliticaRetencion.md
|
||||
-- §5.4. Reporta:
|
||||
-- - ultimo_ok: el archivado exitoso más reciente por cada tipo
|
||||
-- ('pagos', 'agregado_reservas'), con su mes objetivo y cuándo
|
||||
-- se ejecutó.
|
||||
-- - fallos_recientes: hasta 20 intentos `fallo`, más nuevo primero,
|
||||
-- con su detalle_error para diagnóstico.
|
||||
--
|
||||
-- NO calcula "meses pendientes" (qué mes debería haberse archivado y no
|
||||
-- se hizo): esa detección depende de la lógica de cadencia del pipeline
|
||||
-- (qué mes cumple 12, qué mes recién cerró) y vive en US-R17/US-R21, no
|
||||
-- acá. Esta función reporta lo que la tabla contiene, no lo que falta.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID de sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_configuraciones_generales'. Se reusa ese permiso
|
||||
-- (admin/superadmin) en vez de crear uno nuevo: el estado del pipeline es
|
||||
-- información operativa del mismo tenor que la config general, y evitar
|
||||
-- una acción nueva en internal.permisos mantiene esta entrega
|
||||
-- autocontenida. Si más adelante el monitoreo (US-R21/US-R22) justifica
|
||||
-- un permiso propio (ej. 'ver_estado_sistema'), migrar acá es trivial.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - Los que propague internal.validate_permission (token / permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB { status, ultimo_ok, fallos_recientes }.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE, sólo lectura).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_estado_archivo(p_token UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_ultimo_ok JSONB;
|
||||
v_fallos_recientes JSONB;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_configuraciones_generales');
|
||||
|
||||
-- Último `ok` por tipo: DISTINCT ON (tipo) ordenando por fecha desc.
|
||||
SELECT COALESCE(jsonb_object_agg(s.tipo, s.info), '{}'::jsonb)
|
||||
INTO v_ultimo_ok
|
||||
FROM (
|
||||
SELECT DISTINCT ON (tipo)
|
||||
tipo,
|
||||
jsonb_build_object(
|
||||
'anio_mes_target', anio_mes_target,
|
||||
'ejecutado_en', ejecutado_en
|
||||
) AS info
|
||||
FROM internal.archivo_exports
|
||||
WHERE resultado = 'ok'
|
||||
ORDER BY tipo, ejecutado_en DESC
|
||||
) s;
|
||||
|
||||
-- Fallos recientes (hasta 20), más nuevo primero.
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(f) ORDER BY f.ejecutado_en DESC), '[]'::jsonb)
|
||||
INTO v_fallos_recientes
|
||||
FROM (
|
||||
SELECT anio_mes_target, tipo, ejecutado_en, detalle_error
|
||||
FROM internal.archivo_exports
|
||||
WHERE resultado = 'fallo'
|
||||
ORDER BY ejecutado_en DESC
|
||||
LIMIT 20
|
||||
) f;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'ultimo_ok', v_ultimo_ok,
|
||||
'fallos_recientes', v_fallos_recientes
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_tamano_base
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Termómetro de la base: devuelve, por tabla, la cantidad de filas y el
|
||||
-- espacio que ocupa (tabla + índices + toast), más el tamaño total de la
|
||||
-- base. Sirve para anticipar el momento en que nos acercamos al cap de
|
||||
-- 500 MB del plan Free (Documentation/PoliticaRetencion.md §2, §5.5),
|
||||
-- que es la razón de ser de toda la política de retención.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Implementa el "dashboard de tamaño de BD" del backlog (US-R22). Es la
|
||||
-- última línea de defensa que menciona §5.5: si el pipeline mensual
|
||||
-- fallara en silencio, los datos viejos se acumularían y esta función lo
|
||||
-- haría visible antes de chocar el límite.
|
||||
--
|
||||
-- El nombre diverge del `fc_estado_retencion` sugerido en el backlog (que
|
||||
-- admitía "o similar"): "tamaño base" describe mejor qué reporta y no se
|
||||
-- confunde con fc_obtener_estado_archivo (estado del pipeline).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID de sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_configuraciones_generales' (admin/superadmin),
|
||||
-- el mismo que el resto de las lecturas de estado del sistema. SECURITY
|
||||
-- DEFINER para poder leer pg_catalog y contar tablas del schema internal.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - Los que propague internal.validate_permission (token / permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB { status, db_total_bytes, db_total_tamano, tablas: [ {schema,
|
||||
-- tabla, filas, bytes, tamano} ] }. `tablas` viene ordenado por tamaño
|
||||
-- descendente (la más grande primero — la candidata a vigilar).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (sólo lectura). El count(*) por tabla es exacto; a la escala
|
||||
-- del sistema (≤200 usuarios, tablas chicas) es despreciable. Si la base
|
||||
-- creciera mucho, conviene pasar a la estimación de pg_class.reltuples.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_tamano_base(p_token UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tablas JSONB := '[]'::jsonb;
|
||||
v_rec RECORD;
|
||||
v_filas BIGINT;
|
||||
v_total BIGINT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token, 'ver_configuraciones_generales');
|
||||
|
||||
FOR v_rec IN
|
||||
SELECT n.nspname AS schema_name,
|
||||
c.relname AS table_name,
|
||||
pg_total_relation_size(c.oid) AS bytes
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r'
|
||||
AND n.nspname IN ('public', 'internal')
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC, n.nspname, c.relname
|
||||
LOOP
|
||||
EXECUTE format('SELECT count(*) FROM %I.%I', v_rec.schema_name, v_rec.table_name)
|
||||
INTO v_filas;
|
||||
|
||||
v_tablas := v_tablas || jsonb_build_object(
|
||||
'schema', v_rec.schema_name,
|
||||
'tabla', v_rec.table_name,
|
||||
'filas', v_filas,
|
||||
'bytes', v_rec.bytes,
|
||||
'tamano', pg_size_pretty(v_rec.bytes)
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
v_total := pg_database_size(current_database());
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'success',
|
||||
'db_total_bytes', v_total,
|
||||
'db_total_tamano', pg_size_pretty(v_total),
|
||||
'tablas', v_tablas
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ============================================================================
|
||||
-- internal.agregado_reservas_mensual
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Calcula, para un mes dado, la concurrencia por actividad: cuántas
|
||||
-- reservas hubo en total y cuántas se cancelaron. Es la fuente de datos
|
||||
-- de la hoja ACTIVIDAD_MENSUAL del archivo histórico
|
||||
-- (Documentation/PoliticaRetencion.md §4.1).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- El sistema descarta los turnos pasados (higiene, 2 meses) pero la
|
||||
-- concurrencia mensual sí vale conservar agregada. Esta función produce
|
||||
-- ese agregado ANTES de que el pipeline purgue los turnos del mes
|
||||
-- (§5.2): el orden importa, porque una vez borrados los turnos las
|
||||
-- reservas caen por CASCADE y el dato se pierde.
|
||||
--
|
||||
-- Sólo aparecen actividades con al menos una reserva en el mes (el JOIN
|
||||
-- contra reservas las filtra naturalmente). Una actividad sin reservas
|
||||
-- no aporta fila: su concurrencia fue cero y no hay nada que archivar.
|
||||
--
|
||||
-- "totales" incluye las canceladas; "canceladas" es el subconjunto soft-
|
||||
-- deleted (reservas.cancelada = true). La asistencia neta se deriva
|
||||
-- restando, pero se archivan los dos números crudos para no perder
|
||||
-- información.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_anio_mes DATE. Cualquier día del mes objetivo; se trunca al día 1.
|
||||
-- El mes se delimita con un rango semiabierto [inicio, inicio+1mes) sobre
|
||||
-- turnos.fecha.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No valida permiso propio. El schema `internal` no es alcanzable por
|
||||
-- roles cliente (ver hardening en `database/schema/00_schemas.sql`).
|
||||
-- Caller legítimo: el pipeline mensual (US-R17). Read-only: no muta
|
||||
-- estado; la persistencia ocurre cuando la Edge Function appendea al
|
||||
-- XLSX.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- SETOF filas (anio_mes, actividad_nombre, reservas_totales,
|
||||
-- reservas_canceladas). Vacío si el mes no tuvo reservas. `anio_mes` se
|
||||
-- devuelve como TEXTO 'YYYY-MM-DD' (misma razón que en pagos_del_mes: el
|
||||
-- pipeline lo usa como clave de reemplazo idempotente en el XLSX).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.agregado_reservas_mensual(p_anio_mes DATE)
|
||||
RETURNS TABLE (
|
||||
anio_mes TEXT,
|
||||
actividad_nombre TEXT,
|
||||
reservas_totales INTEGER,
|
||||
reservas_canceladas INTEGER
|
||||
)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
to_char(date_trunc('month', p_anio_mes), 'YYYY-MM-DD') AS anio_mes,
|
||||
a.nombre AS actividad_nombre,
|
||||
count(r.id)::int AS reservas_totales,
|
||||
count(r.id) FILTER (WHERE r.cancelada)::int AS reservas_canceladas
|
||||
FROM reservas r
|
||||
JOIN turnos t ON t.id = r.turno_id
|
||||
JOIN actividades a ON a.id = t.actividad_id
|
||||
WHERE t.fecha >= date_trunc('month', p_anio_mes)::date
|
||||
AND t.fecha < (date_trunc('month', p_anio_mes) + INTERVAL '1 month')::date
|
||||
GROUP BY a.nombre
|
||||
ORDER BY a.nombre;
|
||||
$$;
|
||||
|
||||
-- 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.agregado_reservas_mensual(DATE)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
@@ -0,0 +1,95 @@
|
||||
-- ============================================================================
|
||||
-- internal.pagos_del_mes
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve el detalle de los pagos cobrados en un mes dado, ya con la
|
||||
-- forma de la hoja PAGOS del archivo histórico
|
||||
-- (Documentation/PoliticaRetencion.md §4.1). Es la fuente que el
|
||||
-- pipeline mensual (US-R17) appendea al XLSX antes de borrar esos pagos
|
||||
-- de la base.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- El archivo conserva "una fila por pago realmente cobrado" (§4.1).
|
||||
-- Por eso:
|
||||
-- - Se EXCLUYEN los pagos anulados (§4.2): una anulación se manifiesta
|
||||
-- como ausencia de fila, no como fila marcada. anulado_at IS NULL.
|
||||
-- - El "plan" sale del snapshot guardado en detalle->>'cuota_nombre'
|
||||
-- al momento del cobro (lo escribe fc_insertar_pago), con fallback al
|
||||
-- nombre vigente en tipos_cuota. El snapshot es lo correcto para un
|
||||
-- archivo: preserva el nombre que tenía el plan cuando se cobró,
|
||||
-- aunque después se renombre o se borre.
|
||||
-- - El "metodo" y "registrado_por" se resuelven por join; sus tablas
|
||||
-- (metodos_pago, usuarios) no se purgan, así que el dato sigue vivo.
|
||||
--
|
||||
-- NOTA sobre tipos: hoy todos los pagos son 'cuota_mensual' (los
|
||||
-- correctivos —devolucion/ajuste— son una decisión cerrada de NO
|
||||
-- modelar, DominioPagos §10). Si alguna vez se implementan, habrá que
|
||||
-- revisar qué cuenta como "cobro real" para el archivo; por ahora el
|
||||
-- único filtro necesario es el de anulados.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_anio_mes DATE. Cualquier día del mes objetivo; se compara contra
|
||||
-- pagos.anio_mes_pagado, que ya está truncado al día 1 por constraint.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No valida permiso propio. Schema `internal` no alcanzable por roles
|
||||
-- cliente (hardening en 00_schemas.sql). Caller legítimo: el pipeline
|
||||
-- mensual (US-R17), vía conexión PG directa. Read-only.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio.
|
||||
--
|
||||
-- RETORNA
|
||||
-- SETOF filas con las columnas de la hoja PAGOS, en el orden del §4.1.
|
||||
-- Vacío si el mes no tuvo pagos cobrados (caso normal del primer año,
|
||||
-- §4.3).
|
||||
--
|
||||
-- Las columnas de fecha se devuelven como TEXTO estable
|
||||
-- (`fecha_pago` = 'YYYY-MM-DD HH24:MI', `anio_mes_pagado` = 'YYYY-MM-DD').
|
||||
-- Es deliberado: el pipeline (US-R17) usa `anio_mes_pagado` como clave
|
||||
-- para "reemplazar las filas de este mes" en el XLSX de forma idempotente,
|
||||
-- y un texto fijo se compara sin la ambigüedad de las celdas-fecha de
|
||||
-- Excel. Ordena lexicográfico = cronológico.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.pagos_del_mes(p_anio_mes DATE)
|
||||
RETURNS TABLE (
|
||||
cliente_nombre TEXT,
|
||||
cliente_dni TEXT,
|
||||
fecha_pago TEXT,
|
||||
anio_mes_pagado TEXT,
|
||||
monto NUMERIC,
|
||||
metodo TEXT,
|
||||
plan TEXT,
|
||||
registrado_por TEXT
|
||||
)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
SET timezone = 'America/Argentina/Buenos_Aires'
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT
|
||||
trim(cl.nombre || ' ' || COALESCE(cl.apellido, '')) AS cliente_nombre,
|
||||
cl.dni AS cliente_dni,
|
||||
to_char(p.fecha_pago, 'YYYY-MM-DD HH24:MI') AS fecha_pago,
|
||||
to_char(p.anio_mes_pagado, 'YYYY-MM-DD') AS anio_mes_pagado,
|
||||
p.monto_total AS monto,
|
||||
m.descripcion AS metodo,
|
||||
COALESCE(p.detalle->>'cuota_nombre', tc.nombre) AS plan,
|
||||
ru.nombre AS registrado_por
|
||||
FROM pagos p
|
||||
JOIN usuarios cl ON cl.id = p.cliente_id
|
||||
LEFT JOIN metodos_pago m ON m.id = p.metodo_id
|
||||
LEFT JOIN tipos_cuota tc ON tc.id = p.tipo_cuota_id
|
||||
LEFT JOIN usuarios ru ON ru.id = p.created_by
|
||||
WHERE p.anio_mes_pagado = date_trunc('month', p_anio_mes)::date
|
||||
AND p.anulado_at IS NULL
|
||||
ORDER BY cliente_nombre, p.fecha_pago;
|
||||
$$;
|
||||
|
||||
-- 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.pagos_del_mes(DATE)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
@@ -0,0 +1,61 @@
|
||||
-- ============================================================================
|
||||
-- internal.pagos_mes_archivado
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Predicado de dominio: ¿los pagos del mes dado ya fueron archivados al
|
||||
-- XLSX histórico? Centraliza la definición de "mes archivado" para que
|
||||
-- los guards de pagos (fc_editar_pago, fc_anular_pago — US-R11) y
|
||||
-- cualquier futuro consumidor consulten un único criterio en vez de
|
||||
-- reimplementar el EXISTS y arriesgar divergencias.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Un mes de pagos está archivado sii existe un registro `ok` de archivado
|
||||
-- para ese mes en internal.archivo_exports (tipo = 'pagos'). Ver
|
||||
-- Documentation/PoliticaRetencion.md §5.3 y DominioPagos.md §14.
|
||||
--
|
||||
-- Sutilezas que el helper encierra:
|
||||
-- - Sólo cuenta resultado = 'ok'. Un intento `fallo` NO archiva: el
|
||||
-- mes sigue editable hasta que el archivado tenga éxito.
|
||||
-- - El target se compara truncado al día 1, igual que se guarda.
|
||||
-- - Las corridas vacías del primer año escriben `ok` con
|
||||
-- anio_mes_target NULL (US-R17); como `NULL = <mes>` nunca es TRUE,
|
||||
-- esas filas no marcan ningún mes como archivado. Correcto: no
|
||||
-- archivaron nada.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_anio_mes DATE. El mes a consultar (cualquier día; se trunca).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- No valida permiso propio. Schema `internal` no alcanzable por roles
|
||||
-- cliente (hardening en 00_schemas.sql). Lo invocan fachadas públicas
|
||||
-- SECURITY DEFINER (fc_editar_pago, fc_anular_pago) ya autorizadas.
|
||||
--
|
||||
-- RETORNA
|
||||
-- boolean. TRUE si el mes está archivado; FALSE si no (incluye p_anio_mes
|
||||
-- NULL).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE, sólo lectura).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION internal.pagos_mes_archivado(p_anio_mes DATE)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM internal.archivo_exports
|
||||
WHERE tipo = 'pagos'
|
||||
AND resultado = 'ok'
|
||||
AND anio_mes_target = date_trunc('month', p_anio_mes)::date
|
||||
);
|
||||
$$;
|
||||
|
||||
-- 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.pagos_mes_archivado(DATE)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ============================================================================
|
||||
-- fc_eliminar_tipo_cuota
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Elimina un tipo de cuota (DELETE real, no soft). Apoyado en constraints:
|
||||
-- las asociaciones a actividades se borran en CASCADE, y los usuarios que
|
||||
-- tenían este plan como default quedan con plan NULL (SET NULL).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Baja definitiva de un plan de la configuración del gimnasio. A
|
||||
-- diferencia de los pagos (que se anulan, nunca se borran), los tipos de
|
||||
-- cuota son configuración: eliminarlos no destruye información de
|
||||
-- pagos pasados, porque cada pago capturó el snapshot del plan en su
|
||||
-- detalle al momento del cobro (Documentation/DominioPagos.md §5.1).
|
||||
-- Sin embargo, los pagos históricos pierden el FK al tipo de cuota
|
||||
-- (queda como referencia colgante o se anula según el DDL — depende del
|
||||
-- constraint configurado en tablas).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_id UUID ID del tipo de cuota a eliminar.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'eliminar_tipo_cuota'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El tipo de cuota con ID X no existe.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- BOOL TRUE en caso de éxito.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - DELETE de la fila en tipos_cuota.
|
||||
-- - DELETE en cascada de las asociaciones en actividades_tipos_cuota.
|
||||
-- - SET NULL en usuarios.tipo_cuota para los usuarios que tenían este
|
||||
-- plan como default.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_eliminar_tipo_cuota(
|
||||
p_id UUID,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS BOOL
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE -- Checklist #2: Modifica datos
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_id_borrado UUID;
|
||||
BEGIN
|
||||
-- -----------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos (Centralizado)
|
||||
-- -----------------------------------------------------------
|
||||
PERFORM internal.validate_permission(p_token, 'eliminar_tipo_cuota');
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 1. DELETE + RETURNING
|
||||
-- -----------------------------------------------------------
|
||||
-- Aprovechamos tus constraints:
|
||||
-- 1. Borra relaciones en 'actividades_tipos_cuota' (CASCADE)
|
||||
-- 2. Pone en NULL el campo 'tipo_cuota' en 'usuarios' (SET NULL)
|
||||
DELETE FROM tipos_cuota
|
||||
WHERE id = p_id
|
||||
RETURNING id INTO v_id_borrado;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 2. Validación de existencia
|
||||
-- -----------------------------------------------------------
|
||||
IF v_id_borrado IS NULL THEN
|
||||
RAISE EXCEPTION 'El tipo de cuota con ID % no existe.', p_id;
|
||||
END IF;
|
||||
|
||||
-- -----------------------------------------------------------
|
||||
-- 3. Retorno Informativo
|
||||
-- -----------------------------------------------------------
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,118 @@
|
||||
-- ============================================================================
|
||||
-- fc_insertar_tipo_cuota
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Crea un tipo de cuota (plan) nuevo y, opcionalmente, le asocia un
|
||||
-- conjunto inicial de actividades.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Alta de un plan ofrecido por el gimnasio
|
||||
-- (Documentation/DominioPagos.md §5.1). Los planes existentes son
|
||||
-- referenciados por pagos y por usuarios (como plan por defecto), por lo
|
||||
-- que se trata de configuración: no se "anulan" como los pagos, pero su
|
||||
-- eliminación tiene reglas (ver fc_eliminar_tipo_cuota).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_datos JSONB con el payload del tipo de cuota. Claves:
|
||||
-- nombre TEXT (obligatorio).
|
||||
-- descripcion TEXT (opcional).
|
||||
-- dias_semana SMALLINT (obligatorio) cantidad / bitmask de días
|
||||
-- de la semana incluidos.
|
||||
-- precio NUMERIC (opcional, default 0).
|
||||
-- para_socios BOOLEAN (opcional, default FALSE). NOTA: el payload
|
||||
-- usa "para_socios" pero la columna en la
|
||||
-- tabla se llama "parasocios".
|
||||
-- dia_de_pago SMALLINT (obligatorio) día sugerido del mes para el
|
||||
-- pago.
|
||||
-- recargo NUMERIC (opcional, default NULL) monto extra
|
||||
-- aplicable si se paga después del día sugerido.
|
||||
-- actividades_ids JSONB array de INT (opcional) IDs de las actividades
|
||||
-- incluidas en el plan.
|
||||
-- p_token UUID de sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'agregar_tipo_cuota'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El campo actividades_ids debe ser un array JSON' si se pasa pero no
|
||||
-- es un array.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array con un único elemento: el tipo de cuota recién creado, con
|
||||
-- el mismo shape que fc_obtener_tipo_cuota (delega en esa función para
|
||||
-- armar el resultado).
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - INSERT en tipos_cuota.
|
||||
-- - Si actividades_ids no es vacío: INSERTs en actividades_tipos_cuota.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_insertar_tipo_cuota(
|
||||
p_datos JSONB,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE -- Checklist #2: Escribe datos
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_tipo_id UUID;
|
||||
BEGIN
|
||||
----------------------------------------------------------------------
|
||||
-- Checklist #3: Validación de permisos (Centralizada)
|
||||
----------------------------------------------------------------------
|
||||
-- Asumimos la acción 'crear_tipo_cuota'
|
||||
PERFORM internal.validate_permission(p_token, 'agregar_tipo_cuota');
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- 1. Inserción del Padre (Tipos Cuota)
|
||||
----------------------------------------------------------------------
|
||||
INSERT INTO tipos_cuota (
|
||||
-- No insertamos ID manual, dejamos que el default gen_random_uuid() actúe
|
||||
nombre,
|
||||
descripcion,
|
||||
dias_semana,
|
||||
precio,
|
||||
parasocios,
|
||||
dia_de_pago,
|
||||
recargo
|
||||
) VALUES (
|
||||
p_datos->>'nombre',
|
||||
p_datos->>'descripcion',
|
||||
(p_datos->>'dias_semana')::SMALLINT,
|
||||
COALESCE((p_datos->>'precio')::NUMERIC, 0),
|
||||
COALESCE((p_datos->>'para_socios')::BOOLEAN, FALSE),
|
||||
(p_datos->>'dia_de_pago')::SMALLINT, -- Cast a SMALLINT según DDL
|
||||
(p_datos->>'recargo')::NUMERIC -- NULL si no viene, correcto
|
||||
)
|
||||
RETURNING id INTO v_tipo_id; -- Capturamos el ID generado
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- 2. Inserción Masiva de Actividades (Sin Bucle)
|
||||
----------------------------------------------------------------------
|
||||
IF p_datos ? 'actividades_ids' THEN
|
||||
|
||||
IF jsonb_typeof(p_datos->'actividades_ids') <> 'array' THEN
|
||||
RAISE EXCEPTION 'El campo actividades_ids debe ser un array JSON';
|
||||
END IF;
|
||||
|
||||
-- MAGIA SQL: Insertamos todo de un golpe desglosando el array
|
||||
INSERT INTO actividades_tipos_cuota (tipo_cuota_id, actividad_id)
|
||||
SELECT
|
||||
v_tipo_id,
|
||||
(actividad_json)::INT
|
||||
FROM jsonb_array_elements_text(p_datos->'actividades_ids') AS actividad_json;
|
||||
|
||||
END IF;
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- 3. Retorno Reutilizando Lógica
|
||||
----------------------------------------------------------------------
|
||||
-- Aprovechamos que ya optimizamos fc_obtener_tipo_cuota para devolver
|
||||
-- el objeto completo con sus actividades ya formateadas.
|
||||
-- Nota: Esto devuelve un array de 1 elemento [{}], es consistente.
|
||||
RETURN fc_obtener_tipo_cuota(p_token, v_tipo_id);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,120 @@
|
||||
-- ============================================================================
|
||||
-- fc_modificar_tipo_cuota
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Modifica un tipo de cuota existente. Semántica PATCH para los campos
|
||||
-- escalares; el array de actividades, si se pasa, REEMPLAZA al conjunto
|
||||
-- anterior (no merge).
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Mantenimiento de los planes ofrecidos. La modificación del precio o
|
||||
-- de las actividades NO afecta retroactivamente a los pagos ya
|
||||
-- registrados: cada pago captura un snapshot del plan en su detalle al
|
||||
-- momento del cobro (Documentation/DominioPagos.md §5.1).
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_id UUID (obligatorio) ID del tipo de cuota a modificar.
|
||||
-- p_datos JSONB con los campos a actualizar. Sólo se modifican las claves
|
||||
-- presentes. Mismos nombres que fc_insertar_tipo_cuota.
|
||||
-- Caso especial:
|
||||
-- - actividades_ids: si está presente, REEMPLAZA la lista
|
||||
-- actual (borra todo y reinserta). Pasar [] vacía la
|
||||
-- asociación; no pasar la clave deja el set intacto.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_tipos_cuota'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El tipo de cuota con ID X no existe.'
|
||||
-- - 'El campo actividades_ids debe ser un array JSON' si se pasa pero
|
||||
-- no es un array.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB (objeto único, no array) con el tipo de cuota tras la
|
||||
-- modificación, mismo shape que un ítem de fc_obtener_tipo_cuota.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - UPDATE en tipos_cuota.
|
||||
-- - Si actividades_ids está presente: DELETE de las filas previas en
|
||||
-- actividades_tipos_cuota para este tipo de cuota + INSERTs nuevos.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_modificar_tipo_cuota(
|
||||
p_id UUID, -- Recibimos el ID explícitamente (Mejor práctica)
|
||||
p_datos JSONB,
|
||||
p_token UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
VOLATILE -- Checklist #2: Modifica datos masivamente
|
||||
AS $function$
|
||||
DECLARE
|
||||
-- No necesitamos variables extra
|
||||
BEGIN
|
||||
-------------------------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos (Centralizado)
|
||||
-------------------------------------------------------------------------
|
||||
-- Acción sugerida: 'modificar_tipo_cuota'
|
||||
PERFORM internal.validate_permission(p_token, 'modificar_tipos_cuota');
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
-- 1. Validaciones previas
|
||||
-------------------------------------------------------------------------
|
||||
-- Verificamos existencia del registro padre antes de intentar nada
|
||||
PERFORM 1 FROM tipos_cuota WHERE id = p_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'El tipo de cuota con ID % no existe.', p_id;
|
||||
END IF;
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
-- 2. UPDATE dinámico del Padre (Tipos Cuota)
|
||||
-------------------------------------------------------------------------
|
||||
-- Usamos tu lógica de CASE WHEN ? para respetar nulos vs no enviados
|
||||
UPDATE tipos_cuota
|
||||
SET
|
||||
nombre = CASE WHEN p_datos ? 'nombre' THEN p_datos->>'nombre' ELSE nombre END,
|
||||
descripcion = CASE WHEN p_datos ? 'descripcion' THEN p_datos->>'descripcion' ELSE descripcion END,
|
||||
dias_semana = CASE WHEN p_datos ? 'dias_semana' THEN (p_datos->>'dias_semana')::SMALLINT ELSE dias_semana END,
|
||||
precio = CASE WHEN p_datos ? 'precio' THEN (p_datos->>'precio')::NUMERIC ELSE precio END,
|
||||
parasocios = CASE WHEN p_datos ? 'para_socios' THEN (p_datos->>'para_socios')::BOOL ELSE parasocios END,
|
||||
dia_de_pago = CASE WHEN p_datos ? 'dia_de_pago' THEN (p_datos->>'dia_de_pago')::SMALLINT ELSE dia_de_pago END, -- Cast a SMALLINT
|
||||
recargo = CASE WHEN p_datos ? 'recargo' THEN (p_datos->>'recargo')::NUMERIC ELSE recargo END
|
||||
WHERE id = p_id;
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
-- 3. Actualización de Relaciones (Actividades) - SET BASED
|
||||
-------------------------------------------------------------------------
|
||||
-- Solo entramos si la key existe. Si mandan [] borra todo. Si no la mandan, no se toca.
|
||||
IF p_datos ? 'actividades_ids' THEN
|
||||
|
||||
IF jsonb_typeof(p_datos->'actividades_ids') <> 'array' THEN
|
||||
RAISE EXCEPTION 'El campo actividades_ids debe ser un array JSON';
|
||||
END IF;
|
||||
|
||||
-- 3.1 Borrado masivo (Limpiar la casa)
|
||||
DELETE FROM actividades_tipos_cuota
|
||||
WHERE tipo_cuota_id = p_id;
|
||||
|
||||
-- 3.2 Inserción masiva (Sin bucle FOR)
|
||||
-- Desglosamos el JSON array en filas y las insertamos de golpe
|
||||
INSERT INTO actividades_tipos_cuota (tipo_cuota_id, actividad_id)
|
||||
SELECT
|
||||
p_id,
|
||||
(actividad_json)::INT
|
||||
FROM jsonb_array_elements_text(p_datos->'actividades_ids') AS actividad_json;
|
||||
|
||||
END IF;
|
||||
|
||||
-------------------------------------------------------------------------
|
||||
-- 4. Retorno reutilizando la función de lectura
|
||||
-------------------------------------------------------------------------
|
||||
-- Como fc_obtener_tipo_cuota devuelve un array [{}], extraemos el elemento 0
|
||||
RETURN (
|
||||
SELECT jsonb_array_element(fc_obtener_tipo_cuota(p_token, p_id), 0)
|
||||
);
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_tipo_cuota
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve los tipos de cuota (planes) configurados. Si se pasa p_id, sólo
|
||||
-- retorna ese.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Los tipos de cuota representan los "planes" que el gimnasio ofrece a sus
|
||||
-- clientes (Documentation/DominioPagos.md §5.1): definen precio, frecuencia
|
||||
-- semanal, día sugerido de pago, recargo por mora opcional, y el conjunto
|
||||
-- de actividades que el cliente puede usar. Cada pago de cuota_mensual
|
||||
-- referencia un tipo de cuota al momento del cobro.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_id UUID (opcional, default NULL) si se pasa, filtra al tipo de
|
||||
-- cuota con ese ID. Si es NULL, devuelve todos.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_tipos_cuota'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: id, nombre, descripcion,
|
||||
-- dias_semana, precio, parasocios, dia_de_pago, recargo y un array
|
||||
-- actividades_ids con los IDs de actividades asociadas. Ordenado por
|
||||
-- nombre ascendente.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_obtener_tipo_cuota(
|
||||
p_token UUID,
|
||||
p_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE -- Checklist #2: Solo lectura
|
||||
AS $function$
|
||||
DECLARE
|
||||
-- No hacen falta variables extra
|
||||
BEGIN
|
||||
------------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos (Centralizado)
|
||||
------------------------------------------------------------
|
||||
-- Asumimos la acción 'ver_tipos_cuota'
|
||||
PERFORM internal.validate_permission(p_token, 'ver_tipos_cuota');
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Checklist #4: Lógica unificada y eficiente (Sin N+1)
|
||||
------------------------------------------------------------
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(cuota_json), '[]'::jsonb)
|
||||
FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', t.id,
|
||||
'nombre', t.nombre,
|
||||
'descripcion', t.descripcion,
|
||||
'dias_semana', t.dias_semana,
|
||||
'precio', t.precio,
|
||||
'parasocios', t.parasocios,
|
||||
'dia_de_pago', t.dia_de_pago,
|
||||
'recargo', t.recargo,
|
||||
|
||||
-- OPTIMIZACIÓN CRÍTICA:
|
||||
-- En lugar de llamar a una función externa que valide token N veces,
|
||||
-- hacemos una subquery directa a la tabla intermedia que me pasaste.
|
||||
'actividades_ids', (
|
||||
SELECT COALESCE(jsonb_agg(atc.actividad_id), '[]'::jsonb)
|
||||
FROM public.actividades_tipos_cuota atc
|
||||
WHERE atc.tipo_cuota_id = t.id
|
||||
)
|
||||
|
||||
-- Opcional: Si en el futuro quieres devolver los objetos completos (id, nombre)
|
||||
-- en lugar de solo los IDs, cambiarías la subquery de arriba por un JOIN
|
||||
-- a la tabla 'actividades'.
|
||||
) AS cuota_json
|
||||
FROM public.tipos_cuota t
|
||||
WHERE (p_id IS NULL OR t.id = p_id)
|
||||
ORDER BY t.nombre ASC
|
||||
) result
|
||||
);
|
||||
END;
|
||||
$function$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,113 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_usuario_tipo_cuota
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve la asociación usuario → tipo de cuota: para cada usuario, qué
|
||||
-- plan tiene asignado (o NULL si no tiene). Filtra por DNI si se pasa.
|
||||
--
|
||||
-- DOMINIO
|
||||
-- Vista cruzada entre usuarios y tipos de cuota. Cada cliente puede tener
|
||||
-- asociado un plan por defecto (Documentation/DominioPagos.md §5.1), que
|
||||
-- es el que se usa al registrar un pago si no se especifica otro.
|
||||
--
|
||||
-- NOTA HISTÓRICA
|
||||
-- El comentario original sobre esta función dice "SI NO SE USA, BORRAR".
|
||||
-- Antes de eliminarla, revisar usos en el frontend y en otras funciones.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_dni TEXT (opcional, default NULL) si se pasa, devuelve sólo el
|
||||
-- usuario con ese DNI.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_usuarios_cuotas'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'No existe un usuario con el DNI X' cuando p_dni se pasa y no
|
||||
-- coincide con ningún usuario.
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: { cliente: {id, nombre,
|
||||
-- apellido, dni}, cuota: {id, nombre, descripcion, precio, dias_semana,
|
||||
-- dia_de_pago, parasocios} | NULL }. La cuota es NULL cuando el usuario
|
||||
-- no tiene plan asignado. Ordenado por apellido, nombre.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_obtener_usuario_tipo_cuota(
|
||||
p_token UUID,
|
||||
p_dni TEXT DEFAULT NULL -- Opcional
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_cliente_id UUID := NULL;
|
||||
BEGIN
|
||||
-- ------------------------------------------------------------
|
||||
-- Checklist #3: Validar permisos
|
||||
-- ------------------------------------------------------------
|
||||
-- Acción sugerida: 'ver_usuarios_cuotas'
|
||||
PERFORM internal.validate_permission(p_token, 'ver_usuarios_cuotas');
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1. Resolver ID si viene DNI
|
||||
-- ------------------------------------------------------------
|
||||
IF p_dni IS NOT NULL THEN
|
||||
SELECT id INTO v_cliente_id
|
||||
FROM usuarios
|
||||
WHERE dni = p_dni;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2. Consulta Unificada y Estructurada
|
||||
-- ------------------------------------------------------------
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(fila_json), '[]'::jsonb)
|
||||
FROM (
|
||||
SELECT jsonb_build_object(
|
||||
-- Objeto CLIENTE
|
||||
'cliente', jsonb_build_object(
|
||||
'id', u.id,
|
||||
'nombre', u.nombre,
|
||||
'apellido', u.apellido,
|
||||
'dni', u.dni
|
||||
),
|
||||
-- Objeto CUOTA (Puede ser NULL si el usuario no tiene plan)
|
||||
'cuota', CASE WHEN t.id IS NULL THEN NULL ELSE
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'nombre', t.nombre,
|
||||
'descripcion', t.descripcion,
|
||||
'precio', t.precio,
|
||||
'dias_semana', t.dias_semana,
|
||||
'dia_de_pago', t.dia_de_pago,
|
||||
'parasocios', t.parasocios
|
||||
)
|
||||
END
|
||||
) AS fila_json
|
||||
|
||||
-- CORRECCIÓN DE JOIN:
|
||||
-- Partimos de usuarios, porque queremos saber qué cuota tienen ELLOS.
|
||||
FROM public.usuarios u
|
||||
LEFT JOIN public.tipos_cuota t ON u.tipo_cuota = t.id
|
||||
|
||||
WHERE
|
||||
-- Filtro inteligente: Todos o Uno
|
||||
(v_cliente_id IS NULL OR u.id = v_cliente_id)
|
||||
|
||||
-- Ordenar por apellido para que la lista sea legible
|
||||
ORDER BY u.apellido, u.nombre
|
||||
) c
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ============================================================================
|
||||
-- fc_cambiar_propia_contrasena
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Permite a un admin o superadmin cambiar su propia contraseña,
|
||||
-- confirmando primero la contraseña actual. Autoservicio: no recibe el id
|
||||
-- de ningún otro usuario, solo opera sobre el dueño del token.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor (también identifica de quién
|
||||
-- es la contraseña a cambiar).
|
||||
-- p_password_actual TEXT contraseña actual en texto plano, para
|
||||
-- confirmar identidad antes de cambiarla.
|
||||
-- p_password_nueva TEXT contraseña nueva en texto plano (mínimo 8
|
||||
-- caracteres; se valida también en el cliente,
|
||||
-- pero el servidor es la última línea).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'cambiar_propia_contrasena'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'La contraseña actual es incorrecta.'
|
||||
-- - 'La contraseña nueva debe tener al menos 8 caracteres.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE de password_hash en public.usuarios, fila del propio actor.
|
||||
-- fecha_modificacion no se toca (no es un cambio de "perfil").
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_cambiar_propia_contrasena(
|
||||
p_token UUID,
|
||||
p_password_actual TEXT,
|
||||
p_password_nueva TEXT
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO extensions, public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_stored_password_hash TEXT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'cambiar_propia_contrasena');
|
||||
|
||||
IF p_password_nueva IS NULL OR length(p_password_nueva) < 8 THEN
|
||||
RAISE EXCEPTION 'La contraseña nueva debe tener al menos 8 caracteres.';
|
||||
END IF;
|
||||
|
||||
SELECT usuario_id INTO v_user_id FROM internal.sesiones WHERE token = p_token;
|
||||
|
||||
SELECT password_hash INTO v_stored_password_hash
|
||||
FROM public.usuarios
|
||||
WHERE id = v_user_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_stored_password_hash IS NULL
|
||||
OR v_stored_password_hash <> crypt(p_password_actual, v_stored_password_hash) THEN
|
||||
RAISE EXCEPTION 'La contraseña actual es incorrecta.';
|
||||
END IF;
|
||||
|
||||
UPDATE public.usuarios
|
||||
SET password_hash = internal.create_password_hash(p_password_nueva)
|
||||
WHERE id = v_user_id;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- ============================================================================
|
||||
-- fc_eliminar_usuario
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Elimina un usuario (DELETE real). Si tiene registros asociados (pagos,
|
||||
-- historial, etc.) y un FK lo impide, atrapa la violación y devuelve un
|
||||
-- mensaje amigable al frontend.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_dni TEXT DNI del usuario a eliminar.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'eliminar_usuarios'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'No se puede eliminar el usuario X porque tiene registros asociados
|
||||
-- (pagos, historial, etc). Primero debe archivar o borrar esos datos.'
|
||||
-- - 'No existe un usuario con el DNI X'
|
||||
--
|
||||
-- RETORNA
|
||||
-- BOOL TRUE en caso de éxito.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- DELETE de la fila en public.usuarios. Las eliminaciones en cascada
|
||||
-- dependen de los FKs definidos en las tablas hijas.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_eliminar_usuario(p_dni text, p_token uuid)
|
||||
RETURNS bool
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_id_eliminado UUID;
|
||||
|
||||
BEGIN
|
||||
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'eliminar_usuarios');
|
||||
|
||||
BEGIN
|
||||
DELETE FROM public.usuarios
|
||||
WHERE dni = p_dni
|
||||
RETURNING id INTO v_id_eliminado;
|
||||
|
||||
EXCEPTION
|
||||
WHEN foreign_key_violation THEN
|
||||
-- Este mensaje es mucho más amigable que el error nativo de Postgres
|
||||
RAISE EXCEPTION 'No se puede eliminar el usuario % porque tiene registros asociados (pagos, historial, etc). Primero debe archivar o borrar esos datos.', p_dni;
|
||||
END;
|
||||
|
||||
-- 3. Validar si encontró al usuario
|
||||
IF v_id_eliminado IS NULL THEN
|
||||
RAISE EXCEPTION 'No existe un usuario con el DNI %', p_dni;
|
||||
END IF;
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,143 @@
|
||||
-- ============================================================================
|
||||
-- fc_insertar_usuario
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Crea un usuario nuevo. Si el rol es 'admin' o 'superadmin', exige un
|
||||
-- permiso adicional sobre el del actor. Hashea la contraseña recibida en
|
||||
-- texto plano antes de persistir.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_datos JSONB con el payload del usuario. Claves:
|
||||
-- dni TEXT (obligatorio)
|
||||
-- nombre TEXT (obligatorio)
|
||||
-- apellido TEXT (opcional)
|
||||
-- mail TEXT (opcional)
|
||||
-- telefono TEXT (opcional)
|
||||
-- peso DECIMAL (opcional)
|
||||
-- altura INTEGER (opcional)
|
||||
-- fuerza_max DECIMAL (opcional)
|
||||
-- sexo TEXT (opcional, default 'Hombre')
|
||||
-- rol TEXT (obligatorio) p.ej. 'cliente', 'admin', 'superadmin'.
|
||||
-- password TEXT (obligatorio si rol != 'cliente'; opcional si
|
||||
-- rol = 'cliente') en texto plano; se hashea
|
||||
-- internamente.
|
||||
-- isActive BOOLEAN (obligatorio).
|
||||
-- tipo_cuota UUID (opcional) plan asociado. Si se pasa debe existir
|
||||
-- en tipos_cuota.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_usuarios'. Si rol = 'superadmin' exige además
|
||||
-- 'agregar_superadmin'. Si rol = 'admin' exige además 'agregar_admin'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'El tipo_cuota X no existe.'
|
||||
-- - 'La contraseña es obligatoria' (si rol != 'cliente' y no se mandó)
|
||||
-- - 'Error al generar el hash de la contraseña'
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- INSERT en public.usuarios con password_hash calculado. Si rol = 'cliente'
|
||||
-- y no se mandó password, password_hash se inserta NULL (los clientes no
|
||||
-- inician sesión en este panel, usan el bot de WhatsApp).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_insertar_usuario(p_datos jsonb, p_token uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO extensions, public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_password_hash TEXT;
|
||||
v_rol TEXT;
|
||||
|
||||
-- Campos del nuevo usuario
|
||||
new_dni TEXT;
|
||||
new_nombre TEXT;
|
||||
new_apellido TEXT;
|
||||
new_mail TEXT;
|
||||
new_telefono TEXT;
|
||||
new_peso DECIMAL(5,2);
|
||||
new_altura INTEGER;
|
||||
new_fuerza_max DECIMAL(10,2);
|
||||
new_sexo TEXT;
|
||||
new_rol TEXT;
|
||||
new_password TEXT;
|
||||
new_isactive BOOL;
|
||||
new_tipo_cuota UUID;
|
||||
|
||||
BEGIN
|
||||
-- 1. Validar el token de sesión y obtener el rol del usuario que llama.
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'modificar_usuarios');
|
||||
|
||||
-- 2. Validar que no esté intentando crear un admin o superadmin si no puede
|
||||
|
||||
new_rol := p_datos->>'rol';
|
||||
|
||||
IF new_rol = 'superadmin' THEN
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'agregar_superadmin');
|
||||
ELSIF new_rol = 'admin' THEN
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'agregar_admin');
|
||||
END IF;
|
||||
|
||||
-- 2.5 Extraer campos del JSONB
|
||||
new_dni := p_datos->>'dni';
|
||||
new_nombre := p_datos->>'nombre';
|
||||
new_apellido := p_datos->>'apellido';
|
||||
new_mail := p_datos->>'mail';
|
||||
new_telefono := p_datos->>'telefono';
|
||||
new_peso := (p_datos->>'peso')::DECIMAL;
|
||||
new_altura := (p_datos->>'altura')::INTEGER;
|
||||
new_fuerza_max := (p_datos->>'fuerza_max')::DECIMAL;
|
||||
new_sexo := COALESCE(p_datos->>'sexo', 'Hombre');
|
||||
|
||||
new_password := p_datos->>'password';
|
||||
new_isactive := (p_datos->>'isActive')::BOOLEAN;
|
||||
|
||||
-- CORRECCIÓN 1: Validar que exista la key Y que NO sea null antes de buscar en la BD
|
||||
IF p_datos ? 'tipo_cuota' AND (p_datos->>'tipo_cuota') IS NOT NULL THEN
|
||||
new_tipo_cuota := (p_datos->>'tipo_cuota')::UUID;
|
||||
|
||||
-- Validar que exista el tipo_cuota en la tabla padre
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tipos_cuota
|
||||
WHERE id = new_tipo_cuota
|
||||
) THEN
|
||||
RAISE EXCEPTION 'El tipo_cuota % no existe.', new_tipo_cuota;
|
||||
END IF;
|
||||
ELSE
|
||||
-- Si no viene o es null, asignamos NULL explícitamente
|
||||
new_tipo_cuota := NULL;
|
||||
END IF;
|
||||
|
||||
-- La contraseña es obligatoria salvo para clientes: no necesitan loguearse
|
||||
-- en este panel (usan el bot de WhatsApp). Si es cliente y no la mandan,
|
||||
-- password_hash queda NULL.
|
||||
IF new_password IS NULL AND new_rol <> 'cliente' THEN
|
||||
RAISE EXCEPTION 'La contraseña es obligatoria';
|
||||
END IF;
|
||||
|
||||
-- 3. Hashear la contraseña solo si vino (clientes pueden no traerla)
|
||||
IF new_password IS NOT NULL THEN
|
||||
SELECT internal.create_password_hash(new_password) INTO v_password_hash;
|
||||
IF v_password_hash IS NULL THEN
|
||||
RAISE EXCEPTION 'Error al generar el hash de la contraseña';
|
||||
END IF;
|
||||
ELSE
|
||||
v_password_hash := NULL;
|
||||
END IF;
|
||||
|
||||
-- 4. Insertar el nuevo usuario
|
||||
-- CORRECCIÓN 2: Agregué 'tipo_cuota' y 'new_tipo_cuota' al INSERT
|
||||
INSERT INTO public.usuarios (
|
||||
dni, nombre, apellido, mail, telefono, peso, altura, fuerza_max,
|
||||
sexo, rol, password_hash, isactive, tipo_cuota
|
||||
) VALUES (
|
||||
new_dni, new_nombre, new_apellido, new_mail, new_telefono, new_peso, new_altura, new_fuerza_max,
|
||||
new_sexo, new_rol, v_password_hash, new_isactive, new_tipo_cuota
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- ============================================================================
|
||||
-- fc_modificar_estado_usuario
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Activa o desactiva un usuario (toggle de isactive). Pensado para "dar
|
||||
-- de baja" sin perder el registro.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_id UUID ID del usuario.
|
||||
-- p_estado BOOLEAN nuevo valor de isactive.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'desactivar_usuarios'. (El mismo permiso cubre
|
||||
-- activación y desactivación.)
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio. Si p_id no existe, el UPDATE no afecta filas y la
|
||||
-- función igualmente termina sin error.
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE de isactive en public.usuarios. NO toca fecha_modificacion.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_modificar_estado_usuario(p_token uuid, p_id uuid, p_estado boolean)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO public
|
||||
AS $function$
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'desactivar_usuarios');
|
||||
|
||||
update public.usuarios
|
||||
set isactive = p_estado
|
||||
where id = p_id;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- ============================================================================
|
||||
-- fc_modificar_rol_usuario
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Cambia el rol de un usuario y actualiza su fecha_modificacion.
|
||||
-- NO valida que p_nuevo_rol sea un valor permitido; el constraint de
|
||||
-- tabla es la última línea de defensa.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_uid UUID ID del usuario a modificar.
|
||||
-- p_nuevo_rol TEXT nuevo valor de rol.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_rol_usuarios'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso o constraint de tabla).
|
||||
-- Si p_uid no existe, no hace nada (no rebota).
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE en public.usuarios: setea rol y fecha_modificacion = now().
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_modificar_rol_usuario(p_uid uuid, p_nuevo_rol text, p_token uuid)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO public
|
||||
AS $function$
|
||||
DECLARE
|
||||
BEGIN
|
||||
-- Exigir el permiso específico para esta acción
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'modificar_rol_usuarios');
|
||||
|
||||
UPDATE public.usuarios
|
||||
SET rol = p_nuevo_rol,
|
||||
fecha_modificacion = now()
|
||||
WHERE id = p_uid;
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,141 @@
|
||||
-- ============================================================================
|
||||
-- fc_modificar_usuario
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Modifica los datos de un usuario existente. Semántica PATCH: sólo se
|
||||
-- actualizan las claves presentes en p_datos. Identifica al usuario por
|
||||
-- id; si no viene, usa dni como fallback.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_datos JSONB con los campos a actualizar. Para identificar al usuario:
|
||||
-- id UUID (preferido)
|
||||
-- dni TEXT (fallback si no se pasa id)
|
||||
-- Campos editables (todos opcionales):
|
||||
-- nombre, apellido, peso, altura, mail, telefono, fuerza_max, sexo,
|
||||
-- tipo_cuota, rol, password.
|
||||
-- Caso especial - rol:
|
||||
-- Si se pasa 'rol' y es distinto al actual, se delega a
|
||||
-- fc_modificar_rol_usuario (que valida un permiso adicional). Tras la
|
||||
-- delegación, la key 'rol' se elimina del UPDATE principal para evitar
|
||||
-- escribir dos veces el mismo campo.
|
||||
-- Caso especial - tipo_cuota:
|
||||
-- Si se pasa la key con valor NULL, el plan se desasocia (queda NULL).
|
||||
-- Si no se pasa la key, no se toca.
|
||||
-- Caso especial - password:
|
||||
-- Opcional; solo superadmin puede setearla (permiso dedicado). Si se
|
||||
-- omite o viene vacía, no se toca la contraseña existente.
|
||||
-- p_token UUID sesión del actor.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'modificar_usuarios'. Si se cambia el rol, además
|
||||
-- requiere 'modificar_rol_usuarios' (impuesto por la función delegada).
|
||||
-- Si se pasa 'password' no vacía, además requiere
|
||||
-- 'resetear_contrasena_usuario'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'Se requiere un ID o un DNI para identificar al usuario.'
|
||||
-- - 'Usuario no encontrado con los datos proporcionados.'
|
||||
-- - 'El tipo_cuota X no existe.'
|
||||
-- - 'Error al generar el hash de la contraseña'
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- - UPDATE en public.usuarios con fecha_modificacion = now().
|
||||
-- - Si hubo cambio de rol, además UPDATE adicional vía
|
||||
-- fc_modificar_rol_usuario.
|
||||
-- - Si vino 'password' no vacía, además actualiza password_hash.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_modificar_usuario(p_datos jsonb, p_token uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO extensions, public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_isActive_new BOOLEAN := NULL;
|
||||
v_rol_actual TEXT;
|
||||
v_uid UUID;
|
||||
v_password_hash TEXT;
|
||||
BEGIN
|
||||
|
||||
-- Validar token
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'modificar_usuarios');
|
||||
|
||||
-- Evaluamos primero si el JSON trae un ID válido
|
||||
IF p_datos ? 'id' AND p_datos->>'id' IS NOT NULL THEN
|
||||
SELECT u.id, u.rol INTO v_uid, v_rol_actual
|
||||
FROM public.usuarios u
|
||||
WHERE u.id = (p_datos->>'id')::UUID
|
||||
FOR UPDATE;
|
||||
|
||||
-- Si no hay ID, intentamos usar el DNI (fallback)
|
||||
ELSIF p_datos ? 'dni' AND p_datos->>'dni' IS NOT NULL THEN
|
||||
SELECT u.id, u.rol INTO v_uid, v_rol_actual
|
||||
FROM public.usuarios u
|
||||
WHERE u.dni = p_datos->>'dni'
|
||||
FOR UPDATE;
|
||||
|
||||
-- Si no mandaron ni ID ni DNI, no sabemos a quién modificar
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Se requiere un ID o un DNI para identificar al usuario.';
|
||||
END IF;
|
||||
|
||||
-- Si hicimos la búsqueda pero no encontramos coincidencias
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Usuario no encontrado con los datos proporcionados.';
|
||||
END IF;
|
||||
|
||||
-- Validar existencia del tipo_cuota SOLO si viene en el JSON y NO es null
|
||||
IF p_datos ? 'tipo_cuota' AND (p_datos->>'tipo_cuota') IS NOT NULL THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM tipos_cuota
|
||||
WHERE id = (p_datos->>'tipo_cuota')::UUID
|
||||
) THEN
|
||||
RAISE EXCEPTION 'El tipo_cuota % no existe.', p_datos->>'tipo_cuota';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Validar si viene un rol en el JSON y si es distinto al que ya está en la DB
|
||||
IF p_datos ? 'rol' AND (p_datos->>'rol') IS DISTINCT FROM v_rol_actual THEN
|
||||
-- Delegamos la responsabilidad a la nueva función
|
||||
PERFORM public.fc_modificar_rol_usuario(v_uid, p_datos->>'rol', p_token);
|
||||
|
||||
-- Si llega hasta acá (no tiró excepción), eliminamos la key 'rol' del json
|
||||
-- para que el UPDATE de abajo no lo sobreescriba innecesariamente.
|
||||
p_datos := p_datos - 'rol';
|
||||
END IF;
|
||||
|
||||
-- Password opcional: solo si viene la key Y no es null/vacía, se re-hashea
|
||||
-- y se agrega al UPDATE. Acción sensible -> permiso dedicado, no alcanza
|
||||
-- con 'modificar_usuarios' (mismo criterio que ya se usa para 'rol').
|
||||
IF p_datos ? 'password' AND COALESCE(p_datos->>'password', '') <> '' THEN
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'resetear_contrasena_usuario');
|
||||
v_password_hash := internal.create_password_hash(p_datos->>'password');
|
||||
IF v_password_hash IS NULL THEN
|
||||
RAISE EXCEPTION 'Error al generar el hash de la contraseña';
|
||||
END IF;
|
||||
ELSE
|
||||
v_password_hash := NULL;
|
||||
END IF;
|
||||
|
||||
UPDATE public.usuarios
|
||||
SET
|
||||
nombre = CASE WHEN p_datos ? 'nombre' THEN p_datos->>'nombre' ELSE nombre END,
|
||||
apellido = CASE WHEN p_datos ? 'apellido' THEN p_datos->>'apellido' ELSE apellido END,
|
||||
peso = CASE WHEN p_datos ? 'peso' THEN (p_datos->>'peso')::DECIMAL ELSE peso END,
|
||||
altura = CASE WHEN p_datos ? 'altura' THEN (p_datos->>'altura')::DECIMAL ELSE altura END,
|
||||
mail = CASE WHEN p_datos ? 'mail' THEN p_datos->>'mail' ELSE mail END,
|
||||
telefono = CASE WHEN p_datos ? 'telefono' THEN p_datos->>'telefono' ELSE telefono END,
|
||||
fuerza_max= CASE WHEN p_datos ? 'fuerza_max' THEN (p_datos->>'fuerza_max')::DECIMAL ELSE fuerza_max END,
|
||||
sexo = CASE WHEN p_datos ? 'sexo' THEN p_datos->>'sexo' ELSE sexo END,
|
||||
-- Esto ahora permite cambiar el tipo de cuota a NULL si mandas null, o cambiarlo a otro ID
|
||||
tipo_cuota= CASE WHEN p_datos ? 'tipo_cuota' THEN (p_datos->>'tipo_cuota')::UUID ELSE tipo_cuota END,
|
||||
password_hash = CASE WHEN v_password_hash IS NOT NULL THEN v_password_hash ELSE password_hash END,
|
||||
fecha_modificacion = now()
|
||||
WHERE id = v_uid;
|
||||
|
||||
END;
|
||||
$function$;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_usuario_por_token
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve los datos del usuario asociado a una sesión vigente identificada
|
||||
-- por p_user_token. El segundo parámetro (p_token) es la sesión del actor
|
||||
-- que realiza la consulta y se usa sólo para autorizar.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor que llama (autorización).
|
||||
-- p_user_token UUID token de la sesión del usuario que se quiere consultar.
|
||||
-- La sesión debe estar vigente (expires_at > NOW()).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_usuarios'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB objeto único con los datos del usuario (id, dni, nombre, apellido,
|
||||
-- mail, telefono, peso, altura, sexo, fuerza_max, isActive, rol,
|
||||
-- tipo_cuota, fecha_creacion, fecha_modificacion). Si p_user_token no
|
||||
-- corresponde a una sesión vigente, retorna NULL.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_obtener_usuario_por_token(p_token UUID, p_user_token UUID)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
|
||||
PERFORM internal.validate_permission(p_token, 'ver_usuarios');
|
||||
|
||||
RETURN (
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id,
|
||||
'dni', u.dni,
|
||||
'nombre', u.nombre,
|
||||
'mail', u.mail,
|
||||
'peso', u.peso,
|
||||
'sexo', u.sexo,
|
||||
'altura', u.altura,
|
||||
'apellido', u.apellido,
|
||||
'fecha_creacion', u.fecha_creacion,
|
||||
'fecha_modificacion', u.fecha_modificacion,
|
||||
'fuerza_max', u.fuerza_max,
|
||||
'isActive', u.isactive,
|
||||
'rol', u.rol,
|
||||
'telefono', u.telefono,
|
||||
'tipo_cuota', u.tipo_cuota
|
||||
)
|
||||
FROM public.usuarios u
|
||||
JOIN internal.sesiones s ON s.usuario_id = u.id
|
||||
AND s.token = p_user_token
|
||||
AND s.expires_at > NOW()
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ============================================================================
|
||||
-- fc_obtener_usuarios
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Devuelve un lote paginado de usuarios. Opcionalmente filtra por DNI.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor.
|
||||
-- p_dni TEXT (opcional, default NULL) si se pasa, sólo retorna el
|
||||
-- usuario con ese DNI. NULL = todos.
|
||||
-- p_pagina INT (default 1) página, base 1.
|
||||
-- p_cantidad INT (default 50) ítems por página.
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'ver_usuarios'.
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- Ninguno propio (sólo los del validador de permiso).
|
||||
--
|
||||
-- RETORNA
|
||||
-- JSONB array (puede ser vacío). Cada ítem: id, dni, nombre, apellido,
|
||||
-- peso, altura, fecha_creacion, fecha_modificacion, telefono, fuerza_max,
|
||||
-- sexo, tipo_cuota, mail, rol, isActive. Ordenado por nombre, apellido.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- Ninguno (STABLE).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION fc_obtener_usuarios(
|
||||
p_token UUID,
|
||||
p_dni TEXT DEFAULT NULL,
|
||||
p_pagina INT DEFAULT 1, -- Página actual (empieza en 1)
|
||||
p_cantidad INT DEFAULT 50 -- Cuántos traer (default 30)
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_offset INT;
|
||||
BEGIN
|
||||
-- 1. Validar permisos
|
||||
PERFORM internal.validate_permission(p_token, 'ver_usuarios');
|
||||
|
||||
-- 2. Calcular desde dónde empezar a leer (OFFSET)
|
||||
-- Si página es 1, offset es 0. Si es 2, offset es 20, etc.
|
||||
v_offset := (p_pagina - 1) * p_cantidad;
|
||||
|
||||
-- 3. Consulta corregida: Paginación dentro, JSON fuera
|
||||
RETURN (
|
||||
SELECT COALESCE(jsonb_agg(t.usuario_json), '[]'::jsonb)
|
||||
FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'id', u.id,
|
||||
'dni', u.dni,
|
||||
'nombre', u.nombre,
|
||||
'apellido', u.apellido,
|
||||
'peso', u.peso,
|
||||
'altura', u.altura,
|
||||
'fecha_creacion', u.fecha_creacion,
|
||||
'fecha_modificacion', u.fecha_modificacion,
|
||||
'telefono', u.telefono,
|
||||
'fuerza_max', u.fuerza_max,
|
||||
'sexo', u.sexo,
|
||||
'tipo_cuota', u.tipo_cuota,
|
||||
'mail', u.mail,
|
||||
'rol', u.rol,
|
||||
'isActive', u.isactive
|
||||
) AS usuario_json
|
||||
FROM public.usuarios u
|
||||
WHERE (p_dni IS NULL OR u.dni = p_dni)
|
||||
ORDER BY u.nombre, u.apellido -- Vital para que el OFFSET sea consistente
|
||||
LIMIT p_cantidad
|
||||
OFFSET v_offset
|
||||
) t -- 't' es la subconsulta con solo 50 filas
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- ============================================================================
|
||||
-- fc_resetear_contrasena_usuario
|
||||
-- ============================================================================
|
||||
-- PROPÓSITO
|
||||
-- Permite al superadmin fijar una contraseña nueva para cualquier usuario
|
||||
-- no-cliente (admin o superadmin), sin pedir ni validar la contraseña
|
||||
-- actual del usuario objetivo. Reset administrativo sin restricción.
|
||||
--
|
||||
-- PARÁMETROS
|
||||
-- p_token UUID sesión del actor (debe ser superadmin).
|
||||
-- p_usuario_id UUID id del usuario objetivo (admin o superadmin).
|
||||
-- p_password_nueva TEXT contraseña nueva en texto plano (mínimo 8
|
||||
-- caracteres).
|
||||
--
|
||||
-- AUTORIZACIÓN
|
||||
-- Requiere permiso 'resetear_contrasena_usuario' (solo superadmin).
|
||||
--
|
||||
-- ERRORES (RAISE EXCEPTION)
|
||||
-- - 'La contraseña nueva debe tener al menos 8 caracteres.'
|
||||
-- - 'Usuario no encontrado.'
|
||||
-- - 'No se puede resetear la contraseña de un cliente: no inician sesión en este panel.'
|
||||
--
|
||||
-- RETORNA
|
||||
-- void.
|
||||
--
|
||||
-- EFECTOS SECUNDARIOS
|
||||
-- UPDATE de password_hash en public.usuarios, fila de p_usuario_id.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fc_resetear_contrasena_usuario(
|
||||
p_token UUID,
|
||||
p_usuario_id UUID,
|
||||
p_password_nueva TEXT
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO extensions, public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_rol_objetivo TEXT;
|
||||
BEGIN
|
||||
PERFORM internal.validate_permission(p_token := p_token, p_accion := 'resetear_contrasena_usuario');
|
||||
|
||||
IF p_password_nueva IS NULL OR length(p_password_nueva) < 8 THEN
|
||||
RAISE EXCEPTION 'La contraseña nueva debe tener al menos 8 caracteres.';
|
||||
END IF;
|
||||
|
||||
SELECT rol INTO v_rol_objetivo
|
||||
FROM public.usuarios
|
||||
WHERE id = p_usuario_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Usuario no encontrado.';
|
||||
END IF;
|
||||
|
||||
IF v_rol_objetivo = 'cliente' THEN
|
||||
RAISE EXCEPTION 'No se puede resetear la contraseña de un cliente: no inician sesión en este panel.';
|
||||
END IF;
|
||||
|
||||
UPDATE public.usuarios
|
||||
SET password_hash = internal.create_password_hash(p_password_nueva)
|
||||
WHERE id = p_usuario_id;
|
||||
END;
|
||||
$function$;
|
||||
Reference in New Issue
Block a user