Add budget and badge RPC functions (changesets 011-012)

Implement core.fn_budget_* for list, chapter/item CRUD, and atomic
replace import; core.fn_badge_job_* for create, list, get, and delivery
tracking. All functions use SECURITY INVOKER, rpc envelope helpers, Spanish
messages, and GRANT EXECUTE to panels_core_app.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-03 23:50:46 +00:00
parent a73090bd11
commit 7f2e84e47c
No known key found for this signature in database
2 changed files with 1030 additions and 0 deletions

View file

@ -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;

View file

@ -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;