panels-origin/db/core/changesets/047-dispersion.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

239 lines
9.9 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · dispersión de nómina
--changeset panel:core-047a-tables endDelimiter:; splitStatements:true
CREATE TABLE IF NOT EXISTS core.payroll_dispersions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
week_id BIGINT NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'abierta' CHECK (status IN ('abierta', 'corte', 'definitiva')),
net NUMERIC(14,2) NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (week_id)
);
ALTER TABLE core.payroll_dispersions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON core.payroll_dispersions;
CREATE POLICY tenant_isolation ON core.payroll_dispersions
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.payroll_dispersion_lines (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dispersion_id BIGINT NOT NULL REFERENCES core.payroll_dispersions(id) ON DELETE CASCADE,
worker_id BIGINT NOT NULL REFERENCES core.workers(id),
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
UNIQUE (dispersion_id, worker_id)
);
CREATE TABLE IF NOT EXISTS core.payroll_dispersion_attachments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dispersion_id BIGINT NOT NULL REFERENCES core.payroll_dispersions(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
uploaded_by_name TEXT NOT NULL DEFAULT '',
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersions TO panels_core_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersion_lines TO panels_core_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersion_attachments TO panels_core_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
--changeset panel:core-047b-fn-dispersion splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_dispersion_get(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_id bigint;
v_row jsonb;
v_lines jsonb;
v_files jsonb;
v_net numeric;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_dispersion_get: week_id es obligatorio',
jsonb_build_object('fn', 'fn_dispersion_get'));
END IF;
SELECT COALESCE(SUM(l.payable_net), 0) INTO v_net
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = v_week_id;
INSERT INTO payroll_dispersions (tenant_id, week_id, net)
VALUES (v_tid, v_week_id, core.round_money(v_net))
ON CONFLICT (week_id) DO UPDATE SET updated_at = now()
RETURNING id INTO v_id;
SELECT to_jsonb(d) INTO v_row FROM payroll_dispersions d WHERE d.id = v_id;
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_name_p), '[]'::jsonb) INTO v_lines
FROM (
SELECT dl.*, w.first_name, w.last_name_p
FROM payroll_dispersion_lines dl
JOIN workers w ON w.id = dl.worker_id
WHERE dl.dispersion_id = v_id
) x;
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.uploaded_at), '[]'::jsonb) INTO v_files
FROM payroll_dispersion_attachments a WHERE a.dispersion_id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('dispersion', v_row, 'lines', v_lines, 'attachments', v_files, 'week_net', v_net),
'Dispersión cargada', jsonb_build_object('fn', 'fn_dispersion_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_dispersion_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_dispersion_cut(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_d payroll_dispersions%ROWTYPE;
v_week_status text;
BEGIN
SELECT * INTO v_d FROM payroll_dispersions WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
END IF;
IF v_d.status = 'definitiva' THEN
RETURN core.rpc_err('VALIDATION', 'La dispersión ya es definitiva', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_d.week_id;
DELETE FROM payroll_dispersion_lines WHERE dispersion_id = v_id;
INSERT INTO payroll_dispersion_lines (dispersion_id, worker_id, amount)
SELECT v_id, l.worker_id, core.round_money(SUM(l.payable_net))
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = v_d.week_id
GROUP BY l.worker_id;
UPDATE payroll_dispersions SET
status = 'corte',
net = (SELECT COALESCE(SUM(amount), 0) FROM payroll_dispersion_lines WHERE dispersion_id = v_id),
updated_at = now()
WHERE id = v_id;
RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'week_status', v_week_status),
'Corte semanal de dispersión aplicado', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_dispersion_cut', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_dispersion_line_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
v_amount numeric := (payload->>'amount')::numeric;
v_status text;
v_week_status text;
v_disp_id bigint;
BEGIN
SELECT d.status, w.status, d.id INTO v_status, v_week_status, v_disp_id
FROM payroll_dispersion_lines l
JOIN payroll_dispersions d ON d.id = l.dispersion_id
JOIN payroll_weeks w ON w.id = d.week_id
WHERE l.id = v_line_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', 'Línea de dispersión no encontrada',
jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
END IF;
IF v_status = 'definitiva' OR v_week_status = 'paid' THEN
RETURN core.rpc_err('VALIDATION', 'La dispersión ya no se puede corregir',
jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
END IF;
UPDATE payroll_dispersion_lines
SET amount = core.round_money(COALESCE(v_amount, amount)),
note = CASE WHEN payload ? 'note' THEN coalesce(payload->>'note', '') ELSE note END
WHERE id = v_line_id;
UPDATE payroll_dispersions
SET net = (SELECT COALESCE(SUM(amount), 0) FROM payroll_dispersion_lines WHERE dispersion_id = v_disp_id),
updated_at = now()
WHERE id = v_disp_id;
RETURN core.rpc_ok(jsonb_build_object('id', v_line_id), 'Línea de dispersión actualizada',
jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_dispersion_line_update', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_dispersion_finalize(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_status text;
BEGIN
SELECT status INTO v_status FROM payroll_dispersions WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada', jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
END IF;
IF v_status = 'abierta' THEN
RETURN core.rpc_err('VALIDATION', 'Aplique el corte semanal antes de dejar la dispersión definitiva',
jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
END IF;
UPDATE payroll_dispersions SET status = 'definitiva', note = coalesce(payload->>'note', note), updated_at = now()
WHERE id = v_id;
RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'status', 'definitiva'),
'Dispersión definitiva', jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_dispersion_finalize', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_dispersion_attachment_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'dispersion_id', '')::bigint;
v_att_id bigint;
BEGIN
IF NOT EXISTS (SELECT 1 FROM payroll_dispersions WHERE id = v_id) THEN
RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada',
jsonb_build_object('fn', 'fn_dispersion_attachment_add', 'id', v_id));
END IF;
INSERT INTO payroll_dispersion_attachments (
dispersion_id, original_name, mime, size_bytes, sha256, iv, storage_name, uploaded_by_name
) VALUES (
v_id,
coalesce(payload->>'original_name', 'comprobante'),
coalesce(payload->>'mime', 'application/pdf'),
COALESCE((payload->>'size_bytes')::bigint, 0),
coalesce(payload->>'sha256', ''),
coalesce(payload->>'iv', ''),
coalesce(payload->>'storage_name', ''),
coalesce(payload->>'uploaded_by_name', '')
) RETURNING id INTO v_att_id;
RETURN core.rpc_created(jsonb_build_object('id', v_att_id), 'Comprobante de dispersión anexado',
jsonb_build_object('fn', 'fn_dispersion_attachment_add', 'id', v_att_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_dispersion_attachment_add', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-047c-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_dispersion_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_dispersion_cut(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_dispersion_line_update(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_dispersion_finalize(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_dispersion_attachment_add(jsonb) TO panels_core_app;