panels-origin/db/core/changesets/025-rpc-payroll-cost-sync.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

197 lines
7.5 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · sincronización nómina/destajo → gastos
--changeset panel:core-025a-fn-payroll-sync-cost-entries splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_payroll_sync_cost_entries(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_line record;
v_company_id bigint;
v_expense_date date;
v_desc text;
v_budget_item_id bigint;
v_inserted integer := 0;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_payroll_sync_cost_entries: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_sync_cost_entries'));
END IF;
SELECT week_end INTO v_expense_date FROM payroll_weeks WHERE id = v_week_id;
FOR v_line IN
SELECT l.id AS line_id, l.payable_net, l.concepto, l.project_id, l.worker_id,
s.kind AS sheet_kind, s.project_id AS sheet_project_id,
w.company_id AS worker_company_id, p.company_id AS project_company_id,
dj.budget_item_id AS destajo_budget_item_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
JOIN workers w ON w.id = l.worker_id
LEFT JOIN projects p ON p.id = COALESCE(l.project_id, s.project_id)
LEFT JOIN destajo_cut_lines dcl ON dcl.id = l.destajo_cut_line_id
LEFT JOIN destajo_jobs dj ON dj.id = dcl.job_id
WHERE s.week_id = v_week_id
AND l.payable_net > 0
LOOP
v_company_id := COALESCE(v_line.project_company_id, v_line.worker_company_id);
IF v_company_id IS NULL THEN
SELECT id INTO v_company_id FROM companies WHERE tenant_id = v_tid AND kind = 'principal' LIMIT 1;
END IF;
v_budget_item_id := v_line.destajo_budget_item_id;
v_desc := coalesce(nullif(btrim(v_line.concepto), ''),
CASE v_line.sheet_kind
WHEN 'destajo' THEN 'Destajo semana ' || v_week_id
WHEN 'obra' THEN 'Nómina obra semana ' || v_week_id
ELSE 'Nómina admin semana ' || v_week_id
END);
IF EXISTS (
SELECT 1 FROM expense_entries
WHERE source = 'payroll' AND source_ref_id = v_line.line_id AND status <> 'void'
) THEN
UPDATE expense_entries SET
subtotal = v_line.payable_net,
total = v_line.payable_net,
project_id = COALESCE(v_line.project_id, v_line.sheet_project_id),
budget_item_id = v_budget_item_id,
description = v_desc,
expense_date = v_expense_date
WHERE source = 'payroll' AND source_ref_id = v_line.line_id AND status <> 'void';
ELSE
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, source_ref_id,
description, subtotal, iva_rate, iva_amount, total, tax_included,
is_deductible, counts_toward_budget, is_locked, status,
budget_item_id, created_by_name
) VALUES (
v_tid, v_company_id, COALESCE(v_line.project_id, v_line.sheet_project_id),
v_expense_date, 'payroll', v_line.line_id,
v_desc, v_line.payable_net, 0, 0, v_line.payable_net, false,
false, true, true, 'confirmed',
v_budget_item_id, 'nómina'
);
END IF;
v_inserted := v_inserted + 1;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id, 'lines_synced', v_inserted),
format('Sincronizados %s gastos de nómina de la semana %s', v_inserted, v_week_id),
jsonb_build_object('fn', 'fn_payroll_sync_cost_entries', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_sync_cost_entries', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-025b-fn-payroll-week-pay-with-costs splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_payroll_week_pay(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_now_iso date := COALESCE(
NULLIF(btrim(payload->>'now_iso'), '')::date,
core._payroll_tenant_today(v_tid)
);
v_week record;
v_line record;
v_loan record;
v_take numeric;
v_n integer;
v_period_id bigint;
v_job record;
v_paid numeric;
v_env jsonb;
v_sync jsonb;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_pay: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_pay', 'field', 'week_id')
);
END IF;
SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_week_pay: semana id=%s no encontrada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
END IF;
IF v_week.status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_pay: la semana ya está pagada',
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
END IF;
IF v_week.status <> 'assembled' THEN
v_env := core.fn_payroll_week_assemble(jsonb_build_object(
'week_id', v_week_id,
'tenant_id', v_tid,
'now_iso', v_now_iso::text
));
IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
RETURN v_env;
END IF;
END IF;
PERFORM core._payroll_recalc_week(v_week_id);
FOR v_line IN
SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = v_week_id
AND l.loan_id IS NOT NULL
AND l.loan_discount > 0
LOOP
SELECT * INTO v_loan FROM loans WHERE id = v_line.loan_id;
IF NOT FOUND OR v_loan.balance <= 0 THEN
CONTINUE;
END IF;
v_take := core.round_money(LEAST(v_line.loan_discount, v_loan.balance));
IF v_take <= 0 THEN
CONTINUE;
END IF;
SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label)
VALUES (v_loan.id, v_week_id, v_take, v_n, v_line.loan_label);
UPDATE loans
SET balance = core.round_money(balance - v_take)
WHERE id = v_loan.id;
END LOOP;
SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
IF v_period_id IS NOT NULL THEN
FOR v_job IN SELECT id, qty_total_estimated FROM destajo_jobs LOOP
SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) INTO v_paid
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id = c.period_id
JOIN payroll_weeks w ON w.id = p.week_id
WHERE c.job_id = v_job.id
AND (w.status = 'paid' OR w.id = v_week_id);
IF v_paid >= v_job.qty_total_estimated THEN
UPDATE destajo_jobs SET status = 'done' WHERE id = v_job.id;
END IF;
END LOOP;
END IF;
UPDATE payroll_weeks SET status = 'paid', paid_at = now() WHERE id = v_week_id;
v_sync := core.fn_payroll_sync_cost_entries(jsonb_build_object('week_id', v_week_id, 'tenant_id', v_tid));
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id, 'status', 'paid', 'cost_sync', v_sync->'data'),
format('Semana de nómina %s marcada como pagada y costos sincronizados', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_pay', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-025c-payroll-cost-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_payroll_sync_cost_entries(jsonb) TO panels_core_app;