-- 0003_grid_unidades_realtime.sql -- 3 features de BD en una migracion: -- A) imagen_path en materiales (bucket 'materiales-fotos' se crea aparte) -- B) material_unidades (rastreo por unidad para laptops/proyectores) + -- flag trackeado_por_unidad + material_unidad_id en solicitud_items + -- sync_stock/crear_solicitud reescritos + RPC reasignar_unidad(admin) -- C) publication supabase_realtime += prestamos.solicitudes (badge admin) begin; -- ============================================================ -- A) Imagen del material (guarda solo el path dentro del bucket) -- ============================================================ alter table prestamos.materiales add column imagen_path text; -- ============================================================ -- B.1) Flag + tabla de unidades individuales -- ============================================================ alter table prestamos.materiales add column trackeado_por_unidad boolean not null default false; create table prestamos.material_unidades ( id bigserial primary key, material_id int not null references prestamos.materiales(id) on delete cascade, etiqueta text not null, estado text not null default 'disponible' check (estado in ('disponible','prestado','mantenimiento','baja')), notas text, created_at timestamptz not null default now(), unique (material_id, etiqueta) ); create index material_unidades_material_estado_idx on prestamos.material_unidades(material_id, estado); -- ============================================================ -- B.2) Ligar renglon de solicitud a unidad especifica (nullable) -- ============================================================ alter table prestamos.solicitud_items add column material_unidad_id bigint references prestamos.material_unidades(id); create index solicitud_items_unidad_idx on prestamos.solicitud_items(material_unidad_id) where material_unidad_id is not null; -- ============================================================ -- B.3) sync_stock reescrito: itera renglones y, si el renglon tiene -- material_unidad_id, tambien alterna el estado de esa unidad. -- cantidad_disponible del material sigue reflejando el count -- real disponible en ambos modos (por unidad o por cantidad). -- ============================================================ create or replace function prestamos.sync_stock() returns trigger language plpgsql security definer set search_path = prestamos, pg_temp as $$ declare item record; begin -- sacar stock: pendiente -> aprobado/activo if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then for item in select material_id, cantidad, material_unidad_id 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; if item.material_unidad_id is not null then update prestamos.material_unidades set estado = 'prestado' where id = item.material_unidad_id; end if; end loop; end if; -- devolver stock: aprobado/activo -> devuelto/rechazado if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then for item in select material_id, cantidad, material_unidad_id 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; if item.material_unidad_id is not null then update prestamos.material_unidades set estado = 'disponible' where id = item.material_unidad_id; end if; end loop; end if; return new; end; $$; -- ============================================================ -- B.4) Trigger para mantener cantidad_total y cantidad_disponible -- del material sincronizados con material_unidades cuando -- trackeado_por_unidad=true. Cambios a considerar: -- - INSERT unidad (nueva ficha) -> total += 1, disponible += (1 si 'disponible') -- - DELETE unidad -> total -= 1, disponible -= (1 si 'disponible') -- - UPDATE estado unidad -> disponible +/- 1 segun transicion -- (tomando 'disponible' como el unico estado que cuenta como stock) -- ============================================================ create or replace function prestamos.sync_material_desde_unidad() returns trigger language plpgsql security definer set search_path = prestamos, pg_temp as $$ declare v_delta_total int := 0; v_delta_disp int := 0; v_material_id int; begin if tg_op = 'INSERT' then v_material_id := new.material_id; v_delta_total := 1; if new.estado = 'disponible' then v_delta_disp := 1; end if; elsif tg_op = 'DELETE' then v_material_id := old.material_id; v_delta_total := -1; if old.estado = 'disponible' then v_delta_disp := -1; end if; elsif tg_op = 'UPDATE' then v_material_id := new.material_id; if old.estado = 'disponible' and new.estado <> 'disponible' then v_delta_disp := -1; elsif old.estado <> 'disponible' and new.estado = 'disponible' then v_delta_disp := 1; end if; end if; if v_delta_total <> 0 or v_delta_disp <> 0 then update prestamos.materiales set cantidad_total = cantidad_total + v_delta_total, cantidad_disponible = cantidad_disponible + v_delta_disp where id = v_material_id; end if; return null; end; $$; drop trigger if exists prestamos_sync_material_desde_unidad on prestamos.material_unidades; create trigger prestamos_sync_material_desde_unidad after insert or update of estado or delete on prestamos.material_unidades for each row execute function prestamos.sync_material_desde_unidad(); -- ============================================================ -- B.5) crear_solicitud reescrito: si el material esta trackeado_por_unidad, -- toma la primera unidad 'disponible' (FOR UPDATE SKIP LOCKED) y la -- liga al renglon; deja que sync_stock marque la unidad al aprobar. -- No descuenta stock en creacion (comportamiento igual al 0002). -- ============================================================ 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; v_trackeado boolean; v_unidad_id bigint; v_asignadas int; 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 por orden ascendente de material_id, evita deadlocks 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, trackeado_por_unidad into v_disponible, v_estado_mat, v_trackeado 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; if v_trackeado then -- asignar 1 unidad por copia solicitada; N renglones (uno por unidad) v_asignadas := 0; while v_asignadas < v_cantidad loop select id into v_unidad_id from prestamos.material_unidades where material_id = v_material_id and estado = 'disponible' order by id for update skip locked limit 1; if v_unidad_id is null then raise exception 'sin_unidad_disponible: %', v_material_id; end if; insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion, material_unidad_id) values (v_solicitud_id, v_material_id, 1, v_descripcion, v_unidad_id); -- marcamos la unidad como reservada usando 'prestado' aqui NO seria -- correcto (aun no aprobada). Usamos un truco simple: dejar la -- unidad como 'disponible' hasta que sync_stock la mueva al aprobar, -- pero excluirla de nuevas asignaciones via join con -- solicitud_items donde solicitud.estado='pendiente'. -- Para evitar carrera con otro alumno pidiendo la misma: -- volvemos a lockear via el WHERE de arriba (FOR UPDATE SKIP LOCKED -- solo aguanta hasta el commit). Solucion pragmatica: dentro de -- ESTA misma transaccion no habra colision porque estamos en un -- unico crear_solicitud. Entre transacciones concurrentes, dos -- alumnos pueden llegar a asignar la misma unidad si ambos hacen -- crear_solicitud simultaneo y la unidad aun esta 'disponible': -- para blindar eso, cambiamos a 'mantenimiento' temporal seria feo. -- La opcion limpia: reflejar la reserva marcando la unidad como -- 'prestado' YA en la creacion. Trade-off: la unidad queda ocupada -- aunque el admin luego rechace. El sync_stock al rechazar -- ('pendiente' -> 'rechazado' no dispara sync porque el OLD ya es -- pendiente); necesitamos liberar la unidad en rechazar tambien. -- Elegimos ESTA ruta (marcar como 'prestado' aqui) por seguridad -- de concurrencia, y extendemos sync_stock/log de rechazo para -- liberar unidades cuando se rechaza desde 'pendiente'. update prestamos.material_unidades set estado = 'prestado' where id = v_unidad_id; v_asignadas := v_asignadas + 1; end loop; -- para materiales trackeados, cantidad_disponible se mantiene -- automatico via sync_material_desde_unidad(); nada mas que hacer. else insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion) values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion); end if; end loop; return v_solicitud_id; end; $$; grant execute on function prestamos.crear_solicitud(text, text, jsonb) to authenticated; -- ============================================================ -- B.6) sync_stock: cubrir tambien la ruta pendiente->rechazado para -- liberar unidades reservadas (crear_solicitud las marca 'prestado' -- preventivamente para bloquear la carrera). -- Para materiales no-trackeados, el stock nunca se descuenta antes -- de aprobar, asi que este bloque solo importa para material_unidades. -- ============================================================ create or replace function prestamos.sync_stock() returns trigger language plpgsql security definer set search_path = prestamos, pg_temp as $$ declare item record; begin -- sacar stock: !aprobado/activo -> aprobado/activo if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then for item in select material_id, cantidad, material_unidad_id from prestamos.solicitud_items where solicitud_id = new.id order by material_id loop -- materiales no-trackeados: descontar cantidad_disponible -- (para trackeados, la unidad ya se marco 'prestado' en crear_solicitud -- y sync_material_desde_unidad ya bajo cantidad_disponible en su momento; -- no volvemos a bajar aqui) if item.material_unidad_id is null then update prestamos.materiales set cantidad_disponible = cantidad_disponible - item.cantidad where id = item.material_id; end if; end loop; end if; -- devolver stock: aprobado/activo -> devuelto/rechazado if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then for item in select material_id, cantidad, material_unidad_id from prestamos.solicitud_items where solicitud_id = new.id order by material_id loop if item.material_unidad_id is null then update prestamos.materiales set cantidad_disponible = cantidad_disponible + item.cantidad where id = item.material_id; else update prestamos.material_unidades set estado = 'disponible' where id = item.material_unidad_id; end if; end loop; end if; -- pendiente -> rechazado: liberar unidades reservadas -- (no toca cantidad_disponible de no-trackeados: aun no se descontaba) if new.estado = 'rechazado' and old.estado = 'pendiente' then for item in select material_unidad_id from prestamos.solicitud_items where solicitud_id = new.id and material_unidad_id is not null loop update prestamos.material_unidades set estado = 'disponible' where id = item.material_unidad_id; end loop; end if; return new; end; $$; -- ============================================================ -- B.7) RPC reasignar_unidad: admin cambia la unidad ligada a un renglon. -- Solo permitido si la solicitud esta en aprobado/activo y el item -- ya tiene unidad. La unidad nueva debe ser del mismo material y -- estar 'disponible'. Log en audit_log con accion='reasignar_unidad'. -- ============================================================ create or replace function prestamos.reasignar_unidad( p_item_id bigint, p_nueva_unidad_id bigint ) returns void language plpgsql security definer set search_path = prestamos, pg_temp as $$ declare v_solicitud_id bigint; v_estado_sol text; v_material_id int; v_unidad_vieja bigint; v_material_nueva int; v_estado_nueva text; begin if not prestamos.is_admin() then raise exception 'no_autorizado'; end if; select si.solicitud_id, s.estado, si.material_id, si.material_unidad_id into v_solicitud_id, v_estado_sol, v_material_id, v_unidad_vieja from prestamos.solicitud_items si join prestamos.solicitudes s on s.id = si.solicitud_id where si.id = p_item_id; if v_solicitud_id is null then raise exception 'item_no_existe'; end if; if v_estado_sol not in ('aprobado','activo') then raise exception 'solicitud_no_activa'; end if; if v_unidad_vieja is null then raise exception 'item_sin_unidad'; end if; select material_id, estado into v_material_nueva, v_estado_nueva from prestamos.material_unidades where id = p_nueva_unidad_id for update; if v_material_nueva is null then raise exception 'unidad_no_existe'; end if; if v_material_nueva <> v_material_id then raise exception 'unidad_de_otro_material'; end if; if v_estado_nueva <> 'disponible' then raise exception 'unidad_no_disponible'; end if; update prestamos.material_unidades set estado = 'disponible' where id = v_unidad_vieja; update prestamos.material_unidades set estado = 'prestado' where id = p_nueva_unidad_id; update prestamos.solicitud_items set material_unidad_id = p_nueva_unidad_id where id = p_item_id; insert into prestamos.audit_log (solicitud_id, actor_id, accion, estado_anterior, estado_nuevo, payload) values ( v_solicitud_id, auth.uid(), 'reasignar_unidad', null, null, jsonb_build_object('item_id', p_item_id, 'unidad_vieja', v_unidad_vieja, 'unidad_nueva', p_nueva_unidad_id) ); end; $$; grant execute on function prestamos.reasignar_unidad(bigint, bigint) to authenticated; -- ============================================================ -- B.8) RLS material_unidades: todos leen, solo admin escribe -- ============================================================ alter table prestamos.material_unidades enable row level security; create policy material_unidades_read_all on prestamos.material_unidades for select to authenticated using (true); create policy material_unidades_admin_write on prestamos.material_unidades for all to authenticated using (prestamos.is_admin()) with check (prestamos.is_admin()); grant select, insert, update, delete on prestamos.material_unidades to authenticated, service_role; grant usage, select on prestamos.material_unidades_id_seq to authenticated, service_role; -- ============================================================ -- C) Publication para Realtime (badge de pendientes) -- replica identity default alcanza para INSERT/DELETE; para UPDATE -- Realtime tambien emite pero solo con columnas de PK. Suficiente -- para el caso de uso (contar cambios a/desde 'pendiente'). -- ============================================================ do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'prestamos' and tablename = 'solicitudes' ) then alter publication supabase_realtime add table prestamos.solicitudes; end if; end $$; -- ============================================================ -- D) Bucket publico 'materiales-fotos' + policies de storage -- Lectura anonima (bucket publico); escritura solo si is_admin(). -- ============================================================ insert into storage.buckets (id, name, public) values ('materiales-fotos', 'materiales-fotos', true) on conflict (id) do update set public = true; drop policy if exists "materiales_fotos_read_all" on storage.objects; drop policy if exists "materiales_fotos_admin_write" on storage.objects; create policy "materiales_fotos_read_all" on storage.objects for select to anon, authenticated using (bucket_id = 'materiales-fotos'); create policy "materiales_fotos_admin_write" on storage.objects for all to authenticated using (bucket_id = 'materiales-fotos' and prestamos.is_admin()) with check (bucket_id = 'materiales-fotos' and prestamos.is_admin()); commit;