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