mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 10:43:18 +00:00
<!-- 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> <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> </div>
697 lines
25 KiB
PL/PgSQL
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;
|