Migración 0003: imagen_path, unidades individuales, publication realtime, bucket materiales-fotos

- 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
This commit is contained in:
2026-08-22 18:02:10 -07:00
parent c52bc905ca
commit 43959056f8
@@ -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;