81a5ea016e
El vale físico del LSC permite pedir varios materiales en un solo trámite; el sistema modelaba 1 solicitud = 1 material. Migra prestamos.prestamos -> solicitudes (cabecera) + solicitud_items (renglones), vía RENAME + backfill (preserva ids/historial real). - RPC prestamos.crear_solicitud: transaccional, lockea materiales por fila, arregla la race condition del insert directo anterior. El alumno ya no inserta directo (RLS lo bloquea). - sync_stock/log_estado reescritos para iterar renglones por vale. - maestro_responsable (por vale) y profiles.semestre (perfil, nullable, sin UI todavía — onboarding queda para después). - Alumno: carrito (SolicitudCart/AgregarMaterial) reemplaza el modal de solicitud único por material. - Admin: 3 vistas de solicitudes, reportes y CSV export listan renglones por vale; api/admin/prestamos -> api/admin/solicitudes. De paso corrige un bug preexistente en detalles.ts (audit_log nunca se ordenaba por la columna correcta). Verificado: build limpio, ciclo RPC+triggers probado en una transacción revertida en prod (sin residuo), 9 vistas SSR smoke-testeadas contra el schema real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
9.2 KiB
PL/PgSQL
236 lines
9.2 KiB
PL/PgSQL
-- 0002_solicitudes_multi_item.sql
|
|
-- Rediseno a "vale multi-item": prestamos.prestamos -> prestamos.solicitudes (cabecera)
|
|
-- + prestamos.solicitud_items (renglones). Fiel al vale de papel del LSC:
|
|
-- un tramite agrupa varios materiales (cada uno con cantidad + descripcion),
|
|
-- mas maestro_responsable a nivel de vale y semestre a nivel de perfil.
|
|
|
|
begin;
|
|
|
|
-- ============================================================
|
|
-- 1) Cabecera: renombrar prestamos -> solicitudes (preserva ids/FKs/policies)
|
|
-- ============================================================
|
|
alter table prestamos.prestamos rename to solicitudes;
|
|
|
|
alter index prestamos.prestamos_alumno_idx rename to solicitudes_alumno_idx;
|
|
alter index prestamos.prestamos_estado_idx rename to solicitudes_estado_idx;
|
|
|
|
-- ============================================================
|
|
-- 2) Renglones: solicitud_items, poblados desde las columnas legacy
|
|
-- ============================================================
|
|
create table prestamos.solicitud_items (
|
|
id bigserial primary key,
|
|
solicitud_id bigint not null references prestamos.solicitudes(id) on delete cascade,
|
|
material_id int not null references prestamos.materiales(id),
|
|
cantidad int not null check (cantidad > 0),
|
|
descripcion text
|
|
);
|
|
|
|
create index solicitud_items_solicitud_idx on prestamos.solicitud_items(solicitud_id);
|
|
create index solicitud_items_material_idx on prestamos.solicitud_items(material_id);
|
|
|
|
-- backfill: cada solicitud legacy (1 material) -> 1 renglon
|
|
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad)
|
|
select id, material_id, cantidad from prestamos.solicitudes;
|
|
|
|
-- material_id/cantidad ya viven en solicitud_items; fuera de la cabecera
|
|
drop index if exists prestamos.prestamos_material_idx;
|
|
alter table prestamos.solicitudes drop column material_id;
|
|
alter table prestamos.solicitudes drop column cantidad;
|
|
|
|
-- ============================================================
|
|
-- 3) maestro_responsable (dato por-vale, requerido; backfill legacy)
|
|
-- ============================================================
|
|
alter table prestamos.solicitudes add column maestro_responsable text;
|
|
update prestamos.solicitudes
|
|
set maestro_responsable = 'Sin especificar (registro previo a este campo)'
|
|
where maestro_responsable is null;
|
|
alter table prestamos.solicitudes alter column maestro_responsable set not null;
|
|
|
|
-- ============================================================
|
|
-- 4) semestre en profiles (dato del alumno; nullable; SIN UI esta ronda)
|
|
-- ============================================================
|
|
alter table prestamos.profiles add column semestre text;
|
|
|
|
-- ============================================================
|
|
-- 5) audit_log: prestamo_id -> solicitud_id (mismo objeto FK, solo renombre)
|
|
-- ============================================================
|
|
alter table prestamos.audit_log rename column prestamo_id to solicitud_id;
|
|
alter index prestamos.audit_prestamo_idx rename to audit_solicitud_idx;
|
|
|
|
-- ============================================================
|
|
-- 6) sync_stock(): itera solicitud_items de la solicitud afectada.
|
|
-- Mismo trigger prestamos_sync_stock (sobrevivio el RENAME de tabla),
|
|
-- solo se reemplaza el cuerpo de la funcion.
|
|
-- ============================================================
|
|
create or replace function prestamos.sync_stock()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
declare
|
|
item record;
|
|
begin
|
|
-- transiciones que sacan stock
|
|
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
|
|
for item in
|
|
select material_id, cantidad from prestamos.solicitud_items
|
|
where solicitud_id = new.id order by material_id
|
|
loop
|
|
update prestamos.materiales
|
|
set cantidad_disponible = cantidad_disponible - item.cantidad
|
|
where id = item.material_id;
|
|
end loop;
|
|
end if;
|
|
|
|
-- transiciones que devuelven stock
|
|
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
|
|
for item in
|
|
select material_id, cantidad from prestamos.solicitud_items
|
|
where solicitud_id = new.id order by material_id
|
|
loop
|
|
update prestamos.materiales
|
|
set cantidad_disponible = cantidad_disponible + item.cantidad
|
|
where id = item.material_id;
|
|
end loop;
|
|
end if;
|
|
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
-- ============================================================
|
|
-- 7) log_estado(): usa solicitud_id. Mismo trigger prestamos_log_estado.
|
|
-- ============================================================
|
|
create or replace function prestamos.log_estado()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
begin
|
|
if new.estado is distinct from old.estado then
|
|
insert into prestamos.audit_log (solicitud_id, actor_id, accion, estado_anterior, estado_nuevo)
|
|
values (new.id, auth.uid(), 'cambio_estado', old.estado, new.estado);
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
-- ============================================================
|
|
-- 8) RPC: crear_solicitud - reemplaza el insert directo del alumno.
|
|
-- Transaccion unica (todo o nada) + lock por fila de material para
|
|
-- consistencia entre solicitudes concurrentes. El stock real se
|
|
-- descuenta recien al aprobar (sync_stock), igual que hoy; este lock
|
|
-- da atomicidad transaccional del alta multi-item.
|
|
-- ============================================================
|
|
create or replace function prestamos.crear_solicitud(
|
|
p_maestro_responsable text,
|
|
p_notas text,
|
|
p_items jsonb
|
|
) returns bigint
|
|
language plpgsql
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
declare
|
|
v_alumno uuid := auth.uid();
|
|
v_solicitud_id bigint;
|
|
v_item jsonb;
|
|
v_material_id int;
|
|
v_cantidad int;
|
|
v_descripcion text;
|
|
v_disponible int;
|
|
v_estado_mat text;
|
|
begin
|
|
if v_alumno is null then
|
|
raise exception 'no_autenticado';
|
|
end if;
|
|
|
|
if p_maestro_responsable is null or length(trim(p_maestro_responsable)) = 0 then
|
|
raise exception 'maestro_responsable_requerido';
|
|
end if;
|
|
|
|
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
|
|
raise exception 'items_requeridos';
|
|
end if;
|
|
|
|
-- lock consistente (orden ascendente) de las filas de material involucradas,
|
|
-- evita deadlocks si dos alumnos solicitan materiales solapados a la vez
|
|
for v_material_id in
|
|
select distinct (elem->>'material_id')::int
|
|
from jsonb_array_elements(p_items) elem
|
|
order by 1
|
|
loop
|
|
perform 1 from prestamos.materiales where id = v_material_id for update;
|
|
end loop;
|
|
|
|
insert into prestamos.solicitudes (alumno_id, estado, maestro_responsable, notas)
|
|
values (v_alumno, 'pendiente', trim(p_maestro_responsable), nullif(trim(coalesce(p_notas, '')), ''))
|
|
returning id into v_solicitud_id;
|
|
|
|
for v_item in select * from jsonb_array_elements(p_items)
|
|
loop
|
|
v_material_id := (v_item->>'material_id')::int;
|
|
v_cantidad := (v_item->>'cantidad')::int;
|
|
v_descripcion := nullif(trim(coalesce(v_item->>'descripcion', '')), '');
|
|
|
|
if v_material_id is null or v_cantidad is null or v_cantidad <= 0 then
|
|
raise exception 'item_invalido';
|
|
end if;
|
|
|
|
select cantidad_disponible, estado into v_disponible, v_estado_mat
|
|
from prestamos.materiales where id = v_material_id;
|
|
|
|
if v_estado_mat is null then
|
|
raise exception 'material_no_existe: %', v_material_id;
|
|
end if;
|
|
if v_estado_mat <> 'disponible' then
|
|
raise exception 'material_no_disponible: %', v_material_id;
|
|
end if;
|
|
if v_disponible < v_cantidad then
|
|
raise exception 'stock_insuficiente: %', v_material_id;
|
|
end if;
|
|
|
|
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion)
|
|
values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion);
|
|
end loop;
|
|
|
|
return v_solicitud_id;
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function prestamos.crear_solicitud(text, text, jsonb) to authenticated;
|
|
|
|
-- ============================================================
|
|
-- 9) RLS: solicitudes / solicitud_items
|
|
-- ============================================================
|
|
alter table prestamos.solicitud_items enable row level security;
|
|
|
|
-- El insert directo del alumno ya no existe: crear_solicitud es SECURITY DEFINER
|
|
-- y hace bypass de RLS. Sin policy de insert, RLS bloquea cualquier insert
|
|
-- directo via supabase-js aunque el GRANT de tabla siga vigente.
|
|
drop policy if exists prestamos_insert_own on prestamos.solicitudes;
|
|
-- solicitudes_select_own_or_admin / solicitudes_update_admin: los nombres de
|
|
-- policy no cambiaron con el RENAME de tabla (siguen siendo
|
|
-- prestamos_select_own_or_admin / prestamos_update_admin), su USING solo
|
|
-- referencia alumno_id/is_admin() y sigue funcionando sin tocarlas.
|
|
|
|
create policy solicitud_items_select_own_or_admin on prestamos.solicitud_items
|
|
for select to authenticated
|
|
using (
|
|
exists (
|
|
select 1 from prestamos.solicitudes s
|
|
where s.id = solicitud_items.solicitud_id
|
|
and (s.alumno_id = auth.uid() or prestamos.is_admin())
|
|
)
|
|
);
|
|
|
|
-- ============================================================
|
|
-- 10) Grants (explicitos, mismo patron que las demas tablas)
|
|
-- ============================================================
|
|
grant select, insert, update, delete on prestamos.solicitud_items to authenticated, service_role;
|
|
grant usage, select on prestamos.solicitud_items_id_seq to authenticated, service_role;
|
|
|
|
commit;
|