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:
@@ -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;
|
||||
Reference in New Issue
Block a user