panels-origin/db/core/changesets/011-rpc-budget.sql
Alberto Martinez b9bf7044b2 refactor(core): migrate core CRUD to PostgreSQL RPC + unified HTTP errors
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.

### Database (Liquibase changesets 006–017)

- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets

### API

- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`

### Envelope contract

DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.

### Out of scope

`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.

## Test plan

- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-04 02:34:43 +00:00

697 lines
25 KiB
PL/PgSQL

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