From 17936ce7774a67a7a96844e920d73be3ed62c068 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:52:05 +0000 Subject: [PATCH] Add PostgreSQL RPC functions for workers domain Port worker list/get/create/update, pipeline, assign/unassign, find-existing, refresh-pipeline, and checklist-raw from api/main.ts and api/db.ts with Spanish RPC envelopes, IMSS flag helpers, and panels_core_app grants. Co-authored-by: alberto.martinez --- db/core/changesets/010-rpc-workers.sql | 1453 ++++++++++++++++++++++++ 1 file changed, 1453 insertions(+) create mode 100644 db/core/changesets/010-rpc-workers.sql diff --git a/db/core/changesets/010-rpc-workers.sql b/db/core/changesets/010-rpc-workers.sql new file mode 100644 index 0000000..6224623 --- /dev/null +++ b/db/core/changesets/010-rpc-workers.sql @@ -0,0 +1,1453 @@ +--liquibase formatted sql +-- PANELS · core · RPC trabajadores (personal) + +--changeset panel:core-010a-worker-helpers endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core._worker_trim_text(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT btrim(COALESCE(p_value, '')); +$$; + +CREATE OR REPLACE FUNCTION core._worker_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._worker_format_nss(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT regexp_replace(COALESCE(p_value, ''), '\D', '', 'g'); +$$; + +CREATE OR REPLACE FUNCTION core._worker_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._worker_validate_curp(p_raw text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_curp text := core._worker_norm_upper(p_raw); +BEGIN + IF v_curp !~ '^[A-Z][AEIOUX][A-Z]{2}\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[HMX](?:AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[A-Z\d]\d$' THEN + RETURN 'CURP inválida (18 caracteres, formato oficial)'; + END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_validate_rfc(p_raw text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_rfc text := core._worker_norm_upper(p_raw); +BEGIN + IF v_rfc !~ '^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$' + AND v_rfc !~ '^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$' THEN + RETURN 'RFC inválido (12 o 13 caracteres, formato SAT)'; + END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_validate_nss(p_raw text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_nss text := core._worker_format_nss(p_raw); + v_sum integer := 0; + v_n integer; + v_check integer; + i integer; +BEGIN + IF length(v_nss) <> 11 THEN + RETURN 'NSS inválido (11 dígitos)'; + END IF; + FOR i IN 1..10 LOOP + v_n := substring(v_nss, i, 1)::integer * CASE WHEN (i - 1) % 2 = 1 THEN 2 ELSE 1 END; + IF v_n > 9 THEN + v_n := v_n - 9; + END IF; + v_sum := v_sum + v_n; + END LOOP; + v_check := (10 - (v_sum % 10)) % 10; + IF v_check <> substring(v_nss, 11, 1)::integer THEN + RETURN 'NSS inválido (dígito verificador)'; + END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_validate_fields(p_payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_errors jsonb := '{}'::jsonb; + v_err text; + v_wt text := upper(core._worker_trim_text(p_payload->>'work_type')); +BEGIN + IF core._worker_trim_text(p_payload->>'first_name') = '' THEN + v_errors := v_errors || jsonb_build_object('first_name', 'Nombre obligatorio'); + END IF; + IF core._worker_trim_text(p_payload->>'last_name_p') = '' THEN + v_errors := v_errors || jsonb_build_object('last_name_p', 'Apellido paterno obligatorio'); + END IF; + IF core._worker_trim_text(p_payload->>'last_name_m') = '' THEN + v_errors := v_errors || jsonb_build_object('last_name_m', 'Apellido materno obligatorio'); + END IF; + v_err := core._worker_validate_curp(p_payload->>'curp'); + IF v_err IS NOT NULL THEN + v_errors := v_errors || jsonb_build_object('curp', v_err); + END IF; + v_err := core._worker_validate_rfc(p_payload->>'rfc'); + IF v_err IS NOT NULL THEN + v_errors := v_errors || jsonb_build_object('rfc', v_err); + END IF; + v_err := core._worker_validate_nss(p_payload->>'nss'); + IF v_err IS NOT NULL THEN + v_errors := v_errors || jsonb_build_object('nss', v_err); + END IF; + IF core._worker_trim_text(p_payload->>'phone') = '' THEN + v_errors := v_errors || jsonb_build_object('phone', 'Teléfono obligatorio'); + END IF; + IF core._worker_trim_text(p_payload->>'email') = '' THEN + v_errors := v_errors || jsonb_build_object('email', 'Correo obligatorio'); + END IF; + IF core._worker_trim_text(p_payload->>'address') = '' THEN + v_errors := v_errors || jsonb_build_object('address', 'Dirección obligatoria'); + END IF; + IF core._worker_trim_text(p_payload->>'position') = '' THEN + v_errors := v_errors || jsonb_build_object('position', 'Cargo obligatorio'); + END IF; + IF core._worker_trim_text(p_payload->>'risk_code') = '' THEN + v_errors := v_errors || jsonb_build_object('risk_code', 'Riesgo obligatorio'); + END IF; + IF v_wt NOT IN ('N', 'D') THEN + v_errors := v_errors || jsonb_build_object('work_type', 'Tipo de trabajo N o D'); + END IF; + IF p_payload->>'daily_wage' IS NULL OR btrim(p_payload->>'daily_wage') = '' THEN + v_errors := v_errors || jsonb_build_object('daily_wage', 'Jornal obligatorio'); + ELSIF (p_payload->>'daily_wage')::numeric IS NULL THEN + v_errors := v_errors || jsonb_build_object('daily_wage', 'Jornal obligatorio'); + END IF; + RETURN v_errors; +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_normalize(p_payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_middle text := core._worker_trim_text(p_payload->>'middle_name'); + v_blood text := core._worker_norm_upper(p_payload->>'blood_type'); + v_needs_badge boolean := NOT ( + p_payload->>'needs_badge' = 'false' + OR p_payload->>'needs_badge' = '0' + OR p_payload->'needs_badge' = 'false'::jsonb + OR p_payload->'needs_badge' = '0'::jsonb + ); +BEGIN + RETURN jsonb_build_object( + 'first_name', initcap(lower(core._worker_trim_text(p_payload->>'first_name'))), + 'middle_name', CASE WHEN v_middle = '' THEN NULL ELSE initcap(lower(v_middle)) END, + 'last_name_p', initcap(lower(core._worker_trim_text(p_payload->>'last_name_p'))), + 'last_name_m', initcap(lower(core._worker_trim_text(p_payload->>'last_name_m'))), + 'curp', core._worker_norm_upper(p_payload->>'curp'), + 'rfc', core._worker_norm_upper(p_payload->>'rfc'), + 'nss', core._worker_format_nss(p_payload->>'nss'), + 'phone', core._worker_trim_text(p_payload->>'phone'), + 'email', lower(core._worker_trim_text(p_payload->>'email')), + 'address', core._worker_trim_text(p_payload->>'address'), + 'blood_type', CASE WHEN v_blood = '' THEN NULL ELSE v_blood END, + 'hire_type', upper(core._worker_trim_text(p_payload->>'hire_type')), + 'position', initcap(lower(core._worker_trim_text(p_payload->>'position'))), + 'risk_code', core._worker_canonical_risk(p_payload->>'risk_code'), + 'work_type', upper(core._worker_trim_text(p_payload->>'work_type')), + 'daily_wage', COALESCE((p_payload->>'daily_wage')::numeric(12,2), 0), + 'needs_badge', v_needs_badge, + 'status', CASE WHEN p_payload->>'status' = 'baja' THEN 'baja' ELSE 'activo' END + ); +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_full_name( + p_first_name text, + p_middle_name text, + p_last_name_p text, + p_last_name_m text +) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT array_to_string( + array_remove(ARRAY[p_first_name, p_middle_name, p_last_name_p, p_last_name_m], NULL), + ' '); +$$; + +CREATE OR REPLACE FUNCTION core._worker_tenant_timezone(p_tenant_id integer) +RETURNS text +LANGUAGE plpgsql +STABLE +SET search_path = core +AS $$ +DECLARE + v_tz text; +BEGIN + IF p_tenant_id IS NULL THEN + RETURN 'America/Mexico_City'; + END IF; + SELECT timezone INTO v_tz + FROM tenant_settings + WHERE tenant_id = p_tenant_id; + RETURN COALESCE(v_tz, 'America/Mexico_City'); +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_today(p_tenant_id integer) +RETURNS date +LANGUAGE sql +STABLE +SET search_path = core +AS $$ + SELECT (timezone(core._worker_tenant_timezone(p_tenant_id), now()))::date; +$$; + +CREATE OR REPLACE FUNCTION core._worker_imss_context(p_worker_id bigint) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SET search_path = core +AS $$ +DECLARE + v_ctx jsonb; +BEGIN + SELECT jsonb_build_object( + 'imss_status', COALESCE(w.imss_status, 'sin_alta'), + 'last_rehire_at', w.last_rehire_at, + 'current_alta_at', COALESCE(d.imss_alta_at, w.imss_alta_at, d.uploaded_at::date) + ) + INTO v_ctx + FROM workers w + LEFT JOIN LATERAL ( + SELECT doc.imss_alta_at, doc.uploaded_at + FROM documents doc + WHERE doc.worker_id = w.id + AND doc.type_code = 'alta_imss' + AND doc.is_current = true + LIMIT 1 + ) d ON true + WHERE w.id = p_worker_id; + RETURN COALESCE(v_ctx, jsonb_build_object( + 'imss_status', 'sin_alta', + 'last_rehire_at', NULL, + 'current_alta_at', NULL + )); +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_freshness_required(p_ctx jsonb) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_rehire date; + v_alta date; +BEGIN + IF COALESCE(p_ctx->>'imss_status', 'sin_alta') <> 'alta' THEN + RETURN true; + END IF; + IF p_ctx->>'last_rehire_at' IS NULL OR btrim(p_ctx->>'last_rehire_at') = '' THEN + RETURN false; + END IF; + v_rehire := (p_ctx->>'last_rehire_at')::date; + IF p_ctx->>'current_alta_at' IS NULL OR btrim(p_ctx->>'current_alta_at') = '' THEN + RETURN true; + END IF; + v_alta := (p_ctx->>'current_alta_at')::date; + RETURN v_alta < v_rehire; +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_eval_doc_validity( + p_policy jsonb, + p_doc jsonb, + p_ctx jsonb, + p_today date +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_mode text := COALESCE(p_policy->>'validity_mode', 'none'); + v_issued date; + v_expires date; + v_need_fresh boolean; + v_days integer; +BEGIN + IF p_doc IS NULL THEN + RETURN jsonb_build_object( + 'present', false, + 'valid', false, + 'validity_status', 'missing', + 'issued_at', NULL, + 'expires_at', NULL + ); + END IF; + v_issued := NULLIF(p_doc->>'issued_at', '')::date; + v_expires := NULLIF(p_doc->>'expires_at', '')::date; + v_need_fresh := core._worker_freshness_required(p_ctx); + IF v_mode = 'expiry' THEN + IF COALESCE((p_policy->>'requires_expires_at')::boolean, false) AND v_expires IS NULL THEN + RETURN jsonb_build_object( + 'present', true, + 'valid', false, + 'validity_status', 'missing_dates', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + IF v_expires IS NOT NULL AND v_expires < p_today THEN + RETURN jsonb_build_object( + 'present', true, + 'valid', false, + 'validity_status', 'expired', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + RETURN jsonb_build_object( + 'present', true, + 'valid', true, + 'validity_status', 'ok', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + IF v_mode = 'freshness' THEN + IF COALESCE((p_policy->>'requires_issued_at')::boolean, false) AND v_issued IS NULL THEN + RETURN jsonb_build_object( + 'present', true, + 'valid', false, + 'validity_status', 'missing_dates', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + IF v_need_fresh AND v_issued IS NOT NULL THEN + v_days := COALESCE((p_policy->>'freshness_days')::integer, 90); + IF (p_today - v_issued) > v_days THEN + RETURN jsonb_build_object( + 'present', true, + 'valid', false, + 'validity_status', 'stale_for_alta', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + END IF; + RETURN jsonb_build_object( + 'present', true, + 'valid', true, + 'validity_status', 'ok', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + RETURN jsonb_build_object( + 'present', true, + 'valid', true, + 'validity_status', 'ok', + 'issued_at', v_issued, + 'expires_at', v_expires + ); +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_checklist_items( + p_worker_id bigint, + p_tenant_id integer DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SET search_path = core +AS $$ +DECLARE + v_today date := core._worker_today(p_tenant_id); + v_ctx jsonb := core._worker_imss_context(p_worker_id); + v_items jsonb := '[]'::jsonb; + v_freshness_required boolean := core._worker_freshness_required(v_ctx); + v_rec record; + v_doc jsonb; + v_policy jsonb; + v_evaled jsonb; +BEGIN + FOR v_rec IN + SELECT code, label, required, validity_mode, freshness_days, + requires_issued_at, requires_expires_at, category + FROM document_types + ORDER BY required DESC, label + LOOP + SELECT jsonb_build_object( + 'type_code', d.type_code, + 'issued_at', d.issued_at, + 'expires_at', d.expires_at, + 'uploaded_at', d.uploaded_at, + 'imss_alta_at', d.imss_alta_at + ) + INTO v_doc + FROM documents d + WHERE d.worker_id = p_worker_id + AND d.type_code = v_rec.code + AND d.is_current = true + LIMIT 1; + v_policy := jsonb_build_object( + 'code', v_rec.code, + 'label', v_rec.label, + 'required', v_rec.required, + 'validity_mode', COALESCE(v_rec.validity_mode, 'none'), + 'freshness_days', v_rec.freshness_days, + 'requires_issued_at', v_rec.requires_issued_at, + 'requires_expires_at', v_rec.requires_expires_at + ); + v_evaled := core._worker_eval_doc_validity(v_policy, v_doc, v_ctx, v_today); + v_items := v_items || jsonb_build_array( + jsonb_build_object( + 'code', v_rec.code, + 'label', v_rec.label, + 'required', v_rec.required, + 'validity_mode', COALESCE(v_rec.validity_mode, 'none'), + 'freshness_days', v_rec.freshness_days, + 'requires_issued_at', v_rec.requires_issued_at, + 'requires_expires_at', v_rec.requires_expires_at, + 'category', COALESCE(v_rec.category, 'otro'), + 'present', COALESCE((v_evaled->>'present')::boolean, false), + 'valid', COALESCE((v_evaled->>'valid')::boolean, false), + 'validity_status', v_evaled->>'validity_status', + 'issued_at', v_evaled->>'issued_at', + 'expires_at', v_evaled->>'expires_at' + ) + ); + END LOOP; + RETURN jsonb_build_object( + 'ctx', v_ctx, + 'freshness_required', v_freshness_required, + 'items', v_items + ); +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_imss_flags( + p_worker_id bigint, + p_tenant_id integer DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SET search_path = core +AS $$ +DECLARE + v_pack jsonb := core._worker_checklist_items(p_worker_id, p_tenant_id); + v_items jsonb := v_pack->'items'; + v_ctx jsonb := v_pack->'ctx'; + v_required_ok boolean; + v_in_project boolean; + v_has_imss boolean; +BEGIN + SELECT COALESCE(bool_and( + COALESCE((elem->>'present')::boolean, false) + AND COALESCE((elem->>'valid')::boolean, false) + ), false) + INTO v_required_ok + FROM jsonb_array_elements(v_items) AS elem + WHERE COALESCE((elem->>'required')::boolean, false); + SELECT EXISTS ( + SELECT 1 FROM assignments a + WHERE a.worker_id = p_worker_id AND a.active = true + LIMIT 1 + ) INTO v_in_project; + v_has_imss := COALESCE(v_ctx->>'imss_status', 'sin_alta') = 'alta'; + RETURN jsonb_build_object( + 'imss_status', COALESCE(v_ctx->>'imss_status', 'sin_alta'), + 'freshness_required', COALESCE((v_pack->>'freshness_required')::boolean, false), + 'imss_ready', v_required_ok AND NOT v_has_imss, + 'in_project_without_imss', v_in_project AND NOT v_has_imss, + 'expediente_ok', v_required_ok + ); +END; +$$; + +CREATE OR REPLACE FUNCTION core._worker_conflict_message( + p_row workers, + p_curp text, + p_rfc text, + p_nss text +) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_field text; +BEGIN + IF p_row.curp::text = p_curp THEN + v_field := 'CURP'; + ELSIF p_row.rfc::text = p_rfc THEN + v_field := 'RFC'; + ELSE + v_field := 'NSS'; + END IF; + RETURN format( + 'Este %s ya pertenece a %s %s', + v_field, p_row.first_name, p_row.last_name_p + ); +END; +$$; + +--changeset panel:core-010b-fn-worker-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_status text := nullif(btrim(payload->>'status'), ''); + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_q text := nullif(btrim(payload->>'q'), ''); + v_like text; + v_workers jsonb; + v_count integer; + v_imss_stats jsonb; +BEGIN + IF v_q IS NOT NULL THEN + v_like := '%' || v_q || '%'; + END IF; + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.last_name_p, x.first_name), '[]'::jsonb), + COUNT(*)::integer + INTO v_workers, v_count + FROM ( + SELECT + w.*, + r.label AS risk_label, + r.color AS risk_color, + r.text_color AS risk_text, + c.name AS company_name, + c.kind AS company_kind, + ic.name AS imss_company_name, + ic.code AS imss_company_code, + ic.registro_patronal AS imss_registro_patronal, + (SELECT string_agg(p.name, ', ') + FROM assignments a + JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS proyectos, + (SELECT string_agg(p.code || '|' || replace(p.name, '|', '/'), ';;') + FROM assignments a + JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS proyecto_pairs, + (SELECT string_agg(p.id::text, ',') + FROM assignments a + JOIN projects p ON p.id = a.project_id + WHERE a.worker_id = w.id AND a.active = true) AS project_ids, + (SELECT COUNT(*) + FROM document_types t + WHERE t.required = true + AND NOT EXISTS ( + SELECT 1 FROM documents d + WHERE d.worker_id = w.id + AND d.type_code = t.code + AND d.is_current = true + )) AS missing_docs, + (SELECT COALESCE(SUM(l.balance), 0) + FROM loans l + WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance, + EXISTS ( + SELECT 1 FROM assignments a + WHERE a.worker_id = w.id AND a.active = true + ) AS in_project, + flags.data->>'imss_status' AS imss_status, + (flags.data->>'imss_ready')::boolean AS imss_ready, + (flags.data->>'in_project_without_imss')::boolean AS in_project_without_imss, + (flags.data->>'expediente_ok')::boolean AS expediente_ok, + (flags.data->>'freshness_required')::boolean AS freshness_required + FROM workers w + JOIN risk_levels r ON r.code = w.risk_code + LEFT JOIN companies c ON c.id = w.company_id + LEFT JOIN companies ic ON ic.id = w.imss_company_id + LEFT JOIN LATERAL ( + SELECT core._worker_imss_flags(w.id, v_tid) AS data + ) flags ON true + WHERE (v_tid IS NULL OR w.tenant_id = v_tid) + AND (v_status IS NULL OR w.status = v_status) + AND (v_status IS DISTINCT FROM 'activo' OR w.pipeline_status <> 'baja') + AND (v_project_id IS NULL OR EXISTS ( + SELECT 1 FROM assignments a + WHERE a.worker_id = w.id + AND a.project_id = v_project_id + AND a.active = true + )) + AND (v_q IS NULL OR ( + w.first_name ILIKE v_like + OR w.last_name_p ILIKE v_like + OR w.curp::text ILIKE v_like + OR w.rfc::text ILIKE v_like + OR w.nss ILIKE v_like + )) + ) x; + SELECT jsonb_build_object( + 'with_imss', COUNT(*) FILTER ( + WHERE elem->>'status' = 'activo' + AND elem->>'pipeline_status' <> 'baja' + AND elem->>'imss_status' = 'alta' + ), + 'without_imss', COUNT(*) FILTER ( + WHERE elem->>'status' = 'activo' + AND elem->>'pipeline_status' <> 'baja' + AND elem->>'imss_status' IS DISTINCT FROM 'alta' + ), + 'in_project_without_imss', COUNT(*) FILTER ( + WHERE elem->>'status' = 'activo' + AND elem->>'pipeline_status' <> 'baja' + AND COALESCE((elem->>'in_project_without_imss')::boolean, false) + ), + 'imss_ready_count', COUNT(*) FILTER ( + WHERE elem->>'status' = 'activo' + AND elem->>'pipeline_status' <> 'baja' + AND COALESCE((elem->>'imss_ready')::boolean, false) + ) + ) + INTO v_imss_stats + FROM jsonb_array_elements(v_workers) AS elem; + RETURN core.rpc_ok( + jsonb_build_object( + 'workers', v_workers, + 'imss_stats', v_imss_stats + ), + format('Listado de trabajadores: %s registro(s)%s', + v_count, + CASE WHEN v_tid IS NOT NULL THEN format(' para tenant_id=%s', v_tid) ELSE '' END), + jsonb_build_object('fn', 'fn_worker_list', 'tenant_id', v_tid, 'count', v_count) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010c-fn-worker-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_worker jsonb; + v_documents jsonb; + v_assignments jsonb; + v_loans jsonb; + v_checklist_pack jsonb; + v_flags jsonb; + v_doc_types jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_get: id es obligatorio para consultar un trabajador', + jsonb_build_object('fn', 'fn_worker_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb + INTO v_worker + FROM ( + SELECT + w.*, + r.label AS risk_label, + r.color AS risk_color, + r.text_color AS risk_text, + c.name AS company_name, + c.kind AS company_kind, + ic.name AS imss_company_name, + ic.code AS imss_company_code, + ic.registro_patronal AS imss_registro_patronal, + core._worker_full_name(w.first_name, w.middle_name, w.last_name_p, w.last_name_m) AS full_name + FROM workers w + JOIN risk_levels r ON r.code = w.risk_code + LEFT JOIN companies c ON c.id = w.company_id + LEFT JOIN companies ic ON ic.id = w.imss_company_id + WHERE w.id = v_id + AND (v_tid IS NULL OR w.tenant_id = v_tid) + ) x; + IF v_worker IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_get: trabajador id=%s no encontrado%s', v_id, + CASE WHEN v_tid IS NOT NULL THEN format(' en tenant_id=%s', v_tid) ELSE '' END), + jsonb_build_object('fn', 'fn_worker_get', 'id', v_id, 'tenant_id', v_tid) + ); + END IF; + SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.uploaded_at DESC), '[]'::jsonb) + INTO v_documents + FROM ( + SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at, + issued_at, expires_at, imss_company_id, imss_alta_at + FROM documents + WHERE worker_id = v_id + ) d; + SELECT COALESCE(jsonb_agg(row_to_json(a)::jsonb ORDER BY a.active DESC, a.start_date DESC, a.id DESC), '[]'::jsonb) + INTO v_assignments + FROM ( + SELECT asn.*, p.name AS project_name, p.code AS project_code + FROM assignments asn + JOIN projects p ON p.id = asn.project_id + WHERE asn.worker_id = v_id + ) a; + SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id DESC), '[]'::jsonb) + INTO v_loans + FROM loans l + WHERE l.worker_id = v_id; + v_checklist_pack := core._worker_checklist_items(v_id, v_tid); + v_flags := core._worker_imss_flags(v_id, v_tid); + v_worker := v_worker || v_flags || jsonb_build_object('full_name', v_worker->>'full_name'); + SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.required DESC, t.label), '[]'::jsonb) + INTO v_doc_types + FROM document_types t; + RETURN core.rpc_ok( + jsonb_build_object( + 'worker', v_worker, + 'documents', v_documents, + 'assignments', v_assignments, + 'loans', v_loans, + 'checklist', v_checklist_pack->'items', + 'document_types', v_doc_types, + 'freshness_required', v_checklist_pack->'freshness_required' + ), + format('Trabajador id=%s cargado (%s)', v_id, v_worker->>'full_name'), + jsonb_build_object('fn', 'fn_worker_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010d-fn-worker-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_errors jsonb; + v_norm jsonb; + v_existing workers%ROWTYPE; + v_row workers%ROWTYPE; + v_project_status text; + v_refresh jsonb; +BEGIN + v_errors := core._worker_validate_fields(payload); + IF v_errors <> '{}'::jsonb THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_create: datos del trabajador incompletos o inválidos', + jsonb_build_object('fn', 'fn_worker_create', 'field', 'payload'), + v_errors + ); + END IF; + v_norm := core._worker_normalize(payload); + SELECT * INTO v_existing + FROM workers + WHERE curp = v_norm->>'curp' + OR rfc = v_norm->>'rfc' + OR nss = v_norm->>'nss' + LIMIT 1; + IF FOUND THEN + RETURN core.rpc_err( + 'CONFLICT', + core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'), + jsonb_build_object('fn', 'fn_worker_create', 'worker_id', v_existing.id) + ); + END IF; + IF v_project_id IS NOT NULL THEN + SELECT status INTO v_project_status FROM projects WHERE id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id) + ); + END IF; + IF v_project_status <> 'activo' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_create: solo se asigna personal a proyectos activos', + jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id, 'status', v_project_status) + ); + END IF; + 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 ( + v_norm->>'first_name', + v_norm->>'middle_name', + v_norm->>'last_name_p', + v_norm->>'last_name_m', + v_norm->>'curp', + v_norm->>'rfc', + v_norm->>'nss', + v_norm->>'phone', + v_norm->>'email', + v_norm->>'address', + v_norm->>'blood_type', + '', + NULL, + v_norm->>'position', + v_norm->>'risk_code', + v_norm->>'work_type', + (v_norm->>'daily_wage')::numeric(12,2), + (v_norm->>'needs_badge')::boolean, + v_norm->>'status', + v_tid + ) + RETURNING * INTO v_row; + IF v_project_id IS NOT NULL THEN + INSERT INTO assignments (worker_id, project_id, active, start_date) + VALUES (v_row.id, v_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 IF; + v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object( + 'worker_id', v_row.id, + 'tenant_id', v_tid + )); + IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN + RETURN v_refresh; + END IF; + SELECT pipeline_status INTO v_row.pipeline_status FROM workers WHERE id = v_row.id; + RETURN core.rpc_created( + jsonb_build_object('id', v_row.id, 'status', v_row.status, 'pipeline_status', v_row.pipeline_status), + format('Trabajador %s %s registrado (id=%s)', + v_row.first_name, v_row.last_name_p, v_row.id), + jsonb_build_object('fn', 'fn_worker_create', 'id', v_row.id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010e-fn-worker-update endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_cur workers%ROWTYPE; + v_merged jsonb; + v_errors jsonb; + v_norm jsonb; + v_existing workers%ROWTYPE; + v_refresh jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_update: id es obligatorio para actualizar un trabajador', + jsonb_build_object('fn', 'fn_worker_update', 'field', 'id') + ); + END IF; + SELECT * INTO v_cur FROM workers WHERE id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_update: trabajador id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_worker_update', 'id', v_id) + ); + END IF; + v_merged := to_jsonb(v_cur) || payload; + v_errors := core._worker_validate_fields(v_merged); + IF v_errors <> '{}'::jsonb THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_worker_update: datos inválidos para trabajador id=%s', v_id), + jsonb_build_object('fn', 'fn_worker_update', 'id', v_id), + v_errors + ); + END IF; + v_norm := core._worker_normalize(v_merged); + SELECT * INTO v_existing + FROM workers + WHERE (curp = v_norm->>'curp' OR rfc = v_norm->>'rfc' OR nss = v_norm->>'nss') + AND id <> v_id + LIMIT 1; + IF FOUND THEN + RETURN core.rpc_err( + 'CONFLICT', + core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'), + jsonb_build_object('fn', 'fn_worker_update', 'id', v_id, 'worker_id', v_existing.id) + ); + END IF; + UPDATE workers SET + first_name = v_norm->>'first_name', + middle_name = v_norm->>'middle_name', + last_name_p = v_norm->>'last_name_p', + last_name_m = v_norm->>'last_name_m', + curp = v_norm->>'curp', + rfc = v_norm->>'rfc', + nss = v_norm->>'nss', + phone = v_norm->>'phone', + email = v_norm->>'email', + address = v_norm->>'address', + blood_type = v_norm->>'blood_type', + hire_type = v_cur.hire_type, + company_id = v_cur.company_id, + position = v_norm->>'position', + risk_code = v_norm->>'risk_code', + work_type = v_norm->>'work_type', + daily_wage = (v_norm->>'daily_wage')::numeric(12,2), + needs_badge = (v_norm->>'needs_badge')::boolean, + status = v_norm->>'status', + updated_at = now() + WHERE id = v_id; + v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object( + 'worker_id', v_id, + 'tenant_id', v_tid + )); + IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN + RETURN v_refresh; + END IF; + RETURN core.rpc_ok( + jsonb_build_object('id', v_id, 'status', v_norm->>'status'), + format('Trabajador id=%s actualizado correctamente', v_id), + jsonb_build_object('fn', 'fn_worker_update', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010f-fn-worker-set-pipeline endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_set_pipeline(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := COALESCE( + NULLIF(payload->>'id', '')::bigint, + NULLIF(payload->>'worker_id', '')::bigint + ); + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_pipeline text := nullif(btrim(payload->>'pipeline_status'), ''); + v_prev workers%ROWTYPE; + v_worker jsonb; + v_refresh jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_set_pipeline: id o worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_set_pipeline', 'field', 'id') + ); + END IF; + IF v_pipeline IS NULL OR v_pipeline NOT IN ('incompleto', 'listo_gafete', 'impreso', 'activo', 'baja') THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_worker_set_pipeline: estado de pipeline inválido (%s)', COALESCE(v_pipeline, 'NULL')), + jsonb_build_object('fn', 'fn_worker_set_pipeline', 'field', 'pipeline_status', 'value', v_pipeline) + ); + END IF; + SELECT * INTO v_prev FROM workers WHERE id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_set_pipeline: trabajador id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_worker_set_pipeline', 'id', v_id) + ); + END IF; + IF v_pipeline = 'baja' THEN + UPDATE workers + SET status = 'baja', + pipeline_status = 'baja', + updated_at = now() + WHERE id = v_id; + ELSE + IF v_prev.status = 'baja' THEN + UPDATE workers + SET status = 'activo', + last_rehire_at = current_date, + updated_at = now() + WHERE id = v_id; + ELSE + UPDATE workers + SET status = 'activo', + updated_at = now() + WHERE id = v_id; + END IF; + v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object( + 'worker_id', v_id, + 'tenant_id', v_tid + )); + IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN + RETURN v_refresh; + END IF; + END IF; + SELECT to_jsonb(w) INTO v_worker + FROM ( + SELECT status, pipeline_status, last_rehire_at, imss_status + FROM workers + WHERE id = v_id + ) w; + RETURN core.rpc_ok( + jsonb_build_object('worker', v_worker), + format('Pipeline del trabajador id=%s actualizado a %s', v_id, v_worker->>'pipeline_status'), + jsonb_build_object('fn', 'fn_worker_set_pipeline', 'id', v_id, 'pipeline_status', v_worker->>'pipeline_status') + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_set_pipeline', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010g-fn-worker-assign endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_assign(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := COALESCE( + NULLIF(payload->>'id', '')::bigint, + NULLIF(payload->>'worker_id', '')::bigint + ); + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_project_status text; + v_refresh jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_assign: id o worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_assign', 'field', 'worker_id') + ); + END IF; + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_assign: project_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_assign', 'field', 'project_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_assign: trabajador id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_worker_assign', 'worker_id', v_id) + ); + END IF; + SELECT status INTO v_project_status FROM projects WHERE id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_assign: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_worker_assign', 'project_id', v_project_id) + ); + END IF; + IF v_project_status <> 'activo' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_assign: solo se asigna personal a proyectos activos', + jsonb_build_object('fn', 'fn_worker_assign', 'project_id', v_project_id, 'status', v_project_status) + ); + END IF; + INSERT INTO assignments (worker_id, project_id, active, start_date) + VALUES (v_id, v_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; + v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object( + 'worker_id', v_id, + 'tenant_id', v_tid + )); + IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN + RETURN v_refresh; + END IF; + RETURN core.rpc_ok( + jsonb_build_object('worker_id', v_id, 'project_id', v_project_id, 'active', true), + format('Trabajador id=%s asignado al proyecto id=%s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_worker_assign', 'worker_id', v_id, 'project_id', v_project_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_assign', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010h-fn-worker-unassign endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_unassign(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := COALESCE( + NULLIF(payload->>'id', '')::bigint, + NULLIF(payload->>'worker_id', '')::bigint + ); + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_updated integer; + v_refresh jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_unassign: id o worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_unassign', 'field', 'worker_id') + ); + END IF; + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_unassign: project_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_unassign', 'field', 'project_id') + ); + END IF; + UPDATE assignments + SET active = false, + end_date = current_date + WHERE worker_id = v_id + AND project_id = v_project_id + AND active = true; + GET DIAGNOSTICS v_updated = ROW_COUNT; + IF v_updated = 0 THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_unassign: no hay asignación activa del trabajador id=%s al proyecto id=%s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_worker_unassign', 'worker_id', v_id, 'project_id', v_project_id) + ); + END IF; + v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object( + 'worker_id', v_id, + 'tenant_id', v_tid + )); + IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN + RETURN v_refresh; + END IF; + RETURN core.rpc_ok( + jsonb_build_object('worker_id', v_id, 'project_id', v_project_id, 'active', false), + format('Trabajador id=%s desasignado del proyecto id=%s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_worker_unassign', 'worker_id', v_id, 'project_id', v_project_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_unassign', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010i-fn-worker-find-existing endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_find_existing(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_curp text := core._worker_norm_upper(payload->>'curp'); + v_rfc text := core._worker_norm_upper(payload->>'rfc'); + v_nss text := core._worker_format_nss(payload->>'nss'); + v_exclude bigint := COALESCE(NULLIF(payload->>'exclude_id', '')::bigint, 0); + v_worker jsonb; +BEGIN + IF v_curp = '' AND v_rfc = '' AND v_nss = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_find_existing: indique al menos curp, rfc o nss para buscar duplicados', + jsonb_build_object('fn', 'fn_worker_find_existing', 'field', 'curp') + ); + END IF; + SELECT to_jsonb(w) INTO v_worker + FROM ( + SELECT id, first_name, last_name_p, curp, rfc, nss + FROM workers + WHERE id <> v_exclude + AND ( + (v_curp <> '' AND curp = v_curp) + OR (v_rfc <> '' AND rfc = v_rfc) + OR (v_nss <> '' AND nss = v_nss) + ) + LIMIT 1 + ) w; + IF v_worker IS NULL THEN + RETURN core.rpc_ok( + jsonb_build_object('worker', NULL, 'found', false), + 'No se encontró trabajador con la misma CURP, RFC o NSS', + jsonb_build_object('fn', 'fn_worker_find_existing', 'found', false) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('worker', v_worker, 'found', true), + format('Trabajador existente encontrado: %s %s (id=%s)', + v_worker->>'first_name', v_worker->>'last_name_p', v_worker->>'id'), + jsonb_build_object('fn', 'fn_worker_find_existing', 'found', true, 'id', v_worker->>'id') + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_find_existing', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010j-fn-worker-refresh-pipeline endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_refresh_pipeline(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := COALESCE( + NULLIF(payload->>'worker_id', '')::bigint, + NULLIF(payload->>'id', '')::bigint + ); + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_status text; + v_pack jsonb; + v_items jsonb; + v_photo jsonb; + v_photo_ok boolean; + v_required_ok boolean; + v_printed boolean; + v_assigned boolean; + v_pipeline text := 'incompleto'; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_refresh_pipeline: worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'field', 'worker_id') + ); + END IF; + SELECT status INTO v_status FROM workers WHERE id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_refresh_pipeline: trabajador id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'worker_id', v_id) + ); + END IF; + IF v_status = 'baja' THEN + UPDATE workers SET pipeline_status = 'baja', updated_at = now() WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('worker_id', v_id, 'pipeline_status', 'baja'), + format('Pipeline del trabajador id=%s fijado en baja', v_id), + jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'worker_id', v_id, 'pipeline_status', 'baja') + ); + END IF; + v_pack := core._worker_checklist_items(v_id, v_tid); + v_items := v_pack->'items'; + SELECT elem INTO v_photo + FROM jsonb_array_elements(v_items) AS elem + WHERE elem->>'code' = 'foto' + LIMIT 1; + v_photo_ok := COALESCE((v_photo->>'present')::boolean, false) + AND COALESCE((v_photo->>'valid')::boolean, false); + SELECT COALESCE(bool_and( + COALESCE((elem->>'present')::boolean, false) + AND COALESCE((elem->>'valid')::boolean, false) + ), false) + INTO v_required_ok + FROM jsonb_array_elements(v_items) AS elem + WHERE COALESCE((elem->>'required')::boolean, false); + SELECT EXISTS ( + SELECT 1 + FROM badge_job_people p + JOIN badge_jobs j ON j.id = p.job_id + WHERE p.worker_id = v_id + LIMIT 1 + ) INTO v_printed; + IF v_required_ok AND v_photo_ok THEN + v_pipeline := CASE WHEN v_printed THEN 'impreso' ELSE 'listo_gafete' END; + END IF; + SELECT EXISTS ( + SELECT 1 FROM assignments a + WHERE a.worker_id = v_id AND a.active = true + LIMIT 1 + ) INTO v_assigned; + IF v_pipeline <> 'incompleto' AND v_assigned THEN + v_pipeline := CASE WHEN v_printed THEN 'activo' ELSE 'listo_gafete' END; + END IF; + UPDATE workers + SET pipeline_status = v_pipeline, + updated_at = now() + WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('worker_id', v_id, 'pipeline_status', v_pipeline), + format('Pipeline del trabajador id=%s recalculado: %s', v_id, v_pipeline), + jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'worker_id', v_id, 'pipeline_status', v_pipeline) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_refresh_pipeline', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010k-fn-worker-checklist-raw endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_worker_checklist_raw(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := COALESCE( + NULLIF(payload->>'worker_id', '')::bigint, + NULLIF(payload->>'id', '')::bigint + ); + v_tid integer := COALESCE( + NULLIF(payload->>'tenant_id', '')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_doc_types jsonb; + v_current_docs jsonb; + v_pack jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_checklist_raw: worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_checklist_raw', 'field', 'worker_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_checklist_raw: trabajador id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_worker_checklist_raw', 'worker_id', v_id) + ); + END IF; + SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.required DESC, t.label), '[]'::jsonb) + INTO v_doc_types + FROM document_types t; + SELECT COALESCE(jsonb_agg(to_jsonb(d)), '[]'::jsonb) + INTO v_current_docs + FROM ( + SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at + FROM documents + WHERE worker_id = v_id AND is_current = true + ) d; + v_pack := core._worker_checklist_items(v_id, v_tid); + RETURN core.rpc_ok( + jsonb_build_object( + 'document_types', v_doc_types, + 'current_documents', v_current_docs, + 'imss_context', v_pack->'ctx', + 'freshness_required', v_pack->'freshness_required', + 'checklist', v_pack->'items' + ), + format('Checklist documental del trabajador id=%s: %s tipo(s)', + v_id, jsonb_array_length(v_pack->'items')), + jsonb_build_object('fn', 'fn_worker_checklist_raw', 'worker_id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_checklist_raw', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-010l-fn-workers-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core._worker_trim_text(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_norm_upper(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_format_nss(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_canonical_risk(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_validate_curp(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_validate_rfc(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_validate_nss(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_validate_fields(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_normalize(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_full_name(text, text, text, text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_tenant_timezone(integer) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_today(integer) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_imss_context(bigint) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_freshness_required(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_eval_doc_validity(jsonb, jsonb, jsonb, date) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_checklist_items(bigint, integer) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_imss_flags(bigint, integer) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._worker_conflict_message(workers, text, text, text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_update(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_set_pipeline(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_assign(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_unassign(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_find_existing(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_refresh_pipeline(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_checklist_raw(jsonb) TO panels_core_app;