panels-origin/db/core/changesets/025-rpc-payroll-cost-sync.sql
Alberto Martinez 41273dfb04 Gastos, almacén y control presupuestal
<!-- 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>&nbsp;<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>&nbsp;</div>
2026-09-04 04:29:02 +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;