panels-origin/db/core/changesets/046-settlements.sql
Cursor Agent caf40e29fb
feat: facturas, ajustes de nómina, finiquitos, dispersión e IMSS
Reutiliza el catálogo de proveedores y agrega carga de facturas, conceptos de la semana, préstamos editables, finiquitos, dispersión, historial de personal, reportes y jornal de cotización.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-24 16:40:08 +00:00

230 lines
9.6 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · finiquitos y liquidaciones
--changeset panel:core-046a-tables endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS core.settlements (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
worker_id BIGINT NOT NULL REFERENCES core.workers(id),
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
company_id BIGINT REFERENCES core.companies(id),
kind TEXT NOT NULL CHECK (kind IN ('finiquito', 'liquidacion')),
termination_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'borrador' CHECK (status IN ('borrador', 'pagado')),
notes TEXT NOT NULL DEFAULT '',
lines_total NUMERIC(14,2) NOT NULL DEFAULT 0,
pending_discounts NUMERIC(14,2) NOT NULL DEFAULT 0,
loan_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
net NUMERIC(14,2) NOT NULL DEFAULT 0,
expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_core_settlements_worker ON core.settlements (tenant_id, worker_id);
ALTER TABLE core.settlements ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.settlements;
CREATE POLICY tenant_isolation ON core.settlements
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
CREATE TABLE IF NOT EXISTS core.settlement_lines (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
settlement_id BIGINT NOT NULL REFERENCES core.settlements(id) ON DELETE CASCADE,
concept TEXT NOT NULL,
base_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT ''
);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.settlements TO panels_core_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON core.settlement_lines TO panels_core_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
--changeset panel:core-046b-fn-settlement splitStatements:false
CREATE OR REPLACE FUNCTION core._settlement_recalc(p_id bigint)
RETURNS void
LANGUAGE plpgsql
SET search_path = core
AS $$
DECLARE
v_s settlements%ROWTYPE;
v_lines numeric;
v_disc numeric;
v_loans numeric;
BEGIN
SELECT * INTO v_s FROM settlements WHERE id = p_id;
SELECT COALESCE(SUM(amount), 0) INTO v_lines FROM settlement_lines WHERE settlement_id = p_id;
SELECT COALESCE(SUM(a.amount), 0) INTO v_disc
FROM payroll_adjustments a
JOIN payroll_weeks w ON w.id = a.week_id
WHERE a.worker_id = v_s.worker_id
AND a.tenant_id = v_s.tenant_id
AND a.kind = 'descuento'
AND w.status <> 'paid';
SELECT COALESCE(SUM(balance), 0) INTO v_loans FROM loans WHERE worker_id = v_s.worker_id AND balance > 0;
UPDATE settlements SET
lines_total = core.round_money(v_lines),
pending_discounts = core.round_money(v_disc),
loan_balance = core.round_money(v_loans),
net = core.round_money(v_lines - v_disc - v_loans)
WHERE id = p_id;
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_settlement_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_kind text := coalesce(nullif(btrim(payload->>'kind'), ''), 'finiquito');
v_date date := NULLIF(payload->>'termination_date', '')::date;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
BEGIN
IF v_kind NOT IN ('finiquito', 'liquidacion') OR v_worker_id IS NULL OR v_date IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_settlement_save: persona, tipo y fecha de baja son obligatorios',
jsonb_build_object('fn', 'fn_settlement_save'));
END IF;
IF v_id IS NULL THEN
INSERT INTO settlements (tenant_id, worker_id, project_id, company_id, kind, termination_date, notes)
VALUES (v_tid, v_worker_id, v_project_id, v_company_id, v_kind, v_date, coalesce(payload->>'notes', ''))
RETURNING id INTO v_id;
ELSE
UPDATE settlements SET
project_id = v_project_id,
company_id = v_company_id,
kind = v_kind,
termination_date = v_date,
notes = coalesce(payload->>'notes', notes)
WHERE id = v_id AND tenant_id = v_tid AND status = 'borrador';
IF NOT FOUND THEN
RETURN core.rpc_err('VALIDATION', 'fn_settlement_save: el finiquito no está en borrador',
jsonb_build_object('fn', 'fn_settlement_save', 'id', v_id));
END IF;
END IF;
IF jsonb_typeof(payload->'lines') = 'array' THEN
DELETE FROM settlement_lines WHERE settlement_id = v_id;
INSERT INTO settlement_lines (settlement_id, concept, base_amount, amount, note)
SELECT v_id,
coalesce(nullif(btrim(l->>'concept'), ''), 'Concepto'),
COALESCE((l->>'base_amount')::numeric, 0),
COALESCE((l->>'amount')::numeric, 0),
coalesce(l->>'note', '')
FROM jsonb_array_elements(payload->'lines') l;
END IF;
PERFORM core._settlement_recalc(v_id);
RETURN core.rpc_ok(jsonb_build_object('id', v_id), 'Finiquito guardado',
jsonb_build_object('fn', 'fn_settlement_save', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_settlement_save', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_settlement_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_row jsonb;
v_lines jsonb;
BEGIN
PERFORM core._settlement_recalc(v_id);
SELECT to_jsonb(s) INTO v_row FROM settlements s WHERE s.id = v_id;
IF v_row IS NULL THEN
RETURN core.rpc_err('NOT_FOUND', 'Finiquito no encontrado',
jsonb_build_object('fn', 'fn_settlement_get', 'id', v_id));
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id), '[]'::jsonb)
INTO v_lines FROM settlement_lines l WHERE l.settlement_id = v_id;
RETURN core.rpc_ok(jsonb_build_object('settlement', v_row, 'lines', v_lines),
'Finiquito cargado', jsonb_build_object('fn', 'fn_settlement_get', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_settlement_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_settlement_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.termination_date DESC, x.id DESC), '[]'::jsonb)
INTO v_rows
FROM (
SELECT s.*, w.first_name, w.last_name_p
FROM settlements s
JOIN workers w ON w.id = s.worker_id
WHERE s.tenant_id = v_tid
AND (v_worker_id IS NULL OR s.worker_id = v_worker_id)
) x;
RETURN core.rpc_ok(jsonb_build_object('settlements', v_rows), 'Finiquitos listados',
jsonb_build_object('fn', 'fn_settlement_list'));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_settlement_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_settlement_pay(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_s settlements%ROWTYPE;
v_company_id bigint;
v_expense_id bigint;
BEGIN
SELECT * INTO v_s FROM settlements WHERE id = NULLIF(payload->>'id', '')::bigint;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', 'Finiquito no encontrado', jsonb_build_object('fn', 'fn_settlement_pay'));
END IF;
IF v_s.status = 'pagado' THEN
RETURN core.rpc_err('VALIDATION', 'El finiquito ya está pagado', jsonb_build_object('fn', 'fn_settlement_pay', 'id', v_s.id));
END IF;
PERFORM core._settlement_recalc(v_s.id);
SELECT * INTO v_s FROM settlements WHERE id = v_s.id;
v_company_id := COALESCE(v_s.company_id, (SELECT company_id FROM projects WHERE id = v_s.project_id), (SELECT company_id FROM workers WHERE id = v_s.worker_id));
IF v_company_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'Indique la empresa para registrar el gasto del finiquito',
jsonb_build_object('fn', 'fn_settlement_pay', 'field', 'company_id'));
END IF;
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
subtotal, iva_amount, total, iva_rate, status, notes
) VALUES (
v_s.tenant_id, v_company_id, v_s.project_id, v_s.termination_date, 'settlement', v_s.id,
format('%s trabajador %s', v_s.kind, v_s.worker_id),
v_s.net, 0, v_s.net, 0, 'confirmed', 'Pago de finiquito o liquidación'
) RETURNING id INTO v_expense_id;
UPDATE settlements SET status = 'pagado', paid_at = now(), expense_id = v_expense_id, company_id = v_company_id
WHERE id = v_s.id;
RETURN core.rpc_ok(
jsonb_build_object('id', v_s.id, 'net', v_s.net, 'expense_id', v_expense_id),
format('Finiquito id=%s pagado por %s', v_s.id, v_s.net),
jsonb_build_object('fn', 'fn_settlement_pay', 'id', v_s.id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_settlement_pay', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-046c-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._settlement_recalc(bigint) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_settlement_save(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_settlement_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_settlement_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_settlement_pay(jsonb) TO panels_core_app;