panels-origin/db/core/changesets/021-cost-control-helpers.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

180 lines
5.3 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · helpers de control de costos (compartidos por gastos/almacén)
--changeset panel:core-021a-cost-tenant-id splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_tenant_id(payload jsonb)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer,
0
);
$$;
--changeset panel:core-021b-cost-settings splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_settings(p_tenant_id integer)
RETURNS TABLE (
budget_warn_pct numeric,
budget_critical_pct numeric,
default_iva_rate numeric
)
LANGUAGE plpgsql
STABLE
SET search_path = core
AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(s.budget_warn_pct, 85::numeric),
COALESCE(s.budget_critical_pct, 100::numeric),
COALESCE(s.default_iva_rate, 0.16::numeric)
FROM (
SELECT budget_warn_pct, budget_critical_pct, default_iva_rate
FROM tenant_cost_settings
WHERE tenant_id = p_tenant_id
) s
UNION ALL
SELECT 85::numeric, 100::numeric, 0.16::numeric
WHERE NOT EXISTS (SELECT 1 FROM tenant_cost_settings WHERE tenant_id = p_tenant_id)
LIMIT 1;
END;
$$;
--changeset panel:core-021c-cost-executed-amount splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_executed_amount(p_budget_item_id bigint)
RETURNS numeric
LANGUAGE sql
STABLE
SET search_path = core
AS $$
SELECT round(COALESCE(
(SELECT SUM(e.subtotal) FROM expense_entries e
WHERE e.budget_item_id = p_budget_item_id
AND e.status = 'confirmed'
AND e.counts_toward_budget = true),
0
) + COALESCE(
(SELECT SUM(m.total_cost) FROM warehouse_movements m
WHERE m.budget_item_id = p_budget_item_id
AND m.movement_type = 'exit'),
0
), 2);
$$;
--changeset panel:core-021d-cost-executed-qty splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_executed_qty(p_budget_item_id bigint)
RETURNS numeric
LANGUAGE sql
STABLE
SET search_path = core
AS $$
SELECT COALESCE(
(SELECT SUM(e.quantity) FROM expense_entries e
WHERE e.budget_item_id = p_budget_item_id
AND e.status = 'confirmed'
AND e.counts_toward_budget = true),
0
) + COALESCE(
(SELECT SUM(m.quantity) FROM warehouse_movements m
WHERE m.budget_item_id = p_budget_item_id
AND m.movement_type = 'exit'),
0
);
$$;
--changeset panel:core-021e-cost-budget-warning splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_budget_warning(
p_tenant_id integer,
p_budget_item_id bigint,
p_add_subtotal numeric DEFAULT 0,
p_add_qty numeric DEFAULT 0
)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SET search_path = core
AS $$
DECLARE
v_item record;
v_warn numeric;
v_critical numeric;
v_exec_amt numeric;
v_exec_qty numeric;
v_pct numeric;
v_level text;
BEGIN
IF p_budget_item_id IS NULL THEN
RETURN NULL;
END IF;
SELECT id, description, amount, quantity INTO v_item
FROM budget_items WHERE id = p_budget_item_id;
IF NOT FOUND OR COALESCE(v_item.amount, 0) <= 0 THEN
RETURN NULL;
END IF;
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical
FROM core._cost_settings(p_tenant_id);
v_exec_amt := core._cost_executed_amount(p_budget_item_id) + COALESCE(p_add_subtotal, 0);
v_exec_qty := core._cost_executed_qty(p_budget_item_id) + COALESCE(p_add_qty, 0);
v_pct := round(v_exec_amt / v_item.amount * 100, 1);
IF v_pct > v_critical THEN
v_level := 'critical';
ELSIF v_pct > v_warn THEN
v_level := 'warn';
ELSE
RETURN NULL;
END IF;
RETURN jsonb_build_object(
'level', v_level,
'budget_item_id', p_budget_item_id,
'pct_after', v_pct,
'executed_amount', v_exec_amt,
'budget_amount', v_item.amount,
'executed_qty', v_exec_qty,
'budget_qty', v_item.quantity,
'message', format(
'Esta operación dejará el concepto "%s" al %s%% del presupuesto (importe)',
left(v_item.description, 80), v_pct
)
);
END;
$$;
--changeset panel:core-021f-cost-calc-iva splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_calc_amounts(
p_subtotal numeric,
p_iva_rate numeric,
p_tax_included boolean,
p_total numeric DEFAULT NULL
)
RETURNS TABLE (subtotal numeric, iva_amount numeric, total numeric)
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_sub numeric;
v_iva numeric;
v_tot numeric;
BEGIN
IF p_tax_included AND p_total IS NOT NULL AND p_total > 0 THEN
v_tot := round(p_total, 2);
v_sub := round(v_tot / (1 + COALESCE(p_iva_rate, 0.16)), 2);
v_iva := round(v_tot - v_sub, 2);
ELSE
v_sub := round(COALESCE(p_subtotal, 0), 2);
v_iva := round(v_sub * COALESCE(p_iva_rate, 0.16), 2);
v_tot := round(v_sub + v_iva, 2);
END IF;
RETURN QUERY SELECT v_sub, v_iva, v_tot;
END;
$$;
--changeset panel:core-021g-cost-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._cost_tenant_id(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_settings(integer) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_executed_amount(bigint) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_executed_qty(bigint) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_budget_warning(integer, bigint, numeric, numeric) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_calc_amounts(numeric, numeric, boolean, numeric) TO panels_core_app;