From d2be5c5b76245bd34dbf4172f6b6d504f99cef35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:52:42 +0000 Subject: [PATCH] Add payroll and import RPC changesets (013, 014) Port key payroll operations from api/payroll.ts to plpgsql RPCs with round_money helper, internal recalc/ensure logic, and core.rpc_* envelope. Add batch worker import and post-S3 document metadata store RPCs with Spanish validation messages and panels_core_app grants. Co-authored-by: alberto.martinez --- db/core/changesets/013-rpc-payroll.sql | 1658 ++++++++++++++++++++++++ db/core/changesets/014-rpc-import.sql | 566 ++++++++ 2 files changed, 2224 insertions(+) create mode 100644 db/core/changesets/013-rpc-payroll.sql create mode 100644 db/core/changesets/014-rpc-import.sql diff --git a/db/core/changesets/013-rpc-payroll.sql b/db/core/changesets/013-rpc-payroll.sql new file mode 100644 index 0000000..5cacc4e --- /dev/null +++ b/db/core/changesets/013-rpc-payroll.sql @@ -0,0 +1,1658 @@ +--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-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; diff --git a/db/core/changesets/014-rpc-import.sql b/db/core/changesets/014-rpc-import.sql new file mode 100644 index 0000000..38fbfc4 --- /dev/null +++ b/db/core/changesets/014-rpc-import.sql @@ -0,0 +1,566 @@ +--liquibase formatted sql +-- PANELS · core · RPC importación de personal y metadatos documentales + +--changeset panel:core-014a-import-helpers endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core._import_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._import_norm_upper(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g')); +$$; + +CREATE OR REPLACE FUNCTION core._import_norm_name(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT regexp_replace(btrim(COALESCE(p_value, '')), '\s+', ' ', 'g'); +$$; + +CREATE OR REPLACE FUNCTION core._import_format_nss(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT regexp_replace(COALESCE(p_value, ''), '\D', '', 'g'); +$$; + +CREATE OR REPLACE FUNCTION core._import_canonical_risk(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT CASE lower(btrim(COALESCE(p_value, ''))) + WHEN 'alto' THEN 'rojo' + WHEN 'medio' THEN 'amarillo' + WHEN 'bajo' THEN 'verde' + ELSE lower(btrim(COALESCE(p_value, ''))) + END; +$$; + +CREATE OR REPLACE FUNCTION core._import_validate_worker_row(p_row jsonb) +RETURNS text[] +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_errors text[] := ARRAY[]::text[]; + v_email text := lower(btrim(COALESCE(p_row->>'email', ''))); + v_wt text := upper(btrim(COALESCE(p_row->>'work_type', ''))); + v_nss text := core._import_format_nss(p_row->>'nss'); +BEGIN + IF core._import_norm_name(p_row->>'first_name') = '' THEN + v_errors := array_append(v_errors, 'Nombre obligatorio'); + END IF; + IF core._import_norm_name(p_row->>'last_name_p') = '' THEN + v_errors := array_append(v_errors, 'Apellido paterno obligatorio'); + END IF; + IF core._import_norm_name(p_row->>'last_name_m') = '' THEN + v_errors := array_append(v_errors, 'Apellido materno obligatorio'); + END IF; + IF length(core._import_norm_upper(p_row->>'curp')) <> 18 THEN + v_errors := array_append(v_errors, 'CURP inválida (18 caracteres, formato oficial)'); + END IF; + IF length(core._import_norm_upper(p_row->>'rfc')) NOT IN (12, 13) THEN + v_errors := array_append(v_errors, 'RFC inválido (12 o 13 caracteres, formato SAT)'); + END IF; + IF length(v_nss) <> 11 THEN + v_errors := array_append(v_errors, 'NSS inválido (11 dígitos)'); + END IF; + IF core._import_norm_name(p_row->>'phone') = '' THEN + v_errors := array_append(v_errors, 'Teléfono obligatorio'); + END IF; + IF v_email = '' THEN + v_errors := array_append(v_errors, 'Correo obligatorio'); + ELSIF v_email !~ '^[^\s@]+@[^\s@]+\.[^\s@]+$' THEN + v_errors := array_append(v_errors, 'Correo inválido'); + END IF; + IF core._import_norm_name(p_row->>'address') = '' THEN + v_errors := array_append(v_errors, 'Dirección obligatoria'); + END IF; + IF core._import_norm_name(p_row->>'position') = '' THEN + v_errors := array_append(v_errors, 'Cargo obligatorio'); + END IF; + IF core._import_canonical_risk(p_row->>'risk_code') = '' THEN + v_errors := array_append(v_errors, 'Riesgo obligatorio'); + END IF; + IF v_wt NOT IN ('N', 'D') THEN + v_errors := array_append(v_errors, 'Tipo de trabajo N o D'); + END IF; + IF p_row->>'daily_wage' IS NULL OR (p_row->>'daily_wage')::numeric IS NULL THEN + v_errors := array_append(v_errors, 'Jornal obligatorio'); + END IF; + RETURN v_errors; +END; +$$; + +CREATE OR REPLACE FUNCTION core._import_find_worker(p_curp text, p_rfc text, p_nss text) +RETURNS TABLE(id bigint, curp text, rfc text, nss text) +LANGUAGE sql +STABLE +SET search_path = core +AS $$ + SELECT w.id, w.curp::text, w.rfc::text, w.nss + FROM workers w + WHERE w.curp = p_curp OR w.rfc = p_rfc OR w.nss = p_nss + LIMIT 1; +$$; + +CREATE OR REPLACE FUNCTION core._import_assign_worker(p_worker_id bigint, p_project_id bigint) +RETURNS void +LANGUAGE plpgsql +SET search_path = core +AS $$ +BEGIN + IF p_project_id IS NULL THEN + RETURN; + END IF; + INSERT INTO assignments (worker_id, project_id, active, start_date) + VALUES (p_worker_id, p_project_id, true, current_date) + ON CONFLICT (worker_id, project_id) DO UPDATE SET + active = true, + start_date = CASE + WHEN assignments.active = false THEN excluded.start_date + ELSE assignments.start_date + END, + end_date = NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION core._import_upsert_worker( + p_row jsonb, + p_company_id bigint, + p_tenant_id integer +) +RETURNS TABLE(worker_id bigint, action text, matched text) +LANGUAGE plpgsql +SET search_path = core +AS $$ +DECLARE + v_curp text := core._import_norm_upper(p_row->>'curp'); + v_rfc text := core._import_norm_upper(p_row->>'rfc'); + v_nss text := core._import_format_nss(p_row->>'nss'); + v_existing record; + v_status text := CASE WHEN lower(btrim(COALESCE(p_row->>'status', 'activo'))) = 'baja' THEN 'baja' ELSE 'activo' END; + v_needs_badge boolean := CASE + WHEN lower(COALESCE(p_row->>'needs_badge', 'true')) IN ('false', '0', 'no') THEN false + ELSE true + END; + v_id bigint; +BEGIN + SELECT * INTO v_existing FROM core._import_find_worker(v_curp, v_rfc, v_nss); + IF FOUND THEN + UPDATE workers SET + first_name = initcap(lower(core._import_norm_name(p_row->>'first_name'))), + middle_name = NULLIF(initcap(lower(core._import_norm_name(p_row->>'middle_name'))), ''), + last_name_p = initcap(lower(core._import_norm_name(p_row->>'last_name_p'))), + last_name_m = initcap(lower(core._import_norm_name(p_row->>'last_name_m'))), + curp = v_curp, + rfc = v_rfc, + nss = v_nss, + phone = core._import_norm_name(p_row->>'phone'), + email = lower(btrim(COALESCE(p_row->>'email', ''))), + address = core._import_norm_name(p_row->>'address'), + blood_type = NULLIF(core._import_norm_upper(p_row->>'blood_type'), ''), + hire_type = upper(btrim(COALESCE(p_row->>'hire_type', ''))), + company_id = p_company_id, + tenant_id = COALESCE(p_tenant_id, tenant_id), + position = initcap(lower(core._import_norm_name(p_row->>'position'))), + risk_code = core._import_canonical_risk(p_row->>'risk_code'), + work_type = upper(btrim(COALESCE(p_row->>'work_type', ''))), + daily_wage = COALESCE((p_row->>'daily_wage')::numeric, 0), + needs_badge = v_needs_badge, + status = v_status, + updated_at = now() + WHERE id = v_existing.id; + worker_id := v_existing.id; + action := 'existed'; + matched := CASE + WHEN v_existing.curp = v_curp THEN 'CURP' + WHEN v_existing.rfc = v_rfc THEN 'RFC' + ELSE 'NSS' + END; + RETURN NEXT; + RETURN; + END IF; + INSERT INTO workers ( + first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, + phone, email, address, blood_type, hire_type, company_id, position, + risk_code, work_type, daily_wage, needs_badge, status, tenant_id + ) VALUES ( + initcap(lower(core._import_norm_name(p_row->>'first_name'))), + NULLIF(initcap(lower(core._import_norm_name(p_row->>'middle_name'))), ''), + initcap(lower(core._import_norm_name(p_row->>'last_name_p'))), + initcap(lower(core._import_norm_name(p_row->>'last_name_m'))), + v_curp, v_rfc, v_nss, + core._import_norm_name(p_row->>'phone'), + lower(btrim(COALESCE(p_row->>'email', ''))), + core._import_norm_name(p_row->>'address'), + NULLIF(core._import_norm_upper(p_row->>'blood_type'), ''), + upper(btrim(COALESCE(p_row->>'hire_type', ''))), + p_company_id, + initcap(lower(core._import_norm_name(p_row->>'position'))), + core._import_canonical_risk(p_row->>'risk_code'), + upper(btrim(COALESCE(p_row->>'work_type', ''))), + COALESCE((p_row->>'daily_wage')::numeric, 0), + v_needs_badge, + v_status, + p_tenant_id + ) + RETURNING id INTO v_id; + worker_id := v_id; + action := 'inserted'; + matched := NULL; + RETURN NEXT; +END; +$$; + +--changeset panel:core-014b-fn-workers-import-batch endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_workers_import_batch(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._import_tenant_id(payload); + v_default_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_rows jsonb := COALESCE(payload->'rows', '[]'::jsonb); + v_row jsonb; + v_idx integer := 0; + v_sheet text; + v_row_no integer; + v_nombre text; + v_errors text[]; + v_company record; + v_company_code text; + v_company_id bigint; + v_project_id bigint; + v_status text; + v_curp text; + v_rfc text; + v_nss text; + v_seen_curp jsonb := '{}'::jsonb; + v_seen_rfc jsonb := '{}'::jsonb; + v_seen_nss jsonb := '{}'::jsonb; + v_dup_row integer; + v_upsert record; + v_inserted integer := 0; + v_existed integer := 0; + v_error_count integer := 0; + v_error_rows jsonb := '[]'::jsonb; + v_existed_rows jsonb := '[]'::jsonb; +BEGIN + IF jsonb_typeof(v_rows) <> 'array' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_workers_import_batch: rows debe ser un arreglo JSON', + jsonb_build_object('fn', 'fn_workers_import_batch', 'field', 'rows') + ); + END IF; + IF jsonb_array_length(v_rows) = 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_workers_import_batch: el arreglo rows está vacío', + jsonb_build_object('fn', 'fn_workers_import_batch', 'field', 'rows') + ); + END IF; + + FOR v_row IN SELECT value FROM jsonb_array_elements(v_rows) LOOP + v_idx := v_idx + 1; + v_sheet := COALESCE(NULLIF(btrim(v_row->>'sheet'), ''), 'IMPORT'); + v_row_no := COALESCE((v_row->>'row')::integer, v_idx); + v_nombre := trim(concat_ws(' ', v_row->>'first_name', v_row->>'last_name_p', v_row->>'last_name_m')); + v_errors := core._import_validate_worker_row(v_row); + + v_company_code := upper(btrim(COALESCE(v_row->>'hire_type', v_row->>'company_code', ''))); + v_company_id := NULLIF(v_row->>'company_id', '')::bigint; + IF v_company_id IS NOT NULL THEN + SELECT id, code, tenant_id INTO v_company FROM companies WHERE id = v_company_id; + ELSIF v_company_code <> '' THEN + SELECT id, code, tenant_id INTO v_company FROM companies WHERE upper(code) = v_company_code; + ELSE + v_company := NULL; + END IF; + IF v_company.id IS NULL THEN + v_errors := array_append( + v_errors, + format('Empresa no encontrada (%s)', COALESCE(NULLIF(v_company_code, ''), '—')) + ); + ELSE + v_company_id := v_company.id; + IF v_tid = 0 AND v_company.tenant_id IS NOT NULL THEN + v_tid := v_company.tenant_id; + END IF; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM risk_levels WHERE code = core._import_canonical_risk(v_row->>'risk_code') + ) THEN + v_errors := array_append( + v_errors, + format('Riesgo inválido (%s). Use alto, medio o bajo', COALESCE(v_row->>'risk_code', '—')) + ); + END IF; + + v_curp := core._import_norm_upper(v_row->>'curp'); + v_rfc := core._import_norm_upper(v_row->>'rfc'); + v_nss := core._import_format_nss(v_row->>'nss'); + + IF cardinality(v_errors) = 0 THEN + IF v_seen_curp ? v_curp THEN + v_dup_row := (v_seen_curp->>v_curp)::integer; + v_errors := array_append(v_errors, format('CURP duplicada en el lote (fila %s)', v_dup_row)); + END IF; + IF v_seen_rfc ? v_rfc THEN + v_dup_row := (v_seen_rfc->>v_rfc)::integer; + v_errors := array_append(v_errors, format('RFC duplicado en el lote (fila %s)', v_dup_row)); + END IF; + IF v_seen_nss ? v_nss THEN + v_dup_row := (v_seen_nss->>v_nss)::integer; + v_errors := array_append(v_errors, format('NSS duplicado en el lote (fila %s)', v_dup_row)); + END IF; + END IF; + + IF cardinality(v_errors) > 0 THEN + v_error_count := v_error_count + 1; + v_error_rows := v_error_rows || jsonb_build_array(jsonb_build_object( + 'sheet', v_sheet, + 'row', v_row_no, + 'nombre', v_nombre, + 'curp', COALESCE(v_row->>'curp', ''), + 'messages', to_jsonb(v_errors) + )); + CONTINUE; + END IF; + + v_seen_curp := v_seen_curp || jsonb_build_object(v_curp, v_row_no); + v_seen_rfc := v_seen_rfc || jsonb_build_object(v_rfc, v_row_no); + v_seen_nss := v_seen_nss || jsonb_build_object(v_nss, v_row_no); + + v_status := CASE WHEN lower(btrim(COALESCE(v_row->>'status', 'activo'))) = 'baja' THEN 'baja' ELSE 'activo' END; + v_project_id := COALESCE(NULLIF(v_row->>'project_id', '')::bigint, v_default_project_id); + IF v_status <> 'activo' THEN + v_project_id := NULL; + END IF; + + SELECT * INTO v_upsert + FROM core._import_upsert_worker( + v_row || jsonb_build_object('hire_type', v_company.code), + v_company_id, + COALESCE(v_tid, v_company.tenant_id, 1) + ); + + IF v_project_id IS NOT NULL THEN + PERFORM core._import_assign_worker(v_upsert.worker_id, v_project_id); + END IF; + + IF v_status = 'baja' THEN + UPDATE workers SET pipeline_status = 'baja' WHERE id = v_upsert.worker_id; + END IF; + + IF v_upsert.action = 'inserted' THEN + v_inserted := v_inserted + 1; + ELSE + v_existed := v_existed + 1; + v_existed_rows := v_existed_rows || jsonb_build_array(jsonb_build_object( + 'sheet', v_sheet, + 'row', v_row_no, + 'nombre', v_nombre, + 'curp', v_curp, + 'matched', COALESCE(v_upsert.matched, 'CURP') + )); + END IF; + END LOOP; + + RETURN core.rpc_ok( + jsonb_build_object( + 'inserted', v_inserted, + 'existed', v_existed, + 'errors', v_error_count, + 'existed_rows', v_existed_rows, + 'error_rows', v_error_rows + ), + format('Importación por lote: %s nuevo(s), %s existente(s), %s error(es)', + v_inserted, v_existed, v_error_count), + jsonb_build_object( + 'fn', 'fn_workers_import_batch', + 'tenant_id', v_tid, + 'inserted', v_inserted, + 'existed', v_existed, + 'errors', v_error_count + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_workers_import_batch', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-014c-fn-worker-document-store endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_document_store(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; + v_type_code text := btrim(COALESCE(payload->>'type_code', payload->>'type', '')); + v_original_name text := btrim(COALESCE(payload->>'original_name', payload->>'filename', '')); + v_mime text := btrim(COALESCE(payload->>'mime', '')); + v_size_bytes bigint := NULLIF(payload->>'size_bytes', '')::bigint; + v_sha256 text := btrim(COALESCE(payload->>'sha256', '')); + v_iv text := btrim(COALESCE(payload->>'iv', '')); + v_storage_name text := btrim(COALESCE(payload->>'storage_name', '')); + v_uploaded_by_id integer := NULLIF(payload->>'uploaded_by_id', '')::integer; + v_uploaded_by_name text := COALESCE(NULLIF(btrim(payload->>'uploaded_by_name'), ''), ''); + v_issued_at date := NULLIF(btrim(payload->>'issued_at'), '')::date; + v_expires_at date := NULLIF(btrim(payload->>'expires_at'), '')::date; + v_imss_company_id integer := NULLIF(payload->>'imss_company_id', '')::integer; + v_imss_alta_at date := NULLIF(btrim(payload->>'imss_alta_at'), '')::date; + v_imss_baja_at date := NULLIF(btrim(payload->>'imss_baja_at'), '')::date; + v_today date := current_date; + v_movement_date date; + v_doc_id bigint; + v_company record; +BEGIN + IF v_worker_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_document_store', '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_worker_document_store: trabajador id=%s no encontrado', v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id) + ); + END IF; + IF v_type_code = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: type_code es obligatorio', + jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'type_code') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM document_types WHERE code = v_type_code) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_worker_document_store: tipo de documento no válido (%s)', v_type_code), + jsonb_build_object('fn', 'fn_worker_document_store', 'type_code', v_type_code) + ); + END IF; + IF v_original_name = '' OR v_mime = '' OR v_sha256 = '' OR v_iv = '' OR v_storage_name = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: original_name, mime, sha256, iv y storage_name son obligatorios tras la carga a almacenamiento', + jsonb_build_object('fn', 'fn_worker_document_store') + ); + END IF; + IF v_size_bytes IS NULL OR v_size_bytes < 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: size_bytes debe ser un entero no negativo', + jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'size_bytes') + ); + END IF; + + v_imss_alta_at := COALESCE(v_imss_alta_at, CASE WHEN v_type_code = 'alta_imss' THEN v_today END); + v_imss_baja_at := COALESCE(v_imss_baja_at, CASE WHEN v_type_code = 'baja_imss' THEN v_today END); + v_movement_date := CASE WHEN v_type_code = 'baja_imss' THEN v_imss_baja_at ELSE v_imss_alta_at END; + + UPDATE documents + SET is_current = false + WHERE worker_id = v_worker_id + AND type_code = v_type_code + AND is_current = true; + + INSERT INTO documents ( + worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, + is_current, parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, + uploaded_by_id, uploaded_by_name + ) VALUES ( + v_worker_id, v_type_code, v_original_name, v_mime, v_size_bytes, v_sha256, v_iv, v_storage_name, + true, 'manual', + COALESCE(v_issued_at, v_movement_date), + v_expires_at, + v_imss_company_id, + v_movement_date, + v_uploaded_by_id, + v_uploaded_by_name + ) + RETURNING id INTO v_doc_id; + + IF v_type_code = 'alta_imss' THEN + v_imss_company_id := COALESCE( + v_imss_company_id, + (SELECT company_id FROM workers WHERE id = v_worker_id) + ); + SELECT id, code INTO v_company FROM companies WHERE id = v_imss_company_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: empresa patrón no válida para alta IMSS', + jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id) + ); + END IF; + UPDATE workers SET + imss_status = 'alta', + imss_company_id = v_company.id, + imss_alta_at = v_imss_alta_at, + imss_baja_at = NULL, + company_id = v_company.id, + hire_type = v_company.code, + updated_at = now() + WHERE id = v_worker_id; + END IF; + + IF v_type_code = 'baja_imss' THEN + UPDATE workers SET + imss_status = 'baja_imss', + imss_baja_at = v_imss_baja_at, + imss_company_id = NULL, + company_id = NULL, + hire_type = '', + updated_at = now() + WHERE id = v_worker_id; + END IF; + + IF (SELECT status FROM workers WHERE id = v_worker_id) = 'baja' THEN + UPDATE workers SET pipeline_status = 'baja' WHERE id = v_worker_id; + END IF; + + RETURN core.rpc_created( + jsonb_build_object( + 'id', v_doc_id, + 'worker_id', v_worker_id, + 'type_code', v_type_code, + 'is_current', true, + 'storage_name', v_storage_name + ), + format('Metadatos del documento %s guardados para trabajador %s (versión actual)', v_type_code, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_store', 'id', v_doc_id, 'worker_id', v_worker_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_document_store', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-014d-fn-import-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_workers_import_batch(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_document_store(jsonb) TO panels_core_app;