From 43959056f869e9160de3a43f859180d28d10fd91 Mon Sep 17 00:00:00 2001 From: Lak-G Date: Sat, 22 Aug 2026 18:02:10 -0700 Subject: [PATCH] =?UTF-8?q?Migraci=C3=B3n=200003:=20imagen=5Fpath,=20unida?= =?UTF-8?q?des=20individuales,=20publication=20realtime,=20bucket=20materi?= =?UTF-8?q?ales-fotos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prestamos.materiales.imagen_path (path dentro del bucket) - prestamos.materiales.trackeado_por_unidad + tabla prestamos.material_unidades con trigger que mantiene cantidad_total/cantidad_disponible sincronizados - prestamos.solicitud_items.material_unidad_id (renglón ligado a unidad concreta) - crear_solicitud reescrita: asigna primera unidad disponible cuando trackeado - RPC reasignar_unidad (admin-only) con audit_log - sync_stock cubre transición pendiente->rechazado para liberar unidades - publication supabase_realtime += prestamos.solicitudes (badge admin) - bucket público 'materiales-fotos' con RLS admin-only para escritura --- .../0003_grid_unidades_realtime.sql | 498 ++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 supabase/migrations/0003_grid_unidades_realtime.sql diff --git a/supabase/migrations/0003_grid_unidades_realtime.sql b/supabase/migrations/0003_grid_unidades_realtime.sql new file mode 100644 index 0000000..d1170dc --- /dev/null +++ b/supabase/migrations/0003_grid_unidades_realtime.sql @@ -0,0 +1,498 @@ +-- 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;