mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:43:17 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Validación de endpoints y SPs Se probaron todos los endpoints nuevos (gastos, almacén, control presupuestal, IAM) y sus RPCs asociados. ### Bugs corregidos en migraciones/SQL 1. **IAM grants en schema core** — `iam-004e` intentaba `GRANT` sobre `core` con rol `iam_owner` (sin permiso). Los grants cruzados se movieron a `core-027-iam-rpc-cross-grants.sql`. 2. **`_cost_settings` ambiguo** — columnas `budget_warn_pct` etc. colisionaban con `RETURNS TABLE` en PL/pgSQL, rompiendo `fn_expense_create` y control presupuestal. Corregido en `core-028-fix-cost-settings-ambiguous.sql`. ### Tests añadidos - `api/cost_modules_test.ts` — CRUD RPC gastos, flujo almacén completo, control presupuestal, IAM permisos - `scripts/crud-smoke-test.sh` — smoke HTTP de todos los endpoints nuevos ### Resultados - `deno test cost_modules_test.ts` — 5/5 OK - `./scripts/crud-smoke-test.sh` — todos los checks OK (gastos CRUD, IVA, cost-settings, almacén, transferencias, cost-control, IAM) <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-eeec3b5c-f789-43e8-a353-0755e4706377?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-eeec3b5c-f789-43e8-a353-0755e4706377&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>
186 lines
8.6 KiB
PL/PgSQL
186 lines
8.6 KiB
PL/PgSQL
--liquibase formatted sql
|
|
-- PANELS · core · RPC control presupuestal
|
|
|
|
--changeset panel:core-024a-fn-cost-control-by-item splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_by_item(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
|
v_tid integer := core._cost_tenant_id(payload);
|
|
v_warn numeric; v_critical numeric;
|
|
v_rows jsonb;
|
|
BEGIN
|
|
IF v_project_id IS NULL THEN
|
|
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_by_item: project_id es obligatorio',
|
|
jsonb_build_object('fn', 'fn_cost_control_by_item', 'field', 'project_id'));
|
|
END IF;
|
|
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical FROM core._cost_settings(v_tid);
|
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.code, x.id), '[]'::jsonb) INTO v_rows
|
|
FROM (
|
|
SELECT
|
|
bi.id, bi.code, bi.description, bi.unit, bi.chapter_id,
|
|
bi.quantity AS qty_budget, bi.amount AS amount_budget, bi.unit_price,
|
|
core._cost_executed_qty(bi.id) AS qty_executed,
|
|
core._cost_executed_amount(bi.id) AS amount_executed,
|
|
round(CASE WHEN bi.amount > 0 THEN core._cost_executed_amount(bi.id) / bi.amount * 100 ELSE 0 END, 1) AS pct_amount,
|
|
round(core._cost_executed_amount(bi.id) - bi.amount, 2) AS delta_amount,
|
|
CASE
|
|
WHEN bi.amount <= 0 AND core._cost_executed_amount(bi.id) > 0 THEN 'critical'
|
|
WHEN bi.amount > 0 AND core._cost_executed_amount(bi.id) / bi.amount * 100 > v_critical THEN 'critical'
|
|
WHEN bi.amount > 0 AND core._cost_executed_amount(bi.id) / bi.amount * 100 > v_warn THEN 'warn'
|
|
ELSE 'ok'
|
|
END AS semaphore
|
|
FROM budget_items bi
|
|
WHERE bi.project_id = v_project_id
|
|
) x;
|
|
RETURN core.rpc_ok(
|
|
jsonb_build_object('items', v_rows, 'count', jsonb_array_length(v_rows)),
|
|
format('Control por concepto del proyecto %s: %s renglón(es)', v_project_id, jsonb_array_length(v_rows)),
|
|
jsonb_build_object('fn', 'fn_cost_control_by_item', 'project_id', v_project_id)
|
|
);
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_cost_control_by_item', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-024b-fn-cost-control-by-chapter splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_by_chapter(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
|
v_tid integer := core._cost_tenant_id(payload);
|
|
v_warn numeric; v_critical numeric;
|
|
v_rows jsonb;
|
|
BEGIN
|
|
IF v_project_id IS NULL THEN
|
|
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_by_chapter: project_id es obligatorio',
|
|
jsonb_build_object('fn', 'fn_cost_control_by_chapter'));
|
|
END IF;
|
|
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical FROM core._cost_settings(v_tid);
|
|
WITH chapter_base AS (
|
|
SELECT bc.id, bc.parent_id, bc.code, bc.name, bc.wbs, bc.sort_order
|
|
FROM budget_chapters bc
|
|
WHERE bc.project_id = v_project_id
|
|
)
|
|
SELECT COALESCE(jsonb_agg(to_jsonb(cs) ORDER BY cs.sort_order, cs.id), '[]'::jsonb) INTO v_rows
|
|
FROM (
|
|
SELECT cb.id, cb.parent_id, cb.code, cb.name, cb.wbs, cb.sort_order,
|
|
COALESCE(SUM(bi.amount), 0) AS amount_budget,
|
|
COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) AS amount_executed,
|
|
round(CASE WHEN COALESCE(SUM(bi.amount), 0) > 0
|
|
THEN COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 ELSE 0 END, 1) AS pct_amount,
|
|
round(COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) - COALESCE(SUM(bi.amount), 0), 2) AS delta_amount,
|
|
CASE
|
|
WHEN COALESCE(SUM(bi.amount), 0) <= 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) > 0 THEN 'critical'
|
|
WHEN COALESCE(SUM(bi.amount), 0) > 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 > v_critical THEN 'critical'
|
|
WHEN COALESCE(SUM(bi.amount), 0) > 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 > v_warn THEN 'warn'
|
|
ELSE 'ok'
|
|
END AS semaphore
|
|
FROM chapter_base cb
|
|
LEFT JOIN budget_items bi ON bi.chapter_id = cb.id
|
|
GROUP BY cb.id, cb.parent_id, cb.code, cb.name, cb.wbs, cb.sort_order
|
|
) cs;
|
|
RETURN core.rpc_ok(jsonb_build_object('chapters', v_rows),
|
|
format('Control por partida del proyecto %s', v_project_id),
|
|
jsonb_build_object('fn', 'fn_cost_control_by_chapter', 'project_id', v_project_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_cost_control_by_chapter', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-024c-fn-cost-control-project-summary splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_project_summary(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
|
v_budget numeric;
|
|
v_executed numeric;
|
|
v_unclassified numeric;
|
|
v_in_warehouse numeric;
|
|
v_red_items integer;
|
|
v_red_chapters integer;
|
|
BEGIN
|
|
IF v_project_id IS NULL THEN
|
|
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_project_summary: project_id es obligatorio',
|
|
jsonb_build_object('fn', 'fn_cost_control_project_summary'));
|
|
END IF;
|
|
SELECT COALESCE(SUM(amount), 0) INTO v_budget FROM budget_items WHERE project_id = v_project_id;
|
|
SELECT COALESCE(SUM(core._cost_executed_amount(id)), 0) INTO v_executed FROM budget_items WHERE project_id = v_project_id;
|
|
SELECT COALESCE(SUM(subtotal), 0) INTO v_unclassified
|
|
FROM expense_entries
|
|
WHERE project_id = v_project_id AND status = 'confirmed'
|
|
AND budget_item_id IS NULL AND counts_toward_budget = true;
|
|
SELECT COALESCE(SUM(s.qty_on_hand * s.avg_unit_cost), 0) INTO v_in_warehouse
|
|
FROM warehouse_stock s
|
|
JOIN warehouses w ON w.id = s.warehouse_id
|
|
WHERE w.project_id = v_project_id AND w.status = 'open';
|
|
SELECT COUNT(*)::integer INTO v_red_items FROM budget_items bi
|
|
WHERE bi.project_id = v_project_id AND bi.amount > 0
|
|
AND core._cost_executed_amount(bi.id) > bi.amount;
|
|
RETURN core.rpc_ok(
|
|
jsonb_build_object(
|
|
'project_id', v_project_id,
|
|
'budget_subtotal', round(v_budget, 2),
|
|
'executed_subtotal', round(v_executed, 2),
|
|
'pct_executed', round(CASE WHEN v_budget > 0 THEN v_executed / v_budget * 100 ELSE 0 END, 1),
|
|
'delta_subtotal', round(v_executed - v_budget, 2),
|
|
'unclassified_amount', round(v_unclassified, 2),
|
|
'warehouse_stock_value', round(v_in_warehouse, 2),
|
|
'concepts_over_budget', v_red_items
|
|
),
|
|
format('Resumen de control del proyecto %s: %s%% ejecutado', v_project_id,
|
|
round(CASE WHEN v_budget > 0 THEN v_executed / v_budget * 100 ELSE 0 END, 1)),
|
|
jsonb_build_object('fn', 'fn_cost_control_project_summary', 'project_id', v_project_id)
|
|
);
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_cost_control_project_summary', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-024d-fn-cost-control-top-deviations splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_top_deviations(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
|
v_limit integer := COALESCE((payload->>'limit')::integer, 10);
|
|
v_rows jsonb;
|
|
BEGIN
|
|
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
|
|
SELECT bi.id, bi.code, bi.description, bi.amount AS amount_budget,
|
|
core._cost_executed_amount(bi.id) AS amount_executed,
|
|
round(core._cost_executed_amount(bi.id) - bi.amount, 2) AS delta_amount
|
|
FROM budget_items bi
|
|
WHERE bi.project_id = v_project_id AND bi.amount > 0
|
|
AND core._cost_executed_amount(bi.id) > bi.amount
|
|
ORDER BY core._cost_executed_amount(bi.id) - bi.amount DESC
|
|
LIMIT v_limit
|
|
) x;
|
|
RETURN core.rpc_ok(jsonb_build_object('deviations', v_rows),
|
|
format('Top %s desviaciones del proyecto %s', v_limit, v_project_id),
|
|
jsonb_build_object('fn', 'fn_cost_control_top_deviations', 'project_id', v_project_id));
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_cost_control_top_deviations', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
--changeset panel:core-024e-cost-control-grants endDelimiter:; splitStatements:true
|
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_by_item(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_by_chapter(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_project_summary(jsonb) TO panels_core_app;
|
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_top_deviations(jsonb) TO panels_core_app;
|