diff --git a/db/core/changesets/011-rpc-budget.sql b/db/core/changesets/011-rpc-budget.sql new file mode 100644 index 0000000..62bb24b --- /dev/null +++ b/db/core/changesets/011-rpc-budget.sql @@ -0,0 +1,697 @@ +--liquibase formatted sql +-- PANELS · core · RPC presupuesto (capítulos, partidas, importación atómica) + +--changeset panel:core-011a-fn-budget-list endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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 endDelimiter:; splitStatements:true +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; diff --git a/db/core/changesets/012-rpc-badges.sql b/db/core/changesets/012-rpc-badges.sql new file mode 100644 index 0000000..29db9f2 --- /dev/null +++ b/db/core/changesets/012-rpc-badges.sql @@ -0,0 +1,333 @@ +--liquibase formatted sql +-- PANELS · core · RPC gafetes (jobs de impresión y entrega) + +--changeset panel:core-012a-fn-badge-job-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_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_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer; + v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); + v_worker_ids integer[]; + v_wid integer; + v_job_id bigint; + v_project record; + v_missing integer; + v_count integer := 0; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_create: project_id es obligatorio para generar gafetes', + jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'project_id') + ); + END IF; + + SELECT p.id, p.code, p.name, p.status + INTO v_project + FROM projects p + WHERE p.id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id) + ); + END IF; + IF v_project.status <> 'activo' THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_badge_job_create: solo se generan gafetes de proyectos activos (proyecto %s está en %s)', + v_project.code, v_project.status), + jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'status', v_project.status) + ); + END IF; + + IF payload->'worker_ids' IS NULL OR jsonb_typeof(payload->'worker_ids') <> 'array' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_create: worker_ids debe ser un arreglo de enteros', + jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id) + ); + END IF; + + SELECT array_agg(DISTINCT (elem::text)::integer ORDER BY (elem::text)::integer) + INTO v_worker_ids + FROM jsonb_array_elements(payload->'worker_ids') AS elem + WHERE nullif(btrim(elem::text), '') IS NOT NULL; + + IF v_worker_ids IS NULL OR cardinality(v_worker_ids) = 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_create: debe indicar al menos un trabajador en worker_ids', + jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id) + ); + END IF; + + SELECT COUNT(*) + INTO v_missing + FROM unnest(v_worker_ids) AS wid + WHERE NOT EXISTS (SELECT 1 FROM workers w WHERE w.id = wid); + IF v_missing > 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_badge_job_create: %s trabajador(es) en worker_ids no existen en el catálogo', v_missing), + jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'missing_count', v_missing) + ); + END IF; + + INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) + VALUES (v_project_id, 'done', v_created_by_id, v_created_by_name) + RETURNING id INTO v_job_id; + + FOREACH v_wid IN ARRAY v_worker_ids + LOOP + INSERT INTO badge_job_people (job_id, worker_id) + VALUES (v_job_id::integer, v_wid) + ON CONFLICT (job_id, worker_id) DO NOTHING; + v_count := v_count + 1; + END LOOP; + + RETURN core.rpc_created( + jsonb_build_object( + 'id', v_job_id, + 'project_id', v_project_id, + 'count', v_count, + 'worker_ids', to_jsonb(v_worker_ids) + ), + format('Job de gafetes #%s creado para proyecto %s (%s): %s persona(s)', + v_job_id, v_project.code, v_project.name, v_count), + jsonb_build_object( + 'fn', 'fn_badge_job_create', + 'job_id', v_job_id, + 'project_id', v_project_id, + 'count', v_count, + 'created_by_id', v_created_by_id + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012b-fn-badge-job-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_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_jobs jsonb; + v_count integer; +BEGIN + SELECT + COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.id DESC), '[]'::jsonb), + COUNT(*)::integer + INTO v_jobs, v_count + FROM ( + SELECT + j.id, + j.project_id, + j.status, + j.pdf_path, + j.created_by_id, + j.created_by_name, + j.created_at, + p.code AS project_code, + p.name AS project_name, + (SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id) AS people, + (SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id AND bp.delivered = true) AS delivered + FROM badge_jobs j + JOIN projects p ON p.id = j.project_id + WHERE v_project_id IS NULL OR j.project_id = v_project_id + ) x; + + RETURN core.rpc_ok( + jsonb_build_object('jobs', v_jobs), + format('Listado de jobs de gafetes: %s registro(s)%s', + v_count, + CASE WHEN v_project_id IS NOT NULL THEN format(' para proyecto %s', v_project_id) ELSE '' END), + jsonb_build_object( + 'fn', 'fn_badge_job_list', + 'project_id', v_project_id, + 'count', v_count + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012c-fn-badge-job-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_job_id bigint := COALESCE( + NULLIF(payload->>'job_id', '')::bigint, + NULLIF(payload->>'id', '')::bigint + ); + v_job jsonb; + v_people jsonb; +BEGIN + IF v_job_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_get: job_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_get', 'field', 'job_id') + ); + END IF; + + SELECT to_jsonb(x) + INTO v_job + FROM ( + SELECT + j.*, + p.code AS project_code, + p.name AS project_name + FROM badge_jobs j + JOIN projects p ON p.id = j.project_id + WHERE j.id = v_job_id + ) x; + + IF v_job IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_get: job de gafetes id=%s no encontrado', v_job_id), + jsonb_build_object('fn', 'fn_badge_job_get', 'job_id', v_job_id) + ); + END IF; + + SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY w.last_name_p, w.first_name), '[]'::jsonb) + INTO v_people + FROM ( + SELECT + bp.job_id, + bp.worker_id, + bp.delivered, + bp.delivered_at, + w.first_name, + w.last_name_p, + w.last_name_m, + w.position + FROM badge_job_people bp + JOIN workers w ON w.id = bp.worker_id + WHERE bp.job_id = v_job_id::integer + ) p; + + RETURN core.rpc_ok( + jsonb_build_object('job', v_job, 'people', v_people), + format('Job de gafetes #%s cargado: %s persona(s) en el lote', + v_job_id, jsonb_array_length(v_people)), + jsonb_build_object( + 'fn', 'fn_badge_job_get', + 'job_id', v_job_id, + 'people_count', jsonb_array_length(v_people) + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012d-fn-badge-job-set-delivered endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_set_delivered(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_job_id bigint := NULLIF(payload->>'job_id', '')::bigint; + v_worker_id integer := NULLIF(payload->>'worker_id', '')::integer; + v_delivered boolean; + v_updated boolean; +BEGIN + IF v_job_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_set_delivered: job_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'job_id') + ); + END IF; + IF v_worker_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_set_delivered: worker_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'worker_id', 'job_id', v_job_id) + ); + END IF; + IF payload->>'delivered' IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_set_delivered: delivered (boolean) es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'delivered', 'job_id', v_job_id, 'worker_id', v_worker_id) + ); + END IF; + + v_delivered := (payload->>'delivered')::boolean; + + IF NOT EXISTS (SELECT 1 FROM badge_jobs WHERE id = v_job_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_set_delivered: job de gafetes id=%s no encontrado', v_job_id), + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_badge_job_set_delivered: trabajador id=%s no encontrado', v_worker_id), + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'worker_id', v_worker_id, 'job_id', v_job_id) + ); + END IF; + + UPDATE badge_job_people + SET delivered = v_delivered, + delivered_at = CASE WHEN v_delivered THEN now() ELSE NULL END + WHERE job_id = v_job_id::integer + AND worker_id = v_worker_id + RETURNING true INTO v_updated; + + IF NOT COALESCE(v_updated, false) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_set_delivered: trabajador id=%s no pertenece al job %s', v_worker_id, v_job_id), + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id, 'worker_id', v_worker_id) + ); + END IF; + + RETURN core.rpc_ok( + jsonb_build_object( + 'job_id', v_job_id, + 'worker_id', v_worker_id, + 'delivered', v_delivered + ), + format('Entrega de gafete actualizada: trabajador %s en job #%s → %s', + v_worker_id, v_job_id, CASE WHEN v_delivered THEN 'entregado' ELSE 'pendiente' END), + jsonb_build_object( + 'fn', 'fn_badge_job_set_delivered', + 'job_id', v_job_id, + 'worker_id', v_worker_id, + 'delivered', v_delivered + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_set_delivered', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012e-fn-badge-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_badge_job_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_badge_job_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_badge_job_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_badge_job_set_delivered(jsonb) TO panels_core_app;