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