panels-origin/db/core/changesets/024-rpc-cost-control.sql
Cursor Agent ab89727522
feat: gastos, almacén central/obra y control presupuestal
Implementa el módulo integral de costos según el plan acordado:

- Esquema Liquibase: expense_entries, almacén (central + obra), IVA,
  tenant_cost_settings, permisos IAM y RLS
- Funciones RPC core: gastos, almacén, control presupuestal, sync nómina,
  cierre de almacén al concluir proyecto
- API HTTP: expenses_http, warehouse_http, cost_control_http, iam_http
- Middleware requirePermission con matriz IAM
- UI web-panel: /gastos, /almacen, /control-presupuesto, /usuarios
- Componentes BudgetItemPicker, CostSemaphore y semáforos CSS
- Smoke tests extendidos para gastos/almacén/control

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:04:17 +00:00

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;