--liquibase formatted sql -- PANELS · core · RPC presupuesto (capítulos, partidas, importación atómica) --changeset panel:core-011a-fn-budget-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_chapters jsonb; v_items jsonb; v_subtotal numeric(14,2); v_iva numeric(14,2); v_item_count integer; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_list: project_id es obligatorio para consultar el presupuesto', jsonb_build_object('fn', 'fn_budget_list', 'field', 'project_id') ); END IF; IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_list: proyecto id=%s no encontrado', v_project_id), jsonb_build_object('fn', 'fn_budget_list', 'project_id', v_project_id) ); END IF; SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.sort_order, c.id), '[]'::jsonb) INTO v_chapters FROM ( SELECT bc.id, bc.project_id, bc.parent_id, bc.code, bc.name, COALESCE(bc.wbs, '') AS wbs, bc.sort_order FROM budget_chapters bc WHERE bc.project_id = v_project_id ) c; SELECT COALESCE(jsonb_agg(to_jsonb(i) ORDER BY COALESCE(i.ch_sort, 9999), i.sort_order, i.id), '[]'::jsonb), COALESCE(SUM(i.amount), 0), COUNT(*)::integer INTO v_items, v_subtotal, v_item_count FROM ( SELECT bi.*, ch.name AS chapter_name, ch.code AS chapter_code, ch.sort_order AS ch_sort FROM budget_items bi LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id WHERE bi.project_id = v_project_id ) i; v_subtotal := round(COALESCE(v_subtotal, 0), 2); v_iva := round(v_subtotal * 0.16, 2); RETURN core.rpc_ok( jsonb_build_object( 'chapters', v_chapters, 'items', v_items, 'totals', jsonb_build_object( 'subtotal', v_subtotal, 'iva', v_iva, 'total', round(v_subtotal + v_iva, 2), 'item_count', v_item_count ) ), format('Presupuesto del proyecto %s: %s capítulo(s), %s partida(s), subtotal %s', v_project_id, jsonb_array_length(v_chapters), v_item_count, v_subtotal), jsonb_build_object( 'fn', 'fn_budget_list', 'project_id', v_project_id, 'chapter_count', jsonb_array_length(v_chapters), 'item_count', v_item_count ) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_list', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011b-fn-budget-chapter-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_chapter_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_parent_id bigint := NULLIF(payload->>'parent_id', '')::bigint; v_name text := nullif(btrim(payload->>'name'), ''); v_code text := coalesce(nullif(btrim(payload->>'code'), ''), ''); v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), ''); v_sort integer; v_row budget_chapters%ROWTYPE; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_chapter_create: project_id es obligatorio', jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'project_id') ); END IF; IF v_name IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_chapter_create: nombre de capítulo obligatorio', jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'name', 'project_id', v_project_id) ); END IF; IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_chapter_create: proyecto id=%s no encontrado', v_project_id), jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id) ); END IF; IF v_parent_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id ) THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_chapter_create: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id), jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'parent_id', v_parent_id) ); END IF; SELECT COALESCE(MAX(sort_order), 0) + 1 INTO v_sort FROM budget_chapters WHERE project_id = v_project_id; INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (v_project_id, v_parent_id, v_code, v_name, v_wbs, v_sort) RETURNING * INTO v_row; RETURN core.rpc_created( jsonb_build_object('chapter', to_jsonb(v_row)), format('Capítulo "%s" creado en proyecto %s (id=%s)', v_row.name, v_project_id, v_row.id), jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'id', v_row.id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_chapter_create', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011c-fn-budget-chapter-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_chapter_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_id bigint := NULLIF(payload->>'id', '')::bigint; v_cur budget_chapters%ROWTYPE; v_name text; v_code text; v_wbs text; v_parent_id bigint; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_chapter_update: project_id es obligatorio', jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'project_id') ); END IF; IF v_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_chapter_update: id del capítulo es obligatorio', jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'id', 'project_id', v_project_id) ); END IF; SELECT * INTO v_cur FROM budget_chapters WHERE id = v_id AND project_id = v_project_id; IF NOT FOUND THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_chapter_update: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id), jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id) ); END IF; v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name); v_code := CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END; v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END; v_parent_id := CASE WHEN payload ? 'parent_id' THEN NULLIF(payload->>'parent_id', '')::bigint ELSE v_cur.parent_id END; IF v_parent_id IS NOT NULL AND v_parent_id = v_id THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_chapter_update: un capítulo no puede ser padre de sí mismo (id=%s)', v_id), jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id) ); END IF; IF v_parent_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id ) THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_chapter_update: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id), jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'parent_id', v_parent_id) ); END IF; UPDATE budget_chapters SET name = v_name, code = v_code, wbs = v_wbs, parent_id = v_parent_id WHERE id = v_id; RETURN core.rpc_ok( jsonb_build_object('id', v_id, 'name', v_name, 'code', v_code), format('Capítulo id=%s actualizado en proyecto %s', v_id, v_project_id), jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_chapter_update', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011d-fn-budget-chapter-delete splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_chapter_delete(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_id bigint := NULLIF(payload->>'id', '')::bigint; v_name text; v_orphans integer; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_chapter_delete: project_id es obligatorio', jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'project_id') ); END IF; IF v_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_chapter_delete: id del capítulo es obligatorio', jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'id', 'project_id', v_project_id) ); END IF; SELECT name INTO v_name FROM budget_chapters WHERE id = v_id AND project_id = v_project_id; IF NOT FOUND THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_chapter_delete: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id), jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id) ); END IF; UPDATE budget_items SET chapter_id = NULL WHERE project_id = v_project_id AND chapter_id = v_id; GET DIAGNOSTICS v_orphans = ROW_COUNT; DELETE FROM budget_chapters WHERE id = v_id AND project_id = v_project_id; RETURN core.rpc_ok( jsonb_build_object('id', v_id, 'orphaned_items', v_orphans), format('Capítulo "%s" (id=%s) eliminado del proyecto %s; %s partida(s) quedaron sin capítulo', v_name, v_id, v_project_id, v_orphans), jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_chapter_delete', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011e-fn-budget-item-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_item_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_chapter_id bigint := NULLIF(payload->>'chapter_id', '')::bigint; v_description text := nullif(btrim(payload->>'description'), ''); v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), '')); v_unit text := upper(coalesce(nullif(btrim(payload->>'unit'), ''), '')); v_qty numeric(14,4) := coalesce((payload->>'quantity')::numeric(14,4), 0); v_price numeric(14,4) := coalesce((payload->>'unit_price')::numeric(14,4), 0); v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), ''); v_amount numeric(14,2); v_sort integer; v_row budget_items%ROWTYPE; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_item_create: project_id es obligatorio', jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'project_id') ); END IF; IF v_description IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_item_create: descripción de partida obligatoria', jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'description', 'project_id', v_project_id) ); END IF; IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_item_create: proyecto id=%s no encontrado', v_project_id), jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id) ); END IF; IF v_chapter_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id ) THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_item_create: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id), jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'chapter_id', v_chapter_id) ); END IF; v_amount := round(v_qty * v_price, 2); SELECT COALESCE(MAX(sort_order), 0) + 1 INTO v_sort FROM budget_items WHERE project_id = v_project_id; INSERT INTO budget_items ( project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order ) VALUES ( v_project_id, v_chapter_id, v_code, v_description, v_unit, v_qty, v_price, v_amount, v_wbs, v_sort ) RETURNING * INTO v_row; RETURN core.rpc_created( jsonb_build_object('item', to_jsonb(v_row)), format('Partida "%s" creada en proyecto %s (id=%s, importe=%s)', v_row.description, v_project_id, v_row.id, v_row.amount), jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'id', v_row.id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_item_create', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011f-fn-budget-item-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_item_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_id bigint := NULLIF(payload->>'id', '')::bigint; v_cur budget_items%ROWTYPE; v_chapter_id bigint; v_description text; v_code text; v_unit text; v_qty numeric(14,4); v_price numeric(14,4); v_wbs text; v_amount numeric(14,2); BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_item_update: project_id es obligatorio', jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'project_id') ); END IF; IF v_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_item_update: id de partida es obligatorio', jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'id', 'project_id', v_project_id) ); END IF; SELECT * INTO v_cur FROM budget_items WHERE id = v_id AND project_id = v_project_id; IF NOT FOUND THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_item_update: partida id=%s no encontrada en proyecto %s', v_id, v_project_id), jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id) ); END IF; v_chapter_id := CASE WHEN payload ? 'chapter_id' THEN NULLIF(payload->>'chapter_id', '')::bigint ELSE v_cur.chapter_id END; v_description := coalesce(nullif(btrim(payload->>'description'), ''), v_cur.description); v_code := upper(CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END); v_unit := upper(CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE v_cur.unit END); v_qty := CASE WHEN payload ? 'quantity' THEN coalesce((payload->>'quantity')::numeric(14,4), 0) ELSE v_cur.quantity END; v_price := CASE WHEN payload ? 'unit_price' THEN coalesce((payload->>'unit_price')::numeric(14,4), 0) ELSE v_cur.unit_price END; v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END; IF v_chapter_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id ) THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_item_update: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id), jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'chapter_id', v_chapter_id) ); END IF; v_amount := round(v_qty * v_price, 2); UPDATE budget_items SET chapter_id = v_chapter_id, code = v_code, description = v_description, unit = v_unit, quantity = v_qty, unit_price = v_price, amount = v_amount, wbs = v_wbs WHERE id = v_id; RETURN core.rpc_ok( jsonb_build_object('id', v_id, 'amount', v_amount), format('Partida id=%s actualizada en proyecto %s (importe=%s)', v_id, v_project_id, v_amount), jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_item_update', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011g-fn-budget-item-delete splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_item_delete(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_id bigint := NULLIF(payload->>'id', '')::bigint; v_description text; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_item_delete: project_id es obligatorio', jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'project_id') ); END IF; IF v_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_item_delete: id de partida es obligatorio', jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'id', 'project_id', v_project_id) ); END IF; DELETE FROM budget_items WHERE id = v_id AND project_id = v_project_id RETURNING description INTO v_description; IF NOT FOUND THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_item_delete: partida id=%s no encontrada en proyecto %s', v_id, v_project_id), jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id) ); END IF; RETURN core.rpc_ok( jsonb_build_object('id', v_id), format('Partida "%s" (id=%s) eliminada del proyecto %s', v_description, v_id, v_project_id), jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_item_delete', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011h-fn-budget-replace splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_replace(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_chapters jsonb := COALESCE(payload->'chapters', '[]'::jsonb); v_items jsonb := COALESCE(payload->'items', '[]'::jsonb); v_ch jsonb; v_it jsonb; v_wbs text; v_parent_wbs text; v_chapter_wbs text; v_parent_id bigint; v_chapter_id bigint; v_new_id bigint; v_sort integer; v_item_sort integer; v_qty numeric(14,4); v_price numeric(14,4); v_amount numeric(14,2); v_chapter_count integer := 0; v_item_count integer := 0; v_wbs_map jsonb := '{}'::jsonb; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_replace: project_id es obligatorio para reemplazar el presupuesto', jsonb_build_object('fn', 'fn_budget_replace', 'field', 'project_id') ); END IF; IF jsonb_typeof(v_chapters) <> 'array' THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_replace: chapters debe ser un arreglo JSON', jsonb_build_object('fn', 'fn_budget_replace', 'field', 'chapters', 'project_id', v_project_id) ); END IF; IF jsonb_typeof(v_items) <> 'array' THEN RETURN core.rpc_err( 'VALIDATION', 'fn_budget_replace: items debe ser un arreglo JSON', jsonb_build_object('fn', 'fn_budget_replace', 'field', 'items', 'project_id', v_project_id) ); END IF; IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN RETURN core.rpc_err( 'NOT_FOUND', format('fn_budget_replace: proyecto id=%s no encontrado', v_project_id), jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id) ); END IF; DELETE FROM budget_items WHERE project_id = v_project_id; DELETE FROM budget_chapters WHERE project_id = v_project_id; v_sort := 0; FOR v_ch IN SELECT elem FROM jsonb_array_elements(v_chapters) AS elem ORDER BY coalesce(nullif(btrim(elem->>'wbs'), ''), elem->>'sort_order', elem->>'name') LOOP v_sort := v_sort + 1; v_wbs := coalesce(nullif(btrim(v_ch->>'wbs'), ''), v_sort::text); v_parent_wbs := nullif(btrim(v_ch->>'parent_wbs'), ''); v_parent_id := NULL; IF v_parent_wbs IS NOT NULL THEN v_parent_id := NULLIF(v_wbs_map->>v_parent_wbs, '')::bigint; IF v_parent_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_replace: parent_wbs=%s no encontrado al insertar capítulo wbs=%s', v_parent_wbs, v_wbs), jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs, 'parent_wbs', v_parent_wbs) ); END IF; END IF; IF nullif(btrim(v_ch->>'name'), '') IS NULL THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_replace: nombre de capítulo obligatorio (wbs=%s)', v_wbs), jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs) ); END IF; INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES ( v_project_id, v_parent_id, coalesce(nullif(btrim(v_ch->>'code'), ''), ''), btrim(v_ch->>'name'), v_wbs, coalesce((v_ch->>'sort_order')::integer, v_sort) ) RETURNING id INTO v_new_id; v_wbs_map := v_wbs_map || jsonb_build_object(v_wbs, v_new_id::text); v_chapter_count := v_chapter_count + 1; END LOOP; v_item_sort := 0; FOR v_it IN SELECT elem FROM jsonb_array_elements(v_items) AS elem LOOP v_item_sort := v_item_sort + 1; IF nullif(btrim(v_it->>'description'), '') IS NULL THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_replace: descripción obligatoria en partida #%s', v_item_sort), jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'item_index', v_item_sort) ); END IF; v_chapter_wbs := nullif(btrim(v_it->>'chapter_wbs'), ''); v_chapter_id := NULL; IF v_chapter_wbs IS NOT NULL THEN v_chapter_id := NULLIF(v_wbs_map->>v_chapter_wbs, '')::bigint; IF v_chapter_id IS NULL THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_replace: chapter_wbs=%s no encontrado para partida #%s', v_chapter_wbs, v_item_sort), jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_wbs', v_chapter_wbs) ); END IF; ELSIF v_it ? 'chapter_id' AND nullif(v_it->>'chapter_id', '') IS NOT NULL THEN v_chapter_id := (v_it->>'chapter_id')::bigint; IF NOT EXISTS (SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id) THEN RETURN core.rpc_err( 'VALIDATION', format('fn_budget_replace: chapter_id=%s inválido para partida #%s', v_chapter_id, v_item_sort), jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_id', v_chapter_id) ); END IF; END IF; v_qty := coalesce((v_it->>'quantity')::numeric(14,4), 0); v_price := coalesce((v_it->>'unit_price')::numeric(14,4), 0); v_amount := round(coalesce((v_it->>'amount')::numeric(14,2), v_qty * v_price), 2); INSERT INTO budget_items ( project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order ) VALUES ( v_project_id, v_chapter_id, upper(coalesce(nullif(btrim(v_it->>'code'), ''), '')), btrim(v_it->>'description'), upper(coalesce(nullif(btrim(v_it->>'unit'), ''), '')), v_qty, v_price, v_amount, coalesce(nullif(btrim(v_it->>'wbs'), ''), ''), coalesce((v_it->>'sort_order')::integer, v_item_sort) ); v_item_count := v_item_count + 1; END LOOP; RETURN core.rpc_ok( jsonb_build_object( 'project_id', v_project_id, 'chapter_count', v_chapter_count, 'item_count', v_item_count ), format('Presupuesto del proyecto %s reemplazado: %s capítulo(s) y %s partida(s) importados', v_project_id, v_chapter_count, v_item_count), jsonb_build_object( 'fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_count', v_chapter_count, 'item_count', v_item_count ) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_budget_replace', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-011i-fn-budget-grants endDelimiter:; splitStatements:true GRANT EXECUTE ON FUNCTION core.fn_budget_list(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_create(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_update(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_delete(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_item_create(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_item_update(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_item_delete(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_budget_replace(jsonb) TO panels_core_app;