panels-origin/db/core/changesets/044-payroll-adjustments.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

715 lines
25 KiB
PL/PgSQL

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