--liquibase formatted sql -- PANELS · core · RPC almacén --changeset panel:core-023a-fn-warehouse-ensure-central splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_ensure_central(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_id bigint; v_row jsonb; BEGIN SELECT id INTO v_id FROM warehouses WHERE tenant_id = v_tid AND kind = 'central' LIMIT 1; IF v_id IS NULL THEN INSERT INTO warehouses (tenant_id, kind, status) VALUES (v_tid, 'central', 'open') RETURNING id INTO v_id; END IF; SELECT to_jsonb(w) INTO v_row FROM warehouses w WHERE id = v_id; RETURN core.rpc_ok(v_row, format('Almacén central del tenant %s listo (id=%s)', v_tid, v_id), jsonb_build_object('fn', 'fn_warehouse_ensure_central', 'id', v_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_ensure_central', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023b-fn-warehouse-open-project splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_open_project(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_proj record; v_id bigint; v_row jsonb; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_open_project: project_id es obligatorio', jsonb_build_object('fn', 'fn_warehouse_open_project', 'field', 'project_id')); END IF; SELECT id, status, code, name INTO v_proj FROM projects WHERE id = v_project_id; IF NOT FOUND THEN RETURN core.rpc_err('NOT_FOUND', format('fn_warehouse_open_project: proyecto id=%s no encontrado', v_project_id), jsonb_build_object('fn', 'fn_warehouse_open_project', 'project_id', v_project_id)); END IF; IF v_proj.status NOT IN ('activo', 'pausado') THEN RETURN core.rpc_err('VALIDATION', format('fn_warehouse_open_project: proyecto %s está %s, no se abre almacén', v_proj.code, v_proj.status), jsonb_build_object('fn', 'fn_warehouse_open_project', 'project_id', v_project_id)); END IF; SELECT id INTO v_id FROM warehouses WHERE project_id = v_project_id AND kind = 'project'; IF v_id IS NULL THEN INSERT INTO warehouses (tenant_id, kind, project_id, status) VALUES (v_tid, 'project', v_project_id, 'open') RETURNING id INTO v_id; ELSE UPDATE warehouses SET status = 'open', closed_at = NULL, closed_by_name = '' WHERE id = v_id; END IF; PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_tid)); SELECT to_jsonb(w) INTO v_row FROM warehouses w WHERE id = v_id; RETURN core.rpc_ok(v_row, format('Almacén de obra abierto para proyecto %s (%s)', v_proj.code, v_proj.name), jsonb_build_object('fn', 'fn_warehouse_open_project', 'id', v_id, 'project_id', v_project_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_open_project', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023c-fn-warehouse-stock-upsert splitStatements:false CREATE OR REPLACE FUNCTION core._warehouse_stock_apply( p_warehouse_id bigint, p_material_id bigint, p_qty_delta numeric, p_unit_cost numeric ) RETURNS void LANGUAGE plpgsql SET search_path = core AS $$ DECLARE v_cur record; v_new_qty numeric; v_new_avg numeric; BEGIN SELECT * INTO v_cur FROM warehouse_stock WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id; IF NOT FOUND THEN IF p_qty_delta < 0 THEN RAISE EXCEPTION 'Stock insuficiente en almacén % material %', p_warehouse_id, p_material_id; END IF; INSERT INTO warehouse_stock (warehouse_id, material_id, qty_on_hand, avg_unit_cost) VALUES (p_warehouse_id, p_material_id, p_qty_delta, COALESCE(p_unit_cost, 0)); RETURN; END IF; v_new_qty := v_cur.qty_on_hand + p_qty_delta; IF v_new_qty < 0 THEN RAISE EXCEPTION 'Stock insuficiente: disponible % solicitado %', v_cur.qty_on_hand, -p_qty_delta; END IF; IF p_qty_delta > 0 AND p_unit_cost > 0 THEN v_new_avg := CASE WHEN v_new_qty > 0 THEN round((v_cur.qty_on_hand * v_cur.avg_unit_cost + p_qty_delta * p_unit_cost) / v_new_qty, 4) ELSE 0 END; ELSE v_new_avg := v_cur.avg_unit_cost; END IF; UPDATE warehouse_stock SET qty_on_hand = v_new_qty, avg_unit_cost = v_new_avg WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id; END; $$; --changeset panel:core-023d-fn-warehouse-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_include_closed boolean := COALESCE((payload->>'include_closed')::boolean, false); v_rows jsonb; BEGIN PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_tid)); SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.kind, x.id), '[]'::jsonb) INTO v_rows FROM ( SELECT w.*, p.code AS project_code, p.name AS project_name FROM warehouses w LEFT JOIN projects p ON p.id = w.project_id WHERE w.tenant_id = v_tid AND (v_include_closed OR w.status = 'open' OR w.kind = 'central') AND (w.kind = 'central' OR w.status <> 'closed' OR v_include_closed) ) x; RETURN core.rpc_ok( jsonb_build_object('warehouses', v_rows), format('Almacenes del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)), jsonb_build_object('fn', 'fn_warehouse_list', 'tenant_id', v_tid) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_list', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023e-fn-warehouse-stock-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_stock_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint; v_global boolean := COALESCE((payload->>'global')::boolean, false); v_tid integer := core._cost_tenant_id(payload); v_rows jsonb; BEGIN IF v_global THEN SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM ( SELECT w.id AS warehouse_id, w.kind, w.project_id, p.code AS project_code, m.id AS material_id, m.code AS material_code, m.name AS material_name, m.unit, s.qty_on_hand, s.avg_unit_cost, round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value FROM warehouse_stock s JOIN warehouses w ON w.id = s.warehouse_id JOIN warehouse_materials m ON m.id = s.material_id LEFT JOIN projects p ON p.id = w.project_id WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0 ) x; ELSE IF v_warehouse_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_stock_list: warehouse_id es obligatorio si global=false', jsonb_build_object('fn', 'fn_warehouse_stock_list')); END IF; SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM ( SELECT s.*, m.code AS material_code, m.name AS material_name, m.unit, round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value FROM warehouse_stock s JOIN warehouse_materials m ON m.id = s.material_id WHERE s.warehouse_id = v_warehouse_id AND s.qty_on_hand > 0 ) x; END IF; RETURN core.rpc_ok(jsonb_build_object('stock', v_rows), format('Stock consultado: %s línea(s)', jsonb_array_length(COALESCE(v_rows, '[]'::jsonb))), jsonb_build_object('fn', 'fn_warehouse_stock_list', 'warehouse_id', v_warehouse_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_stock_list', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023f-fn-material-upsert splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_material_upsert(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), '')); v_name text := coalesce(nullif(btrim(payload->>'name'), ''), ''); v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), ''); v_id bigint; BEGIN IF v_code = '' OR v_name = '' THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_material_upsert: code y name son obligatorios', jsonb_build_object('fn', 'fn_warehouse_material_upsert')); END IF; INSERT INTO warehouse_materials (tenant_id, code, name, unit) VALUES (v_tid, v_code, v_name, v_unit) ON CONFLICT (tenant_id, code) DO UPDATE SET name = EXCLUDED.name, unit = EXCLUDED.unit RETURNING id INTO v_id; RETURN core.rpc_ok( (SELECT to_jsonb(m) FROM warehouse_materials m WHERE id = v_id), format('Material %s (%s) registrado', v_code, v_name), jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_material_upsert', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023g-fn-warehouse-entry-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_entry_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint; v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint; v_material_code text := nullif(btrim(payload->>'material_code'), ''); v_material_name text := nullif(btrim(payload->>'material_name'), ''); v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), ''); v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0); v_unit_cost numeric := COALESCE((payload->>'unit_cost')::numeric, 0); v_movement_date date := COALESCE(NULLIF(payload->>'movement_date', '')::date, CURRENT_DATE); v_expense_id bigint := NULLIF(payload->>'expense_id', '')::bigint; v_wh record; v_mov_id bigint; v_total numeric; v_env jsonb; BEGIN IF v_warehouse_id IS NULL OR v_qty <= 0 THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_entry_create: warehouse_id y quantity>0 son obligatorios', jsonb_build_object('fn', 'fn_warehouse_entry_create')); END IF; SELECT * INTO v_wh FROM warehouses WHERE id = v_warehouse_id AND tenant_id = v_tid; IF NOT FOUND OR v_wh.status <> 'open' THEN RETURN core.rpc_err('VALIDATION', format('fn_warehouse_entry_create: almacén id=%s no disponible', v_warehouse_id), jsonb_build_object('fn', 'fn_warehouse_entry_create', 'warehouse_id', v_warehouse_id)); END IF; IF v_material_id IS NULL AND v_material_code IS NOT NULL THEN v_env := core.fn_warehouse_material_upsert(jsonb_build_object( 'tenant_id', v_tid, 'code', v_material_code, 'name', coalesce(v_material_name, v_material_code), 'unit', v_unit )); IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN RETURN v_env; END IF; v_material_id := (v_env->'data'->>'id')::bigint; END IF; IF v_material_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_entry_create: material_id o material_code requerido', jsonb_build_object('fn', 'fn_warehouse_entry_create')); END IF; v_total := round(v_qty * v_unit_cost, 2); PERFORM core._warehouse_stock_apply(v_warehouse_id, v_material_id, v_qty, v_unit_cost); INSERT INTO warehouse_movements ( tenant_id, warehouse_id, material_id, movement_type, movement_date, quantity, unit_cost, total_cost, expense_id, created_by_id, created_by_name ) VALUES ( v_tid, v_warehouse_id, v_material_id, 'entry', v_movement_date, v_qty, v_unit_cost, v_total, v_expense_id, NULLIF(payload->>'created_by_id', '')::integer, coalesce(nullif(btrim(payload->>'created_by_name'), ''), '') ) RETURNING id INTO v_mov_id; RETURN core.rpc_created( jsonb_build_object('movement_id', v_mov_id, 'warehouse_id', v_warehouse_id, 'quantity', v_qty, 'total_cost', v_total), format('Entrada de %s unidades al almacén id=%s por %s', v_qty, v_warehouse_id, v_total), jsonb_build_object('fn', 'fn_warehouse_entry_create', 'movement_id', v_mov_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_entry_create', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023h-fn-warehouse-exit-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_exit_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint; v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint; v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0); v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; v_destination text := coalesce(nullif(btrim(payload->>'destination_note'), ''), ''); v_movement_date date := COALESCE(NULLIF(payload->>'movement_date', '')::date, CURRENT_DATE); v_stock record; v_unit_cost numeric; v_total numeric; v_mov_id bigint; v_warning jsonb; BEGIN IF v_warehouse_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_exit_create: warehouse_id, material_id y quantity>0 obligatorios', jsonb_build_object('fn', 'fn_warehouse_exit_create')); END IF; SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_warehouse_id AND material_id = v_material_id; IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN RETURN core.rpc_err('VALIDATION', format('fn_warehouse_exit_create: stock insuficiente (disponible %s, solicitado %s)', COALESCE(v_stock.qty_on_hand, 0), v_qty), jsonb_build_object('fn', 'fn_warehouse_exit_create', 'warehouse_id', v_warehouse_id)); END IF; v_unit_cost := v_stock.avg_unit_cost; v_total := round(v_qty * v_unit_cost, 2); PERFORM core._warehouse_stock_apply(v_warehouse_id, v_material_id, -v_qty, v_unit_cost); INSERT INTO warehouse_movements ( tenant_id, warehouse_id, material_id, movement_type, movement_date, quantity, unit_cost, total_cost, budget_item_id, destination_note, created_by_id, created_by_name ) VALUES ( v_tid, v_warehouse_id, v_material_id, 'exit', v_movement_date, v_qty, v_unit_cost, v_total, v_budget_item_id, v_destination, NULLIF(payload->>'created_by_id', '')::integer, coalesce(nullif(btrim(payload->>'created_by_name'), ''), '') ) RETURNING id INTO v_mov_id; IF v_budget_item_id IS NOT NULL THEN v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0); END IF; RETURN core.rpc_created( jsonb_build_object('movement_id', v_mov_id, 'total_cost', v_total, 'budget_warning', v_warning), format('Salida de %s unidades del almacén id=%s hacia concepto %s', v_qty, v_warehouse_id, v_budget_item_id), jsonb_build_object('fn', 'fn_warehouse_exit_create', 'movement_id', v_mov_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_exit_create', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023i-fn-warehouse-transfer-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_transfer_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_from_id bigint := NULLIF(payload->>'from_warehouse_id', '')::bigint; v_to_id bigint := NULLIF(payload->>'to_warehouse_id', '')::bigint; v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint; v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0); v_transfer_date date := COALESCE(NULLIF(payload->>'transfer_date', '')::date, CURRENT_DATE); v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); v_stock record; v_unit_cost numeric; v_transfer_id bigint; v_created_by text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); BEGIN IF v_from_id IS NULL OR v_to_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 OR v_from_id = v_to_id THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: origen, destino, material y cantidad válidos son obligatorios', jsonb_build_object('fn', 'fn_warehouse_transfer_create')); END IF; SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_from_id AND material_id = v_material_id; IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: stock insuficiente en almacén origen', jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id)); END IF; v_unit_cost := v_stock.avg_unit_cost; INSERT INTO warehouse_transfers ( tenant_id, from_warehouse_id, to_warehouse_id, material_id, quantity, unit_cost, status, transfer_date, notes, created_by_name ) VALUES ( v_tid, v_from_id, v_to_id, v_material_id, v_qty, v_unit_cost, 'completed', v_transfer_date, v_notes, v_created_by ) RETURNING id INTO v_transfer_id; PERFORM core._warehouse_stock_apply(v_from_id, v_material_id, -v_qty, v_unit_cost); PERFORM core._warehouse_stock_apply(v_to_id, v_material_id, v_qty, v_unit_cost); INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name) VALUES (v_tid, v_from_id, v_material_id, 'transfer_out', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by); INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name) VALUES (v_tid, v_to_id, v_material_id, 'transfer_in', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by); RETURN core.rpc_created( jsonb_build_object('transfer_id', v_transfer_id, 'quantity', v_qty, 'unit_cost', v_unit_cost), format('Transferencia %s unidades del almacén %s al %s', v_qty, v_from_id, v_to_id), jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'transfer_id', v_transfer_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_transfer_create', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023j-fn-warehouse-movement-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_movement_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint; v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint; v_rows jsonb; BEGIN SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.movement_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows FROM ( SELECT m.*, mat.code AS material_code, mat.name AS material_name, bi.description AS budget_item_description FROM warehouse_movements m JOIN warehouse_materials mat ON mat.id = m.material_id LEFT JOIN budget_items bi ON bi.id = m.budget_item_id WHERE (v_warehouse_id IS NULL OR m.warehouse_id = v_warehouse_id) AND (v_material_id IS NULL OR m.material_id = v_material_id) ) x; RETURN core.rpc_ok(jsonb_build_object('movements', v_rows), format('Kardex: %s movimiento(s)', jsonb_array_length(v_rows)), jsonb_build_object('fn', 'fn_warehouse_movement_list', 'warehouse_id', v_warehouse_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_movement_list', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023k-fn-warehouse-close-project splitStatements:false CREATE OR REPLACE FUNCTION core.fn_warehouse_close_project(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_force boolean := COALESCE((payload->>'force')::boolean, false); v_closed_by text := coalesce(nullif(btrim(payload->>'closed_by_name'), ''), ''); v_notes text := coalesce(nullif(btrim(payload->>'close_notes'), ''), ''); v_wh record; v_stock_count integer; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_warehouse_close_project: project_id es obligatorio', jsonb_build_object('fn', 'fn_warehouse_close_project')); END IF; SELECT * INTO v_wh FROM warehouses WHERE project_id = v_project_id AND kind = 'project'; IF NOT FOUND THEN RETURN core.rpc_ok(jsonb_build_object('closed', false, 'reason', 'no_warehouse'), format('Proyecto %s no tenía almacén abierto', v_project_id), jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id)); END IF; IF v_wh.status = 'closed' THEN RETURN core.rpc_ok(jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed'), format('Almacén del proyecto %s ya estaba cerrado', v_project_id), jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id)); END IF; SELECT COUNT(*)::integer INTO v_stock_count FROM warehouse_stock WHERE warehouse_id = v_wh.id AND qty_on_hand > 0; IF v_stock_count > 0 AND NOT v_force THEN RETURN core.rpc_err('VALIDATION', format('fn_warehouse_close_project: quedan %s material(es) con stock; transfiera o ajuste antes de cerrar', v_stock_count), jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id, 'stock_lines', v_stock_count)); END IF; UPDATE warehouses SET status = 'closed', closed_at = now(), closed_by_name = v_closed_by, close_notes = v_notes WHERE id = v_wh.id; RETURN core.rpc_ok( jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed', 'forced', v_force), format('Almacén del proyecto %s cerrado', v_project_id), jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_warehouse_close_project', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-023l-warehouse-grants endDelimiter:; splitStatements:true GRANT EXECUTE ON FUNCTION core._warehouse_stock_apply(bigint, bigint, numeric, numeric) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_ensure_central(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_open_project(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_list(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_stock_list(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_upsert(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_entry_create(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_exit_create(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_transfer_create(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_movement_list(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_warehouse_close_project(jsonb) TO panels_core_app;