--liquibase formatted sql -- PANELS · core · bono, descuentos, horas y días festivos --changeset panel:core-044a-payroll-adjustments endDelimiter:; splitStatements:true CREATE TABLE IF NOT EXISTS core.holidays ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_id INTEGER NOT NULL, holiday_date DATE NOT NULL, name TEXT NOT NULL, UNIQUE (tenant_id, holiday_date) ); ALTER TABLE core.holidays ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS tenant_isolation ON core.holidays; CREATE POLICY tenant_isolation ON core.holidays USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer); GRANT SELECT, INSERT, UPDATE, DELETE ON core.holidays TO panels_core_app; CREATE TABLE IF NOT EXISTS core.payroll_adjustments ( 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, worker_id BIGINT NOT NULL REFERENCES core.workers(id), project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL, kind TEXT NOT NULL CHECK (kind IN ('descuento', 'bono', 'horas')), concept TEXT NOT NULL, amount NUMERIC(14,2) NOT NULL DEFAULT 0, hours NUMERIC(8,2) NOT NULL DEFAULT 0, note TEXT NOT NULL DEFAULT '', adjustment_date DATE NOT NULL DEFAULT CURRENT_DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_core_payroll_adjustments_week ON core.payroll_adjustments (week_id, worker_id); ALTER TABLE core.payroll_adjustments ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS tenant_isolation ON core.payroll_adjustments; CREATE POLICY tenant_isolation ON core.payroll_adjustments USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer); GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_adjustments TO panels_core_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app; --changeset panel:core-044b-fn-apply-adjustments splitStatements:false CREATE OR REPLACE FUNCTION core._payroll_apply_adjustments(p_week_id bigint) RETURNS void LANGUAGE plpgsql SET search_path = core AS $$ DECLARE v_adj record; v_line_id bigint; v_wage numeric; v_extra numeric; BEGIN FOR v_adj IN SELECT * FROM payroll_adjustments WHERE week_id = p_week_id ORDER BY id LOOP SELECT l.id INTO v_line_id FROM payroll_week_lines l JOIN payroll_sheets s ON s.id = l.sheet_id WHERE s.week_id = p_week_id AND l.worker_id = v_adj.worker_id AND (v_adj.project_id IS NULL OR s.project_id = v_adj.project_id OR l.project_id = v_adj.project_id) ORDER BY CASE WHEN s.project_id IS NOT DISTINCT FROM v_adj.project_id THEN 0 ELSE 1 END, l.gross DESC, l.id LIMIT 1; IF v_line_id IS NULL THEN CONTINUE; END IF; IF v_adj.kind = 'bono' THEN UPDATE payroll_week_lines SET gross = core.round_money(gross + v_adj.amount), payable_net = core.round_money(payable_net + v_adj.amount) WHERE id = v_line_id; ELSIF v_adj.kind = 'descuento' THEN UPDATE payroll_week_lines SET discounts = core.round_money(discounts + v_adj.amount), payable_net = core.round_money(payable_net - v_adj.amount) WHERE id = v_line_id; ELSIF v_adj.kind = 'horas' THEN SELECT w.daily_wage INTO v_wage FROM workers w WHERE w.id = v_adj.worker_id; v_extra := core.round_money(v_adj.hours * COALESCE(v_wage, 0) / 8); UPDATE payroll_week_lines SET gross = core.round_money(gross + v_extra), payable_net = core.round_money(payable_net + v_extra) WHERE id = v_line_id; END IF; END LOOP; END; $$; --changeset panel:core-044c-recalc-week splitStatements:false CREATE OR REPLACE FUNCTION core._payroll_recalc_week(p_week_id bigint) RETURNS void LANGUAGE plpgsql SET search_path = core AS $$ DECLARE v_week record; v_sheet record; v_worker record; v_cut record; v_line record; v_loan record; v_line_id bigint; v_att_n integer; v_holiday_n integer; v_wage numeric; v_gross numeric; v_payable_qty numeric; v_required_gross numeric; v_keep bigint[]; v_stale record; v_tot numeric; v_worker_id bigint; v_lines record; v_gross_sum numeric; v_remaining numeric; v_placed boolean; v_n integer; v_is_last boolean; v_want numeric; v_take numeric; v_label text; v_host_id bigint; v_host_gross numeric; v_host_discounts numeric; BEGIN SELECT * INTO v_week FROM payroll_weeks WHERE id = p_week_id; IF NOT FOUND OR v_week.status = 'paid' THEN RETURN; END IF; UPDATE payroll_week_lines SET loan_id = NULL, loan_discount = 0, loan_label = NULL, discounts = 0 WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id); FOR v_sheet IN SELECT * FROM payroll_sheets WHERE week_id = p_week_id LOOP IF v_sheet.kind = 'obra' AND v_sheet.project_id IS NOT NULL THEN FOR v_worker IN SELECT w.id, w.daily_wage FROM workers w WHERE w.status = 'activo' AND w.pipeline_status != 'baja' AND ( EXISTS ( SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = v_sheet.project_id AND a.active = true AND w.work_type = 'N' ) OR EXISTS ( SELECT 1 FROM attendance att WHERE att.worker_id = w.id AND att.project_id = v_sheet.project_id AND att.work_date BETWEEN v_week.week_start AND v_week.week_end ) OR EXISTS ( SELECT 1 FROM payroll_week_lines l JOIN payroll_sheets s ON s.id = l.sheet_id WHERE l.worker_id = w.id AND s.project_id = v_sheet.project_id AND s.kind = 'obra' AND s.week_id = p_week_id ) ) ORDER BY w.last_name_p, w.first_name LOOP v_line_id := core._payroll_sheet_line(v_sheet.id, v_worker.id, NULL); SELECT COUNT(*)::integer INTO v_att_n FROM attendance WHERE worker_id = v_worker.id AND project_id = v_sheet.project_id AND work_date BETWEEN v_week.week_start AND v_week.week_end AND present = true; SELECT COUNT(*)::integer INTO v_holiday_n FROM holidays h WHERE h.tenant_id = v_week.tenant_id AND h.holiday_date BETWEEN v_week.week_start AND v_week.week_end AND NOT EXISTS ( SELECT 1 FROM attendance att WHERE att.worker_id = v_worker.id AND att.project_id = v_sheet.project_id AND att.work_date = h.holiday_date ); SELECT daily_wage INTO v_wage FROM payroll_week_lines WHERE id = v_line_id; IF v_week.status = 'assembled' AND COALESCE(v_wage, 0) > 0 THEN NULL; ELSE v_wage := v_worker.daily_wage; END IF; v_gross := core.round_money((v_att_n + v_holiday_n) * v_wage); UPDATE payroll_week_lines SET days = v_att_n + v_holiday_n, daily_wage = v_wage, amount = v_gross, gross = v_gross, discounts = 0, payable_net = v_gross WHERE id = v_line_id; END LOOP; END IF; IF v_sheet.kind = 'destajo' AND v_sheet.project_id IS NOT NULL THEN SELECT id INTO v_line_id FROM destajo_periods WHERE week_id = p_week_id; IF v_line_id IS NULL THEN CONTINUE; END IF; v_keep := ARRAY[]::bigint[]; FOR v_cut IN SELECT c.id, c.qty_planned, c.qty_actual, c.qty_extra, j.worker_id, j.unit_price, j.unit_code, j.concepto FROM destajo_cut_lines c JOIN destajo_jobs j ON j.id = c.job_id WHERE c.period_id = v_line_id AND j.project_id = v_sheet.project_id LOOP v_line_id := core._payroll_sheet_line(v_sheet.id, v_cut.worker_id, v_cut.id); v_keep := array_append(v_keep, v_line_id); IF v_week.status = 'draft' AND v_cut.qty_actual = 0 AND v_cut.qty_extra = 0 THEN v_payable_qty := core.round_money(v_cut.qty_planned); ELSE v_payable_qty := core.round_money(v_cut.qty_actual + v_cut.qty_extra); END IF; v_gross := core.round_money(v_payable_qty * v_cut.unit_price); v_required_gross := core.round_money(v_cut.qty_planned * v_cut.unit_price); UPDATE payroll_week_lines SET destajo_cut_line_id = v_cut.id, qty_planned = v_cut.qty_planned, qty_actual = v_cut.qty_actual, qty_extra = v_cut.qty_extra, unit_price = v_cut.unit_price, unit_code = v_cut.unit_code, concepto = v_cut.concepto, amount = v_required_gross, gross = v_gross, payable_net = v_gross WHERE id = v_line_id; END LOOP; FOR v_stale IN SELECT id FROM payroll_week_lines WHERE sheet_id = v_sheet.id LOOP IF NOT (v_stale.id = ANY (v_keep)) THEN DELETE FROM payroll_week_lines WHERE id = v_stale.id; END IF; END LOOP; END IF; IF v_sheet.kind = 'admin' THEN FOR v_line IN SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id = v_sheet.id LOOP v_gross := core.round_money(v_line.amount); UPDATE payroll_week_lines SET gross = v_gross, payable_net = core.round_money(v_gross - v_line.discounts) WHERE id = v_line.id; END LOOP; END IF; END LOOP; PERFORM core._payroll_apply_adjustments(p_week_id); FOR v_worker_id IN SELECT DISTINCT l.worker_id FROM payroll_week_lines l JOIN payroll_sheets s ON s.id = l.sheet_id WHERE s.week_id = p_week_id LOOP SELECT core.round_money(COALESCE(SUM(l.gross - l.discounts), 0)) INTO v_gross_sum FROM payroll_week_lines l JOIN payroll_sheets s ON s.id = l.sheet_id WHERE s.week_id = p_week_id AND l.worker_id = v_worker_id; v_remaining := GREATEST(0, v_gross_sum); v_placed := false; FOR v_loan IN SELECT * FROM loans WHERE worker_id = v_worker_id AND balance > 0 AND first_due IS NOT NULL AND first_due <= v_week.week_end ORDER BY first_due, id LOOP EXIT WHEN v_remaining <= 0; SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id; v_is_last := v_n >= GREATEST(COALESCE(v_loan.installments_n, 1), 1); IF v_is_last THEN v_want := v_loan.balance; ELSE v_want := LEAST(COALESCE(NULLIF(v_loan.weekly_payment, 0), v_loan.balance), v_loan.balance); END IF; v_take := core.round_money(LEAST(v_want, v_remaining)); IF v_take <= 0 THEN CONTINUE; END IF; v_remaining := core.round_money(v_remaining - v_take); IF NOT v_placed THEN SELECT l.id, l.gross, l.discounts INTO v_host_id, v_host_gross, v_host_discounts FROM payroll_week_lines l JOIN payroll_sheets s ON s.id = l.sheet_id WHERE s.week_id = p_week_id AND l.worker_id = v_worker_id ORDER BY l.gross DESC, l.id LIMIT 1; v_label := core._payroll_loan_condition_label( v_loan.delivered, v_loan.commission_pct, v_take, v_n, v_loan.installments_n ); UPDATE payroll_week_lines SET loan_id = v_loan.id, loan_discount = v_take, loan_label = v_label, payable_net = core.round_money(v_host_gross - v_host_discounts - v_take) WHERE id = v_host_id; v_placed := true; END IF; EXIT; END LOOP; IF NOT v_placed THEN UPDATE payroll_week_lines l SET payable_net = core.round_money(l.gross - l.discounts) FROM payroll_sheets s WHERE s.id = l.sheet_id AND s.week_id = p_week_id AND l.worker_id = v_worker_id; ELSE UPDATE payroll_week_lines l SET payable_net = core.round_money(l.gross - l.discounts) FROM payroll_sheets s WHERE s.id = l.sheet_id AND s.week_id = p_week_id AND l.worker_id = v_worker_id AND l.id <> v_host_id; END IF; END LOOP; SELECT COALESCE(SUM(l.payable_net), 0) INTO v_tot FROM payroll_week_lines l JOIN payroll_sheets s ON s.id = l.sheet_id WHERE s.week_id = p_week_id; UPDATE payroll_weeks SET payable_net = core.round_money(v_tot) WHERE id = p_week_id; IF v_week.status = 'draft' THEN UPDATE payroll_weeks SET required_net = core.round_money(v_tot) WHERE id = p_week_id; UPDATE payroll_week_lines SET required_net = payable_net WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id); END IF; END; $$; CREATE OR REPLACE FUNCTION core._payroll_ensure_cut_for_open_jobs(p_week_id bigint) RETURNS void LANGUAGE plpgsql SET search_path = core AS $$ DECLARE v_period record; v_job record; BEGIN SELECT id, tenant_id INTO v_period FROM destajo_periods WHERE week_id = p_week_id; IF NOT FOUND THEN RETURN; END IF; FOR v_job IN SELECT id FROM destajo_jobs WHERE tenant_id = v_period.tenant_id AND status = 'open' LOOP INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra) VALUES (v_period.id, v_job.id, 0, 0, 0) ON CONFLICT (period_id, job_id) DO NOTHING; END LOOP; END; $$; CREATE OR REPLACE FUNCTION core._payroll_ensure_week(p_tenant_id integer, p_week_start date) RETURNS bigint LANGUAGE plpgsql SET search_path = core AS $$ DECLARE v_week_start date; v_week_end date; v_week_id bigint; v_bounds record; v_period_id bigint; v_proj record; BEGIN PERFORM core._payroll_seed_defaults(p_tenant_id); SELECT wc.week_start, wc.week_end INTO v_week_start, v_week_end FROM core._payroll_week_containing(p_week_start) wc; SELECT id INTO v_week_id FROM payroll_weeks WHERE tenant_id = p_tenant_id AND week_start = v_week_start; IF v_week_id IS NULL THEN INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id) VALUES (v_week_start, v_week_end, 'draft', p_tenant_id) RETURNING id INTO v_week_id; END IF; SELECT * INTO v_bounds FROM core._payroll_destajo_period_bounds(v_week_start); SELECT id INTO v_period_id FROM destajo_periods WHERE tenant_id = p_tenant_id AND period_end = v_bounds.period_end; IF v_period_id IS NULL THEN INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id) VALUES (v_bounds.period_start, v_bounds.period_end, v_week_id, p_tenant_id); END IF; FOR v_proj IN SELECT id FROM projects WHERE status IN ('activo', 'pausado') AND (p_tenant_id = 0 OR tenant_id = p_tenant_id OR tenant_id IS NULL) ORDER BY name LOOP IF NOT EXISTS ( SELECT 1 FROM payroll_sheets WHERE week_id = v_week_id AND kind = 'obra' AND COALESCE(project_id, 0) = v_proj.id ) THEN INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'obra', v_proj.id); END IF; IF NOT EXISTS ( SELECT 1 FROM payroll_sheets WHERE week_id = v_week_id AND kind = 'destajo' AND COALESCE(project_id, 0) = v_proj.id ) THEN INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'destajo', v_proj.id); END IF; END LOOP; IF NOT EXISTS ( SELECT 1 FROM payroll_sheets WHERE week_id = v_week_id AND kind = 'admin' AND project_id IS NULL ) THEN INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'admin', NULL); END IF; RETURN v_week_id; END; $$; CREATE OR REPLACE FUNCTION core._payroll_first_due_for_loan( p_delivered numeric, p_small_max numeric, p_plan text, p_grant_iso date, p_week_assembled boolean, p_weekday_mon0 integer ) RETURNS date LANGUAGE plpgsql IMMUTABLE AS $$ BEGIN IF p_delivered <= p_small_max AND p_plan = 'single' THEN RETURN core._payroll_next_saturday_after_week(p_grant_iso); END IF; IF p_week_assembled AND p_weekday_mon0 >= 4 THEN RETURN core._payroll_next_saturday_after_week(p_grant_iso); END IF; RETURN core._payroll_this_saturday(p_grant_iso); END; $$; CREATE OR REPLACE FUNCTION core._payroll_prefill_thu_sat( p_project_id bigint, p_week_start date, p_worker_id bigint ) RETURNS void LANGUAGE plpgsql SET search_path = core AS $$ DECLARE v_offset integer; v_day date; v_other record; v_existing record; BEGIN FOR v_offset IN SELECT unnest(ARRAY[3, 4, 5]) LOOP v_day := core._payroll_add_days(p_week_start, v_offset); SELECT a.project_id, p.name AS project_name INTO v_other FROM attendance a JOIN projects p ON p.id = a.project_id WHERE a.worker_id = p_worker_id AND a.work_date = v_day AND a.present = true AND a.project_id <> p_project_id LIMIT 1; IF FOUND THEN CONTINUE; END IF; SELECT id, present INTO v_existing FROM attendance WHERE worker_id = p_worker_id AND project_id = p_project_id AND work_date = v_day; IF FOUND THEN CONTINUE; END IF; INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (p_worker_id, p_project_id, v_day, true); END LOOP; END; $$; --changeset panel:core-044d-fn-adjustments splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_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_week_id bigint := NULLIF(payload->>'week_id', '')::bigint; v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_kind text := nullif(btrim(payload->>'kind'), ''); v_concept text := coalesce(nullif(btrim(payload->>'concept'), ''), ''); v_note text := coalesce(nullif(btrim(payload->>'note'), ''), ''); v_amount numeric := COALESCE((payload->>'amount')::numeric, 0); v_hours numeric := COALESCE((payload->>'hours')::numeric, 0); v_date date := COALESCE(NULLIF(payload->>'adjustment_date', '')::date, CURRENT_DATE); v_status text; BEGIN IF v_week_id IS NULL OR v_worker_id IS NULL OR v_kind IS NULL OR v_concept = '' THEN RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: semana, persona, tipo y concepto son obligatorios', jsonb_build_object('fn', 'fn_payroll_adjustment_save')); END IF; IF v_kind NOT IN ('descuento', 'bono', 'horas') THEN RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: tipo inválido', jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'kind', v_kind)); END IF; IF v_kind = 'descuento' AND v_note = '' THEN RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: el descuento requiere observación', jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'field', 'note')); END IF; SELECT status INTO v_status FROM payroll_weeks WHERE id = v_week_id AND tenant_id = v_tid; IF NOT FOUND THEN RETURN core.rpc_err('NOT_FOUND', format('fn_payroll_adjustment_save: semana id=%s no encontrada', v_week_id), jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'week_id', v_week_id)); END IF; IF v_status = 'paid' THEN RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: la semana ya está pagada', jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'week_id', v_week_id)); END IF; IF v_id IS NULL THEN INSERT INTO payroll_adjustments ( tenant_id, week_id, worker_id, project_id, kind, concept, amount, hours, note, adjustment_date ) VALUES ( v_tid, v_week_id, v_worker_id, v_project_id, v_kind, v_concept, v_amount, v_hours, v_note, v_date ) RETURNING id INTO v_id; ELSE UPDATE payroll_adjustments SET project_id = v_project_id, kind = v_kind, concept = v_concept, amount = v_amount, hours = v_hours, note = v_note, adjustment_date = v_date WHERE id = v_id AND tenant_id = v_tid AND week_id = v_week_id; END IF; PERFORM core._payroll_recalc_week(v_week_id); RETURN core.rpc_ok( jsonb_build_object('id', v_id), format('Ajuste de nómina %s guardado', v_kind), jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_payroll_adjustment_save', SQLSTATE, SQLERRM); END; $$; CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_list(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_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_kind text := nullif(btrim(payload->>'kind'), ''); v_rows jsonb; BEGIN SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.adjustment_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows FROM ( SELECT a.*, w.first_name, w.last_name_p, p.name AS project_name FROM payroll_adjustments a JOIN workers w ON w.id = a.worker_id LEFT JOIN projects p ON p.id = a.project_id WHERE a.tenant_id = v_tid AND (v_week_id IS NULL OR a.week_id = v_week_id) AND (v_worker_id IS NULL OR a.worker_id = v_worker_id) AND (v_project_id IS NULL OR a.project_id = v_project_id) AND (v_kind IS NULL OR a.kind = v_kind) ) x; RETURN core.rpc_ok( jsonb_build_object('adjustments', v_rows, 'count', jsonb_array_length(v_rows)), format('Ajustes de nómina: %s', jsonb_array_length(v_rows)), jsonb_build_object('fn', 'fn_payroll_adjustment_list') ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_payroll_adjustment_list', SQLSTATE, SQLERRM); END; $$; CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_delete(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_week_id bigint; v_status text; BEGIN SELECT a.week_id, wk.status INTO v_week_id, v_status FROM payroll_adjustments a JOIN payroll_weeks wk ON wk.id = a.week_id WHERE a.id = v_id AND a.tenant_id = v_tid; IF NOT FOUND THEN RETURN core.rpc_err('NOT_FOUND', 'Ajuste no encontrado', jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'id', v_id)); END IF; IF v_status = 'paid' THEN RETURN core.rpc_err('VALIDATION', 'La semana ya está pagada', jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'week_id', v_week_id)); END IF; DELETE FROM payroll_adjustments WHERE id = v_id; PERFORM core._payroll_recalc_week(v_week_id); RETURN core.rpc_ok(jsonb_build_object('id', v_id), 'Ajuste eliminado', jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'id', v_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_payroll_adjustment_delete', SQLSTATE, SQLERRM); END; $$; CREATE OR REPLACE FUNCTION core.fn_holiday_save(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._payroll_tenant_id(payload); v_date date := NULLIF(payload->>'holiday_date', '')::date; v_name text := coalesce(nullif(btrim(payload->>'name'), ''), ''); v_id bigint; v_week record; BEGIN IF v_date IS NULL OR v_name = '' THEN RETURN core.rpc_err('VALIDATION', 'fn_holiday_save: fecha y nombre son obligatorios', jsonb_build_object('fn', 'fn_holiday_save')); END IF; INSERT INTO holidays (tenant_id, holiday_date, name) VALUES (v_tid, v_date, v_name) ON CONFLICT (tenant_id, holiday_date) DO UPDATE SET name = EXCLUDED.name RETURNING id INTO v_id; FOR v_week IN SELECT id FROM payroll_weeks WHERE tenant_id = v_tid AND status <> 'paid' AND v_date BETWEEN week_start AND week_end LOOP PERFORM core._payroll_recalc_week(v_week.id); END LOOP; RETURN core.rpc_ok(jsonb_build_object('id', v_id), format('Festivo %s registrado', v_name), jsonb_build_object('fn', 'fn_holiday_save', 'id', v_id)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_holiday_save', SQLSTATE, SQLERRM); END; $$; CREATE OR REPLACE FUNCTION core.fn_holiday_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._payroll_tenant_id(payload); v_from date := NULLIF(payload->>'date_from', '')::date; v_to date := NULLIF(payload->>'date_to', '')::date; v_rows jsonb; BEGIN SELECT COALESCE(jsonb_agg(to_jsonb(h) ORDER BY h.holiday_date), '[]'::jsonb) INTO v_rows FROM holidays h WHERE h.tenant_id = v_tid AND (v_from IS NULL OR h.holiday_date >= v_from) AND (v_to IS NULL OR h.holiday_date <= v_to); RETURN core.rpc_ok(jsonb_build_object('holidays', v_rows), 'Festivos cargados', jsonb_build_object('fn', 'fn_holiday_list')); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_holiday_list', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-044e-grants endDelimiter:; splitStatements:true GRANT EXECUTE ON FUNCTION core._payroll_apply_adjustments(bigint) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_save(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_list(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_delete(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_holiday_save(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_holiday_list(jsonb) TO panels_core_app;