Rediseño: vale de préstamo multi-ítem, fiel al formulario de papel

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>
This commit is contained in:
2026-08-17 08:37:44 -07:00
parent 4005008872
commit 81a5ea016e
23 changed files with 999 additions and 413 deletions
@@ -0,0 +1,235 @@
-- 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;