panels-origin/db/core/changesets/013-rpc-payroll.sql
Cursor Agent 8717c00a83
Refactor payroll_http and excel to use core RPC functions
- Replace db.prepare() in payroll_http with callCoreFn for attendance,
  loans, loan receipts, and legacy payroll period routes
- Use respondRpc/respondApiError for standardized HTTP responses
- Add fn_loan_list/get, fn_loan_payment_get, and fn_payroll_period_* RPCs
  to 013-rpc-payroll.sql with grants
- Refactor excel storeDocument to fn_worker_document_store (S3 stays in TS)
- Refactor importExcel and upsertWorker to fn_workers_import_batch

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-03 23:57:56 +00:00

2032 lines
69 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · RPC nómina (port de api/payroll.ts)
--changeset panel:core-013a-payroll-helpers endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.round_money(p_value numeric)
RETURNS numeric
LANGUAGE sql
IMMUTABLE
AS $$
SELECT round(COALESCE(p_value, 0)::numeric, 2);
$$;
CREATE OR REPLACE FUNCTION core._payroll_tenant_id(payload jsonb)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer,
0
);
$$;
CREATE OR REPLACE FUNCTION core._payroll_add_days(p_iso date, p_days integer)
RETURNS date
LANGUAGE sql
IMMUTABLE
AS $$
SELECT (p_iso + make_interval(days => p_days))::date;
$$;
CREATE OR REPLACE FUNCTION core._payroll_weekday_mon0(p_iso date)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$
SELECT (extract(isodow FROM p_iso)::integer - 1);
$$;
CREATE OR REPLACE FUNCTION core._payroll_week_containing(p_iso date)
RETURNS TABLE(week_start date, week_end date)
LANGUAGE sql
IMMUTABLE
AS $$
WITH ws AS (
SELECT core._payroll_add_days(p_iso, -core._payroll_weekday_mon0(p_iso)) AS week_start
)
SELECT ws.week_start, core._payroll_add_days(ws.week_start, 5) AS week_end
FROM ws;
$$;
CREATE OR REPLACE FUNCTION core._payroll_destajo_period_bounds(p_week_start date)
RETURNS TABLE(period_start date, period_end date)
LANGUAGE sql
IMMUTABLE
AS $$
WITH pe AS (
SELECT core._payroll_add_days(p_week_start, 3) AS period_end
)
SELECT core._payroll_add_days(pe.period_end, -7), pe.period_end
FROM pe;
$$;
CREATE OR REPLACE FUNCTION core._payroll_this_saturday(p_iso date)
RETURNS date
LANGUAGE sql
IMMUTABLE
AS $$
SELECT week_end FROM core._payroll_week_containing(p_iso);
$$;
CREATE OR REPLACE FUNCTION core._payroll_next_saturday_after_week(p_iso date)
RETURNS date
LANGUAGE sql
IMMUTABLE
AS $$
SELECT core._payroll_add_days(core._payroll_this_saturday(p_iso), 7);
$$;
CREATE OR REPLACE FUNCTION core._payroll_week_assemble_date(p_week_start date)
RETURNS date
LANGUAGE sql
IMMUTABLE
AS $$
SELECT core._payroll_add_days(p_week_start, 3);
$$;
CREATE OR REPLACE FUNCTION core._payroll_can_assemble_week(p_week_start date, p_today date)
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
SELECT p_today >= core._payroll_week_assemble_date(p_week_start);
$$;
CREATE OR REPLACE FUNCTION core._payroll_tenant_today(p_tenant_id integer)
RETURNS date
LANGUAGE sql
STABLE
SET search_path = core
AS $$
SELECT (now() AT TIME ZONE COALESCE(
(SELECT timezone FROM tenant_settings WHERE tenant_id = p_tenant_id),
'America/Mexico_City'
))::date;
$$;
CREATE OR REPLACE FUNCTION core._payroll_money_label(p_amount numeric)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
SELECT '$' || trim(to_char(core.round_money(p_amount), 'FM999,999,999,990.00'));
$$;
CREATE OR REPLACE FUNCTION core._payroll_commission_amount(p_delivered numeric, p_pct numeric)
RETURNS numeric
LANGUAGE sql
IMMUTABLE
AS $$
SELECT core.round_money(COALESCE(p_delivered, 0) * COALESCE(p_pct, 0) / 100.0);
$$;
CREATE OR REPLACE FUNCTION core._payroll_loan_condition_label(
p_delivered numeric,
p_commission_pct numeric,
p_pay_amount numeric,
p_installment_n integer,
p_installments_n integer
)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
SELECT format(
'%s · %s/%s · %s',
core._payroll_money_label(p_pay_amount),
p_installment_n,
GREATEST(COALESCE(p_installments_n, 1), 1),
CASE
WHEN COALESCE(p_commission_pct, 0) > 0 THEN
format('préstamo %s + comisión %s%%',
core._payroll_money_label(p_delivered),
trim(to_char(p_commission_pct, 'FM999990.##')))
ELSE format('préstamo %s', core._payroll_money_label(p_delivered))
END
);
$$;
CREATE OR REPLACE FUNCTION core._payroll_seed_defaults(p_tenant_id integer)
RETURNS void
LANGUAGE plpgsql
SET search_path = core
AS $$
BEGIN
INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
VALUES (p_tenant_id, true, 10, 500)
ON CONFLICT (tenant_id) DO NOTHING;
IF NOT EXISTS (SELECT 1 FROM destajo_units WHERE tenant_id = p_tenant_id) THEN
INSERT INTO destajo_units (tenant_id, code, label) VALUES
(p_tenant_id, 'm', 'Metro (m)'),
(p_tenant_id, 'm2', 'Metro cuadrado (m²)'),
(p_tenant_id, 'm3', 'Metro cúbico (m³)'),
(p_tenant_id, 'ml', 'Metro lineal (ml)'),
(p_tenant_id, 'pza', 'Pieza');
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION core._payroll_sheet_line(
p_sheet_id bigint,
p_worker_id bigint,
p_destajo_cut_line_id bigint
)
RETURNS bigint
LANGUAGE plpgsql
SET search_path = core
AS $$
DECLARE
v_id bigint;
BEGIN
IF p_destajo_cut_line_id IS NOT NULL THEN
SELECT id INTO v_id
FROM payroll_week_lines
WHERE sheet_id = p_sheet_id
AND destajo_cut_line_id = p_destajo_cut_line_id;
ELSE
SELECT id INTO v_id
FROM payroll_week_lines
WHERE sheet_id = p_sheet_id
AND worker_id = p_worker_id
AND destajo_cut_line_id IS NULL;
END IF;
IF v_id IS NOT NULL THEN
RETURN v_id;
END IF;
INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
VALUES (p_sheet_id, p_worker_id, p_destajo_cut_line_id)
RETURNING id INTO v_id;
RETURN v_id;
END;
$$;
--changeset panel:core-013b-payroll-recalc endDelimiter:; splitStatements:true
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_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
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 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_wage);
UPDATE payroll_week_lines
SET days = v_att_n,
daily_wage = v_wage,
amount = v_gross,
gross = v_gross,
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;
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-013c-fn-payroll-settings endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_settings_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_settings jsonb;
v_units jsonb;
BEGIN
PERFORM core._payroll_seed_defaults(v_tid);
SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.id), '[]'::jsonb)
INTO v_units
FROM destajo_units u
WHERE u.tenant_id = v_tid;
RETURN core.rpc_ok(
jsonb_build_object('settings', v_settings, 'units', v_units),
format('Configuración de nómina del tenant %s cargada', v_tid),
jsonb_build_object('fn', 'fn_payroll_settings_get', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_settings_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_settings_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_cur payroll_settings%ROWTYPE;
v_enabled boolean;
v_pct numeric;
v_small_max numeric;
v_settings jsonb;
BEGIN
PERFORM core._payroll_seed_defaults(v_tid);
SELECT * INTO v_cur FROM payroll_settings WHERE tenant_id = v_tid;
v_enabled := COALESCE((payload->>'loan_commission_enabled')::boolean, v_cur.loan_commission_enabled);
v_pct := COALESCE((payload->>'loan_commission_pct')::numeric, v_cur.loan_commission_pct, 0);
v_small_max := COALESCE((payload->>'loan_small_max')::numeric, v_cur.loan_small_max, 0);
UPDATE payroll_settings
SET loan_commission_enabled = v_enabled,
loan_commission_pct = COALESCE(v_pct, 0),
loan_small_max = COALESCE(v_small_max, 0)
WHERE tenant_id = v_tid;
SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
RETURN core.rpc_ok(
jsonb_build_object('settings', v_settings),
format('Configuración de nómina del tenant %s guardada', v_tid),
jsonb_build_object('fn', 'fn_payroll_settings_save', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_settings_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013d-fn-payroll-units endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_unit_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_units jsonb;
BEGIN
PERFORM core._payroll_seed_defaults(v_tid);
SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.id), '[]'::jsonb)
INTO v_units
FROM destajo_units u
WHERE u.tenant_id = v_tid;
RETURN core.rpc_ok(
jsonb_build_object('units', v_units),
format('Unidades de destajo del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_units)),
jsonb_build_object('fn', 'fn_payroll_unit_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_units))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_unit_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_unit_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_code text := lower(regexp_replace(btrim(COALESCE(payload->>'code', '')), '\s+', '', 'g'));
v_label text := COALESCE(NULLIF(btrim(payload->>'label'), ''), v_code);
v_id bigint;
BEGIN
IF v_code = '' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_unit_add: unidad requerida',
jsonb_build_object('fn', 'fn_payroll_unit_add', 'field', 'code')
);
END IF;
PERFORM core._payroll_seed_defaults(v_tid);
INSERT INTO destajo_units (tenant_id, code, label)
VALUES (v_tid, v_code, v_label)
RETURNING id INTO v_id;
RETURN core.rpc_created(
jsonb_build_object('id', v_id, 'code', v_code, 'label', v_label),
format('Unidad de destajo %s (%s) agregada', v_label, v_code),
jsonb_build_object('fn', 'fn_payroll_unit_add', 'tenant_id', v_tid, 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_unit_add', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013e-fn-attendance endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_attendance_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_from date := NULLIF(btrim(payload->>'from'), '')::date;
v_to date := NULLIF(btrim(payload->>'to'), '')::date;
v_rows jsonb;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_attendance_list: project_id es obligatorio',
jsonb_build_object('fn', 'fn_attendance_list', 'field', 'project_id')
);
END IF;
IF v_from IS NULL OR v_to IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_attendance_list: from y to son obligatorios (fechas ISO)',
jsonb_build_object('fn', 'fn_attendance_list', 'fields', jsonb_build_array('from', 'to'))
);
END IF;
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.work_date, x.last_name_p), '[]'::jsonb)
INTO v_rows
FROM (
SELECT a.*, w.first_name, w.last_name_p
FROM attendance a
JOIN workers w ON w.id = a.worker_id
WHERE a.project_id = v_project_id
AND a.work_date BETWEEN v_from AND v_to
) x;
RETURN core.rpc_ok(
jsonb_build_object('attendance', v_rows),
format('Asistencia del proyecto %s: %s registro(s) entre %s y %s',
v_project_id, jsonb_array_length(v_rows), v_from, v_to),
jsonb_build_object('fn', 'fn_attendance_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_attendance_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_attendance_set(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_work_date date := NULLIF(btrim(payload->>'work_date'), '')::date;
v_present boolean := COALESCE((payload->>'present')::boolean, false);
v_other record;
v_week_start date;
v_week record;
BEGIN
IF v_project_id IS NULL OR v_worker_id IS NULL OR v_work_date IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_attendance_set: project_id, worker_id y work_date son obligatorios',
jsonb_build_object('fn', 'fn_attendance_set')
);
END IF;
IF v_present THEN
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 = v_worker_id
AND a.work_date = v_work_date
AND a.present = true
AND a.project_id <> v_project_id
LIMIT 1;
IF FOUND THEN
RETURN core.rpc_err(
'CONFLICT',
format('Ese día ya está marcado en %s', v_other.project_name),
jsonb_build_object(
'fn', 'fn_attendance_set',
'worker_id', v_worker_id,
'work_date', v_work_date,
'other_project_id', v_other.project_id
),
jsonb_build_object(
'other', jsonb_build_object(
'project_id', v_other.project_id,
'project_name', v_other.project_name
)
)
);
END IF;
END IF;
INSERT INTO attendance (worker_id, project_id, work_date, present)
VALUES (v_worker_id, v_project_id, v_work_date, v_present)
ON CONFLICT (worker_id, project_id, work_date)
DO UPDATE SET present = EXCLUDED.present;
SELECT week_start INTO v_week_start
FROM core._payroll_week_containing(v_work_date);
SELECT id, status INTO v_week
FROM payroll_weeks
WHERE tenant_id = v_tid AND week_start = v_week_start;
IF FOUND AND v_week.status <> 'paid' THEN
PERFORM core._payroll_recalc_week(v_week.id);
END IF;
RETURN core.rpc_ok(
jsonb_build_object('ok', true),
format('Asistencia registrada para trabajador %s el %s', v_worker_id, v_work_date),
jsonb_build_object('fn', 'fn_attendance_set', 'worker_id', v_worker_id, 'work_date', v_work_date)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_attendance_set', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013f-fn-loan-create endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_loan_create(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_delivered numeric;
v_settings payroll_settings%ROWTYPE;
v_enabled boolean;
v_pct numeric;
v_comm numeric;
v_balance numeric;
v_small boolean;
v_plan text;
v_installments integer;
v_weekly numeric;
v_grant_iso date;
v_week record;
v_assembled boolean;
v_first_due date;
v_loan_id bigint;
BEGIN
IF v_worker_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_loan_create: worker_id es obligatorio',
jsonb_build_object('fn', 'fn_loan_create', 'field', 'worker_id')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_loan_create: persona id=%s no encontrada', v_worker_id),
jsonb_build_object('fn', 'fn_loan_create', 'worker_id', v_worker_id)
);
END IF;
PERFORM core._payroll_seed_defaults(v_tid);
SELECT * INTO v_settings FROM payroll_settings WHERE tenant_id = v_tid;
v_delivered := core.round_money(COALESCE(
(payload->>'delivered')::numeric,
(payload->>'amount')::numeric,
0
));
IF v_delivered <= 0 THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_loan_create: monto entregado requerido y mayor a cero',
jsonb_build_object('fn', 'fn_loan_create', 'field', 'delivered')
);
END IF;
v_enabled := COALESCE(
(payload->>'commission_enabled')::boolean,
v_settings.loan_commission_enabled
);
IF v_enabled THEN
v_pct := COALESCE((payload->>'commission_pct')::numeric, v_settings.loan_commission_pct, 0);
ELSE
v_pct := 0;
END IF;
v_comm := core._payroll_commission_amount(v_delivered, v_pct);
v_balance := core.round_money(v_delivered + v_comm);
v_small := v_delivered <= v_settings.loan_small_max;
v_plan := COALESCE(NULLIF(btrim(payload->>'plan'), ''), 'single');
v_installments := GREATEST(1, floor(COALESCE((payload->>'installments_n')::numeric, 1))::integer);
IF v_small THEN
v_plan := 'single';
v_installments := 1;
END IF;
IF v_plan = 'installments' AND v_installments < 2 THEN
v_installments := 2;
END IF;
IF v_plan = 'single' THEN
v_installments := 1;
END IF;
v_weekly := core.round_money(v_balance / v_installments);
v_grant_iso := COALESCE(
NULLIF(btrim(payload->>'grant_iso'), '')::date,
core._payroll_tenant_today(v_tid)
);
SELECT id, status INTO v_week
FROM payroll_weeks
WHERE tenant_id = v_tid
AND week_start = (SELECT week_start FROM core._payroll_week_containing(v_grant_iso));
v_assembled := FOUND AND v_week.status IN ('assembled', 'paid');
v_first_due := core._payroll_first_due_for_loan(
v_delivered,
v_settings.loan_small_max,
v_plan,
v_grant_iso,
v_assembled,
core._payroll_weekday_mon0(v_grant_iso)
);
INSERT INTO loans (
worker_id, amount, delivered, balance, weekly_payment, note,
commission_pct, commission_amount, plan, installments_n, first_due
) VALUES (
v_worker_id, v_delivered, v_delivered, v_balance, v_weekly,
NULLIF(btrim(payload->>'note'), ''),
v_pct, v_comm, v_plan, v_installments, v_first_due
)
RETURNING id INTO v_loan_id;
IF FOUND AND v_week.id IS NOT NULL AND v_week.status <> 'paid' THEN
PERFORM core._payroll_recalc_week(v_week.id);
END IF;
RETURN core.rpc_created(
jsonb_build_object('id', v_loan_id),
format('Préstamo de %s registrado para trabajador %s', core._payroll_money_label(v_delivered), v_worker_id),
jsonb_build_object('fn', 'fn_loan_create', 'id', v_loan_id, 'worker_id', v_worker_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013g-fn-payroll-week endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_week_ensure(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_week_start date := NULLIF(btrim(payload->>'week_start'), '')::date;
v_week_id bigint;
BEGIN
IF v_week_start IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_ensure: week_start es obligatorio (fecha ISO)',
jsonb_build_object('fn', 'fn_payroll_week_ensure', 'field', 'week_start')
);
END IF;
v_week_id := core._payroll_ensure_week(v_tid, v_week_start);
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id),
format('Semana de nómina asegurada (id=%s) para inicio %s', v_week_id, v_week_start),
jsonb_build_object('fn', 'fn_payroll_week_ensure', 'week_id', v_week_id, 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_ensure', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_week_recalc(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_recalc: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_recalc', 'field', 'week_id')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM payroll_weeks WHERE id = v_week_id) THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_week_recalc: semana id=%s no encontrada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_recalc', 'week_id', v_week_id)
);
END IF;
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id),
format('Semana de nómina %s recalculada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_recalc', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_recalc', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_week_assemble(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_now_iso date := COALESCE(
NULLIF(btrim(payload->>'now_iso'), '')::date,
core._payroll_tenant_today(v_tid)
);
v_week record;
v_sheet record;
v_worker record;
v_assigned boolean;
v_has_line boolean;
v_period_id bigint;
v_payable numeric;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_assemble: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_assemble', 'field', 'week_id')
);
END IF;
SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_week_assemble: semana id=%s no encontrada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id)
);
END IF;
IF v_week.status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_assemble: la semana ya está pagada',
jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id, 'status', v_week.status)
);
END IF;
IF NOT core._payroll_can_assemble_week(v_week.week_start, v_now_iso) THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_assemble: la nómina se arma el jueves o después; aún no es jueves de esta semana',
jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id, 'today', v_now_iso)
);
END IF;
FOR v_sheet IN
SELECT * FROM payroll_sheets WHERE week_id = v_week_id AND kind = 'obra'
LOOP
IF v_sheet.project_id IS NULL THEN
CONTINUE;
END IF;
FOR v_worker IN
SELECT w.id, w.work_type
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 core._payroll_add_days(v_week.week_start, 5)
)
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 = v_week_id
)
)
ORDER BY w.last_name_p, w.first_name
LOOP
SELECT EXISTS (
SELECT 1 FROM assignments
WHERE worker_id = v_worker.id AND project_id = v_sheet.project_id AND active = true
) INTO v_assigned;
IF NOT v_assigned THEN
CONTINUE;
END IF;
IF v_worker.work_type <> 'N' THEN
SELECT EXISTS (
SELECT 1 FROM payroll_week_lines
WHERE sheet_id = v_sheet.id AND worker_id = v_worker.id
) INTO v_has_line;
IF NOT v_has_line THEN
CONTINUE;
END IF;
END IF;
PERFORM core._payroll_prefill_thu_sat(v_sheet.project_id, v_week.week_start, v_worker.id);
END LOOP;
END LOOP;
SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
IF v_period_id IS NOT NULL THEN
UPDATE destajo_cut_lines
SET qty_actual = qty_planned
WHERE period_id = v_period_id
AND qty_actual = 0
AND qty_planned > 0;
END IF;
UPDATE payroll_weeks
SET status = 'assembled', assembled_at = now()
WHERE id = v_week_id;
PERFORM core._payroll_recalc_week(v_week_id);
SELECT payable_net INTO v_payable FROM payroll_weeks WHERE id = v_week_id;
UPDATE payroll_weeks SET required_net = v_payable WHERE id = v_week_id;
UPDATE payroll_week_lines
SET required_net = payable_net
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id, 'status', 'assembled'),
format('Semana de nómina %s armada; neto a pagar %s', v_week_id, core._payroll_money_label(v_payable)),
jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_assemble', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_week_pay(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_now_iso date := COALESCE(
NULLIF(btrim(payload->>'now_iso'), '')::date,
core._payroll_tenant_today(v_tid)
);
v_week record;
v_line record;
v_loan record;
v_take numeric;
v_n integer;
v_period_id bigint;
v_job record;
v_paid numeric;
v_env jsonb;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_pay: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_pay', 'field', 'week_id')
);
END IF;
SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_week_pay: semana id=%s no encontrada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
END IF;
IF v_week.status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_pay: la semana ya está pagada',
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
END IF;
IF v_week.status <> 'assembled' THEN
v_env := core.fn_payroll_week_assemble(jsonb_build_object(
'week_id', v_week_id,
'tenant_id', v_tid,
'now_iso', v_now_iso::text
));
IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
RETURN v_env;
END IF;
END IF;
PERFORM core._payroll_recalc_week(v_week_id);
FOR v_line IN
SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = v_week_id
AND l.loan_id IS NOT NULL
AND l.loan_discount > 0
LOOP
SELECT * INTO v_loan FROM loans WHERE id = v_line.loan_id;
IF NOT FOUND OR v_loan.balance <= 0 THEN
CONTINUE;
END IF;
v_take := core.round_money(LEAST(v_line.loan_discount, v_loan.balance));
IF v_take <= 0 THEN
CONTINUE;
END IF;
SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label)
VALUES (v_loan.id, v_week_id, v_take, v_n, v_line.loan_label);
UPDATE loans
SET balance = core.round_money(balance - v_take)
WHERE id = v_loan.id;
END LOOP;
SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
IF v_period_id IS NOT NULL THEN
FOR v_job IN SELECT id, qty_total_estimated FROM destajo_jobs LOOP
SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) INTO v_paid
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id = c.period_id
JOIN payroll_weeks w ON w.id = p.week_id
WHERE c.job_id = v_job.id
AND (w.status = 'paid' OR w.id = v_week_id);
IF v_paid >= v_job.qty_total_estimated THEN
UPDATE destajo_jobs SET status = 'done' WHERE id = v_job.id;
END IF;
END LOOP;
END IF;
UPDATE payroll_weeks SET status = 'paid', paid_at = now() WHERE id = v_week_id;
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id, 'status', 'paid'),
format('Semana de nómina %s marcada como pagada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_pay', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_week_bundle(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_week_start date := COALESCE(
NULLIF(btrim(payload->>'week_start'), '')::date,
(SELECT week_start FROM core._payroll_week_containing(core._payroll_tenant_today(v_tid)))
);
v_week_id bigint;
v_week jsonb;
v_sheets jsonb;
BEGIN
v_week_id := core._payroll_ensure_week(v_tid, v_week_start);
PERFORM core._payroll_ensure_cut_for_open_jobs(v_week_id);
PERFORM core._payroll_recalc_week(v_week_id);
SELECT to_jsonb(w) INTO v_week FROM payroll_weeks w WHERE id = v_week_id;
SELECT COALESCE(jsonb_agg(
to_jsonb(s)::jsonb || jsonb_build_object(
'project_name', p.name,
'project_code', p.code,
'lines', COALESCE((
SELECT jsonb_agg(row_to_json(lx)::jsonb ORDER BY lx.last_name_p, lx.first_name, lx.id)
FROM (
SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
w.position, w.work_type
FROM payroll_week_lines l
JOIN workers w ON w.id = l.worker_id
WHERE l.sheet_id = s.id
) lx
), '[]'::jsonb)
)
ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name
), '[]'::jsonb)
INTO v_sheets
FROM payroll_sheets s
LEFT JOIN projects p ON p.id = s.project_id
WHERE s.week_id = v_week_id;
RETURN core.rpc_ok(
jsonb_build_object('week', v_week, 'sheets', v_sheets),
format('Paquete de semana de nómina %s cargado (inicio %s)', v_week_id, v_week->>'week_start'),
jsonb_build_object('fn', 'fn_payroll_week_bundle', 'week_id', v_week_id, 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_bundle', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013h-fn-destajo endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_destajo_job_create(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_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_concepto text := btrim(COALESCE(payload->>'concepto', ''));
v_unit_code text := btrim(COALESCE(payload->>'unit_code', ''));
v_qty_total numeric := COALESCE((payload->>'qty_total_estimated')::numeric, 0);
v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0);
v_qty_planned numeric := COALESCE((payload->>'qty_planned')::numeric, 0);
v_qty_extra numeric := COALESCE((payload->>'qty_extra')::numeric, 0);
v_week_status text;
v_period_id bigint;
v_job_id bigint;
BEGIN
IF v_week_id IS NULL OR v_project_id IS NULL OR v_worker_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_destajo_job_create: week_id, project_id y worker_id son obligatorios',
jsonb_build_object('fn', 'fn_destajo_job_create')
);
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND OR v_week_status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_destajo_job_create: no se puede editar una semana pagada o inexistente',
jsonb_build_object('fn', 'fn_destajo_job_create', 'week_id', v_week_id)
);
END IF;
IF v_concepto = '' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_destajo_job_create: concepto requerido',
jsonb_build_object('fn', 'fn_destajo_job_create', 'field', 'concepto')
);
END IF;
INSERT INTO destajo_jobs (
project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id
) VALUES (
v_project_id, v_worker_id, v_concepto, v_unit_code, v_qty_total, v_unit_price, v_tid
)
RETURNING id INTO v_job_id;
SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
IF v_period_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
format('fn_destajo_job_create: periodo destajo no encontrado para semana %s', v_week_id),
jsonb_build_object('fn', 'fn_destajo_job_create', 'week_id', v_week_id)
);
END IF;
INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
VALUES (v_period_id, v_job_id, v_qty_planned, 0, v_qty_extra);
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_created(
jsonb_build_object('id', v_job_id),
format('Trabajo de destajo %s creado para semana %s', v_job_id, v_week_id),
jsonb_build_object('fn', 'fn_destajo_job_create', 'id', v_job_id, 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_destajo_job_create', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_destajo_cut_patch(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_cut_id bigint := NULLIF(payload->>'cut_id', '')::bigint;
v_week_status text;
v_cut record;
v_planned numeric;
v_actual numeric;
v_extra numeric;
BEGIN
IF v_week_id IS NULL OR v_cut_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_destajo_cut_patch: week_id y cut_id son obligatorios',
jsonb_build_object('fn', 'fn_destajo_cut_patch')
);
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND OR v_week_status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_destajo_cut_patch: no se puede editar una semana pagada o inexistente',
jsonb_build_object('fn', 'fn_destajo_cut_patch', 'week_id', v_week_id)
);
END IF;
SELECT * INTO v_cut FROM destajo_cut_lines WHERE id = v_cut_id;
IF NOT FOUND THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_destajo_cut_patch: partida id=%s no encontrada', v_cut_id),
jsonb_build_object('fn', 'fn_destajo_cut_patch', 'cut_id', v_cut_id)
);
END IF;
v_planned := COALESCE((payload->>'qty_planned')::numeric, v_cut.qty_planned);
v_actual := COALESCE((payload->>'qty_actual')::numeric, v_cut.qty_actual);
v_extra := COALESCE((payload->>'qty_extra')::numeric, v_cut.qty_extra);
UPDATE destajo_cut_lines
SET qty_planned = v_planned, qty_actual = v_actual, qty_extra = v_extra
WHERE id = v_cut_id;
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('cut_id', v_cut_id, 'week_id', v_week_id),
format('Partida de destajo %s actualizada', v_cut_id),
jsonb_build_object('fn', 'fn_destajo_cut_patch', 'cut_id', v_cut_id, 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_destajo_cut_patch', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i-fn-payroll-admin-jornal endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_amount numeric := core.round_money(COALESCE((payload->>'amount')::numeric, 0));
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_week_status text;
v_sheet_id bigint;
v_line_id bigint;
BEGIN
IF v_week_id IS NULL OR v_worker_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_admin_line_add: week_id y worker_id son obligatorios',
jsonb_build_object('fn', 'fn_payroll_admin_line_add')
);
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND OR v_week_status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_admin_line_add: no se puede editar una semana pagada o inexistente',
jsonb_build_object('fn', 'fn_payroll_admin_line_add', 'week_id', v_week_id)
);
END IF;
SELECT id INTO v_sheet_id
FROM payroll_sheets
WHERE week_id = v_week_id AND kind = 'admin' AND project_id IS NULL;
IF v_sheet_id IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_admin_line_add: hoja administrativa no encontrada para semana %s', v_week_id),
jsonb_build_object('fn', 'fn_payroll_admin_line_add', 'week_id', v_week_id)
);
END IF;
v_line_id := core._payroll_sheet_line(v_sheet_id, v_worker_id, NULL);
UPDATE payroll_week_lines
SET amount = v_amount,
project_id = v_project_id,
gross = v_amount,
payable_net = v_amount
WHERE id = v_line_id;
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('line_id', v_line_id, 'week_id', v_week_id),
format('Línea administrativa %s agregada por %s', v_line_id, core._payroll_money_label(v_amount)),
jsonb_build_object('fn', 'fn_payroll_admin_line_add', 'line_id', v_line_id, 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_admin_line_add', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
v_amount numeric := core.round_money(COALESCE((payload->>'amount')::numeric, 0));
v_week_status text;
BEGIN
IF v_week_id IS NULL OR v_line_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_admin_line_update: week_id y line_id son obligatorios',
jsonb_build_object('fn', 'fn_payroll_admin_line_update')
);
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND OR v_week_status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_admin_line_update: no se puede editar una semana pagada o inexistente',
jsonb_build_object('fn', 'fn_payroll_admin_line_update', 'week_id', v_week_id)
);
END IF;
UPDATE payroll_week_lines SET amount = v_amount WHERE id = v_line_id;
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('line_id', v_line_id, 'amount', v_amount),
format('Línea administrativa %s actualizada a %s', v_line_id, core._payroll_money_label(v_amount)),
jsonb_build_object('fn', 'fn_payroll_admin_line_update', 'line_id', v_line_id, 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_admin_line_update', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_remove(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
v_week_status text;
BEGIN
IF v_week_id IS NULL OR v_line_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_admin_line_remove: week_id y line_id son obligatorios',
jsonb_build_object('fn', 'fn_payroll_admin_line_remove')
);
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND OR v_week_status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_admin_line_remove: no se puede editar una semana pagada o inexistente',
jsonb_build_object('fn', 'fn_payroll_admin_line_remove', 'week_id', v_week_id)
);
END IF;
DELETE FROM payroll_week_lines WHERE id = v_line_id;
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('line_id', v_line_id, 'week_id', v_week_id),
format('Línea administrativa %s eliminada de la semana %s', v_line_id, v_week_id),
jsonb_build_object('fn', 'fn_payroll_admin_line_remove', 'line_id', v_line_id, 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_admin_line_remove', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_jornal_worker_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_week_status text;
v_sheet_id bigint;
v_line_id bigint;
BEGIN
IF v_week_id IS NULL OR v_project_id IS NULL OR v_worker_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_jornal_worker_add: week_id, project_id y worker_id son obligatorios',
jsonb_build_object('fn', 'fn_payroll_jornal_worker_add')
);
END IF;
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND OR v_week_status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_jornal_worker_add: no se puede editar una semana pagada o inexistente',
jsonb_build_object('fn', 'fn_payroll_jornal_worker_add', 'week_id', v_week_id)
);
END IF;
SELECT id INTO v_sheet_id
FROM payroll_sheets
WHERE week_id = v_week_id AND kind = 'obra' AND project_id = v_project_id;
IF v_sheet_id IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_jornal_worker_add: hoja de obra no encontrada (semana %s, proyecto %s)', v_week_id, v_project_id),
jsonb_build_object('fn', 'fn_payroll_jornal_worker_add', 'week_id', v_week_id, 'project_id', v_project_id)
);
END IF;
v_line_id := core._payroll_sheet_line(v_sheet_id, v_worker_id, NULL);
PERFORM core._payroll_recalc_week(v_week_id);
RETURN core.rpc_ok(
jsonb_build_object('line_id', v_line_id, 'week_id', v_week_id),
format('Trabajador %s agregado a jornal (proyecto %s, semana %s)', v_worker_id, v_project_id, v_week_id),
jsonb_build_object('fn', 'fn_payroll_jornal_worker_add', 'line_id', v_line_id, 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_jornal_worker_add', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i2-fn-payroll-week-list-open endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_week_list_open(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := (payload->>'tenant_id')::integer;
v_weeks jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(w) ORDER BY w.week_start DESC), '[]'::jsonb)
INTO v_weeks
FROM (
SELECT pw.*,
(SELECT COALESCE(SUM(l.loan_discount), 0)
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = pw.id) AS loan_recovery
FROM payroll_weeks pw
WHERE pw.tenant_id = v_tid AND pw.status != 'paid'
) w;
RETURN core.rpc_ok(
jsonb_build_object('weeks', v_weeks),
format('Semanas abiertas listadas para tenant %s: %s semana(s)', v_tid, jsonb_array_length(v_weeks)),
jsonb_build_object('fn', 'fn_payroll_week_list_open', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_list_open', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i3-fn-payroll-week-csv endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_week_csv(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := (payload->>'week_id')::bigint;
v_csv text;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_payroll_week_csv: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_csv', 'field', 'week_id'));
END IF;
SELECT string_agg(row_text, E'\n' ORDER BY ord)
INTO v_csv
FROM (
SELECT 0 AS ord, 'Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar' AS row_text
UNION ALL
SELECT 1 AS ord,
concat_ws(',',
w.first_name, w.last_name_p, s.kind, COALESCE(p.name, ''),
l.days, l.daily_wage, COALESCE(l.concepto, ''),
COALESCE(l.qty_actual, 0) + COALESCE(l.qty_extra, 0),
COALESCE(l.unit_code, ''), l.gross, l.discounts, l.loan_discount,
replace(COALESCE(l.loan_label, ''), ',', ' '),
l.required_net, l.payable_net
)
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
JOIN workers w ON w.id = l.worker_id
LEFT JOIN projects p ON p.id = s.project_id
WHERE s.week_id = v_week_id
ORDER BY s.kind, p.name, w.last_name_p
) q;
RETURN core.rpc_ok(
jsonb_build_object('csv', v_csv),
format('CSV de nómina generado para semana id=%s', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_csv', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_csv', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i4-fn-loan-read endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_loan_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_loans jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb)
INTO v_loans
FROM (
SELECT l.*, w.first_name, w.last_name_p
FROM loans l
JOIN workers w ON w.id = l.worker_id
WHERE v_worker_id IS NULL OR l.worker_id = v_worker_id
) x;
RETURN core.rpc_ok(
jsonb_build_object('loans', v_loans),
format('Préstamos listados: %s registro(s)', jsonb_array_length(v_loans)),
jsonb_build_object('fn', 'fn_loan_list', 'worker_id', v_worker_id, 'count', jsonb_array_length(v_loans))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_loan_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_loan jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_loan_get: id es obligatorio',
jsonb_build_object('fn', 'fn_loan_get', 'field', 'id')
);
END IF;
SELECT row_to_json(x)::jsonb INTO v_loan
FROM (
SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m
FROM loans l
JOIN workers w ON w.id = l.worker_id
WHERE l.id = v_id
) x;
IF v_loan IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Préstamo no encontrado',
jsonb_build_object('fn', 'fn_loan_get', 'id', v_id)
);
END IF;
RETURN core.rpc_ok(
jsonb_build_object('loan', v_loan),
format('Préstamo id=%s cargado', v_id),
jsonb_build_object('fn', 'fn_loan_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_loan_payment_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_payment jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_loan_payment_get: id es obligatorio',
jsonb_build_object('fn', 'fn_loan_payment_get', 'field', 'id')
);
END IF;
SELECT row_to_json(x)::jsonb INTO v_payment
FROM (
SELECT p.*, l.delivered, l.commission_pct,
w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
wk.week_end
FROM loan_payments p
JOIN loans l ON l.id = p.loan_id
JOIN workers w ON w.id = l.worker_id
JOIN payroll_weeks wk ON wk.id = p.week_id
WHERE p.id = v_id
) x;
IF v_payment IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Recibo no encontrado',
jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id)
);
END IF;
RETURN core.rpc_ok(
jsonb_build_object('payment', v_payment),
format('Pago de préstamo id=%s cargado', v_id),
jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_payment_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i5-fn-payroll-period endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_period_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_week_start date := NULLIF(btrim(payload->>'week_start'), '')::date;
v_week_end date := NULLIF(btrim(payload->>'week_end'), '')::date;
v_extra jsonb := COALESCE(payload->'extra_discounts', '{}'::jsonb);
v_period_id bigint;
v_worker record;
v_att_n integer;
v_days integer;
v_gross numeric;
v_extra_disc numeric;
v_net numeric;
BEGIN
IF v_project_id IS NULL OR v_week_start IS NULL OR v_week_end IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_period_create: project_id, week_start y week_end son obligatorios',
jsonb_build_object('fn', 'fn_payroll_period_create')
);
END IF;
INSERT INTO payroll_periods (project_id, week_start, week_end, status)
VALUES (v_project_id, v_week_start, v_week_end, 'draft')
RETURNING id INTO v_period_id;
FOR v_worker IN
SELECT w.id, w.daily_wage
FROM workers w
JOIN assignments a ON a.worker_id = w.id AND a.project_id = v_project_id AND a.active = true
WHERE w.status = 'activo'
LOOP
SELECT COUNT(*)::integer INTO v_att_n
FROM attendance
WHERE worker_id = v_worker.id
AND project_id = v_project_id
AND work_date BETWEEN v_week_start AND v_week_end
AND present = true;
v_days := v_att_n;
v_gross := v_days * v_worker.daily_wage;
v_extra_disc := COALESCE((v_extra->>v_worker.id::text)::numeric, 0);
v_net := v_gross - v_extra_disc;
INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
VALUES (v_period_id, v_worker.id, v_days, v_worker.daily_wage, v_gross, v_extra_disc, 0, v_net);
END LOOP;
RETURN core.rpc_created(
jsonb_build_object('id', v_period_id),
format('Periodo de nómina %s creado para proyecto %s', v_period_id, v_project_id),
jsonb_build_object('fn', 'fn_payroll_period_create', 'id', v_period_id, 'project_id', v_project_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_create', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_period_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_periods jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb)
INTO v_periods
FROM (
SELECT pe.*, p.name AS project_name
FROM payroll_periods pe
JOIN projects p ON p.id = pe.project_id
WHERE v_project_id IS NULL OR pe.project_id = v_project_id
) x;
RETURN core.rpc_ok(
jsonb_build_object('periods', v_periods),
format('Periodos de nómina listados: %s registro(s)', jsonb_array_length(v_periods)),
jsonb_build_object('fn', 'fn_payroll_period_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_periods))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_period_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_period jsonb;
v_lines jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_period_get: id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_period_get', 'field', 'id')
);
END IF;
SELECT row_to_json(x)::jsonb INTO v_period
FROM (
SELECT pe.*, p.name AS project_name
FROM payroll_periods pe
JOIN projects p ON p.id = pe.project_id
WHERE pe.id = v_id
) x;
IF v_period IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Periodo no encontrado',
jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id)
);
END IF;
SELECT COALESCE(jsonb_agg(row_to_json(lx)::jsonb ORDER BY lx.last_name_p), '[]'::jsonb)
INTO v_lines
FROM (
SELECT l.*, w.first_name, w.last_name_p, w.position
FROM payroll_lines l
JOIN workers w ON w.id = l.worker_id
WHERE l.period_id = v_id
) lx;
RETURN core.rpc_ok(
jsonb_build_object('period', v_period, 'lines', v_lines),
format('Periodo de nómina %s cargado con %s línea(s)', v_id, jsonb_array_length(v_lines)),
jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id, 'line_count', jsonb_array_length(v_lines))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_period_patch(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_status text := btrim(COALESCE(payload->>'status', ''));
BEGIN
IF v_id IS NULL OR v_status = '' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_period_patch: id y status son obligatorios',
jsonb_build_object('fn', 'fn_payroll_period_patch')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM payroll_periods WHERE id = v_id) THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Periodo no encontrado',
jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id)
);
END IF;
UPDATE payroll_periods SET status = v_status WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('ok', true, 'id', v_id, 'status', v_status),
format('Periodo de nómina %s actualizado a estado %s', v_id, v_status),
jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id, 'status', v_status)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_patch', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013j-fn-payroll-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.round_money(numeric) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_save(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_unit_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_unit_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_attendance_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_attendance_set(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_ensure(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_recalc(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_assemble(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_pay(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_bundle(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_destajo_job_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_destajo_cut_patch(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_update(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_remove(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_jornal_worker_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_list_open(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_csv(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_payment_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_patch(jsonb) TO panels_core_app;