--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;