From 02098d37b3b44e72e70b4ee87b24968e06de78a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:48:29 +0000 Subject: [PATCH 01/11] Add PostgreSQL RPC functions for companies CRUD Implement fn_company_list, fn_company_get, fn_company_get_by_code, fn_company_create, fn_company_update, and fn_next_company_code with shared validation helpers mirroring api/companies.ts logic. Uses RPC envelope helpers from 006-rpc-infra.sql with detailed Spanish error messages and GRANT EXECUTE for panels_core_app. Co-authored-by: alberto.martinez --- db/core/changesets/008-rpc-companies.sql | 639 +++++++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 db/core/changesets/008-rpc-companies.sql diff --git a/db/core/changesets/008-rpc-companies.sql b/db/core/changesets/008-rpc-companies.sql new file mode 100644 index 0000000..09c7627 --- /dev/null +++ b/db/core/changesets/008-rpc-companies.sql @@ -0,0 +1,639 @@ +--liquibase formatted sql +-- PANELS · core · RPC empresas (CRUD) + +--changeset panel:core-008a-company-helpers endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core._company_trim_text(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT btrim(COALESCE(p_value, '')); +$$; + +CREATE OR REPLACE FUNCTION core._company_norm_code(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g')); +$$; + +CREATE OR REPLACE FUNCTION core._company_norm_rfc(p_value text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g')); +$$; + +CREATE OR REPLACE FUNCTION core._company_code_from_name(p_name text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT COALESCE( + NULLIF( + left( + upper( + regexp_replace( + translate( + COALESCE(p_name, ''), + 'áàäâãåéèëêíìïîóòöôõúùüûñÁÀÄÂÃÅÉÈËÊÍÌÏÎÓÒÖÔÕÚÙÜÛÑ', + 'aaaaaaeeeeiiiiooooouuuunAAAAAAEEEEIIIIOOOOOUUUUN' + ), + '[^A-Z0-9]+', + '', + 'g' + ) + ), + 12 + ), + '' + ), + 'EMP' + ); +$$; + +CREATE OR REPLACE FUNCTION core._company_validate_code(p_code text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + IF p_code IS NULL OR p_code = '' THEN + RETURN 'Código de 2 a 16 caracteres (letras, números, _ o -)'; + END IF; + IF p_code !~ '^[A-Z0-9][A-Z0-9_-]{1,15}$' THEN + RETURN 'Código de 2 a 16 caracteres (letras, números, _ o -)'; + END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION core._company_validate_profile( + p_payload jsonb, + p_require_legal boolean DEFAULT false +) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_rfc text := core._company_norm_rfc(p_payload->>'rfc'); + v_clase text := upper(core._company_trim_text(p_payload->>'clase_riesgo')); +BEGIN + IF v_rfc <> '' AND v_rfc !~ '^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$' THEN + RETURN 'RFC inválido (formato mexicano de 12 o 13 caracteres)'; + END IF; + IF p_require_legal THEN + IF core._company_trim_text(p_payload->>'razon_social') = '' + AND core._company_trim_text(p_payload->>'name') = '' + AND core._company_trim_text(p_payload->>'nombre_comercial') = '' THEN + RETURN 'Indique razón social o nombre comercial'; + END IF; + END IF; + IF v_clase <> '' AND v_clase NOT IN ('I', 'II', 'III', 'IV', 'V') THEN + RETURN 'Clase de riesgo IMSS debe ser I, II, III, IV o V'; + END IF; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION core._company_normalize_profile( + p_payload jsonb, + p_fallback_name text DEFAULT '' +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_nombre_comercial text; + v_razon_social text; + v_name text; +BEGIN + v_nombre_comercial := COALESCE( + NULLIF(core._company_trim_text(p_payload->>'nombre_comercial'), ''), + NULLIF(core._company_trim_text(p_payload->>'name'), ''), + p_fallback_name + ); + v_razon_social := COALESCE( + NULLIF(core._company_trim_text(p_payload->>'razon_social'), ''), + NULLIF(v_nombre_comercial, ''), + p_fallback_name + ); + v_name := COALESCE( + NULLIF(core._company_trim_text(p_payload->>'name'), ''), + NULLIF(v_nombre_comercial, ''), + NULLIF(v_razon_social, ''), + p_fallback_name + ); + RETURN jsonb_build_object( + 'name', v_name, + 'nombre_comercial', v_nombre_comercial, + 'razon_social', v_razon_social, + 'rfc', core._company_norm_rfc(p_payload->>'rfc'), + 'regimen_fiscal', core._company_trim_text(p_payload->>'regimen_fiscal'), + 'registro_patronal', upper(core._company_trim_text(p_payload->>'registro_patronal')), + 'clase_riesgo', upper(core._company_trim_text(p_payload->>'clase_riesgo')), + 'domicilio_fiscal', core._company_trim_text(p_payload->>'domicilio_fiscal'), + 'codigo_postal', core._company_trim_text(p_payload->>'codigo_postal'), + 'ciudad', core._company_trim_text(p_payload->>'ciudad'), + 'estado', core._company_trim_text(p_payload->>'estado'), + 'telefono', core._company_trim_text(p_payload->>'telefono'), + 'email', lower(core._company_trim_text(p_payload->>'email')), + 'representante_legal', core._company_trim_text(p_payload->>'representante_legal'), + 'giro', core._company_trim_text(p_payload->>'giro') + ); +END; +$$; + +--changeset panel:core-008b-fn-company-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := NULLIF(payload->>'tenant_id', '')::integer; + v_companies jsonb; + v_count integer; +BEGIN + SELECT COALESCE( + jsonb_agg(row_to_json(x)::jsonb ORDER BY + CASE x.kind WHEN 'principal' THEN 0 ELSE 1 END, + lower(x.name)), + '[]'::jsonb + ), + COUNT(*)::integer + INTO v_companies, v_count + FROM ( + SELECT c.*, + p.code AS parent_code, + p.name AS parent_name, + (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count + FROM companies c + LEFT JOIN companies p ON p.id = c.parent_id + WHERE v_tid IS NULL OR c.tenant_id = v_tid + ) x; + RETURN core.rpc_ok( + jsonb_build_object('companies', v_companies), + format('Listado de empresas: %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_company_list', 'tenant_id', v_tid, 'count', v_count) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-008c-fn-company-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_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 := NULLIF(payload->>'tenant_id', '')::integer; + v_company jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_get: id es obligatorio para consultar una empresa', + jsonb_build_object('fn', 'fn_company_get', 'field', 'id') + ); + END IF; + SELECT to_jsonb(c) INTO v_company + FROM companies c + WHERE c.id = v_id + AND (v_tid IS NULL OR c.tenant_id IS NULL OR c.tenant_id = v_tid); + IF v_company IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_company_get: empresa id=%s no encontrada%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_company_get', 'id', v_id, 'tenant_id', v_tid) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('company', v_company), + format('Empresa id=%s cargada (%s)', v_id, v_company->>'code'), + jsonb_build_object('fn', 'fn_company_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-008d-fn-company-get-by-code endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_get_by_code(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_code text := core._company_norm_code(payload->>'code'); + v_company jsonb; +BEGIN + IF v_code = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_get_by_code: code es obligatorio para consultar una empresa', + jsonb_build_object('fn', 'fn_company_get_by_code', 'field', 'code') + ); + END IF; + SELECT to_jsonb(c) INTO v_company + FROM companies c + WHERE c.code = v_code; + IF v_company IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_company_get_by_code: empresa con código %s no encontrada', v_code), + jsonb_build_object('fn', 'fn_company_get_by_code', 'code', v_code) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('company', v_company), + format('Empresa con código %s cargada (id=%s)', v_code, v_company->>'id'), + jsonb_build_object('fn', 'fn_company_get_by_code', 'code', v_code) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_get_by_code', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-008e-fn-next-company-code endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_next_company_code(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_name text := core._company_trim_text(payload->>'name'); + v_base text; + v_n integer; + v_code text; +BEGIN + IF v_name = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_next_company_code: name es obligatorio para generar un código de empresa', + jsonb_build_object('fn', 'fn_next_company_code', 'field', 'name') + ); + END IF; + v_base := core._company_code_from_name(v_name); + IF NOT EXISTS (SELECT 1 FROM companies WHERE code = v_base) THEN + v_code := v_base; + ELSE + SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1 + INTO v_n + FROM companies + WHERE code ~ '^EMP-[0-9]{4}'; + v_code := 'EMP-' || lpad(v_n::text, 4, '0'); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('code', v_code), + format('Código sugerido para "%s": %s', v_name, v_code), + jsonb_build_object('fn', 'fn_next_company_code', 'name', v_name, 'code', v_code) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_next_company_code', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-008f-fn-company-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := NULLIF(payload->>'tenant_id', '')::integer; + v_profile_err text; + v_profile jsonb; + v_principal companies%ROWTYPE; + v_parent_id bigint; + v_parent companies%ROWTYPE; + v_code text; + v_code_err text; + v_new_id bigint; + v_n integer; + v_company jsonb; +BEGIN + v_profile_err := core._company_validate_profile(payload, true); + IF v_profile_err IS NOT NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_create: %s', v_profile_err), + jsonb_build_object('fn', 'fn_company_create', 'field', 'profile') + ); + END IF; + + v_profile := core._company_normalize_profile(payload, ''); + IF core._company_trim_text(v_profile->>'name') = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_create: nombre de empresa obligatorio', + jsonb_build_object('fn', 'fn_company_create', 'field', 'name') + ); + END IF; + + IF v_tid IS NOT NULL THEN + SELECT * INTO v_principal + FROM companies + WHERE kind = 'principal' AND tenant_id = v_tid + ORDER BY id + LIMIT 1; + ELSE + SELECT * INTO v_principal + FROM companies + WHERE kind = 'principal' + ORDER BY id + LIMIT 1; + END IF; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_create: no hay empresa principal configurada en el tenant', + jsonb_build_object('fn', 'fn_company_create', 'tenant_id', v_tid) + ); + END IF; + + v_parent_id := COALESCE(NULLIF(payload->>'parent_id', '')::bigint, v_principal.id); + SELECT * INTO v_parent FROM companies WHERE id = v_parent_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_create: empresa padre id=%s no encontrada', v_parent_id), + jsonb_build_object('fn', 'fn_company_create', 'parent_id', v_parent_id) + ); + END IF; + IF v_tid IS NOT NULL AND v_parent.tenant_id IS NOT NULL AND v_parent.tenant_id <> v_tid THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_create: empresa padre id=%s pertenece a tenant_id=%s, distinto del solicitado (%s)', + v_parent_id, v_parent.tenant_id, v_tid), + jsonb_build_object('fn', 'fn_company_create', 'parent_id', v_parent_id, 'tenant_id', v_tid) + ); + END IF; + + v_code := core._company_norm_code(payload->>'code'); + IF v_code = '' THEN + v_code := core._company_code_from_name(v_profile->>'name'); + IF EXISTS (SELECT 1 FROM companies WHERE code = v_code) THEN + SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1 + INTO v_n + FROM companies + WHERE code ~ '^EMP-[0-9]{4}'; + v_code := 'EMP-' || lpad(v_n::text, 4, '0'); + END IF; + END IF; + v_code_err := core._company_validate_code(v_code); + IF v_code_err IS NOT NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_create: %s (código=%s)', v_code_err, v_code), + jsonb_build_object('fn', 'fn_company_create', 'field', 'code', 'code', v_code) + ); + END IF; + IF EXISTS (SELECT 1 FROM companies WHERE code = v_code) THEN + RETURN core.rpc_err( + 'CONFLICT', + format('fn_company_create: ya existe una empresa con el código %s', v_code), + jsonb_build_object('fn', 'fn_company_create', 'code', v_code) + ); + END IF; + + INSERT INTO companies ( + code, name, parent_id, kind, status, tenant_id, + registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, + domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro + ) VALUES ( + v_code, + v_profile->>'name', + v_parent.id, + 'sub', + 'activo', + COALESCE(v_tid, v_principal.tenant_id), + v_profile->>'registro_patronal', + v_profile->>'razon_social', + v_profile->>'nombre_comercial', + v_profile->>'rfc', + v_profile->>'regimen_fiscal', + v_profile->>'clase_riesgo', + v_profile->>'domicilio_fiscal', + v_profile->>'codigo_postal', + v_profile->>'ciudad', + v_profile->>'estado', + v_profile->>'telefono', + v_profile->>'email', + v_profile->>'representante_legal', + v_profile->>'giro' + ) + RETURNING id INTO v_new_id; + + SELECT to_jsonb(c) INTO v_company FROM companies c WHERE c.id = v_new_id; + RETURN core.rpc_created( + jsonb_build_object('company', v_company), + format('Subempresa creada: %s (%s), id=%s', v_company->>'name', v_code, v_new_id), + jsonb_build_object('fn', 'fn_company_create', 'id', v_new_id, 'code', v_code, 'parent_id', v_parent.id) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err( + 'CONFLICT', + format('fn_company_create: ya existe una empresa con el código %s', v_code), + jsonb_build_object('fn', 'fn_company_create', 'code', v_code) + ); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-008g-fn-company-update endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_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 := NULLIF(payload->>'tenant_id', '')::integer; + v_current companies%ROWTYPE; + v_merged jsonb; + v_profile_err text; + v_profile jsonb; + v_code text; + v_code_err text; + v_status text; + v_clash_id bigint; + v_company jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_update: id es obligatorio para actualizar una empresa', + jsonb_build_object('fn', 'fn_company_update', 'field', 'id') + ); + END IF; + + SELECT * INTO v_current FROM companies WHERE id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_company_update: empresa id=%s no encontrada', v_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'tenant_id', v_tid) + ); + END IF; + IF v_tid IS NOT NULL AND v_current.tenant_id IS NOT NULL AND v_current.tenant_id <> v_tid THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_company_update: empresa id=%s no encontrada en tenant_id=%s', v_id, v_tid), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'tenant_id', v_tid) + ); + END IF; + + v_merged := jsonb_build_object( + 'name', CASE WHEN payload ? 'name' THEN payload->>'name' ELSE v_current.name END, + 'code', CASE WHEN payload ? 'code' THEN payload->>'code' ELSE v_current.code END, + 'status', CASE WHEN payload ? 'status' THEN payload->>'status' ELSE v_current.status END, + 'registro_patronal', CASE WHEN payload ? 'registro_patronal' THEN payload->>'registro_patronal' ELSE v_current.registro_patronal END, + 'razon_social', CASE WHEN payload ? 'razon_social' THEN payload->>'razon_social' ELSE v_current.razon_social END, + 'nombre_comercial', CASE WHEN payload ? 'nombre_comercial' THEN payload->>'nombre_comercial' ELSE v_current.nombre_comercial END, + 'rfc', CASE WHEN payload ? 'rfc' THEN payload->>'rfc' ELSE v_current.rfc END, + 'regimen_fiscal', CASE WHEN payload ? 'regimen_fiscal' THEN payload->>'regimen_fiscal' ELSE v_current.regimen_fiscal END, + 'clase_riesgo', CASE WHEN payload ? 'clase_riesgo' THEN payload->>'clase_riesgo' ELSE v_current.clase_riesgo END, + 'domicilio_fiscal', CASE WHEN payload ? 'domicilio_fiscal' THEN payload->>'domicilio_fiscal' ELSE v_current.domicilio_fiscal END, + 'codigo_postal', CASE WHEN payload ? 'codigo_postal' THEN payload->>'codigo_postal' ELSE v_current.codigo_postal END, + 'ciudad', CASE WHEN payload ? 'ciudad' THEN payload->>'ciudad' ELSE v_current.ciudad END, + 'estado', CASE WHEN payload ? 'estado' THEN payload->>'estado' ELSE v_current.estado END, + 'telefono', CASE WHEN payload ? 'telefono' THEN payload->>'telefono' ELSE v_current.telefono END, + 'email', CASE WHEN payload ? 'email' THEN payload->>'email' ELSE v_current.email END, + 'representante_legal', CASE WHEN payload ? 'representante_legal' THEN payload->>'representante_legal' ELSE v_current.representante_legal END, + 'giro', CASE WHEN payload ? 'giro' THEN payload->>'giro' ELSE v_current.giro END + ); + + v_profile_err := core._company_validate_profile(v_merged, false); + IF v_profile_err IS NOT NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_update: %s (id=%s)', v_profile_err, v_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'profile') + ); + END IF; + + v_profile := core._company_normalize_profile(v_merged, v_current.name); + IF core._company_trim_text(v_profile->>'name') = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_update: nombre de empresa obligatorio (id=%s)', v_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'name') + ); + END IF; + + v_code := v_current.code; + IF payload ? 'code' THEN + v_code := core._company_norm_code(payload->>'code'); + v_code_err := core._company_validate_code(v_code); + IF v_code_err IS NOT NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_update: %s (id=%s, código=%s)', v_code_err, v_id, v_code), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'code', 'code', v_code) + ); + END IF; + SELECT id INTO v_clash_id FROM companies WHERE code = v_code AND id <> v_id; + IF FOUND THEN + RETURN core.rpc_err( + 'CONFLICT', + format('fn_company_update: ya existe otra empresa con el código %s (id=%s)', v_code, v_clash_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code, 'conflict_id', v_clash_id) + ); + END IF; + END IF; + + v_status := v_current.status; + IF payload ? 'status' THEN + IF (payload->>'status') NOT IN ('activo', 'inactivo') THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_update: estatus debe ser activo o inactivo (recibido=%s, id=%s)', + payload->>'status', v_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'status', 'status', payload->>'status') + ); + END IF; + IF v_current.kind = 'principal' AND payload->>'status' = 'inactivo' THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_company_update: la empresa principal id=%s no se puede inactivar', v_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'kind', v_current.kind) + ); + END IF; + v_status := payload->>'status'; + END IF; + + UPDATE companies SET + name = v_profile->>'name', + code = v_code, + status = v_status, + registro_patronal = v_profile->>'registro_patronal', + razon_social = v_profile->>'razon_social', + nombre_comercial = v_profile->>'nombre_comercial', + rfc = v_profile->>'rfc', + regimen_fiscal = v_profile->>'regimen_fiscal', + clase_riesgo = v_profile->>'clase_riesgo', + domicilio_fiscal = v_profile->>'domicilio_fiscal', + codigo_postal = v_profile->>'codigo_postal', + ciudad = v_profile->>'ciudad', + estado = v_profile->>'estado', + telefono = v_profile->>'telefono', + email = v_profile->>'email', + representante_legal = v_profile->>'representante_legal', + giro = v_profile->>'giro' + WHERE id = v_id; + + IF v_code <> v_current.code THEN + UPDATE workers SET hire_type = v_code WHERE company_id = v_id; + END IF; + + SELECT to_jsonb(c) INTO v_company FROM companies c WHERE c.id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('company', v_company), + format('Empresa id=%s actualizada (%s)', v_id, v_code), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err( + 'CONFLICT', + format('fn_company_update: ya existe una empresa con el código %s (id=%s)', v_code, v_id), + jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code) + ); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-008h-fn-companies-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core._company_trim_text(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._company_norm_code(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._company_norm_rfc(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._company_code_from_name(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._company_validate_code(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._company_validate_profile(jsonb, boolean) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core._company_normalize_profile(jsonb, text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_get_by_code(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_next_company_code(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_update(jsonb) TO panels_core_app; From a73090bd11ada4b6ead8b027dff84fc9f5904440 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:48:40 +0000 Subject: [PATCH 02/11] Add PostgreSQL RPC functions for projects domain (009-rpc-projects) Co-authored-by: alberto.martinez --- db/core/changesets/009-rpc-projects.sql | 604 ++++++++++++++++++++++++ 1 file changed, 604 insertions(+) create mode 100644 db/core/changesets/009-rpc-projects.sql diff --git a/db/core/changesets/009-rpc-projects.sql b/db/core/changesets/009-rpc-projects.sql new file mode 100644 index 0000000..d24ef02 --- /dev/null +++ b/db/core/changesets/009-rpc-projects.sql @@ -0,0 +1,604 @@ +--liquibase formatted sql +-- PANELS · core · RPC proyectos (obras) + +--changeset panel:core-009a-fn-next-project-code endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_next_project_code(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_n integer; + v_code text; +BEGIN + SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1 + INTO v_n + FROM projects + WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'; + v_code := 'PRY-' || lpad(v_n::text, 4, '0'); + RETURN core.rpc_ok( + jsonb_build_object('code', v_code), + format('Siguiente código de proyecto asignado: %s', v_code), + jsonb_build_object('fn', 'fn_next_project_code', 'sequence', v_n) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_next_project_code', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009b-fn-project-assert-status endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_assert_status(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := (payload->>'project_id')::bigint; + v_allowed text[]; + v_proj record; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_assert_status: project_id es obligatorio', + jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'project_id') + ); + END IF; + IF payload->'allowed_statuses' IS NULL OR jsonb_typeof(payload->'allowed_statuses') <> 'array' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_assert_status: allowed_statuses debe ser un arreglo JSON', + jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'allowed_statuses') + ); + END IF; + SELECT array_agg(btrim(elem)) + INTO v_allowed + FROM jsonb_array_elements_text(payload->'allowed_statuses') AS elem + WHERE btrim(elem) IN ('activo', 'pausado', 'concluido', 'cancelado'); + IF v_allowed IS NULL OR cardinality(v_allowed) = 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_assert_status: allowed_statuses no contiene estados válidos', + jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'allowed_statuses') + ); + END IF; + SELECT p.id, p.code, p.name, p.status + INTO v_proj + FROM projects p + WHERE p.id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_project_assert_status: proyecto id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_project_assert_status', 'project_id', v_id) + ); + END IF; + IF NOT (v_proj.status = ANY (v_allowed)) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_project_assert_status: proyecto %s (%s) está en estado %s, no permitido para esta operación', + v_proj.code, v_id, v_proj.status), + jsonb_build_object( + 'fn', 'fn_project_assert_status', + 'project_id', v_id, + 'status', v_proj.status, + 'allowed_statuses', to_jsonb(v_allowed) + ) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object( + 'project_id', v_proj.id, + 'code', v_proj.code, + 'name', v_proj.name, + 'status', v_proj.status + ), + format('Proyecto %s en estado %s válido para la operación', v_proj.code, v_proj.status), + jsonb_build_object('fn', 'fn_project_assert_status', 'project_id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_assert_status', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009c-fn-project-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_status_raw text := nullif(btrim(payload->>'status'), ''); + v_statuses text[]; + v_projects jsonb; +BEGIN + IF v_status_raw IS NOT NULL THEN + SELECT array_agg(DISTINCT btrim(s)) + INTO v_statuses + FROM unnest(string_to_array(v_status_raw, ',')) AS s + WHERE btrim(s) IN ('activo', 'pausado', 'concluido', 'cancelado'); + IF v_statuses IS NULL OR cardinality(v_statuses) = 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_project_list: ningún estado válido en status=%s', v_status_raw), + jsonb_build_object('fn', 'fn_project_list', 'field', 'status', 'value', v_status_raw) + ); + END IF; + END IF; + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.sort_key, x.id), '[]'::jsonb) + INTO v_projects + FROM ( + SELECT + p.*, + c.name AS company_name, + c.code AS company_code, + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count, + (SELECT COUNT(*) FROM project_document_types t + WHERE t.required = true AND NOT EXISTS ( + SELECT 1 FROM project_documents d + WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true + )) AS missing_docs, + (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count, + CASE p.status + WHEN 'activo' THEN 0 + WHEN 'pausado' THEN 1 + WHEN 'concluido' THEN 2 + ELSE 3 + END AS sort_key + FROM projects p + LEFT JOIN companies c ON c.id = p.company_id + WHERE v_statuses IS NULL OR p.status = ANY (v_statuses) + ) x; + RETURN core.rpc_ok( + jsonb_build_object('projects', v_projects), + format('Listado de proyectos: %s registro(s)', jsonb_array_length(v_projects)), + jsonb_build_object('fn', 'fn_project_list', 'count', jsonb_array_length(v_projects)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009d-fn-project-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := (payload->>'id')::bigint; + v_project jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_get: id es obligatorio', + jsonb_build_object('fn', 'fn_project_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb + INTO v_project + FROM ( + SELECT + p.*, + c.name AS company_name, + c.code AS company_code, + (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count + FROM projects p + LEFT JOIN companies c ON c.id = p.company_id + WHERE p.id = v_id + ) x; + IF v_project IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_project_get: proyecto id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_project_get', 'id', v_id) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('project', v_project), + format('Proyecto %s cargado', v_project->>'code'), + jsonb_build_object('fn', 'fn_project_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009e-fn-project-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := COALESCE( + (payload->>'tenant_id')::integer, + NULLIF(current_setting('app.tenant_id', true), '')::integer + ); + v_name text := nullif(btrim(payload->>'name'), ''); + v_address text := coalesce(nullif(btrim(payload->>'address'), ''), ''); + v_theme_id text := coalesce(nullif(btrim(payload->>'theme_id'), ''), 'arctec-dos-logos-fold'); + v_status text := coalesce(nullif(btrim(payload->>'status'), ''), 'activo'); + v_company_id integer := (payload->>'company_id')::integer; + v_contract_amount numeric(14,2) := (payload->>'contract_amount')::numeric(14,2); + v_start_date date := nullif(btrim(payload->>'start_date'), '')::date; + v_end_date date := nullif(btrim(payload->>'end_date'), '')::date; + v_resident_name text := coalesce(nullif(btrim(payload->>'resident_name'), ''), ''); + v_siroc text := upper(coalesce(nullif(btrim(payload->>'siroc'), ''), '')); + v_payroll_tax_pct numeric(5,2) := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), 4); + v_code_env jsonb; + v_code text; + v_row projects%ROWTYPE; +BEGIN + IF v_name IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_create: nombre de proyecto obligatorio', + jsonb_build_object('fn', 'fn_project_create', 'field', 'name') + ); + END IF; + IF v_company_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_create: company_id es obligatorio', + jsonb_build_object('fn', 'fn_project_create', 'field', 'company_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_project_create: empresa id=%s no encontrada', v_company_id), + jsonb_build_object('fn', 'fn_project_create', 'company_id', v_company_id) + ); + END IF; + IF v_status NOT IN ('activo', 'pausado', 'concluido', 'cancelado') THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_project_create: estado inválido %s', v_status), + jsonb_build_object('fn', 'fn_project_create', 'field', 'status', 'value', v_status) + ); + END IF; + IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_create: la fecha de término no puede ser anterior al inicio', + jsonb_build_object('fn', 'fn_project_create', 'field', 'end_date') + ); + END IF; + v_code_env := core.fn_next_project_code('{}'::jsonb); + IF NOT COALESCE((v_code_env->>'ok')::boolean, false) THEN + RETURN v_code_env; + END IF; + v_code := v_code_env->'data'->>'code'; + INSERT INTO projects ( + code, name, address, theme_id, status, company_id, contract_amount, + start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id + ) VALUES ( + v_code, v_name, v_address, v_theme_id, v_status, v_company_id, v_contract_amount, + v_start_date, v_end_date, v_resident_name, v_siroc, v_payroll_tax_pct, v_tid + ) + RETURNING * INTO v_row; + RETURN core.rpc_created( + jsonb_build_object('id', v_row.id, 'code', v_row.code, 'status', v_row.status), + format('Proyecto %s (%s) creado', v_row.code, v_row.name), + jsonb_build_object('fn', 'fn_project_create', 'id', v_row.id, 'code', v_row.code) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009f-fn-project-update endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := (payload->>'id')::bigint; + v_cur projects%ROWTYPE; + v_name text; + v_address text; + v_theme_id text; + v_status text; + v_company_id integer; + v_contract_amount numeric(14,2); + v_start_date date; + v_end_date date; + v_resident_name text; + v_siroc text; + v_payroll_tax_pct numeric(5,2); +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_update: id es obligatorio', + jsonb_build_object('fn', 'fn_project_update', 'field', 'id') + ); + END IF; + SELECT * INTO v_cur FROM projects WHERE id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_project_update: proyecto id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_project_update', 'id', v_id) + ); + END IF; + v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name); + v_address := CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE v_cur.address END; + v_theme_id := coalesce(nullif(btrim(payload->>'theme_id'), ''), v_cur.theme_id); + v_status := CASE + WHEN payload ? 'status' AND btrim(payload->>'status') IN ('activo', 'pausado', 'concluido', 'cancelado') + THEN btrim(payload->>'status') + ELSE v_cur.status + END; + v_company_id := coalesce((payload->>'company_id')::integer, v_cur.company_id); + v_contract_amount := CASE + WHEN payload ? 'contract_amount' THEN (payload->>'contract_amount')::numeric(14,2) + ELSE v_cur.contract_amount + END; + v_start_date := CASE + WHEN payload ? 'start_date' THEN nullif(btrim(payload->>'start_date'), '')::date + ELSE v_cur.start_date + END; + v_end_date := CASE + WHEN payload ? 'end_date' THEN nullif(btrim(payload->>'end_date'), '')::date + ELSE v_cur.end_date + END; + v_resident_name := CASE + WHEN payload ? 'resident_name' THEN coalesce(nullif(btrim(payload->>'resident_name'), ''), '') + ELSE v_cur.resident_name + END; + v_siroc := CASE + WHEN payload ? 'siroc' THEN upper(coalesce(nullif(btrim(payload->>'siroc'), ''), '')) + ELSE v_cur.siroc + END; + v_payroll_tax_pct := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), v_cur.payroll_tax_pct); + IF v_company_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_update: company_id es obligatorio', + jsonb_build_object('fn', 'fn_project_update', 'field', 'company_id', 'id', v_id) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_project_update: empresa id=%s no encontrada', v_company_id), + jsonb_build_object('fn', 'fn_project_update', 'company_id', v_company_id, 'id', v_id) + ); + END IF; + IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_update: la fecha de término no puede ser anterior al inicio', + jsonb_build_object('fn', 'fn_project_update', 'field', 'end_date', 'id', v_id) + ); + END IF; + UPDATE projects SET + name = v_name, + address = v_address, + theme_id = v_theme_id, + status = v_status, + company_id = v_company_id, + contract_amount = v_contract_amount, + start_date = v_start_date, + end_date = v_end_date, + resident_name = v_resident_name, + siroc = v_siroc, + payroll_tax_pct = v_payroll_tax_pct + WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('id', v_id, 'code', v_cur.code, 'status', v_status), + format('Proyecto %s actualizado', v_cur.code), + jsonb_build_object('fn', 'fn_project_update', 'id', v_id, 'code', v_cur.code) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009g-fn-project-document-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_document_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := (payload->>'project_id')::bigint; + v_documents jsonb; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_document_list: project_id es obligatorio', + jsonb_build_object('fn', 'fn_project_document_list', 'field', 'project_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_project_document_list: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_project_document_list', 'project_id', v_project_id) + ); + 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 + FROM project_documents + WHERE project_id = v_project_id + ) d; + RETURN core.rpc_ok( + jsonb_build_object('documents', v_documents), + format('Documentos del proyecto %s: %s archivo(s)', v_project_id, jsonb_array_length(v_documents)), + jsonb_build_object('fn', 'fn_project_document_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_documents)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_document_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009h-fn-company-document-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_document_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_company_id integer := (payload->>'company_id')::integer; + v_documents jsonb; +BEGIN + IF v_company_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_document_list: company_id es obligatorio', + jsonb_build_object('fn', 'fn_company_document_list', 'field', 'company_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_company_document_list: empresa id=%s no encontrada', v_company_id), + jsonb_build_object('fn', 'fn_company_document_list', 'company_id', v_company_id) + ); + 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 + FROM company_documents + WHERE company_id = v_company_id + ) d; + RETURN core.rpc_ok( + jsonb_build_object('documents', v_documents), + format('Documentos de la empresa %s: %s archivo(s)', v_company_id, jsonb_array_length(v_documents)), + jsonb_build_object('fn', 'fn_company_document_list', 'company_id', v_company_id, 'count', jsonb_array_length(v_documents)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_document_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009i-fn-project-checklist endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_checklist(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := (payload->>'project_id')::bigint; + v_checklist jsonb; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_project_checklist: project_id es obligatorio', + jsonb_build_object('fn', 'fn_project_checklist', 'field', 'project_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_project_checklist: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_project_checklist', 'project_id', v_project_id) + ); + END IF; + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'code', t.code, + 'label', t.label, + 'required', t.required, + 'category', coalesce(t.category, 'otro'), + 'present', EXISTS ( + SELECT 1 FROM project_documents d + WHERE d.project_id = v_project_id + AND d.type_code = t.code + AND d.is_current = true + ) + ) ORDER BY t.code + ), '[]'::jsonb) + INTO v_checklist + FROM project_document_types t; + RETURN core.rpc_ok( + jsonb_build_object('checklist', v_checklist), + format('Checklist documental del proyecto %s: %s tipo(s)', v_project_id, jsonb_array_length(v_checklist)), + jsonb_build_object('fn', 'fn_project_checklist', 'project_id', v_project_id, 'count', jsonb_array_length(v_checklist)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_checklist', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009j-fn-company-checklist endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_checklist(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_company_id integer := (payload->>'company_id')::integer; + v_checklist jsonb; +BEGIN + IF v_company_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_company_checklist: company_id es obligatorio', + jsonb_build_object('fn', 'fn_company_checklist', 'field', 'company_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_company_checklist: empresa id=%s no encontrada', v_company_id), + jsonb_build_object('fn', 'fn_company_checklist', 'company_id', v_company_id) + ); + END IF; + SELECT COALESCE(jsonb_agg( + jsonb_build_object( + 'code', t.code, + 'label', t.label, + 'required', t.required, + 'category', coalesce(t.category, 'otro'), + 'present', EXISTS ( + SELECT 1 FROM company_documents d + WHERE d.company_id = v_company_id + AND d.type_code = t.code + AND d.is_current = true + ) + ) ORDER BY t.code + ), '[]'::jsonb) + INTO v_checklist + FROM company_document_types t; + RETURN core.rpc_ok( + jsonb_build_object('checklist', v_checklist), + format('Checklist documental de la empresa %s: %s tipo(s)', v_company_id, jsonb_array_length(v_checklist)), + jsonb_build_object('fn', 'fn_company_checklist', 'company_id', v_company_id, 'count', jsonb_array_length(v_checklist)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_checklist', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-009k-fn-projects-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_next_project_code(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_assert_status(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_update(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_document_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_document_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_checklist(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_checklist(jsonb) TO panels_core_app; From 7f2e84e47c0f62c7d4ae1a706a75eb0414337837 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:50:46 +0000 Subject: [PATCH 03/11] Add budget and badge RPC functions (changesets 011-012) Implement core.fn_budget_* for list, chapter/item CRUD, and atomic replace import; core.fn_badge_job_* for create, list, get, and delivery tracking. All functions use SECURITY INVOKER, rpc envelope helpers, Spanish messages, and GRANT EXECUTE to panels_core_app. Co-authored-by: alberto.martinez --- db/core/changesets/011-rpc-budget.sql | 697 ++++++++++++++++++++++++++ db/core/changesets/012-rpc-badges.sql | 333 ++++++++++++ 2 files changed, 1030 insertions(+) create mode 100644 db/core/changesets/011-rpc-budget.sql create mode 100644 db/core/changesets/012-rpc-badges.sql diff --git a/db/core/changesets/011-rpc-budget.sql b/db/core/changesets/011-rpc-budget.sql new file mode 100644 index 0000000..62bb24b --- /dev/null +++ b/db/core/changesets/011-rpc-budget.sql @@ -0,0 +1,697 @@ +--liquibase formatted sql +-- PANELS · core · RPC presupuesto (capítulos, partidas, importación atómica) + +--changeset panel:core-011a-fn-budget-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_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_chapters jsonb; + v_items jsonb; + v_subtotal numeric(14,2); + v_iva numeric(14,2); + v_item_count integer; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_list: project_id es obligatorio para consultar el presupuesto', + jsonb_build_object('fn', 'fn_budget_list', 'field', 'project_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_list: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_budget_list', 'project_id', v_project_id) + ); + END IF; + + SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.sort_order, c.id), '[]'::jsonb) + INTO v_chapters + FROM ( + SELECT + bc.id, + bc.project_id, + bc.parent_id, + bc.code, + bc.name, + COALESCE(bc.wbs, '') AS wbs, + bc.sort_order + FROM budget_chapters bc + WHERE bc.project_id = v_project_id + ) c; + + SELECT + COALESCE(jsonb_agg(to_jsonb(i) ORDER BY COALESCE(i.ch_sort, 9999), i.sort_order, i.id), '[]'::jsonb), + COALESCE(SUM(i.amount), 0), + COUNT(*)::integer + INTO v_items, v_subtotal, v_item_count + FROM ( + SELECT + bi.*, + ch.name AS chapter_name, + ch.code AS chapter_code, + ch.sort_order AS ch_sort + FROM budget_items bi + LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id + WHERE bi.project_id = v_project_id + ) i; + + v_subtotal := round(COALESCE(v_subtotal, 0), 2); + v_iva := round(v_subtotal * 0.16, 2); + + RETURN core.rpc_ok( + jsonb_build_object( + 'chapters', v_chapters, + 'items', v_items, + 'totals', jsonb_build_object( + 'subtotal', v_subtotal, + 'iva', v_iva, + 'total', round(v_subtotal + v_iva, 2), + 'item_count', v_item_count + ) + ), + format('Presupuesto del proyecto %s: %s capítulo(s), %s partida(s), subtotal %s', + v_project_id, jsonb_array_length(v_chapters), v_item_count, v_subtotal), + jsonb_build_object( + 'fn', 'fn_budget_list', + 'project_id', v_project_id, + 'chapter_count', jsonb_array_length(v_chapters), + 'item_count', v_item_count + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011b-fn-budget-chapter-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_chapter_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_parent_id bigint := NULLIF(payload->>'parent_id', '')::bigint; + v_name text := nullif(btrim(payload->>'name'), ''); + v_code text := coalesce(nullif(btrim(payload->>'code'), ''), ''); + v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), ''); + v_sort integer; + v_row budget_chapters%ROWTYPE; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_chapter_create: project_id es obligatorio', + jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'project_id') + ); + END IF; + IF v_name IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_chapter_create: nombre de capítulo obligatorio', + jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'name', 'project_id', v_project_id) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_chapter_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id) + ); + END IF; + IF v_parent_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id + ) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_chapter_create: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'parent_id', v_parent_id) + ); + END IF; + + SELECT COALESCE(MAX(sort_order), 0) + 1 + INTO v_sort + FROM budget_chapters + WHERE project_id = v_project_id; + + INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) + VALUES (v_project_id, v_parent_id, v_code, v_name, v_wbs, v_sort) + RETURNING * INTO v_row; + + RETURN core.rpc_created( + jsonb_build_object('chapter', to_jsonb(v_row)), + format('Capítulo "%s" creado en proyecto %s (id=%s)', v_row.name, v_project_id, v_row.id), + jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'id', v_row.id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_chapter_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011c-fn-budget-chapter-update endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_chapter_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_cur budget_chapters%ROWTYPE; + v_name text; + v_code text; + v_wbs text; + v_parent_id bigint; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_chapter_update: project_id es obligatorio', + jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'project_id') + ); + END IF; + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_chapter_update: id del capítulo es obligatorio', + jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'id', 'project_id', v_project_id) + ); + END IF; + + SELECT * INTO v_cur FROM budget_chapters WHERE id = v_id AND project_id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_chapter_update: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id) + ); + END IF; + + v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name); + v_code := CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END; + v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END; + v_parent_id := CASE + WHEN payload ? 'parent_id' THEN NULLIF(payload->>'parent_id', '')::bigint + ELSE v_cur.parent_id + END; + + IF v_parent_id IS NOT NULL AND v_parent_id = v_id THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_chapter_update: un capítulo no puede ser padre de sí mismo (id=%s)', v_id), + jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id) + ); + END IF; + IF v_parent_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id + ) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_chapter_update: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'parent_id', v_parent_id) + ); + END IF; + + UPDATE budget_chapters + SET name = v_name, + code = v_code, + wbs = v_wbs, + parent_id = v_parent_id + WHERE id = v_id; + + RETURN core.rpc_ok( + jsonb_build_object('id', v_id, 'name', v_name, 'code', v_code), + format('Capítulo id=%s actualizado en proyecto %s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_chapter_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011d-fn-budget-chapter-delete endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_chapter_delete(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_name text; + v_orphans integer; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_chapter_delete: project_id es obligatorio', + jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'project_id') + ); + END IF; + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_chapter_delete: id del capítulo es obligatorio', + jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'id', 'project_id', v_project_id) + ); + END IF; + + SELECT name INTO v_name FROM budget_chapters WHERE id = v_id AND project_id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_chapter_delete: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id) + ); + END IF; + + UPDATE budget_items + SET chapter_id = NULL + WHERE project_id = v_project_id + AND chapter_id = v_id; + GET DIAGNOSTICS v_orphans = ROW_COUNT; + + DELETE FROM budget_chapters WHERE id = v_id AND project_id = v_project_id; + + RETURN core.rpc_ok( + jsonb_build_object('id', v_id, 'orphaned_items', v_orphans), + format('Capítulo "%s" (id=%s) eliminado del proyecto %s; %s partida(s) quedaron sin capítulo', + v_name, v_id, v_project_id, v_orphans), + jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_chapter_delete', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011e-fn-budget-item-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_item_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_chapter_id bigint := NULLIF(payload->>'chapter_id', '')::bigint; + v_description text := nullif(btrim(payload->>'description'), ''); + v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), '')); + v_unit text := upper(coalesce(nullif(btrim(payload->>'unit'), ''), '')); + v_qty numeric(14,4) := coalesce((payload->>'quantity')::numeric(14,4), 0); + v_price numeric(14,4) := coalesce((payload->>'unit_price')::numeric(14,4), 0); + v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), ''); + v_amount numeric(14,2); + v_sort integer; + v_row budget_items%ROWTYPE; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_item_create: project_id es obligatorio', + jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'project_id') + ); + END IF; + IF v_description IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_item_create: descripción de partida obligatoria', + jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'description', 'project_id', v_project_id) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_item_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id) + ); + END IF; + IF v_chapter_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id + ) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_item_create: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'chapter_id', v_chapter_id) + ); + END IF; + + v_amount := round(v_qty * v_price, 2); + SELECT COALESCE(MAX(sort_order), 0) + 1 + INTO v_sort + FROM budget_items + WHERE project_id = v_project_id; + + INSERT INTO budget_items ( + project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order + ) VALUES ( + v_project_id, v_chapter_id, v_code, v_description, v_unit, v_qty, v_price, v_amount, v_wbs, v_sort + ) + RETURNING * INTO v_row; + + RETURN core.rpc_created( + jsonb_build_object('item', to_jsonb(v_row)), + format('Partida "%s" creada en proyecto %s (id=%s, importe=%s)', + v_row.description, v_project_id, v_row.id, v_row.amount), + jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'id', v_row.id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_item_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011f-fn-budget-item-update endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_item_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_cur budget_items%ROWTYPE; + v_chapter_id bigint; + v_description text; + v_code text; + v_unit text; + v_qty numeric(14,4); + v_price numeric(14,4); + v_wbs text; + v_amount numeric(14,2); +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_item_update: project_id es obligatorio', + jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'project_id') + ); + END IF; + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_item_update: id de partida es obligatorio', + jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'id', 'project_id', v_project_id) + ); + END IF; + + SELECT * INTO v_cur FROM budget_items WHERE id = v_id AND project_id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_item_update: partida id=%s no encontrada en proyecto %s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id) + ); + END IF; + + v_chapter_id := CASE + WHEN payload ? 'chapter_id' THEN NULLIF(payload->>'chapter_id', '')::bigint + ELSE v_cur.chapter_id + END; + v_description := coalesce(nullif(btrim(payload->>'description'), ''), v_cur.description); + v_code := upper(CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END); + v_unit := upper(CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE v_cur.unit END); + v_qty := CASE WHEN payload ? 'quantity' THEN coalesce((payload->>'quantity')::numeric(14,4), 0) ELSE v_cur.quantity END; + v_price := CASE WHEN payload ? 'unit_price' THEN coalesce((payload->>'unit_price')::numeric(14,4), 0) ELSE v_cur.unit_price END; + v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END; + + IF v_chapter_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id + ) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_item_update: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'chapter_id', v_chapter_id) + ); + END IF; + + v_amount := round(v_qty * v_price, 2); + + UPDATE budget_items + SET chapter_id = v_chapter_id, + code = v_code, + description = v_description, + unit = v_unit, + quantity = v_qty, + unit_price = v_price, + amount = v_amount, + wbs = v_wbs + WHERE id = v_id; + + RETURN core.rpc_ok( + jsonb_build_object('id', v_id, 'amount', v_amount), + format('Partida id=%s actualizada en proyecto %s (importe=%s)', v_id, v_project_id, v_amount), + jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_item_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011g-fn-budget-item-delete endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_item_delete(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_description text; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_item_delete: project_id es obligatorio', + jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'project_id') + ); + END IF; + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_item_delete: id de partida es obligatorio', + jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'id', 'project_id', v_project_id) + ); + END IF; + + DELETE FROM budget_items + WHERE id = v_id + AND project_id = v_project_id + RETURNING description INTO v_description; + + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_item_delete: partida id=%s no encontrada en proyecto %s', v_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id) + ); + END IF; + + RETURN core.rpc_ok( + jsonb_build_object('id', v_id), + format('Partida "%s" (id=%s) eliminada del proyecto %s', v_description, v_id, v_project_id), + jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_item_delete', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011h-fn-budget-replace endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_budget_replace(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_chapters jsonb := COALESCE(payload->'chapters', '[]'::jsonb); + v_items jsonb := COALESCE(payload->'items', '[]'::jsonb); + v_ch jsonb; + v_it jsonb; + v_wbs text; + v_parent_wbs text; + v_chapter_wbs text; + v_parent_id bigint; + v_chapter_id bigint; + v_new_id bigint; + v_sort integer; + v_item_sort integer; + v_qty numeric(14,4); + v_price numeric(14,4); + v_amount numeric(14,2); + v_chapter_count integer := 0; + v_item_count integer := 0; + v_wbs_map jsonb := '{}'::jsonb; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_replace: project_id es obligatorio para reemplazar el presupuesto', + jsonb_build_object('fn', 'fn_budget_replace', 'field', 'project_id') + ); + END IF; + IF jsonb_typeof(v_chapters) <> 'array' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_replace: chapters debe ser un arreglo JSON', + jsonb_build_object('fn', 'fn_budget_replace', 'field', 'chapters', 'project_id', v_project_id) + ); + END IF; + IF jsonb_typeof(v_items) <> 'array' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_budget_replace: items debe ser un arreglo JSON', + jsonb_build_object('fn', 'fn_budget_replace', 'field', 'items', 'project_id', v_project_id) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_budget_replace: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id) + ); + END IF; + + DELETE FROM budget_items WHERE project_id = v_project_id; + DELETE FROM budget_chapters WHERE project_id = v_project_id; + + v_sort := 0; + FOR v_ch IN + SELECT elem + FROM jsonb_array_elements(v_chapters) AS elem + ORDER BY coalesce(nullif(btrim(elem->>'wbs'), ''), elem->>'sort_order', elem->>'name') + LOOP + v_sort := v_sort + 1; + v_wbs := coalesce(nullif(btrim(v_ch->>'wbs'), ''), v_sort::text); + v_parent_wbs := nullif(btrim(v_ch->>'parent_wbs'), ''); + v_parent_id := NULL; + IF v_parent_wbs IS NOT NULL THEN + v_parent_id := NULLIF(v_wbs_map->>v_parent_wbs, '')::bigint; + IF v_parent_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_replace: parent_wbs=%s no encontrado al insertar capítulo wbs=%s', v_parent_wbs, v_wbs), + jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs, 'parent_wbs', v_parent_wbs) + ); + END IF; + END IF; + + IF nullif(btrim(v_ch->>'name'), '') IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_replace: nombre de capítulo obligatorio (wbs=%s)', v_wbs), + jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs) + ); + END IF; + + INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) + VALUES ( + v_project_id, + v_parent_id, + coalesce(nullif(btrim(v_ch->>'code'), ''), ''), + btrim(v_ch->>'name'), + v_wbs, + coalesce((v_ch->>'sort_order')::integer, v_sort) + ) + RETURNING id INTO v_new_id; + + v_wbs_map := v_wbs_map || jsonb_build_object(v_wbs, v_new_id::text); + v_chapter_count := v_chapter_count + 1; + END LOOP; + + v_item_sort := 0; + FOR v_it IN SELECT elem FROM jsonb_array_elements(v_items) AS elem + LOOP + v_item_sort := v_item_sort + 1; + IF nullif(btrim(v_it->>'description'), '') IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_replace: descripción obligatoria en partida #%s', v_item_sort), + jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'item_index', v_item_sort) + ); + END IF; + + v_chapter_wbs := nullif(btrim(v_it->>'chapter_wbs'), ''); + v_chapter_id := NULL; + IF v_chapter_wbs IS NOT NULL THEN + v_chapter_id := NULLIF(v_wbs_map->>v_chapter_wbs, '')::bigint; + IF v_chapter_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_replace: chapter_wbs=%s no encontrado para partida #%s', v_chapter_wbs, v_item_sort), + jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_wbs', v_chapter_wbs) + ); + END IF; + ELSIF v_it ? 'chapter_id' AND nullif(v_it->>'chapter_id', '') IS NOT NULL THEN + v_chapter_id := (v_it->>'chapter_id')::bigint; + IF NOT EXISTS (SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_budget_replace: chapter_id=%s inválido para partida #%s', v_chapter_id, v_item_sort), + jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_id', v_chapter_id) + ); + END IF; + END IF; + + v_qty := coalesce((v_it->>'quantity')::numeric(14,4), 0); + v_price := coalesce((v_it->>'unit_price')::numeric(14,4), 0); + v_amount := round(coalesce((v_it->>'amount')::numeric(14,2), v_qty * v_price), 2); + + INSERT INTO budget_items ( + project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order + ) VALUES ( + v_project_id, + v_chapter_id, + upper(coalesce(nullif(btrim(v_it->>'code'), ''), '')), + btrim(v_it->>'description'), + upper(coalesce(nullif(btrim(v_it->>'unit'), ''), '')), + v_qty, + v_price, + v_amount, + coalesce(nullif(btrim(v_it->>'wbs'), ''), ''), + coalesce((v_it->>'sort_order')::integer, v_item_sort) + ); + v_item_count := v_item_count + 1; + END LOOP; + + RETURN core.rpc_ok( + jsonb_build_object( + 'project_id', v_project_id, + 'chapter_count', v_chapter_count, + 'item_count', v_item_count + ), + format('Presupuesto del proyecto %s reemplazado: %s capítulo(s) y %s partida(s) importados', + v_project_id, v_chapter_count, v_item_count), + jsonb_build_object( + 'fn', 'fn_budget_replace', + 'project_id', v_project_id, + 'chapter_count', v_chapter_count, + 'item_count', v_item_count + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_budget_replace', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-011i-fn-budget-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_budget_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_update(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_delete(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_item_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_item_update(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_item_delete(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_budget_replace(jsonb) TO panels_core_app; diff --git a/db/core/changesets/012-rpc-badges.sql b/db/core/changesets/012-rpc-badges.sql new file mode 100644 index 0000000..29db9f2 --- /dev/null +++ b/db/core/changesets/012-rpc-badges.sql @@ -0,0 +1,333 @@ +--liquibase formatted sql +-- PANELS · core · RPC gafetes (jobs de impresión y entrega) + +--changeset panel:core-012a-fn-badge-job-create endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer; + v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); + v_worker_ids integer[]; + v_wid integer; + v_job_id bigint; + v_project record; + v_missing integer; + v_count integer := 0; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_create: project_id es obligatorio para generar gafetes', + jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'project_id') + ); + END IF; + + SELECT p.id, p.code, p.name, p.status + INTO v_project + FROM projects p + WHERE p.id = v_project_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id) + ); + END IF; + IF v_project.status <> 'activo' THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_badge_job_create: solo se generan gafetes de proyectos activos (proyecto %s está en %s)', + v_project.code, v_project.status), + jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'status', v_project.status) + ); + END IF; + + IF payload->'worker_ids' IS NULL OR jsonb_typeof(payload->'worker_ids') <> 'array' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_create: worker_ids debe ser un arreglo de enteros', + jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id) + ); + END IF; + + SELECT array_agg(DISTINCT (elem::text)::integer ORDER BY (elem::text)::integer) + INTO v_worker_ids + FROM jsonb_array_elements(payload->'worker_ids') AS elem + WHERE nullif(btrim(elem::text), '') IS NOT NULL; + + IF v_worker_ids IS NULL OR cardinality(v_worker_ids) = 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_create: debe indicar al menos un trabajador en worker_ids', + jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id) + ); + END IF; + + SELECT COUNT(*) + INTO v_missing + FROM unnest(v_worker_ids) AS wid + WHERE NOT EXISTS (SELECT 1 FROM workers w WHERE w.id = wid); + IF v_missing > 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_badge_job_create: %s trabajador(es) en worker_ids no existen en el catálogo', v_missing), + jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'missing_count', v_missing) + ); + END IF; + + INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) + VALUES (v_project_id, 'done', v_created_by_id, v_created_by_name) + RETURNING id INTO v_job_id; + + FOREACH v_wid IN ARRAY v_worker_ids + LOOP + INSERT INTO badge_job_people (job_id, worker_id) + VALUES (v_job_id::integer, v_wid) + ON CONFLICT (job_id, worker_id) DO NOTHING; + v_count := v_count + 1; + END LOOP; + + RETURN core.rpc_created( + jsonb_build_object( + 'id', v_job_id, + 'project_id', v_project_id, + 'count', v_count, + 'worker_ids', to_jsonb(v_worker_ids) + ), + format('Job de gafetes #%s creado para proyecto %s (%s): %s persona(s)', + v_job_id, v_project.code, v_project.name, v_count), + jsonb_build_object( + 'fn', 'fn_badge_job_create', + 'job_id', v_job_id, + 'project_id', v_project_id, + 'count', v_count, + 'created_by_id', v_created_by_id + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012b-fn-badge-job-list endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_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_jobs jsonb; + v_count integer; +BEGIN + SELECT + COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.id DESC), '[]'::jsonb), + COUNT(*)::integer + INTO v_jobs, v_count + FROM ( + SELECT + j.id, + j.project_id, + j.status, + j.pdf_path, + j.created_by_id, + j.created_by_name, + j.created_at, + p.code AS project_code, + p.name AS project_name, + (SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id) AS people, + (SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id AND bp.delivered = true) AS delivered + FROM badge_jobs j + JOIN projects p ON p.id = j.project_id + WHERE v_project_id IS NULL OR j.project_id = v_project_id + ) x; + + RETURN core.rpc_ok( + jsonb_build_object('jobs', v_jobs), + format('Listado de jobs de gafetes: %s registro(s)%s', + v_count, + CASE WHEN v_project_id IS NOT NULL THEN format(' para proyecto %s', v_project_id) ELSE '' END), + jsonb_build_object( + 'fn', 'fn_badge_job_list', + 'project_id', v_project_id, + 'count', v_count + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012c-fn-badge-job-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_job_id bigint := COALESCE( + NULLIF(payload->>'job_id', '')::bigint, + NULLIF(payload->>'id', '')::bigint + ); + v_job jsonb; + v_people jsonb; +BEGIN + IF v_job_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_get: job_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_get', 'field', 'job_id') + ); + END IF; + + SELECT to_jsonb(x) + INTO v_job + FROM ( + SELECT + j.*, + p.code AS project_code, + p.name AS project_name + FROM badge_jobs j + JOIN projects p ON p.id = j.project_id + WHERE j.id = v_job_id + ) x; + + IF v_job IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_get: job de gafetes id=%s no encontrado', v_job_id), + jsonb_build_object('fn', 'fn_badge_job_get', 'job_id', v_job_id) + ); + END IF; + + SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY w.last_name_p, w.first_name), '[]'::jsonb) + INTO v_people + FROM ( + SELECT + bp.job_id, + bp.worker_id, + bp.delivered, + bp.delivered_at, + w.first_name, + w.last_name_p, + w.last_name_m, + w.position + FROM badge_job_people bp + JOIN workers w ON w.id = bp.worker_id + WHERE bp.job_id = v_job_id::integer + ) p; + + RETURN core.rpc_ok( + jsonb_build_object('job', v_job, 'people', v_people), + format('Job de gafetes #%s cargado: %s persona(s) en el lote', + v_job_id, jsonb_array_length(v_people)), + jsonb_build_object( + 'fn', 'fn_badge_job_get', + 'job_id', v_job_id, + 'people_count', jsonb_array_length(v_people) + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012d-fn-badge-job-set-delivered endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_badge_job_set_delivered(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_job_id bigint := NULLIF(payload->>'job_id', '')::bigint; + v_worker_id integer := NULLIF(payload->>'worker_id', '')::integer; + v_delivered boolean; + v_updated boolean; +BEGIN + IF v_job_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_set_delivered: job_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'job_id') + ); + END IF; + IF v_worker_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_set_delivered: worker_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'worker_id', 'job_id', v_job_id) + ); + END IF; + IF payload->>'delivered' IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_set_delivered: delivered (boolean) es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'delivered', 'job_id', v_job_id, 'worker_id', v_worker_id) + ); + END IF; + + v_delivered := (payload->>'delivered')::boolean; + + IF NOT EXISTS (SELECT 1 FROM badge_jobs WHERE id = v_job_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_set_delivered: job de gafetes id=%s no encontrado', v_job_id), + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_badge_job_set_delivered: trabajador id=%s no encontrado', v_worker_id), + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'worker_id', v_worker_id, 'job_id', v_job_id) + ); + END IF; + + UPDATE badge_job_people + SET delivered = v_delivered, + delivered_at = CASE WHEN v_delivered THEN now() ELSE NULL END + WHERE job_id = v_job_id::integer + AND worker_id = v_worker_id + RETURNING true INTO v_updated; + + IF NOT COALESCE(v_updated, false) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_set_delivered: trabajador id=%s no pertenece al job %s', v_worker_id, v_job_id), + jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id, 'worker_id', v_worker_id) + ); + END IF; + + RETURN core.rpc_ok( + jsonb_build_object( + 'job_id', v_job_id, + 'worker_id', v_worker_id, + 'delivered', v_delivered + ), + format('Entrega de gafete actualizada: trabajador %s en job #%s → %s', + v_worker_id, v_job_id, CASE WHEN v_delivered THEN 'entregado' ELSE 'pendiente' END), + jsonb_build_object( + 'fn', 'fn_badge_job_set_delivered', + 'job_id', v_job_id, + 'worker_id', v_worker_id, + 'delivered', v_delivered + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_set_delivered', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-012e-fn-badge-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_badge_job_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_badge_job_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_badge_job_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_badge_job_set_delivered(jsonb) TO panels_core_app; From 17936ce7774a67a7a96844e920d73be3ed62c068 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:52:05 +0000 Subject: [PATCH 04/11] 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; From d2be5c5b76245bd34dbf4172f6b6d504f99cef35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:52:42 +0000 Subject: [PATCH 05/11] 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; From cff428d28374dfb6cd3f799c00062198c288ce55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:57:36 +0000 Subject: [PATCH 06/11] Refactor payroll_http and excel to use core RPC functions - Replace db.prepare() in payroll_http with callCoreFn for attendance, loans, loan receipts, and legacy payroll period routes - Use respondRpc/respondApiError for standardized HTTP responses - Add fn_loan_list/get, fn_loan_payment_get, and fn_payroll_period_* RPCs to 013-rpc-payroll.sql with grants - Refactor excel storeDocument to fn_worker_document_store (S3 stays in TS) - Refactor importExcel and upsertWorker to fn_workers_import_batch Co-authored-by: alberto.martinez --- api/excel.ts | 273 ++++++++--------- api/http_errors.ts | 130 ++++++++ api/payroll_http.ts | 166 ++++++----- api/rpc.ts | 125 ++++++++ db/core/changesets/006-rpc-infra.sql | 147 ++++++++++ db/core/changesets/007-rpc-catalogs.sql | 123 ++++++++ db/core/changesets/013-rpc-payroll.sql | 374 ++++++++++++++++++++++++ 7 files changed, 1106 insertions(+), 232 deletions(-) create mode 100644 api/http_errors.ts create mode 100644 api/rpc.ts create mode 100644 db/core/changesets/006-rpc-infra.sql create mode 100644 db/core/changesets/007-rpc-catalogs.sql diff --git a/api/excel.ts b/api/excel.ts index a2639ed..e31b617 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -3,9 +3,10 @@ import type { Db } from "./db.ts"; import { encryptBytes } from "./docs_crypto.ts"; import { sha256Hex } from "./crypto.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; -import { refreshPipeline, lastInsertId } from "./db.ts"; +import { refreshPipeline } from "./db.ts"; import { resolveCompany } from "./companies.ts"; import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; +import { callCoreFn, RpcCallError } from "./rpc.ts"; export const IMPORT_COLUMNS = [ "NOMBRE", @@ -214,82 +215,34 @@ export async function storeDocument( imss_baja_at?: string | null; } = {}, ) { - const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type); - if (!allowed) throw new Error("Tipo de documento no válido"); - const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; await putObject(workerDocKey(workerId, storage), cipher); - await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run( - workerId, - type, - ); const issuedAt = meta.issued_at || null; const expiresAt = meta.expires_at || null; const imssCompanyId = meta.imss_company_id || null; const today = new Date().toISOString().slice(0, 10); const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null); const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); - const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; - await db.prepare( - `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 (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`, - ).run( - workerId, - type, - filename, + const env = await callCoreFn(db, "core.fn_worker_document_store", { + worker_id: workerId, + type_code: type, + original_name: filename, mime, - bytes.byteLength, - hash, + size_bytes: bytes.byteLength, + sha256: hash, iv, - storage, - issuedAt || movementDate, - expiresAt, - imssCompanyId, - movementDate, - userId, - ); - - if (type === "alta_imss") { - const companyId = imssCompanyId || - (await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) - ?.company_id || - null; - const company = companyId - ? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as - | { id: number; code: string } - | undefined - : undefined; - if (!company) throw new Error("Empresa patrón no válida"); - await db.prepare( - `UPDATE workers SET - imss_status = 'alta', - imss_company_id = ?, - imss_alta_at = ?, - imss_baja_at = NULL, - company_id = ?, - hire_type = ?, - updated_at = now() - WHERE id = ?`, - ).run(company.id, imssAltaAt, company.id, company.code, workerId); - } - - if (type === "baja_imss") { - await db.prepare( - `UPDATE workers SET - imss_status = 'baja_imss', - imss_baja_at = ?, - imss_company_id = NULL, - company_id = NULL, - hire_type = '', - updated_at = now() - WHERE id = ?`, - ).run(imssBajaAt, workerId); - } + storage_name: storage, + uploaded_by_id: userId, + issued_at: issuedAt, + expires_at: expiresAt, + imss_company_id: imssCompanyId, + imss_alta_at: imssAltaAt, + imss_baja_at: imssBajaAt, + }); + if (!env.ok) throw new RpcCallError(env); await refreshPipeline(db, workerId); } @@ -357,79 +310,78 @@ async function fetchPhoto(url: string): Promise { } } +export type ImportReport = { + inserted: number; + existed: number; + errors: number; + photos: number; + existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[]; + error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; +}; + +type ImportBatchResult = { + inserted: number; + existed: number; + errors: number; + existed_rows: ImportReport["existed_rows"]; + error_rows: ImportReport["error_rows"]; +}; + +function workerToImportRow( + input: ReturnType & { company_id: number; tenant_id?: number | null }, + sheet: string, + row: number, + projectId: number | null, +) { + return { + sheet, + row, + first_name: input.first_name, + middle_name: input.middle_name, + last_name_p: input.last_name_p, + last_name_m: input.last_name_m, + curp: input.curp, + rfc: input.rfc, + nss: input.nss, + phone: input.phone, + email: input.email, + address: input.address, + blood_type: input.blood_type, + hire_type: input.hire_type, + company_id: input.company_id, + position: input.position, + risk_code: input.risk_code, + work_type: input.work_type, + daily_wage: input.daily_wage, + needs_badge: input.needs_badge, + status: input.status, + project_id: input.status === "activo" ? projectId : null, + tenant_id: input.tenant_id ?? 1, + }; +} + async function upsertWorker( db: Db, input: ReturnType & { company_id: number; tenant_id?: number | null }, projectId: number | null, ) { + const env = await callCoreFn(db, "core.fn_workers_import_batch", { + tenant_id: input.tenant_id ?? 1, + project_id: projectId, + rows: [workerToImportRow(input, "IMPORT", 1, projectId)], + }); + if (!env.ok) throw new RpcCallError(env); + const data = env.data!; + const errorRow = data.error_rows?.[0]; + if (errorRow?.messages?.length) throw new Error(errorRow.messages[0]); const existing = await findExisting(db, input.curp, input.rfc, input.nss); - if (existing) { - await db.prepare( - `UPDATE workers SET - first_name=?, middle_name=?, last_name_p=?, last_name_m=?, - curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, - hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, - needs_badge=?, status=?, updated_at=now() - WHERE id=?`, - ).run( - input.first_name, - input.middle_name, - input.last_name_p, - input.last_name_m, - input.curp, - input.rfc, - input.nss, - input.phone, - input.email, - input.address, - input.blood_type, - input.hire_type, - input.company_id, - input.tenant_id ?? null, - input.position, - input.risk_code, - input.work_type, - input.daily_wage, - input.needs_badge, - input.status, - existing.id, - ); - if (projectId) await assign(db, existing.id, projectId); - await refreshPipeline(db, existing.id, input.tenant_id ?? null); - const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; - return { id: existing.id, action: "existed" as const, matched }; - } - await db.prepare( - `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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ).run( - input.first_name, - input.middle_name, - input.last_name_p, - input.last_name_m, - input.curp, - input.rfc, - input.nss, - input.phone, - input.email, - input.address, - input.blood_type, - input.hire_type, - input.company_id, - input.position, - input.risk_code, - input.work_type, - input.daily_wage, - input.needs_badge, - input.status, - input.tenant_id ?? null, - ); - const id = await lastInsertId(db); - if (projectId) await assign(db, id, projectId); - await refreshPipeline(db, id, input.tenant_id ?? null); - return { id, action: "inserted" as const, matched: null as string | null }; + if (!existing) throw new Error("No se pudo guardar el trabajador"); + await refreshPipeline(db, existing.id, input.tenant_id ?? null); + return { + id: existing.id, + action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const, + matched: data.existed_rows?.[0]?.matched ?? null, + }; } async function assign(db: Db, workerId: number, projectId: number) { @@ -443,15 +395,6 @@ async function assign(db: Db, workerId: number, projectId: number) { ).run(workerId, projectId); } -export type ImportReport = { - inserted: number; - existed: number; - errors: number; - photos: number; - existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[]; - error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; -}; - export async function importExcel( db: Db, bytes: Uint8Array, @@ -496,6 +439,9 @@ export async function importExcel( return report; } + const batchRows: ReturnType[] = []; + const photoJobs: { curp: string; rfc: string; nss: string; url: string; tenant_id: number }[] = []; + for (const job of jobs) { for (const { row, data } of job.rows) { if (isEmptyRow(data)) continue; @@ -539,28 +485,41 @@ export async function importExcel( company_id: company!.id, tenant_id: company!.tenant_id ?? 1, }; - const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); - if (res.action === "inserted") report.inserted++; - else { - report.existed++; - report.existed_rows.push({ - sheet: job.sheet, - row, - nombre, - curp: n.curp, - matched: res.matched || "CURP", - }); - } + batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null)); const url = data["URL FOTO"]; if (url) { - const photo = await fetchPhoto(url); - if (photo) { - await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId); - report.photos++; - } + photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id }); } } } + + if (batchRows.length > 0) { + const env = await callCoreFn(db, "core.fn_workers_import_batch", { + project_id: projectId, + rows: batchRows, + }); + if (!env.ok) throw new RpcCallError(env); + const data = env.data!; + report.inserted = data.inserted ?? 0; + report.existed = data.existed ?? 0; + report.errors += data.errors ?? 0; + report.existed_rows.push(...(data.existed_rows ?? [])); + report.error_rows.push(...(data.error_rows ?? [])); + for (const row of batchRows) { + const worker = await findExisting(db, row.curp, row.rfc, row.nss); + if (worker) await refreshPipeline(db, worker.id, row.tenant_id ?? null); + } + } + + for (const photo of photoJobs) { + const worker = await findExisting(db, photo.curp, photo.rfc, photo.nss); + if (!worker) continue; + const photoBytes = await fetchPhoto(photo.url); + if (photoBytes) { + await storeDocument(db, worker.id, "foto", "foto-import.jpg", "image/jpeg", photoBytes, userId); + report.photos++; + } + } return report; } diff --git a/api/http_errors.ts b/api/http_errors.ts new file mode 100644 index 0000000..183977a --- /dev/null +++ b/api/http_errors.ts @@ -0,0 +1,130 @@ +import type { Context } from "hono"; + +export type RpcCode = + | "OK" + | "CREATED" + | "VALIDATION" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT" + | "INTERNAL" + | "NETWORK"; + +export type ResponseLayer = "db" | "api" | "front"; + +export type RpcEnvelope = { + ok: boolean; + code: RpcCode | string; + layer: ResponseLayer; + message: string; + context?: Record | null; + data?: T | null; + errors?: Record | string[] | null; +}; + +export type ApiResponse = RpcEnvelope & { status: number }; + +const GENERIC_MESSAGES = new Set([ + "ok", "error", "operación exitosa", "operacion exitosa", "algo salió mal", + "algo salio mal", "error interno", "error interno del servidor", +]); + +export function isGenericMessage(message: string): boolean { + return GENERIC_MESSAGES.has(message.trim().toLowerCase()); +} + +export function mapRpcToStatus(code: string): number { + switch (code) { + case "OK": + return 200; + case "CREATED": + return 201; + case "VALIDATION": + return 400; + case "UNAUTHORIZED": + return 401; + case "FORBIDDEN": + return 403; + case "NOT_FOUND": + return 404; + case "CONFLICT": + return 409; + case "NETWORK": + return 503; + case "INTERNAL": + default: + return 500; + } +} + +export function respondRpc(c: Context, envelope: RpcEnvelope) { + const status = mapRpcToStatus(String(envelope.code)); + const body: ApiResponse = { ...envelope, status }; + return c.json(body, status as 200 | 201 | 400 | 401 | 403 | 404 | 409 | 500 | 503); +} + +export function respondApiError( + c: Context, + code: RpcCode, + message: string, + context?: Record, + errors?: Record | string[] | null, +) { + if (isGenericMessage(message)) { + throw new Error(`API error message is too generic: ${message}`); + } + const status = mapRpcToStatus(code); + const body: ApiResponse = { + ok: false, + code, + status, + layer: "api", + message, + context: context ?? {}, + data: null, + errors: errors ?? null, + }; + return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500 | 503); +} + +export function enrichInfraError( + fn: string, + route: string, + cause: string, + detail?: string, +): { message: string; context: Record } { + const message = detail + ? `Falló la llamada a ${fn} en ${route}: ${cause} — ${detail}` + : `Falló la llamada a ${fn} en ${route}: ${cause}`; + return { + message, + context: { fn, route, cause }, + }; +} + +export function routeLabel(c: Context): string { + return `${c.req.method} ${c.req.path}`; +} + +export function onAppError(err: unknown, c: Context) { + console.error(err); + if (err && typeof err === "object" && (err as RpcEnvelope).layer) { + const env = err as RpcEnvelope; + return respondRpc(c, env); + } + const message = err instanceof Error + ? `Error no controlado en ${routeLabel(c)}: ${err.message}` + : `Error no controlado en ${routeLabel(c)}`; + const status = 500; + return c.json({ + ok: false, + code: "INTERNAL", + status, + layer: "api", + message, + context: { route: routeLabel(c) }, + data: null, + errors: null, + }, status); +} diff --git a/api/payroll_http.ts b/api/payroll_http.ts index df72e83..5280453 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -2,9 +2,11 @@ import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { tenantScope } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; -import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts"; +import { projectById, projectMustBe, type Db } from "./db.ts"; import { generateLoanReceiptPdf } from "./pdf.ts"; import { fullName } from "./mx.ts"; +import { callCoreFn } from "./rpc.ts"; +import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; import { addAdminLine, addJornalWorker, @@ -47,13 +49,13 @@ export function registerPayrollRoutes(app: App) { const from = c.req.query("from") ?? ""; const to = c.req.query("to") ?? ""; const db = c.get("db"); - const rows = await db.prepare( - `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=? AND a.work_date BETWEEN ? AND ? - ORDER BY a.work_date, w.last_name_p`, - ).all(projectId, from, to); - return c.json({ attendance: rows }); + const env = await callCoreFn<{ attendance: unknown[] }>( + db, + "core.fn_attendance_list", + { project_id: projectId, from, to }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.put("/v1/attendance", ...requireCoreAuth, async (c) => { @@ -69,7 +71,14 @@ export function registerPayrollRoutes(app: App) { ["activo", "pausado"], "No se registra asistencia en un proyecto concluido o cancelado", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + return respondApiError( + c, + blocked.status === 404 ? "NOT_FOUND" : "VALIDATION", + blocked.error, + { route: routeLabel(c), project_id: body.project_id }, + ); + } const result = await setAttendance(db, tid(c), body); if ("error" in result) { return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); @@ -114,11 +123,13 @@ export function registerPayrollRoutes(app: App) { app.get("/v1/loans", ...requireCoreAuth, async (c) => { const db = c.get("db"); const workerId = c.req.query("worker_id"); - const sql = workerId - ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" - : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; - const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all(); - return c.json({ loans }); + const env = await callCoreFn<{ loans: unknown[] }>( + db, + "core.fn_loan_list", + workerId ? { worker_id: Number(workerId) } : {}, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.post("/v1/loans", ...requireCoreAuth, async (c) => { @@ -154,10 +165,7 @@ export function registerPayrollRoutes(app: App) { app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const loan = await db.prepare( - `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m - FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`, - ).get(id) as { + const env = await callCoreFn<{ loan: { delivered: number; commission_pct: number; commission_amount: number; @@ -172,8 +180,14 @@ export function registerPayrollRoutes(app: App) { last_name_p: string; last_name_m: string; note: string | null; - } | undefined; - if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404); + } }>( + db, + "core.fn_loan_get", + { id }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + const loan = env.data!.loan; const bytes = await generateLoanReceiptPdf({ title: "Recibo de aceptación de préstamo", subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`, @@ -202,14 +216,7 @@ export function registerPayrollRoutes(app: App) { app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const pay = await db.prepare( - `SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end - FROM loan_payments p - JOIN loans l ON l.id=p.loan_id - JOIN workers w ON w.id=l.worker_id - JOIN payroll_weeks wk ON wk.id=p.week_id - WHERE p.id=?`, - ).get(id) as { + const env = await callCoreFn<{ payment: { amount: number; installment_n: number; label: string; @@ -220,8 +227,14 @@ export function registerPayrollRoutes(app: App) { last_name_p: string; last_name_m: string; week_end: string; - } | undefined; - if (!pay) return c.json({ error: "Recibo no encontrado" }, 404); + } }>( + db, + "core.fn_loan_payment_get", + { id }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + const pay = env.data!.payment; const bytes = await generateLoanReceiptPdf({ title: "Recibo de pago de préstamo", subtitle: `Descuento del sábado ${pay.week_end}`, @@ -380,65 +393,63 @@ export function registerPayrollRoutes(app: App) { ["activo", "pausado"], "No se genera nómina de un proyecto concluido o cancelado", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); - await db.prepare( - "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", - ).run(body.project_id, body.week_start, body.week_end); - const periodId = await lastInsertId(db); - const workers = await db.prepare( - `SELECT w.id, w.daily_wage FROM workers w - JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=true - WHERE w.status='activo'`, - ).all(body.project_id) as { id: number; daily_wage: number }[]; - const ins = db.prepare( - `INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const w of workers) { - const att = await db.prepare( - `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, - ).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number }; - const days = Number(att.n); - const gross = days * w.daily_wage; - const extra = Number(body.extra_discounts?.[w.id] ?? 0); - const net = gross - extra; - await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); + if (blocked) { + return respondApiError( + c, + blocked.status === 404 ? "NOT_FOUND" : "VALIDATION", + blocked.error, + { route: routeLabel(c), project_id: body.project_id }, + ); } - return c.json({ id: periodId }); + const env = await callCoreFn<{ id: number }>( + db, + "core.fn_payroll_period_create", + { + project_id: body.project_id, + week_start: body.week_start, + week_end: body.week_end, + extra_discounts: body.extra_discounts ?? {}, + }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => { const db = c.get("db"); const projectId = c.req.query("project_id"); - const sql = projectId - ? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` - : `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; - const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all(); - return c.json({ periods }); + const env = await callCoreFn<{ periods: unknown[] }>( + db, + "core.fn_payroll_period_list", + projectId ? { project_id: Number(projectId) } : {}, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const period = await db.prepare( - `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, - ).get(id); - if (!period) return c.json({ error: "Periodo no encontrado" }, 404); - const lines = await db.prepare( - `SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l - JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, - ).all(id); - return c.json({ period, lines }); + const env = await callCoreFn<{ period: unknown; lines: unknown[] }>( + db, + "core.fn_payroll_period_get", + { id }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const lines = await db.prepare( - `SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net - FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, - ).all(id) as Record[]; + const env = await callCoreFn<{ period: unknown; lines: Array> }>( + db, + "core.fn_payroll_period_get", + { id }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + const lines = env.data?.lines ?? []; const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto"; const rows = lines.map((l) => [l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",") @@ -453,7 +464,12 @@ export function registerPayrollRoutes(app: App) { const id = Number(c.req.param("id")); const { status } = await c.req.json<{ status: string }>(); const db = c.get("db"); - await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); - return c.json({ ok: true }); + const env = await callCoreFn( + db, + "core.fn_payroll_period_patch", + { id, status }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); } diff --git a/api/rpc.ts b/api/rpc.ts new file mode 100644 index 0000000..9855fce --- /dev/null +++ b/api/rpc.ts @@ -0,0 +1,125 @@ +import type { Db } from "./db.ts"; +import type { RpcEnvelope } from "./http_errors.ts"; +import { enrichInfraError } from "./http_errors.ts"; + +const DEADLOCK = "40P01"; + +export class RpcCallError extends Error { + constructor( + readonly envelope: RpcEnvelope, + ) { + super(envelope.message); + this.name = "RpcCallError"; + } +} + +function isEnvelope(value: unknown): value is RpcEnvelope { + if (!value || typeof value !== "object") return false; + const o = value as Record; + return typeof o.ok === "boolean" + && typeof o.code === "string" + && typeof o.message === "string" + && typeof o.layer === "string"; +} + +function postgresCode(e: unknown): string | undefined { + if (!e || typeof e !== "object") return undefined; + return (e as { code?: string }).code; +} + +function postgresMessage(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} + +async function invokeOnce( + db: Db, + fn: string, + payload: Record, +): Promise> { + const row = await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get( + JSON.stringify(payload), + ) as { result: unknown } | undefined; + const raw = row?.result; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) as RpcEnvelope; + if (isEnvelope(parsed)) return parsed; + } catch { + /* fall through */ + } + } + if (isEnvelope(raw)) return raw as RpcEnvelope; + throw new Error(`La función ${fn} no devolvió un envelope RPC válido`); +} + +export async function callCoreFn( + db: Db, + fn: string, + payload: Record = {}, + opts: { route?: string; retries?: number } = {}, +): Promise> { + const route = opts.route ?? fn; + const retries = opts.retries ?? 1; + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const envelope = await invokeOnce(db, fn, payload); + if (!envelope.ok) return envelope; + return envelope; + } catch (e) { + lastErr = e; + const code = postgresCode(e); + if (code === DEADLOCK && attempt < retries) continue; + const infra = enrichInfraError( + fn, + route, + code ?? "exception", + postgresMessage(e), + ); + return { + ok: false, + code: code === DEADLOCK ? "CONFLICT" : "INTERNAL", + layer: "api", + message: infra.message, + context: { ...infra.context, sqlstate: code ?? null }, + data: null, + errors: null, + }; + } + } + const infra = enrichInfraError(fn, route, "exception", postgresMessage(lastErr)); + return { + ok: false, + code: "INTERNAL", + layer: "api", + message: infra.message, + context: infra.context, + data: null, + errors: null, + }; +} + +export async function callCoreFnData( + db: Db, + fn: string, + payload: Record = {}, + opts: { route?: string } = {}, +): Promise { + const envelope = await callCoreFn(db, fn, payload, opts); + if (!envelope.ok) throw new RpcCallError(envelope); + return envelope.data as T; +} + +export function unwrapRpc( + envelope: RpcEnvelope, +): { data?: T; error?: string; code?: string; status?: number } { + if (envelope.ok) return { data: envelope.data as T }; + return { + error: envelope.message, + code: String(envelope.code), + status: envelope.code === "NOT_FOUND" ? 404 + : envelope.code === "CONFLICT" ? 409 + : 400, + }; +} diff --git a/db/core/changesets/006-rpc-infra.sql b/db/core/changesets/006-rpc-infra.sql new file mode 100644 index 0000000..c669017 --- /dev/null +++ b/db/core/changesets/006-rpc-infra.sql @@ -0,0 +1,147 @@ +--liquibase formatted sql +-- PANELS · core · RPC infrastructure (envelope helpers) + +--changeset panel:core-006a-rpc-generic-messages endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_generic_messages() +RETURNS text[] +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT ARRAY[ + 'OK', 'Error', 'ERROR', 'Operación exitosa', 'Operacion exitosa', + 'Algo salió mal', 'Algo salio mal', 'Error interno', 'Error interno del servidor' + ]::text[]; +$$; + +--changeset panel:core-006b-rpc-assert-message endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_assert_message(p_message text) +RETURNS void +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + IF p_message IS NULL OR btrim(p_message) = '' THEN + RAISE EXCEPTION 'RPC message must not be empty'; + END IF; + IF p_message = ANY (core.rpc_generic_messages()) THEN + RAISE EXCEPTION 'RPC message is too generic: %', p_message; + END IF; +END; +$$; + +--changeset panel:core-006c-rpc-ok endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_ok( + p_data jsonb, + p_message text, + p_ctx jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + PERFORM core.rpc_assert_message(p_message); + RETURN jsonb_build_object( + 'ok', true, + 'code', 'OK', + 'layer', 'db', + 'message', p_message, + 'context', COALESCE(p_ctx, '{}'::jsonb), + 'data', p_data, + 'errors', NULL + ); +END; +$$; + +--changeset panel:core-006d-rpc-created endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_created( + p_data jsonb, + p_message text, + p_ctx jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + PERFORM core.rpc_assert_message(p_message); + RETURN jsonb_build_object( + 'ok', true, + 'code', 'CREATED', + 'layer', 'db', + 'message', p_message, + 'context', COALESCE(p_ctx, '{}'::jsonb), + 'data', p_data, + 'errors', NULL + ); +END; +$$; + +--changeset panel:core-006e-rpc-err endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_err( + p_code text, + p_message text, + p_ctx jsonb DEFAULT '{}'::jsonb, + p_errors jsonb DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + PERFORM core.rpc_assert_message(p_message); + RETURN jsonb_build_object( + 'ok', false, + 'code', p_code, + 'layer', 'db', + 'message', p_message, + 'context', COALESCE(p_ctx, '{}'::jsonb), + 'data', NULL, + 'errors', p_errors + ); +END; +$$; + +--changeset panel:core-006f-rpc-map-exception endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_from_exception( + p_fn text, + p_sqlstate text, + p_message text, + p_detail text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_code text := 'INTERNAL'; + v_msg text; + v_ctx jsonb := jsonb_build_object('fn', p_fn, 'sqlstate', p_sqlstate); +BEGIN + IF p_sqlstate = '23505' THEN + v_code := 'CONFLICT'; + v_msg := format('CONFLICT en %s: %s', p_fn, COALESCE(p_detail, p_message)); + ELSIF p_sqlstate = '23503' THEN + v_code := 'VALIDATION'; + v_msg := format('Referencia inválida en %s: %s', p_fn, COALESCE(p_detail, p_message)); + ELSIF p_sqlstate = '23514' THEN + v_code := 'VALIDATION'; + v_msg := format('Restricción de valor en %s: %s', p_fn, COALESCE(p_detail, p_message)); + ELSIF p_sqlstate = '40P01' THEN + v_code := 'CONFLICT'; + v_msg := format('Deadlock en %s: otra operación modificó los mismos registros, intente de nuevo', p_fn); + ELSE + v_code := 'INTERNAL'; + v_msg := format('Error inesperado en %s (sqlstate=%s): %s', p_fn, p_sqlstate, p_message); + END IF; + RETURN core.rpc_err(v_code, v_msg, v_ctx); +END; +$$; + +--changeset panel:core-006g-rpc-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.rpc_generic_messages() TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_assert_message(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_core_app; diff --git a/db/core/changesets/007-rpc-catalogs.sql b/db/core/changesets/007-rpc-catalogs.sql new file mode 100644 index 0000000..49eade4 --- /dev/null +++ b/db/core/changesets/007-rpc-catalogs.sql @@ -0,0 +1,123 @@ +--liquibase formatted sql +-- PANELS · core · RPC catálogos y configuración tenant + +--changeset panel:core-007a-fn-catalogs endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_catalogs(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_risks jsonb; + v_themes jsonb; + v_doc_types jsonb; + v_proj_doc_types jsonb; + v_co_doc_types jsonb; + v_companies jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY r.code), '[]'::jsonb) + INTO v_risks FROM risk_levels r; + SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.id), '[]'::jsonb) + INTO v_themes FROM badge_themes t; + SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.code), '[]'::jsonb) + INTO v_doc_types FROM document_types d; + SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.code), '[]'::jsonb) + INTO v_proj_doc_types FROM project_document_types p; + SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.code), '[]'::jsonb) + INTO v_co_doc_types FROM company_document_types c; + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.kind, lower(x.name)), '[]'::jsonb) + INTO v_companies + FROM ( + SELECT c.*, p.code AS parent_code, p.name AS parent_name, + (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count + FROM companies c + LEFT JOIN companies p ON p.id = c.parent_id + ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, lower(c.name) + ) x; + RETURN core.rpc_ok( + jsonb_build_object( + 'risks', v_risks, + 'themes', v_themes, + 'document_types', v_doc_types, + 'project_document_types', v_proj_doc_types, + 'company_document_types', v_co_doc_types, + 'companies', v_companies + ), + format('Catálogos cargados: %s empresas, %s tipos de documento de personal', + jsonb_array_length(v_companies), jsonb_array_length(v_doc_types)), + jsonb_build_object('fn', 'fn_catalogs', 'company_count', jsonb_array_length(v_companies)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_catalogs', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-007b-fn-tenant-timezone-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_tenant_timezone_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := (payload->>'tenant_id')::integer; + v_tz text; +BEGIN + IF v_tid IS NULL THEN + RETURN core.rpc_ok( + jsonb_build_object('timezone', 'America/Mexico_City'), + 'Zona horaria por defecto America/Mexico_City (sin tenant_id en la solicitud)', + jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', NULL) + ); + END IF; + SELECT timezone INTO v_tz FROM tenant_settings WHERE tenant_id = v_tid; + v_tz := COALESCE(v_tz, 'America/Mexico_City'); + RETURN core.rpc_ok( + jsonb_build_object('timezone', v_tz), + format('Zona horaria del tenant %s: %s', v_tid, v_tz), + jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_tenant_timezone_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-007c-fn-tenant-settings-upsert endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_tenant_settings_upsert(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := (payload->>'tenant_id')::integer; + v_tz text := btrim(payload->>'timezone'); +BEGIN + IF v_tid IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_tenant_settings_upsert: tenant_id es obligatorio para guardar configuración', + jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'field', 'tenant_id')); + END IF; + IF v_tz IS NULL OR v_tz = '' THEN + RETURN core.rpc_err('VALIDATION', + format('fn_tenant_settings_upsert: timezone vacío para tenant_id=%s', v_tid), + jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid, 'field', 'timezone')); + END IF; + INSERT INTO tenant_settings (tenant_id, timezone, updated_at) + VALUES (v_tid, v_tz, now()) + ON CONFLICT (tenant_id) DO UPDATE SET timezone = EXCLUDED.timezone, updated_at = now(); + RETURN core.rpc_ok( + jsonb_build_object('timezone', v_tz), + format('Zona horaria del tenant %s actualizada a %s', v_tid, v_tz), + jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_tenant_settings_upsert', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-007d-fn-catalogs-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_catalogs(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_tenant_timezone_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_tenant_settings_upsert(jsonb) TO panels_core_app; diff --git a/db/core/changesets/013-rpc-payroll.sql b/db/core/changesets/013-rpc-payroll.sql index 5cacc4e..665919e 100644 --- a/db/core/changesets/013-rpc-payroll.sql +++ b/db/core/changesets/013-rpc-payroll.sql @@ -1636,6 +1636,371 @@ EXCEPTION WHEN OTHERS THEN END; $$; +--changeset panel:core-013i2-fn-payroll-week-list-open endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_payroll_week_list_open(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := (payload->>'tenant_id')::integer; + v_weeks jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(w) ORDER BY w.week_start DESC), '[]'::jsonb) + INTO v_weeks + FROM ( + SELECT pw.*, + (SELECT COALESCE(SUM(l.loan_discount), 0) + FROM payroll_week_lines l + JOIN payroll_sheets s ON s.id = l.sheet_id + WHERE s.week_id = pw.id) AS loan_recovery + FROM payroll_weeks pw + WHERE pw.tenant_id = v_tid AND pw.status != 'paid' + ) w; + RETURN core.rpc_ok( + jsonb_build_object('weeks', v_weeks), + format('Semanas abiertas listadas para tenant %s: %s semana(s)', v_tid, jsonb_array_length(v_weeks)), + jsonb_build_object('fn', 'fn_payroll_week_list_open', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_week_list_open', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-013i3-fn-payroll-week-csv endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_payroll_week_csv(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_week_id bigint := (payload->>'week_id')::bigint; + v_csv text; +BEGIN + IF v_week_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_payroll_week_csv: week_id es obligatorio', + jsonb_build_object('fn', 'fn_payroll_week_csv', 'field', 'week_id')); + END IF; + SELECT string_agg(row_text, E'\n' ORDER BY ord) + INTO v_csv + FROM ( + SELECT 0 AS ord, 'Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar' AS row_text + UNION ALL + SELECT 1 AS ord, + concat_ws(',', + w.first_name, w.last_name_p, s.kind, COALESCE(p.name, ''), + l.days, l.daily_wage, COALESCE(l.concepto, ''), + COALESCE(l.qty_actual, 0) + COALESCE(l.qty_extra, 0), + COALESCE(l.unit_code, ''), l.gross, l.discounts, l.loan_discount, + replace(COALESCE(l.loan_label, ''), ',', ' '), + l.required_net, l.payable_net + ) + FROM payroll_week_lines l + JOIN payroll_sheets s ON s.id = l.sheet_id + JOIN workers w ON w.id = l.worker_id + LEFT JOIN projects p ON p.id = s.project_id + WHERE s.week_id = v_week_id + ORDER BY s.kind, p.name, w.last_name_p + ) q; + RETURN core.rpc_ok( + jsonb_build_object('csv', v_csv), + format('CSV de nómina generado para semana id=%s', v_week_id), + jsonb_build_object('fn', 'fn_payroll_week_csv', 'week_id', v_week_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_week_csv', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-013i4-fn-loan-read endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_loan_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; + v_loans jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb) + INTO v_loans + FROM ( + SELECT l.*, w.first_name, w.last_name_p + FROM loans l + JOIN workers w ON w.id = l.worker_id + WHERE v_worker_id IS NULL OR l.worker_id = v_worker_id + ) x; + RETURN core.rpc_ok( + jsonb_build_object('loans', v_loans), + format('Préstamos listados: %s registro(s)', jsonb_array_length(v_loans)), + jsonb_build_object('fn', 'fn_loan_list', 'worker_id', v_worker_id, 'count', jsonb_array_length(v_loans)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_loan_list', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_loan_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_loan jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_loan_get: id es obligatorio', + jsonb_build_object('fn', 'fn_loan_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb INTO v_loan + FROM ( + SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m + FROM loans l + JOIN workers w ON w.id = l.worker_id + WHERE l.id = v_id + ) x; + IF v_loan IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Préstamo no encontrado', + jsonb_build_object('fn', 'fn_loan_get', 'id', v_id) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('loan', v_loan), + format('Préstamo id=%s cargado', v_id), + jsonb_build_object('fn', 'fn_loan_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_loan_get', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_loan_payment_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_payment jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_loan_payment_get: id es obligatorio', + jsonb_build_object('fn', 'fn_loan_payment_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb INTO v_payment + FROM ( + SELECT p.*, l.delivered, l.commission_pct, + w.first_name, w.middle_name, w.last_name_p, w.last_name_m, + wk.week_end + FROM loan_payments p + JOIN loans l ON l.id = p.loan_id + JOIN workers w ON w.id = l.worker_id + JOIN payroll_weeks wk ON wk.id = p.week_id + WHERE p.id = v_id + ) x; + IF v_payment IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Recibo no encontrado', + jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('payment', v_payment), + format('Pago de préstamo id=%s cargado', v_id), + jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_loan_payment_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-013i5-fn-payroll-period endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_payroll_period_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_week_start date := NULLIF(btrim(payload->>'week_start'), '')::date; + v_week_end date := NULLIF(btrim(payload->>'week_end'), '')::date; + v_extra jsonb := COALESCE(payload->'extra_discounts', '{}'::jsonb); + v_period_id bigint; + v_worker record; + v_att_n integer; + v_days integer; + v_gross numeric; + v_extra_disc numeric; + v_net numeric; +BEGIN + IF v_project_id IS NULL OR v_week_start IS NULL OR v_week_end IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_payroll_period_create: project_id, week_start y week_end son obligatorios', + jsonb_build_object('fn', 'fn_payroll_period_create') + ); + END IF; + INSERT INTO payroll_periods (project_id, week_start, week_end, status) + VALUES (v_project_id, v_week_start, v_week_end, 'draft') + RETURNING id INTO v_period_id; + FOR v_worker IN + SELECT w.id, w.daily_wage + FROM workers w + JOIN assignments a ON a.worker_id = w.id AND a.project_id = v_project_id AND a.active = true + WHERE w.status = 'activo' + LOOP + SELECT COUNT(*)::integer INTO v_att_n + FROM attendance + WHERE worker_id = v_worker.id + AND project_id = v_project_id + AND work_date BETWEEN v_week_start AND v_week_end + AND present = true; + v_days := v_att_n; + v_gross := v_days * v_worker.daily_wage; + v_extra_disc := COALESCE((v_extra->>v_worker.id::text)::numeric, 0); + v_net := v_gross - v_extra_disc; + INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net) + VALUES (v_period_id, v_worker.id, v_days, v_worker.daily_wage, v_gross, v_extra_disc, 0, v_net); + END LOOP; + RETURN core.rpc_created( + jsonb_build_object('id', v_period_id), + format('Periodo de nómina %s creado para proyecto %s', v_period_id, v_project_id), + jsonb_build_object('fn', 'fn_payroll_period_create', 'id', v_period_id, 'project_id', v_project_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_create', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_payroll_period_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_periods jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb) + INTO v_periods + FROM ( + SELECT pe.*, p.name AS project_name + FROM payroll_periods pe + JOIN projects p ON p.id = pe.project_id + WHERE v_project_id IS NULL OR pe.project_id = v_project_id + ) x; + RETURN core.rpc_ok( + jsonb_build_object('periods', v_periods), + format('Periodos de nómina listados: %s registro(s)', jsonb_array_length(v_periods)), + jsonb_build_object('fn', 'fn_payroll_period_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_periods)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_list', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_payroll_period_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_period jsonb; + v_lines jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_payroll_period_get: id es obligatorio', + jsonb_build_object('fn', 'fn_payroll_period_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb INTO v_period + FROM ( + SELECT pe.*, p.name AS project_name + FROM payroll_periods pe + JOIN projects p ON p.id = pe.project_id + WHERE pe.id = v_id + ) x; + IF v_period IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Periodo no encontrado', + jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id) + ); + END IF; + SELECT COALESCE(jsonb_agg(row_to_json(lx)::jsonb ORDER BY lx.last_name_p), '[]'::jsonb) + INTO v_lines + FROM ( + SELECT l.*, w.first_name, w.last_name_p, w.position + FROM payroll_lines l + JOIN workers w ON w.id = l.worker_id + WHERE l.period_id = v_id + ) lx; + RETURN core.rpc_ok( + jsonb_build_object('period', v_period, 'lines', v_lines), + format('Periodo de nómina %s cargado con %s línea(s)', v_id, jsonb_array_length(v_lines)), + jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id, 'line_count', jsonb_array_length(v_lines)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_get', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_payroll_period_patch(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_status text := btrim(COALESCE(payload->>'status', '')); +BEGIN + IF v_id IS NULL OR v_status = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_payroll_period_patch: id y status son obligatorios', + jsonb_build_object('fn', 'fn_payroll_period_patch') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM payroll_periods WHERE id = v_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Periodo no encontrado', + jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id) + ); + END IF; + UPDATE payroll_periods SET status = v_status WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('ok', true, 'id', v_id, 'status', v_status), + format('Periodo de nómina %s actualizado a estado %s', v_id, v_status), + jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id, 'status', v_status) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_patch', SQLSTATE, SQLERRM); +END; +$$; + --changeset panel:core-013j-fn-payroll-grants endDelimiter:; splitStatements:true GRANT EXECUTE ON FUNCTION core.round_money(numeric) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_get(jsonb) TO panels_core_app; @@ -1656,3 +2021,12 @@ GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_add(jsonb) TO panels_core_a GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_update(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_remove(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_jornal_worker_add(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_week_list_open(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_week_csv(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_loan_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_loan_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_loan_payment_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_patch(jsonb) TO panels_core_app; From 8717c00a83b6dc01d13d057cbf7d37452c462dca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:57:36 +0000 Subject: [PATCH 07/11] Refactor payroll_http and excel to use core RPC functions - Replace db.prepare() in payroll_http with callCoreFn for attendance, loans, loan receipts, and legacy payroll period routes - Use respondRpc/respondApiError for standardized HTTP responses - Add fn_loan_list/get, fn_loan_payment_get, and fn_payroll_period_* RPCs to 013-rpc-payroll.sql with grants - Refactor excel storeDocument to fn_worker_document_store (S3 stays in TS) - Refactor importExcel and upsertWorker to fn_workers_import_batch Co-authored-by: alberto.martinez --- api/excel.ts | 273 ++++++++--------- api/http_errors.ts | 130 ++++++++ api/payroll_http.ts | 166 ++++++----- api/rpc.ts | 125 ++++++++ db/core/changesets/006-rpc-infra.sql | 147 ++++++++++ db/core/changesets/007-rpc-catalogs.sql | 123 ++++++++ db/core/changesets/013-rpc-payroll.sql | 374 ++++++++++++++++++++++++ 7 files changed, 1106 insertions(+), 232 deletions(-) create mode 100644 api/http_errors.ts create mode 100644 api/rpc.ts create mode 100644 db/core/changesets/006-rpc-infra.sql create mode 100644 db/core/changesets/007-rpc-catalogs.sql diff --git a/api/excel.ts b/api/excel.ts index a2639ed..e31b617 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -3,9 +3,10 @@ import type { Db } from "./db.ts"; import { encryptBytes } from "./docs_crypto.ts"; import { sha256Hex } from "./crypto.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; -import { refreshPipeline, lastInsertId } from "./db.ts"; +import { refreshPipeline } from "./db.ts"; import { resolveCompany } from "./companies.ts"; import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; +import { callCoreFn, RpcCallError } from "./rpc.ts"; export const IMPORT_COLUMNS = [ "NOMBRE", @@ -214,82 +215,34 @@ export async function storeDocument( imss_baja_at?: string | null; } = {}, ) { - const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type); - if (!allowed) throw new Error("Tipo de documento no válido"); - const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; await putObject(workerDocKey(workerId, storage), cipher); - await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run( - workerId, - type, - ); const issuedAt = meta.issued_at || null; const expiresAt = meta.expires_at || null; const imssCompanyId = meta.imss_company_id || null; const today = new Date().toISOString().slice(0, 10); const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null); const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); - const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; - await db.prepare( - `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 (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`, - ).run( - workerId, - type, - filename, + const env = await callCoreFn(db, "core.fn_worker_document_store", { + worker_id: workerId, + type_code: type, + original_name: filename, mime, - bytes.byteLength, - hash, + size_bytes: bytes.byteLength, + sha256: hash, iv, - storage, - issuedAt || movementDate, - expiresAt, - imssCompanyId, - movementDate, - userId, - ); - - if (type === "alta_imss") { - const companyId = imssCompanyId || - (await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) - ?.company_id || - null; - const company = companyId - ? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as - | { id: number; code: string } - | undefined - : undefined; - if (!company) throw new Error("Empresa patrón no válida"); - await db.prepare( - `UPDATE workers SET - imss_status = 'alta', - imss_company_id = ?, - imss_alta_at = ?, - imss_baja_at = NULL, - company_id = ?, - hire_type = ?, - updated_at = now() - WHERE id = ?`, - ).run(company.id, imssAltaAt, company.id, company.code, workerId); - } - - if (type === "baja_imss") { - await db.prepare( - `UPDATE workers SET - imss_status = 'baja_imss', - imss_baja_at = ?, - imss_company_id = NULL, - company_id = NULL, - hire_type = '', - updated_at = now() - WHERE id = ?`, - ).run(imssBajaAt, workerId); - } + storage_name: storage, + uploaded_by_id: userId, + issued_at: issuedAt, + expires_at: expiresAt, + imss_company_id: imssCompanyId, + imss_alta_at: imssAltaAt, + imss_baja_at: imssBajaAt, + }); + if (!env.ok) throw new RpcCallError(env); await refreshPipeline(db, workerId); } @@ -357,79 +310,78 @@ async function fetchPhoto(url: string): Promise { } } +export type ImportReport = { + inserted: number; + existed: number; + errors: number; + photos: number; + existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[]; + error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; +}; + +type ImportBatchResult = { + inserted: number; + existed: number; + errors: number; + existed_rows: ImportReport["existed_rows"]; + error_rows: ImportReport["error_rows"]; +}; + +function workerToImportRow( + input: ReturnType & { company_id: number; tenant_id?: number | null }, + sheet: string, + row: number, + projectId: number | null, +) { + return { + sheet, + row, + first_name: input.first_name, + middle_name: input.middle_name, + last_name_p: input.last_name_p, + last_name_m: input.last_name_m, + curp: input.curp, + rfc: input.rfc, + nss: input.nss, + phone: input.phone, + email: input.email, + address: input.address, + blood_type: input.blood_type, + hire_type: input.hire_type, + company_id: input.company_id, + position: input.position, + risk_code: input.risk_code, + work_type: input.work_type, + daily_wage: input.daily_wage, + needs_badge: input.needs_badge, + status: input.status, + project_id: input.status === "activo" ? projectId : null, + tenant_id: input.tenant_id ?? 1, + }; +} + async function upsertWorker( db: Db, input: ReturnType & { company_id: number; tenant_id?: number | null }, projectId: number | null, ) { + const env = await callCoreFn(db, "core.fn_workers_import_batch", { + tenant_id: input.tenant_id ?? 1, + project_id: projectId, + rows: [workerToImportRow(input, "IMPORT", 1, projectId)], + }); + if (!env.ok) throw new RpcCallError(env); + const data = env.data!; + const errorRow = data.error_rows?.[0]; + if (errorRow?.messages?.length) throw new Error(errorRow.messages[0]); const existing = await findExisting(db, input.curp, input.rfc, input.nss); - if (existing) { - await db.prepare( - `UPDATE workers SET - first_name=?, middle_name=?, last_name_p=?, last_name_m=?, - curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, - hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, - needs_badge=?, status=?, updated_at=now() - WHERE id=?`, - ).run( - input.first_name, - input.middle_name, - input.last_name_p, - input.last_name_m, - input.curp, - input.rfc, - input.nss, - input.phone, - input.email, - input.address, - input.blood_type, - input.hire_type, - input.company_id, - input.tenant_id ?? null, - input.position, - input.risk_code, - input.work_type, - input.daily_wage, - input.needs_badge, - input.status, - existing.id, - ); - if (projectId) await assign(db, existing.id, projectId); - await refreshPipeline(db, existing.id, input.tenant_id ?? null); - const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; - return { id: existing.id, action: "existed" as const, matched }; - } - await db.prepare( - `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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ).run( - input.first_name, - input.middle_name, - input.last_name_p, - input.last_name_m, - input.curp, - input.rfc, - input.nss, - input.phone, - input.email, - input.address, - input.blood_type, - input.hire_type, - input.company_id, - input.position, - input.risk_code, - input.work_type, - input.daily_wage, - input.needs_badge, - input.status, - input.tenant_id ?? null, - ); - const id = await lastInsertId(db); - if (projectId) await assign(db, id, projectId); - await refreshPipeline(db, id, input.tenant_id ?? null); - return { id, action: "inserted" as const, matched: null as string | null }; + if (!existing) throw new Error("No se pudo guardar el trabajador"); + await refreshPipeline(db, existing.id, input.tenant_id ?? null); + return { + id: existing.id, + action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const, + matched: data.existed_rows?.[0]?.matched ?? null, + }; } async function assign(db: Db, workerId: number, projectId: number) { @@ -443,15 +395,6 @@ async function assign(db: Db, workerId: number, projectId: number) { ).run(workerId, projectId); } -export type ImportReport = { - inserted: number; - existed: number; - errors: number; - photos: number; - existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[]; - error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; -}; - export async function importExcel( db: Db, bytes: Uint8Array, @@ -496,6 +439,9 @@ export async function importExcel( return report; } + const batchRows: ReturnType[] = []; + const photoJobs: { curp: string; rfc: string; nss: string; url: string; tenant_id: number }[] = []; + for (const job of jobs) { for (const { row, data } of job.rows) { if (isEmptyRow(data)) continue; @@ -539,28 +485,41 @@ export async function importExcel( company_id: company!.id, tenant_id: company!.tenant_id ?? 1, }; - const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); - if (res.action === "inserted") report.inserted++; - else { - report.existed++; - report.existed_rows.push({ - sheet: job.sheet, - row, - nombre, - curp: n.curp, - matched: res.matched || "CURP", - }); - } + batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null)); const url = data["URL FOTO"]; if (url) { - const photo = await fetchPhoto(url); - if (photo) { - await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId); - report.photos++; - } + photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id }); } } } + + if (batchRows.length > 0) { + const env = await callCoreFn(db, "core.fn_workers_import_batch", { + project_id: projectId, + rows: batchRows, + }); + if (!env.ok) throw new RpcCallError(env); + const data = env.data!; + report.inserted = data.inserted ?? 0; + report.existed = data.existed ?? 0; + report.errors += data.errors ?? 0; + report.existed_rows.push(...(data.existed_rows ?? [])); + report.error_rows.push(...(data.error_rows ?? [])); + for (const row of batchRows) { + const worker = await findExisting(db, row.curp, row.rfc, row.nss); + if (worker) await refreshPipeline(db, worker.id, row.tenant_id ?? null); + } + } + + for (const photo of photoJobs) { + const worker = await findExisting(db, photo.curp, photo.rfc, photo.nss); + if (!worker) continue; + const photoBytes = await fetchPhoto(photo.url); + if (photoBytes) { + await storeDocument(db, worker.id, "foto", "foto-import.jpg", "image/jpeg", photoBytes, userId); + report.photos++; + } + } return report; } diff --git a/api/http_errors.ts b/api/http_errors.ts new file mode 100644 index 0000000..183977a --- /dev/null +++ b/api/http_errors.ts @@ -0,0 +1,130 @@ +import type { Context } from "hono"; + +export type RpcCode = + | "OK" + | "CREATED" + | "VALIDATION" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT" + | "INTERNAL" + | "NETWORK"; + +export type ResponseLayer = "db" | "api" | "front"; + +export type RpcEnvelope = { + ok: boolean; + code: RpcCode | string; + layer: ResponseLayer; + message: string; + context?: Record | null; + data?: T | null; + errors?: Record | string[] | null; +}; + +export type ApiResponse = RpcEnvelope & { status: number }; + +const GENERIC_MESSAGES = new Set([ + "ok", "error", "operación exitosa", "operacion exitosa", "algo salió mal", + "algo salio mal", "error interno", "error interno del servidor", +]); + +export function isGenericMessage(message: string): boolean { + return GENERIC_MESSAGES.has(message.trim().toLowerCase()); +} + +export function mapRpcToStatus(code: string): number { + switch (code) { + case "OK": + return 200; + case "CREATED": + return 201; + case "VALIDATION": + return 400; + case "UNAUTHORIZED": + return 401; + case "FORBIDDEN": + return 403; + case "NOT_FOUND": + return 404; + case "CONFLICT": + return 409; + case "NETWORK": + return 503; + case "INTERNAL": + default: + return 500; + } +} + +export function respondRpc(c: Context, envelope: RpcEnvelope) { + const status = mapRpcToStatus(String(envelope.code)); + const body: ApiResponse = { ...envelope, status }; + return c.json(body, status as 200 | 201 | 400 | 401 | 403 | 404 | 409 | 500 | 503); +} + +export function respondApiError( + c: Context, + code: RpcCode, + message: string, + context?: Record, + errors?: Record | string[] | null, +) { + if (isGenericMessage(message)) { + throw new Error(`API error message is too generic: ${message}`); + } + const status = mapRpcToStatus(code); + const body: ApiResponse = { + ok: false, + code, + status, + layer: "api", + message, + context: context ?? {}, + data: null, + errors: errors ?? null, + }; + return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500 | 503); +} + +export function enrichInfraError( + fn: string, + route: string, + cause: string, + detail?: string, +): { message: string; context: Record } { + const message = detail + ? `Falló la llamada a ${fn} en ${route}: ${cause} — ${detail}` + : `Falló la llamada a ${fn} en ${route}: ${cause}`; + return { + message, + context: { fn, route, cause }, + }; +} + +export function routeLabel(c: Context): string { + return `${c.req.method} ${c.req.path}`; +} + +export function onAppError(err: unknown, c: Context) { + console.error(err); + if (err && typeof err === "object" && (err as RpcEnvelope).layer) { + const env = err as RpcEnvelope; + return respondRpc(c, env); + } + const message = err instanceof Error + ? `Error no controlado en ${routeLabel(c)}: ${err.message}` + : `Error no controlado en ${routeLabel(c)}`; + const status = 500; + return c.json({ + ok: false, + code: "INTERNAL", + status, + layer: "api", + message, + context: { route: routeLabel(c) }, + data: null, + errors: null, + }, status); +} diff --git a/api/payroll_http.ts b/api/payroll_http.ts index df72e83..5280453 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -2,9 +2,11 @@ import type { Context, Hono } from "hono"; import type { AuthUser } from "./auth.ts"; import { tenantScope } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; -import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts"; +import { projectById, projectMustBe, type Db } from "./db.ts"; import { generateLoanReceiptPdf } from "./pdf.ts"; import { fullName } from "./mx.ts"; +import { callCoreFn } from "./rpc.ts"; +import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; import { addAdminLine, addJornalWorker, @@ -47,13 +49,13 @@ export function registerPayrollRoutes(app: App) { const from = c.req.query("from") ?? ""; const to = c.req.query("to") ?? ""; const db = c.get("db"); - const rows = await db.prepare( - `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=? AND a.work_date BETWEEN ? AND ? - ORDER BY a.work_date, w.last_name_p`, - ).all(projectId, from, to); - return c.json({ attendance: rows }); + const env = await callCoreFn<{ attendance: unknown[] }>( + db, + "core.fn_attendance_list", + { project_id: projectId, from, to }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.put("/v1/attendance", ...requireCoreAuth, async (c) => { @@ -69,7 +71,14 @@ export function registerPayrollRoutes(app: App) { ["activo", "pausado"], "No se registra asistencia en un proyecto concluido o cancelado", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + return respondApiError( + c, + blocked.status === 404 ? "NOT_FOUND" : "VALIDATION", + blocked.error, + { route: routeLabel(c), project_id: body.project_id }, + ); + } const result = await setAttendance(db, tid(c), body); if ("error" in result) { return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); @@ -114,11 +123,13 @@ export function registerPayrollRoutes(app: App) { app.get("/v1/loans", ...requireCoreAuth, async (c) => { const db = c.get("db"); const workerId = c.req.query("worker_id"); - const sql = workerId - ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" - : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; - const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all(); - return c.json({ loans }); + const env = await callCoreFn<{ loans: unknown[] }>( + db, + "core.fn_loan_list", + workerId ? { worker_id: Number(workerId) } : {}, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.post("/v1/loans", ...requireCoreAuth, async (c) => { @@ -154,10 +165,7 @@ export function registerPayrollRoutes(app: App) { app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const loan = await db.prepare( - `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m - FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`, - ).get(id) as { + const env = await callCoreFn<{ loan: { delivered: number; commission_pct: number; commission_amount: number; @@ -172,8 +180,14 @@ export function registerPayrollRoutes(app: App) { last_name_p: string; last_name_m: string; note: string | null; - } | undefined; - if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404); + } }>( + db, + "core.fn_loan_get", + { id }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + const loan = env.data!.loan; const bytes = await generateLoanReceiptPdf({ title: "Recibo de aceptación de préstamo", subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`, @@ -202,14 +216,7 @@ export function registerPayrollRoutes(app: App) { app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const pay = await db.prepare( - `SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end - FROM loan_payments p - JOIN loans l ON l.id=p.loan_id - JOIN workers w ON w.id=l.worker_id - JOIN payroll_weeks wk ON wk.id=p.week_id - WHERE p.id=?`, - ).get(id) as { + const env = await callCoreFn<{ payment: { amount: number; installment_n: number; label: string; @@ -220,8 +227,14 @@ export function registerPayrollRoutes(app: App) { last_name_p: string; last_name_m: string; week_end: string; - } | undefined; - if (!pay) return c.json({ error: "Recibo no encontrado" }, 404); + } }>( + db, + "core.fn_loan_payment_get", + { id }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + const pay = env.data!.payment; const bytes = await generateLoanReceiptPdf({ title: "Recibo de pago de préstamo", subtitle: `Descuento del sábado ${pay.week_end}`, @@ -380,65 +393,63 @@ export function registerPayrollRoutes(app: App) { ["activo", "pausado"], "No se genera nómina de un proyecto concluido o cancelado", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); - await db.prepare( - "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", - ).run(body.project_id, body.week_start, body.week_end); - const periodId = await lastInsertId(db); - const workers = await db.prepare( - `SELECT w.id, w.daily_wage FROM workers w - JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=true - WHERE w.status='activo'`, - ).all(body.project_id) as { id: number; daily_wage: number }[]; - const ins = db.prepare( - `INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const w of workers) { - const att = await db.prepare( - `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, - ).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number }; - const days = Number(att.n); - const gross = days * w.daily_wage; - const extra = Number(body.extra_discounts?.[w.id] ?? 0); - const net = gross - extra; - await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); + if (blocked) { + return respondApiError( + c, + blocked.status === 404 ? "NOT_FOUND" : "VALIDATION", + blocked.error, + { route: routeLabel(c), project_id: body.project_id }, + ); } - return c.json({ id: periodId }); + const env = await callCoreFn<{ id: number }>( + db, + "core.fn_payroll_period_create", + { + project_id: body.project_id, + week_start: body.week_start, + week_end: body.week_end, + extra_discounts: body.extra_discounts ?? {}, + }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => { const db = c.get("db"); const projectId = c.req.query("project_id"); - const sql = projectId - ? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` - : `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; - const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all(); - return c.json({ periods }); + const env = await callCoreFn<{ periods: unknown[] }>( + db, + "core.fn_payroll_period_list", + projectId ? { project_id: Number(projectId) } : {}, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const period = await db.prepare( - `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, - ).get(id); - if (!period) return c.json({ error: "Periodo no encontrado" }, 404); - const lines = await db.prepare( - `SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l - JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, - ).all(id); - return c.json({ period, lines }); + const env = await callCoreFn<{ period: unknown; lines: unknown[] }>( + db, + "core.fn_payroll_period_get", + { id }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const lines = await db.prepare( - `SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net - FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, - ).all(id) as Record[]; + const env = await callCoreFn<{ period: unknown; lines: Array> }>( + db, + "core.fn_payroll_period_get", + { id }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + const lines = env.data?.lines ?? []; const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto"; const rows = lines.map((l) => [l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",") @@ -453,7 +464,12 @@ export function registerPayrollRoutes(app: App) { const id = Number(c.req.param("id")); const { status } = await c.req.json<{ status: string }>(); const db = c.get("db"); - await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); - return c.json({ ok: true }); + const env = await callCoreFn( + db, + "core.fn_payroll_period_patch", + { id, status }, + { route: routeLabel(c) }, + ); + return respondRpc(c, env); }); } diff --git a/api/rpc.ts b/api/rpc.ts new file mode 100644 index 0000000..9855fce --- /dev/null +++ b/api/rpc.ts @@ -0,0 +1,125 @@ +import type { Db } from "./db.ts"; +import type { RpcEnvelope } from "./http_errors.ts"; +import { enrichInfraError } from "./http_errors.ts"; + +const DEADLOCK = "40P01"; + +export class RpcCallError extends Error { + constructor( + readonly envelope: RpcEnvelope, + ) { + super(envelope.message); + this.name = "RpcCallError"; + } +} + +function isEnvelope(value: unknown): value is RpcEnvelope { + if (!value || typeof value !== "object") return false; + const o = value as Record; + return typeof o.ok === "boolean" + && typeof o.code === "string" + && typeof o.message === "string" + && typeof o.layer === "string"; +} + +function postgresCode(e: unknown): string | undefined { + if (!e || typeof e !== "object") return undefined; + return (e as { code?: string }).code; +} + +function postgresMessage(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} + +async function invokeOnce( + db: Db, + fn: string, + payload: Record, +): Promise> { + const row = await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get( + JSON.stringify(payload), + ) as { result: unknown } | undefined; + const raw = row?.result; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) as RpcEnvelope; + if (isEnvelope(parsed)) return parsed; + } catch { + /* fall through */ + } + } + if (isEnvelope(raw)) return raw as RpcEnvelope; + throw new Error(`La función ${fn} no devolvió un envelope RPC válido`); +} + +export async function callCoreFn( + db: Db, + fn: string, + payload: Record = {}, + opts: { route?: string; retries?: number } = {}, +): Promise> { + const route = opts.route ?? fn; + const retries = opts.retries ?? 1; + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const envelope = await invokeOnce(db, fn, payload); + if (!envelope.ok) return envelope; + return envelope; + } catch (e) { + lastErr = e; + const code = postgresCode(e); + if (code === DEADLOCK && attempt < retries) continue; + const infra = enrichInfraError( + fn, + route, + code ?? "exception", + postgresMessage(e), + ); + return { + ok: false, + code: code === DEADLOCK ? "CONFLICT" : "INTERNAL", + layer: "api", + message: infra.message, + context: { ...infra.context, sqlstate: code ?? null }, + data: null, + errors: null, + }; + } + } + const infra = enrichInfraError(fn, route, "exception", postgresMessage(lastErr)); + return { + ok: false, + code: "INTERNAL", + layer: "api", + message: infra.message, + context: infra.context, + data: null, + errors: null, + }; +} + +export async function callCoreFnData( + db: Db, + fn: string, + payload: Record = {}, + opts: { route?: string } = {}, +): Promise { + const envelope = await callCoreFn(db, fn, payload, opts); + if (!envelope.ok) throw new RpcCallError(envelope); + return envelope.data as T; +} + +export function unwrapRpc( + envelope: RpcEnvelope, +): { data?: T; error?: string; code?: string; status?: number } { + if (envelope.ok) return { data: envelope.data as T }; + return { + error: envelope.message, + code: String(envelope.code), + status: envelope.code === "NOT_FOUND" ? 404 + : envelope.code === "CONFLICT" ? 409 + : 400, + }; +} diff --git a/db/core/changesets/006-rpc-infra.sql b/db/core/changesets/006-rpc-infra.sql new file mode 100644 index 0000000..c669017 --- /dev/null +++ b/db/core/changesets/006-rpc-infra.sql @@ -0,0 +1,147 @@ +--liquibase formatted sql +-- PANELS · core · RPC infrastructure (envelope helpers) + +--changeset panel:core-006a-rpc-generic-messages endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_generic_messages() +RETURNS text[] +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT ARRAY[ + 'OK', 'Error', 'ERROR', 'Operación exitosa', 'Operacion exitosa', + 'Algo salió mal', 'Algo salio mal', 'Error interno', 'Error interno del servidor' + ]::text[]; +$$; + +--changeset panel:core-006b-rpc-assert-message endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_assert_message(p_message text) +RETURNS void +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + IF p_message IS NULL OR btrim(p_message) = '' THEN + RAISE EXCEPTION 'RPC message must not be empty'; + END IF; + IF p_message = ANY (core.rpc_generic_messages()) THEN + RAISE EXCEPTION 'RPC message is too generic: %', p_message; + END IF; +END; +$$; + +--changeset panel:core-006c-rpc-ok endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_ok( + p_data jsonb, + p_message text, + p_ctx jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + PERFORM core.rpc_assert_message(p_message); + RETURN jsonb_build_object( + 'ok', true, + 'code', 'OK', + 'layer', 'db', + 'message', p_message, + 'context', COALESCE(p_ctx, '{}'::jsonb), + 'data', p_data, + 'errors', NULL + ); +END; +$$; + +--changeset panel:core-006d-rpc-created endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_created( + p_data jsonb, + p_message text, + p_ctx jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + PERFORM core.rpc_assert_message(p_message); + RETURN jsonb_build_object( + 'ok', true, + 'code', 'CREATED', + 'layer', 'db', + 'message', p_message, + 'context', COALESCE(p_ctx, '{}'::jsonb), + 'data', p_data, + 'errors', NULL + ); +END; +$$; + +--changeset panel:core-006e-rpc-err endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_err( + p_code text, + p_message text, + p_ctx jsonb DEFAULT '{}'::jsonb, + p_errors jsonb DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +BEGIN + PERFORM core.rpc_assert_message(p_message); + RETURN jsonb_build_object( + 'ok', false, + 'code', p_code, + 'layer', 'db', + 'message', p_message, + 'context', COALESCE(p_ctx, '{}'::jsonb), + 'data', NULL, + 'errors', p_errors + ); +END; +$$; + +--changeset panel:core-006f-rpc-map-exception endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.rpc_from_exception( + p_fn text, + p_sqlstate text, + p_message text, + p_detail text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_code text := 'INTERNAL'; + v_msg text; + v_ctx jsonb := jsonb_build_object('fn', p_fn, 'sqlstate', p_sqlstate); +BEGIN + IF p_sqlstate = '23505' THEN + v_code := 'CONFLICT'; + v_msg := format('CONFLICT en %s: %s', p_fn, COALESCE(p_detail, p_message)); + ELSIF p_sqlstate = '23503' THEN + v_code := 'VALIDATION'; + v_msg := format('Referencia inválida en %s: %s', p_fn, COALESCE(p_detail, p_message)); + ELSIF p_sqlstate = '23514' THEN + v_code := 'VALIDATION'; + v_msg := format('Restricción de valor en %s: %s', p_fn, COALESCE(p_detail, p_message)); + ELSIF p_sqlstate = '40P01' THEN + v_code := 'CONFLICT'; + v_msg := format('Deadlock en %s: otra operación modificó los mismos registros, intente de nuevo', p_fn); + ELSE + v_code := 'INTERNAL'; + v_msg := format('Error inesperado en %s (sqlstate=%s): %s', p_fn, p_sqlstate, p_message); + END IF; + RETURN core.rpc_err(v_code, v_msg, v_ctx); +END; +$$; + +--changeset panel:core-006g-rpc-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.rpc_generic_messages() TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_assert_message(text) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_core_app; diff --git a/db/core/changesets/007-rpc-catalogs.sql b/db/core/changesets/007-rpc-catalogs.sql new file mode 100644 index 0000000..49eade4 --- /dev/null +++ b/db/core/changesets/007-rpc-catalogs.sql @@ -0,0 +1,123 @@ +--liquibase formatted sql +-- PANELS · core · RPC catálogos y configuración tenant + +--changeset panel:core-007a-fn-catalogs endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_catalogs(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_risks jsonb; + v_themes jsonb; + v_doc_types jsonb; + v_proj_doc_types jsonb; + v_co_doc_types jsonb; + v_companies jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY r.code), '[]'::jsonb) + INTO v_risks FROM risk_levels r; + SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.id), '[]'::jsonb) + INTO v_themes FROM badge_themes t; + SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.code), '[]'::jsonb) + INTO v_doc_types FROM document_types d; + SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.code), '[]'::jsonb) + INTO v_proj_doc_types FROM project_document_types p; + SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.code), '[]'::jsonb) + INTO v_co_doc_types FROM company_document_types c; + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.kind, lower(x.name)), '[]'::jsonb) + INTO v_companies + FROM ( + SELECT c.*, p.code AS parent_code, p.name AS parent_name, + (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count + FROM companies c + LEFT JOIN companies p ON p.id = c.parent_id + ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, lower(c.name) + ) x; + RETURN core.rpc_ok( + jsonb_build_object( + 'risks', v_risks, + 'themes', v_themes, + 'document_types', v_doc_types, + 'project_document_types', v_proj_doc_types, + 'company_document_types', v_co_doc_types, + 'companies', v_companies + ), + format('Catálogos cargados: %s empresas, %s tipos de documento de personal', + jsonb_array_length(v_companies), jsonb_array_length(v_doc_types)), + jsonb_build_object('fn', 'fn_catalogs', 'company_count', jsonb_array_length(v_companies)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_catalogs', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-007b-fn-tenant-timezone-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_tenant_timezone_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := (payload->>'tenant_id')::integer; + v_tz text; +BEGIN + IF v_tid IS NULL THEN + RETURN core.rpc_ok( + jsonb_build_object('timezone', 'America/Mexico_City'), + 'Zona horaria por defecto America/Mexico_City (sin tenant_id en la solicitud)', + jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', NULL) + ); + END IF; + SELECT timezone INTO v_tz FROM tenant_settings WHERE tenant_id = v_tid; + v_tz := COALESCE(v_tz, 'America/Mexico_City'); + RETURN core.rpc_ok( + jsonb_build_object('timezone', v_tz), + format('Zona horaria del tenant %s: %s', v_tid, v_tz), + jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_tenant_timezone_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-007c-fn-tenant-settings-upsert endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_tenant_settings_upsert(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := (payload->>'tenant_id')::integer; + v_tz text := btrim(payload->>'timezone'); +BEGIN + IF v_tid IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_tenant_settings_upsert: tenant_id es obligatorio para guardar configuración', + jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'field', 'tenant_id')); + END IF; + IF v_tz IS NULL OR v_tz = '' THEN + RETURN core.rpc_err('VALIDATION', + format('fn_tenant_settings_upsert: timezone vacío para tenant_id=%s', v_tid), + jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid, 'field', 'timezone')); + END IF; + INSERT INTO tenant_settings (tenant_id, timezone, updated_at) + VALUES (v_tid, v_tz, now()) + ON CONFLICT (tenant_id) DO UPDATE SET timezone = EXCLUDED.timezone, updated_at = now(); + RETURN core.rpc_ok( + jsonb_build_object('timezone', v_tz), + format('Zona horaria del tenant %s actualizada a %s', v_tid, v_tz), + jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_tenant_settings_upsert', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-007d-fn-catalogs-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_catalogs(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_tenant_timezone_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_tenant_settings_upsert(jsonb) TO panels_core_app; diff --git a/db/core/changesets/013-rpc-payroll.sql b/db/core/changesets/013-rpc-payroll.sql index 5cacc4e..665919e 100644 --- a/db/core/changesets/013-rpc-payroll.sql +++ b/db/core/changesets/013-rpc-payroll.sql @@ -1636,6 +1636,371 @@ EXCEPTION WHEN OTHERS THEN END; $$; +--changeset panel:core-013i2-fn-payroll-week-list-open endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_payroll_week_list_open(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := (payload->>'tenant_id')::integer; + v_weeks jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(w) ORDER BY w.week_start DESC), '[]'::jsonb) + INTO v_weeks + FROM ( + SELECT pw.*, + (SELECT COALESCE(SUM(l.loan_discount), 0) + FROM payroll_week_lines l + JOIN payroll_sheets s ON s.id = l.sheet_id + WHERE s.week_id = pw.id) AS loan_recovery + FROM payroll_weeks pw + WHERE pw.tenant_id = v_tid AND pw.status != 'paid' + ) w; + RETURN core.rpc_ok( + jsonb_build_object('weeks', v_weeks), + format('Semanas abiertas listadas para tenant %s: %s semana(s)', v_tid, jsonb_array_length(v_weeks)), + jsonb_build_object('fn', 'fn_payroll_week_list_open', 'tenant_id', v_tid) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_week_list_open', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-013i3-fn-payroll-week-csv endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_payroll_week_csv(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_week_id bigint := (payload->>'week_id')::bigint; + v_csv text; +BEGIN + IF v_week_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_payroll_week_csv: week_id es obligatorio', + jsonb_build_object('fn', 'fn_payroll_week_csv', 'field', 'week_id')); + END IF; + SELECT string_agg(row_text, E'\n' ORDER BY ord) + INTO v_csv + FROM ( + SELECT 0 AS ord, 'Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar' AS row_text + UNION ALL + SELECT 1 AS ord, + concat_ws(',', + w.first_name, w.last_name_p, s.kind, COALESCE(p.name, ''), + l.days, l.daily_wage, COALESCE(l.concepto, ''), + COALESCE(l.qty_actual, 0) + COALESCE(l.qty_extra, 0), + COALESCE(l.unit_code, ''), l.gross, l.discounts, l.loan_discount, + replace(COALESCE(l.loan_label, ''), ',', ' '), + l.required_net, l.payable_net + ) + FROM payroll_week_lines l + JOIN payroll_sheets s ON s.id = l.sheet_id + JOIN workers w ON w.id = l.worker_id + LEFT JOIN projects p ON p.id = s.project_id + WHERE s.week_id = v_week_id + ORDER BY s.kind, p.name, w.last_name_p + ) q; + RETURN core.rpc_ok( + jsonb_build_object('csv', v_csv), + format('CSV de nómina generado para semana id=%s', v_week_id), + jsonb_build_object('fn', 'fn_payroll_week_csv', 'week_id', v_week_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_week_csv', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-013i4-fn-loan-read endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_loan_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; + v_loans jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb) + INTO v_loans + FROM ( + SELECT l.*, w.first_name, w.last_name_p + FROM loans l + JOIN workers w ON w.id = l.worker_id + WHERE v_worker_id IS NULL OR l.worker_id = v_worker_id + ) x; + RETURN core.rpc_ok( + jsonb_build_object('loans', v_loans), + format('Préstamos listados: %s registro(s)', jsonb_array_length(v_loans)), + jsonb_build_object('fn', 'fn_loan_list', 'worker_id', v_worker_id, 'count', jsonb_array_length(v_loans)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_loan_list', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_loan_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_loan jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_loan_get: id es obligatorio', + jsonb_build_object('fn', 'fn_loan_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb INTO v_loan + FROM ( + SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m + FROM loans l + JOIN workers w ON w.id = l.worker_id + WHERE l.id = v_id + ) x; + IF v_loan IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Préstamo no encontrado', + jsonb_build_object('fn', 'fn_loan_get', 'id', v_id) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('loan', v_loan), + format('Préstamo id=%s cargado', v_id), + jsonb_build_object('fn', 'fn_loan_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_loan_get', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_loan_payment_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_payment jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_loan_payment_get: id es obligatorio', + jsonb_build_object('fn', 'fn_loan_payment_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb INTO v_payment + FROM ( + SELECT p.*, l.delivered, l.commission_pct, + w.first_name, w.middle_name, w.last_name_p, w.last_name_m, + wk.week_end + FROM loan_payments p + JOIN loans l ON l.id = p.loan_id + JOIN workers w ON w.id = l.worker_id + JOIN payroll_weeks wk ON wk.id = p.week_id + WHERE p.id = v_id + ) x; + IF v_payment IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Recibo no encontrado', + jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id) + ); + END IF; + RETURN core.rpc_ok( + jsonb_build_object('payment', v_payment), + format('Pago de préstamo id=%s cargado', v_id), + jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_loan_payment_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-013i5-fn-payroll-period endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_payroll_period_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_week_start date := NULLIF(btrim(payload->>'week_start'), '')::date; + v_week_end date := NULLIF(btrim(payload->>'week_end'), '')::date; + v_extra jsonb := COALESCE(payload->'extra_discounts', '{}'::jsonb); + v_period_id bigint; + v_worker record; + v_att_n integer; + v_days integer; + v_gross numeric; + v_extra_disc numeric; + v_net numeric; +BEGIN + IF v_project_id IS NULL OR v_week_start IS NULL OR v_week_end IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_payroll_period_create: project_id, week_start y week_end son obligatorios', + jsonb_build_object('fn', 'fn_payroll_period_create') + ); + END IF; + INSERT INTO payroll_periods (project_id, week_start, week_end, status) + VALUES (v_project_id, v_week_start, v_week_end, 'draft') + RETURNING id INTO v_period_id; + FOR v_worker IN + SELECT w.id, w.daily_wage + FROM workers w + JOIN assignments a ON a.worker_id = w.id AND a.project_id = v_project_id AND a.active = true + WHERE w.status = 'activo' + LOOP + SELECT COUNT(*)::integer INTO v_att_n + FROM attendance + WHERE worker_id = v_worker.id + AND project_id = v_project_id + AND work_date BETWEEN v_week_start AND v_week_end + AND present = true; + v_days := v_att_n; + v_gross := v_days * v_worker.daily_wage; + v_extra_disc := COALESCE((v_extra->>v_worker.id::text)::numeric, 0); + v_net := v_gross - v_extra_disc; + INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net) + VALUES (v_period_id, v_worker.id, v_days, v_worker.daily_wage, v_gross, v_extra_disc, 0, v_net); + END LOOP; + RETURN core.rpc_created( + jsonb_build_object('id', v_period_id), + format('Periodo de nómina %s creado para proyecto %s', v_period_id, v_project_id), + jsonb_build_object('fn', 'fn_payroll_period_create', 'id', v_period_id, 'project_id', v_project_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_create', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_payroll_period_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_periods jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb) + INTO v_periods + FROM ( + SELECT pe.*, p.name AS project_name + FROM payroll_periods pe + JOIN projects p ON p.id = pe.project_id + WHERE v_project_id IS NULL OR pe.project_id = v_project_id + ) x; + RETURN core.rpc_ok( + jsonb_build_object('periods', v_periods), + format('Periodos de nómina listados: %s registro(s)', jsonb_array_length(v_periods)), + jsonb_build_object('fn', 'fn_payroll_period_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_periods)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_list', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_payroll_period_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_period jsonb; + v_lines jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_payroll_period_get: id es obligatorio', + jsonb_build_object('fn', 'fn_payroll_period_get', 'field', 'id') + ); + END IF; + SELECT row_to_json(x)::jsonb INTO v_period + FROM ( + SELECT pe.*, p.name AS project_name + FROM payroll_periods pe + JOIN projects p ON p.id = pe.project_id + WHERE pe.id = v_id + ) x; + IF v_period IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Periodo no encontrado', + jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id) + ); + END IF; + SELECT COALESCE(jsonb_agg(row_to_json(lx)::jsonb ORDER BY lx.last_name_p), '[]'::jsonb) + INTO v_lines + FROM ( + SELECT l.*, w.first_name, w.last_name_p, w.position + FROM payroll_lines l + JOIN workers w ON w.id = l.worker_id + WHERE l.period_id = v_id + ) lx; + RETURN core.rpc_ok( + jsonb_build_object('period', v_period, 'lines', v_lines), + format('Periodo de nómina %s cargado con %s línea(s)', v_id, jsonb_array_length(v_lines)), + jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id, 'line_count', jsonb_array_length(v_lines)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_get', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_payroll_period_patch(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_status text := btrim(COALESCE(payload->>'status', '')); +BEGIN + IF v_id IS NULL OR v_status = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_payroll_period_patch: id y status son obligatorios', + jsonb_build_object('fn', 'fn_payroll_period_patch') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM payroll_periods WHERE id = v_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + 'Periodo no encontrado', + jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id) + ); + END IF; + UPDATE payroll_periods SET status = v_status WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('ok', true, 'id', v_id, 'status', v_status), + format('Periodo de nómina %s actualizado a estado %s', v_id, v_status), + jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id, 'status', v_status) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_payroll_period_patch', SQLSTATE, SQLERRM); +END; +$$; + --changeset panel:core-013j-fn-payroll-grants endDelimiter:; splitStatements:true GRANT EXECUTE ON FUNCTION core.round_money(numeric) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_get(jsonb) TO panels_core_app; @@ -1656,3 +2021,12 @@ GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_add(jsonb) TO panels_core_a GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_update(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_remove(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_jornal_worker_add(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_week_list_open(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_week_csv(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_loan_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_loan_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_loan_payment_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_payroll_period_patch(jsonb) TO panels_core_app; From e92614acb9a15f80f00d710906a36c210a69e7de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:58:55 +0000 Subject: [PATCH 08/11] refactor(api): migrate main.ts CRUD routes to PostgreSQL RPC Replace all db.prepare() calls in main.ts with callCoreFn and the respondRpc/respondApiError HTTP envelope helpers. Company and project routes use the RPC response pattern; worker, budget, badge, catalog, and tenant settings endpoints call their matching core.fn_* functions. Adds app.onError(onAppError) for consistent error handling. Keeps companies.ts wrappers, excel.ts document storage, and auth/PDF/S3 unchanged. Co-authored-by: alberto.martinez --- api/main.ts | 1041 ++++++++++++++++++++++++++++----------------------- 1 file changed, 565 insertions(+), 476 deletions(-) diff --git a/api/main.ts b/api/main.ts index 0ac52b5..ba5a988 100644 --- a/api/main.ts +++ b/api/main.ts @@ -3,7 +3,6 @@ import { cors } from "hono/cors"; import { checklistFor, refreshPipeline, - nextProjectCode, isProjectStatus, projectById, projectMustBe, @@ -11,13 +10,19 @@ import { projectChecklistFor, companyChecklistFor, imssFlagsFor, - checklistItemsFor, - lastInsertId, pingCoreDb, tenantTimezone, getCoreDb, type Db, } from "./db.ts"; +import { callCoreFn } from "./rpc.ts"; +import { + respondRpc, + respondApiError, + onAppError, + routeLabel, + type RpcEnvelope, +} from "./http_errors.ts"; import { config } from "./config.ts"; import { clearSession, @@ -36,14 +41,13 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { decryptBytes } from "./docs_crypto.ts"; -import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, findExisting, assign, buildImportTemplate } from "./excel.ts"; +import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts"; import { normalizeWorker, validateCurp, validateNss, validateRfc, validateWorkerFields, - fullName, formatNss, normUpper, titleCase, @@ -58,15 +62,48 @@ import { exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, - lineAmount, listBudget, } from "./budget.ts"; -import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; +import { badgeJobPdfKey, companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts"; import { pingRedis } from "./redis.ts"; const app = new Hono<{ Variables: { user: AuthUser; db: Db } }>(); +app.onError(onAppError); + +type StorageDoc = { + storage_name: string; + iv: string; + original_name: string; + mime?: string; +}; + +async function rpcDocumentForDownload( + db: Db, + fn: string, + payload: Record, + route: string, +): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> { + const envelope = await callCoreFn<{ document: StorageDoc }>(db, fn, payload, { route }); + if (!envelope.ok) return { envelope }; + const doc = envelope.data?.document; + if (!doc?.storage_name) { + return { + envelope: { + ok: false, + code: "NOT_FOUND", + layer: "db", + message: "Documento no encontrado", + context: { fn, ...payload }, + data: null, + errors: null, + }, + }; + } + return { doc, envelope }; +} + async function scopedCompany( db: Db, id: number, @@ -237,20 +274,16 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); - // Catálogos de baja escritura (Fase 4e): TTL corto, sin invalidación - // explícita. La llave incluye tenant_id porque `companies` sí es dato - // de negocio por-tenant -- risk_levels/badge_themes/document_types son - // globales, pero se cachean juntos por simplicidad de esta única - // respuesta agregada. - const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => ({ - risks: await db.prepare("SELECT * FROM risk_levels").all(), - themes: await db.prepare("SELECT * FROM badge_themes").all(), - document_types: await db.prepare("SELECT * FROM document_types").all(), - project_document_types: await db.prepare("SELECT * FROM project_document_types").all(), - company_document_types: await db.prepare("SELECT * FROM company_document_types").all(), - project_statuses: PROJECT_STATUS_CATALOG, - companies: await listCompanies(db, tid), - })); + const route = routeLabel(c); + const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => { + const env = await callCoreFn>(db, "core.fn_catalogs", {}, { route }); + if (!env.ok) throw new Error(env.message); + return { + ...env.data, + project_statuses: PROJECT_STATUS_CATALOG, + companies: await listCompanies(db, tid), + }; + }); return c.json(payload); }); @@ -265,25 +298,44 @@ app.get("/v1/configuracion", ...requireCoreAuth, async (c) => { app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { const user = c.get("user"); - if (user.role !== "tenant_admin") return c.json({ error: "Solo administradores" }, 403); + if (user.role !== "tenant_admin") { + return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", { + route: routeLabel(c), + }); + } const db = c.get("db"); const tid = tenantScope(user); - if (tid == null) return c.json({ error: "Cuenta sin tenant" }, 400); + if (tid == null) { + return respondApiError(c, "VALIDATION", "Cuenta sin tenant asociada para guardar configuración", { + route: routeLabel(c), + }); + } const body = await c.req.json<{ timezone?: string }>(); const tz = (body.timezone ?? "").trim(); const valid = new Set(Intl.supportedValuesOf("timeZone")); - if (!tz || !valid.has(tz)) return c.json({ error: "Zona horaria inválida" }, 400); - await db.prepare( - `INSERT INTO tenant_settings (tenant_id, timezone, updated_at) VALUES (?, ?, now()) - ON CONFLICT (tenant_id) DO UPDATE SET timezone = excluded.timezone, updated_at = now()`, - ).run(tid, tz); - return c.json({ timezone: tz }); + if (!tz || !valid.has(tz)) { + return respondApiError(c, "VALIDATION", `Zona horaria inválida en ${routeLabel(c)}: ${tz || "(vacío)"}`, { + route: routeLabel(c), + timezone: tz || null, + }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_tenant_settings_upsert", + { tenant_id: tid, timezone: tz }, + { route: routeLabel(c) }, + )); }); app.get("/v1/companies", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); - return c.json({ companies: await listCompanies(db, tid) }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_company_list", + { tenant_id: tid }, + { route: routeLabel(c) }, + )); }); app.post("/v1/companies", ...requireCoreAuth, async (c) => { @@ -291,8 +343,17 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await createSubcompany(db, body, tid); - if (result.error || !result.company) return c.json({ error: result.error }, 400); - return c.json({ company: result.company }, 201); + if (result.error || !result.company) { + const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, result.error ?? "No se pudo crear la empresa", { route: routeLabel(c) }); + } + return respondRpc(c, { + ok: true, + code: "CREATED", + layer: "db", + message: `Empresa ${result.company.code} creada`, + data: { company: result.company }, + }); }); app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { @@ -301,22 +362,50 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await updateCompany(db, id, body, tid); - if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); - return c.json({ company: result.company }); + if (result.error) { + const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, result.error, { route: routeLabel(c), company_id: id }); + } + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Empresa id=${id} actualizada`, + data: { company: result.company }, + }); }); app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const company = await scopedCompany(db, id, tenantScope(c.get("user"))); - if (!company) return c.json({ error: "Empresa no encontrada" }, 404); - const documents = await db.prepare( - "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC", - ).all(id); - return c.json({ - company, - documents, - checklist: await companyChecklistFor(db, id), + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const companyEnv = await callCoreFn<{ company: Record }>(db, "core.fn_company_get", { id }, { route }); + if (!companyEnv.ok) return respondRpc(c, companyEnv); + const company = companyEnv.data?.company; + if (!company) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id }); + } + if (tid != null && company.tenant_id != null && company.tenant_id !== tid) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id }); + } + const docsEnv = await callCoreFn<{ documents: unknown[] }>( + db, + "core.fn_company_document_list", + { company_id: id }, + { route }, + ); + if (!docsEnv.ok) return respondRpc(c, docsEnv); + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Empresa ${company.code} cargada`, + data: { + company, + documents: docsEnv.data?.documents ?? [], + checklist: await companyChecklistFor(db, id), + }, }); }); @@ -341,22 +430,19 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); - const doc = await db.prepare( - "SELECT * FROM company_documents WHERE id=? AND company_id=?", - ).get(docId, companyId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${companyId} no encontrada en ${route}`, { route, company_id: companyId }); + } + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_company_document_get", + { company_id: companyId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(companyDocKey(companyId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - // Documentos subidos por el usuario se sirven como adjunto, nunca inline: - // servir "inline" dejaría que el navegador renderice el Content-Type que - // el propio uploader eligió (ej. un .html disfrazado de "comprobante"), - // habilitando XSS almacenado dentro del origen autenticado de la app. c.header("Content-Type", "application/octet-stream"); c.header("X-Content-Type-Options", "nosniff"); c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); @@ -365,36 +451,20 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { app.get("/v1/projects", ...requireCoreAuth, async (c) => { const db = c.get("db"); - const tid = tenantScope(c.get("user")); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; - const clauses: string[] = []; - const params: (string | number)[] = []; - if (tid != null) { - clauses.push("p.tenant_id = ?"); - params.push(tid); + if (status && !allowed.length) { + return respondApiError(c, "VALIDATION", `Ningún estado de proyecto válido en ${routeLabel(c)}: ${status}`, { + route: routeLabel(c), + status, + }); } - if (allowed.length) { - clauses.push(`p.status IN (${allowed.map(() => "?").join(",")})`); - params.push(...allowed); - } - const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; - return c.json({ - projects: await db.prepare( - `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count, - (SELECT COUNT(*) FROM project_document_types t - WHERE t.required = true AND NOT EXISTS ( - SELECT 1 FROM project_documents d - WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true - )) AS missing_docs, - (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count - FROM projects p - LEFT JOIN companies c ON c.id = p.company_id - ${where} - ORDER BY CASE p.status WHEN 'activo' THEN 0 WHEN 'pausado' THEN 1 WHEN 'concluido' THEN 2 ELSE 3 END, p.id`, - ).all(...params), - }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_list", + { status: allowed.length ? allowed.join(",") : undefined }, + { route: routeLabel(c) }, + )); }); type ProjectInput = { @@ -446,98 +516,129 @@ function projectFields(body: ProjectInput, cur?: Record) { app.post("/v1/projects", ...requireCoreAuth, async (c) => { const body = await c.req.json(); - if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); + if (!body.name?.trim()) { + return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, { + route: routeLabel(c), + }); + } const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; const db = c.get("db"); const fields = projectFields(body); - if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!fields.company_id) { + return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${routeLabel(c)}`, { + route: routeLabel(c), + }); + } if (!await resolveCompany(db, { company_id: fields.company_id })) { - return c.json({ error: "Empresa no encontrada" }, 400); + return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${routeLabel(c)}`, { + route: routeLabel(c), + company_id: fields.company_id, + }); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { - return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${routeLabel(c)}`, { + route: routeLabel(c), + }); } - const code = await nextProjectCode(db); const tid = tenantScope(c.get("user")); - await db.prepare( - `INSERT INTO projects ( - code, name, address, theme_id, status, company_id, contract_amount, - start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - fields.name, - fields.address, - fields.theme_id, - status, - fields.company_id, - fields.contract_amount, - fields.start_date, - fields.end_date, - fields.resident_name, - fields.siroc, - fields.payroll_tax_pct, - tid, - ); - const id = await lastInsertId(db); - return c.json({ id, code, status }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_create", + { + tenant_id: tid, + name: fields.name, + address: fields.address, + theme_id: fields.theme_id, + status, + company_id: fields.company_id, + contract_amount: fields.contract_amount, + start_date: fields.start_date, + end_date: fields.end_date, + resident_name: fields.resident_name, + siroc: fields.siroc, + payroll_tax_pct: fields.payroll_tax_pct, + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = c.get("db"); - // Con RLS activo (db/core/changesets/005-rls.sql), esta consulta ya no - // puede traer una fila de otro tenant: la conexión solo ve app.tenant_id. - const cur = await db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; - if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); + const route = routeLabel(c); + const curEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); + if (!curEnv.ok) return respondRpc(c, curEnv); + const cur = curEnv.data?.project; + if (!cur) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id }); + } const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo"); const fields = projectFields(body, cur); - if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!fields.company_id) { + return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${route}`, { route, project_id: id }); + } if (!await resolveCompany(db, { company_id: fields.company_id })) { - return c.json({ error: "Empresa no encontrada" }, 400); + return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${route}`, { + route, + project_id: id, + company_id: fields.company_id, + }); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { - return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${route}`, { + route, + project_id: id, + }); } - await db.prepare( - `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?, - start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`, - ).run( - fields.name, - fields.address, - fields.theme_id, - status, - fields.company_id, - fields.contract_amount, - fields.start_date, - fields.end_date, - fields.resident_name, - fields.siroc, - fields.payroll_tax_pct, - id, - ); - return c.json({ ok: true, id, code: cur.code, status }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_update", + { + id, + name: fields.name, + address: fields.address, + theme_id: fields.theme_id, + status, + company_id: fields.company_id, + contract_amount: fields.contract_amount, + start_date: fields.start_date, + end_date: fields.end_date, + resident_name: fields.resident_name, + siroc: fields.siroc, + payroll_tax_pct: fields.payroll_tax_pct, + }, + { route }, + )); }); app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const project = await db.prepare( - `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count - FROM projects p - LEFT JOIN companies c ON c.id = p.company_id - WHERE p.id = ?`, - ).get(id); - if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const documents = await db.prepare( - "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC", - ).all(id); - return c.json({ - project, - documents, - checklist: await projectChecklistFor(db, id), + const route = routeLabel(c); + const projectEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); + if (!projectEnv.ok) return respondRpc(c, projectEnv); + const project = projectEnv.data?.project; + if (!project) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id }); + } + const docsEnv = await callCoreFn<{ documents: unknown[] }>( + db, + "core.fn_project_document_list", + { project_id: id }, + { route }, + ); + if (!docsEnv.ok) return respondRpc(c, docsEnv); + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Proyecto ${project.code} cargado`, + data: { + project, + documents: docsEnv.data?.documents ?? [], + checklist: await projectChecklistFor(db, id), + }, }); }); @@ -562,15 +663,14 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - const doc = await db.prepare( - "SELECT * FROM project_documents WHERE id=? AND project_id=?", - ).get(docId, projectId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_project_document_get", + { project_id: projectId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -644,18 +744,24 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); - if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400); + if (!body.name?.trim()) { + return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, { + route: routeLabel(c), + project_id: id, + }); + } const db = c.get("db"); - if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; - await db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( - id, - body.parent_id || null, - (body.code || "").trim(), - body.name.trim(), - sort, - ); - return c.json({ id: await lastInsertId(db) }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_create", + { + project_id: id, + parent_id: body.parent_id || null, + code: (body.code || "").trim(), + name: body.name.trim(), + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { @@ -663,25 +769,29 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); const db = c.get("db"); - const cur = await db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; - if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404); - await db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( - (body.name ?? cur.name).trim(), - (body.code ?? cur.code).trim(), - cid, - ); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_update", + { + project_id: id, + id: cid, + name: body.name, + code: body.code, + }, + { route: routeLabel(c) }, + )); }); app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const db = c.get("db"); - const found = await db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); - if (!found) return c.json({ error: "Capítulo no encontrado" }, 404); - await db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); - await db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_delete", + { project_id: id, id: cid }, + { route: routeLabel(c) }, + )); }); type BudgetItemInput = { @@ -696,27 +806,27 @@ type BudgetItemInput = { app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400); + if (!body.description?.trim()) { + return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, { + route: routeLabel(c), + project_id: id, + }); + } const db = c.get("db"); - if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const qty = Number(body.quantity ?? 0); - const price = Number(body.unit_price ?? 0); - const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; - await db.prepare( - `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - body.chapter_id || null, - (body.code || "").trim().toUpperCase(), - body.description.trim(), - (body.unit || "").trim().toUpperCase(), - qty, - price, - lineAmount(qty, price), - sort, - ); - return c.json({ id: await lastInsertId(db) }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_create", + { + project_id: id, + chapter_id: body.chapter_id || null, + code: (body.code || "").trim(), + description: body.description.trim(), + unit: (body.unit || "").trim(), + quantity: body.quantity ?? 0, + unit_price: body.unit_price ?? 0, + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { @@ -724,39 +834,40 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => const iid = Number(c.req.param("iid")); const body = await c.req.json(); const db = c.get("db"); - const cur = await db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; - if (!cur) return c.json({ error: "Partida no encontrada" }, 404); - const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity); - const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price); - await db.prepare( - `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`, - ).run( - body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id, - (body.code ?? String(cur.code ?? "")).trim().toUpperCase(), - (body.description ?? String(cur.description ?? "")).trim(), - (body.unit ?? String(cur.unit ?? "")).trim().toUpperCase(), - qty, - price, - lineAmount(qty, price), - iid, - ); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_update", + { + project_id: id, + id: iid, + chapter_id: body.chapter_id, + code: body.code, + description: body.description, + unit: body.unit, + quantity: body.quantity, + unit_price: body.unit_price, + }, + { route: routeLabel(c) }, + )); }); app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const db = c.get("db"); - const found = await db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); - if (!found) return c.json({ error: "Partida no encontrada" }, 404); - await db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_delete", + { project_id: id, id: iid }, + { route: routeLabel(c) }, + )); }); app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); + const route = routeLabel(c); const exclude = body.id ?? 0; const checks: { field: string; value: string; err: string | null }[] = [ { field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") }, @@ -765,9 +876,16 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { ]; for (const ch of checks) { if (ch.err) continue; - const row = await db.prepare( - `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`, - ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined; + const env = await callCoreFn<{ worker?: { id: number; first_name: string; last_name_p: string } | null; found?: boolean }>( + db, + "core.fn_worker_find_existing", + { + [ch.field]: ch.value, + exclude_id: exclude, + }, + { route }, + ); + const row = env.ok && env.data?.found ? env.data.worker : undefined; if (row) { errors[ch.field] = `Este ${ch.field.toUpperCase()} ya pertenece a ${row.first_name} ${row.last_name_p}`; @@ -782,128 +900,36 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => { const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); const projectId = c.req.query("project_id"); - let sql = `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, - CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = true) THEN true ELSE false END AS in_project - 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 1=1`; - const params: (string | number)[] = []; - if (tid != null) { - sql += " AND w.tenant_id = ?"; - params.push(tid); - } - if (status) { - sql += " AND w.status = ?"; - params.push(status); - if (status === "activo") sql += " AND w.pipeline_status != 'baja'"; - } - if (projectId) { - sql += - " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = true)"; - params.push(Number(projectId)); - } - if (q) { - sql += - " AND (w.first_name ILIKE ? OR w.last_name_p ILIKE ? OR w.curp ILIKE ? OR w.rfc ILIKE ? OR w.nss ILIKE ?)"; - const like = `%${q}%`; - params.push(like, like, like, like, like); - } - sql += " ORDER BY w.last_name_p, w.first_name"; - const rows = await db.prepare(sql).all(...params) as Array & { - id: number; - status: string; - pipeline_status: string; - imss_status?: string; - in_project?: boolean; - }>; - const workers = []; - for (const row of rows) { - const flags = await imssFlagsFor(db, row.id, tid); - workers.push({ - ...row, - imss_status: flags.imss_status, - imss_ready: flags.imss_ready, - in_project_without_imss: flags.in_project_without_imss, - expediente_ok: flags.expediente_ok, - freshness_required: flags.freshness_required, - }); - } - const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"); - const withImss = active.filter((w) => w.imss_status === "alta").length; - const withoutImss = active.length - withImss; - const inProjectWithoutImss = active.filter((w) => w.in_project_without_imss).length; - const imssReady = active.filter((w) => w.imss_ready).length; - return c.json({ - workers, - imss_stats: { - with_imss: withImss, - without_imss: withoutImss, - in_project_without_imss: inProjectWithoutImss, - imss_ready_count: imssReady, + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_list", + { + tenant_id: tid, + q: q || undefined, + status: status || undefined, + project_id: projectId ? Number(projectId) : undefined, }, - }); + { route: routeLabel(c) }, + )); }); app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); - const worker = await db.prepare( - `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 - 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 = ?`, - ).get(id); - if (!worker) return c.json({ error: "No encontrado" }, 404); - const documents = await db.prepare( - `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 = ? ORDER BY uploaded_at DESC`, - ).all(id); - const assignments = await db.prepare( - `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a - JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ? - ORDER BY a.active DESC, a.start_date DESC, a.id DESC`, - ).all(id); - const loans = await db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); - const { items, freshness_required } = await checklistItemsFor(db, id, tid); - const flags = await imssFlagsFor(db, id, tid); - const docTypes = await db.prepare( - `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category - FROM document_types ORDER BY required DESC, label`, - ).all(); - return c.json({ - worker: { ...worker, full_name: fullName(worker as never), ...flags }, - documents, - assignments, - loans, - checklist: items, - document_types: docTypes, - freshness_required, - }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_get", + { id, tenant_id: tid }, + { route: routeLabel(c) }, + )); }); -function conflict(row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, n: ReturnType, excludeId = 0) { +function conflict( + row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, + n: ReturnType, + excludeId = 0, +) { if (row && row.id !== excludeId) { const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; return { @@ -917,97 +943,111 @@ function conflict(row: { id: number; first_name: string; last_name_p: string; cu return null; } +async function findExistingWorker( + db: Db, + curp: string, + rfc: string, + nss: string, + excludeId = 0, + route = "worker-duplicate-check", +) { + const env = await callCoreFn<{ + worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null; + found?: boolean; + }>(db, "core.fn_worker_find_existing", { curp, rfc, nss, exclude_id: excludeId }, { route }); + if (!env.ok || !env.data?.found) return undefined; + return env.data.worker; +} + app.post("/v1/workers", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); - if (Object.keys(errors).length) return c.json({ errors }, 400); + const route = routeLabel(c); + if (Object.keys(errors).length) { + return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route }, errors); + } const n = normalizeWorker({ ...body, hire_type: "" }); - const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, 0, route); const cf = conflict(existing, n); - if (cf) return c.json(cf.body, cf.status); + if (cf) { + return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id }); + } if (body.project_id) { const blocked = projectMustBe( await projectById(db, body.project_id), ["activo"], "Solo se asigna personal a proyectos activos", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, blocked.error, { route, project_id: body.project_id }); + } } const tid = tenantScope(c.get("user")); - await db.prepare( - `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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ).run( - n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, - n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, tid, - ); - const id = await lastInsertId(db); - if (body.project_id) { - await assign(db, id, body.project_id); - } - await refreshPipeline(db, id, tid); - return c.json({ id }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_create", + { ...body, tenant_id: tid, project_id: body.project_id ?? null }, + { route }, + )); }); app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); - const cur = await db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; - if (!cur) return c.json({ error: "No encontrado" }, 404); + const route = routeLabel(c); + const curEnv = await callCoreFn<{ worker: Record }>(db, "core.fn_worker_get", { id, tenant_id: tid }, { route }); + if (!curEnv.ok) return respondRpc(c, curEnv); + const cur = curEnv.data?.worker; + if (!cur) { + return respondApiError(c, "NOT_FOUND", `Trabajador id=${id} no encontrado en ${route}`, { route, worker_id: id }); + } const body = await c.req.json(); const merged = { ...cur, ...body } as WorkerInput; const errors = validateWorkerFields(merged); - if (Object.keys(errors).length) return c.json({ errors }, 400); - // Empresa/patrón solo cambia con alta/baja IMSS; no se sobrescribe desde el formulario. - const hireType = String(cur.hire_type || ""); - const companyId = (cur.company_id as number | null) ?? null; - const n = normalizeWorker({ ...merged, hire_type: hireType }); - const existing = await findExisting(db, n.curp, n.rfc, n.nss); + if (Object.keys(errors).length) { + return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route, worker_id: id }, errors); + } + const n = normalizeWorker({ ...merged, hire_type: String(cur.hire_type || "") }); + const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, id, route); const cf = conflict(existing, n, id); - if (cf) return c.json(cf.body, cf.status); - await db.prepare( - `UPDATE workers SET - 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=?, updated_at=now() - WHERE id=?`, - ).run( - n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, - n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, id, - ); - await refreshPipeline(db, id, tid); - return c.json({ ok: true }); + if (cf) { + return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_update", + { id, tenant_id: tid, ...body }, + { route }, + )); }); app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; - if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400); + if (!allowed.includes(pipeline_status)) { + return respondApiError(c, "VALIDATION", `Estado de pipeline inválido en ${routeLabel(c)}: ${pipeline_status}`, { + route: routeLabel(c), + worker_id: id, + pipeline_status, + }); + } const db = c.get("db"); const tid = tenantScope(c.get("user")); - const prev = await db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; - if (pipeline_status === "baja") { - await db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); - } else { - // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS. - if (prev?.status === "baja") { - await db.prepare( - "UPDATE workers SET status='activo', last_rehire_at=current_date WHERE id=?", - ).run(id); - } else { - await db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); - } - await refreshPipeline(db, id, tid); - } - const worker = await db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); - return c.json({ ok: true, worker }); + const env = await callCoreFn( + db, + "core.fn_worker_set_pipeline", + { id, tenant_id: tid, pipeline_status }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + return respondRpc(c, { + ...env, + data: { ok: true, ...(env.data as Record) }, + }); }); app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { @@ -1015,20 +1055,35 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); const db = c.get("db"); const tid = tenantScope(c.get("user")); + const route = routeLabel(c); if (active === false) { - await db.prepare( - "UPDATE assignments SET active=false, end_date=current_date WHERE worker_id=? AND project_id=?", - ).run(id, project_id); - } else { - const project = await projectById(db, project_id); - if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - if (project.status !== "activo") { - return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400); - } - await assign(db, id, project_id); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_unassign", + { worker_id: id, project_id, tenant_id: tid }, + { route }, + )); } - await refreshPipeline(db, id, tid); - return c.json({ ok: true }); + const project = await projectById(db, project_id); + if (!project) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${project_id} no encontrado en ${route}`, { + route, + project_id, + }); + } + if (project.status !== "activo") { + return respondApiError(c, "VALIDATION", `Solo se asigna personal a proyectos activos en ${route}`, { + route, + project_id, + status: project.status, + }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_assign", + { worker_id: id, project_id, tenant_id: tid }, + { route }, + )); }); app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { @@ -1063,19 +1118,36 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); - if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); + if (!(file instanceof File) || !type) { + return respondApiError(c, "VALIDATION", `type y file son requeridos en ${routeLabel(c)}`, { + route: routeLabel(c), + worker_id: id, + }); + } const db = c.get("db"); - const exists = await db.prepare("SELECT id FROM workers WHERE id=?").get(id); - if (!exists) return c.json({ error: "No encontrado" }, 404); + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const workerEnv = await callCoreFn(db, "core.fn_worker_get", { id, tenant_id: tid }, { route }); + if (!workerEnv.ok) return respondRpc(c, workerEnv); - const policy = await db.prepare( - `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, - ).get(type) as { + const catEnv = await callCoreFn<{ document_types?: Array> }>( + db, + "core.fn_catalogs", + {}, + { route }, + ); + const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as { validity_mode: string; requires_issued_at: boolean; requires_expires_at: boolean; } | undefined; - if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); + if (!policy) { + return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, { + route, + worker_id: id, + type, + }); + } const issuedAt = String(form.get("issued_at") || "").trim() || null; const expiresAt = String(form.get("expires_at") || "").trim() || null; @@ -1084,19 +1156,19 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; if (policy.requires_issued_at && !issuedAt) { - return c.json({ error: "Indique la fecha de emisión del documento" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id }); } if (policy.requires_expires_at && !expiresAt) { - return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssCompanyId) { - return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssAltaAt) { - return c.json({ error: "Indique la fecha de alta IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "baja_imss" && !imssBajaAt) { - return c.json({ error: "Indique la fecha de baja IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de baja IMSS en ${route}`, { route, worker_id: id }); } const bytes = new Uint8Array(await file.arrayBuffer()); @@ -1109,9 +1181,9 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { imss_baja_at: imssBajaAt, }); } catch (error) { - return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); + const message = error instanceof Error ? error.message : "No se pudo guardar el documento"; + return respondApiError(c, "VALIDATION", message, { route, worker_id: id }); } - const tid = tenantScope(c.get("user")); return c.json({ ok: true, checklist: await checklistFor(db, id, tid), @@ -1123,15 +1195,14 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - const doc = await db.prepare( - "SELECT * FROM documents WHERE id=? AND worker_id=?", - ).get(docId, workerId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_worker_document_get", + { worker_id: workerId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(workerDocKey(workerId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -1169,68 +1240,85 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); const db = c.get("db"); + const user = c.get("user"); + const tid = tenantScope(user); + const route = routeLabel(c); const blocked = projectMustBe( await projectById(db, projectId), ["activo"], "Solo se generan gafetes de proyectos activos", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, blocked.error, { route, project_id: projectId }); + } let ids = body.worker_ids ?? []; if (!ids.length) { - ids = (await db.prepare( - `SELECT w.id FROM workers w - JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = true - WHERE w.status = 'activo' AND w.needs_badge = true - AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=true)`, - ).all(projectId) as { id: number }[]).map((r) => r.id); + const listEnv = await callCoreFn<{ workers?: Array> }>( + db, + "core.fn_worker_list", + { tenant_id: tid, project_id: projectId, status: "activo" }, + { route }, + ); + if (!listEnv.ok) return respondRpc(c, listEnv); + ids = (listEnv.data?.workers ?? []) + .filter((w) => w.needs_badge && ["listo_gafete", "impreso", "activo"].includes(String(w.pipeline_status))) + .map((w) => Number(w.id)); + } + if (!ids.length) { + return respondApiError(c, "VALIDATION", `No hay personal activo con foto para imprimir en ${route}`, { + route, + project_id: projectId, + }); } - if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); const bytes = await generateBadgePdf(db, projectId, ids); - const user = c.get("user"); - await db.prepare( - "INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) VALUES (?, 'done', ?, ?)", - ).run(projectId, user.id, user.display_name); - const jobId = await lastInsertId(db); - const path = await saveJobPdf(bytes, jobId); - await db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); - const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)"); - const tid = tenantScope(user); + const createEnv = await callCoreFn( + db, + "core.fn_badge_job_create", + { + project_id: projectId, + worker_ids: ids, + created_by_id: user.id, + created_by_name: user.display_name, + }, + { route }, + ); + if (!createEnv.ok) return respondRpc(c, createEnv); + const jobId = Number((createEnv.data as { id?: number })?.id); + await saveJobPdf(bytes, jobId); for (const wid of ids) { - await ins.run(jobId, wid); await refreshPipeline(db, wid, tid); } - return c.json({ id: jobId, count: ids.length }); + return respondRpc(c, { + ...createEnv, + data: { ...(createEnv.data as Record), count: ids.length }, + }); }); app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => { const db = c.get("db"); - const jobs = await db.prepare( - `SELECT j.*, p.name AS project_name, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=true) AS delivered - FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`, - ).all(); - return c.json({ jobs }); + return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const job = await db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); - if (!job) return c.json({ error: "Job no encontrado" }, 404); - const people = await db.prepare( - `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p - JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`, - ).all(id); - return c.json({ job, people }); + return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const job = await db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; - if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404); - const bytes = await getObject(job.pdf_path); + const route = routeLabel(c); + const jobEnv = await callCoreFn<{ job?: { pdf_path?: string | null } }>( + db, + "core.fn_badge_job_get", + { job_id: id }, + { route }, + ); + if (!jobEnv.ok) return respondRpc(c, jobEnv); + const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id); + const bytes = await getObject(pdfPath); c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); return c.body(bytes.buffer as ArrayBuffer); @@ -1241,11 +1329,12 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) = const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); const db = c.get("db"); - await db.prepare( - `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN now() ELSE NULL END - WHERE job_id=? AND worker_id=?`, - ).run(delivered, delivered, jobId, workerId); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_badge_job_set_delivered", + { job_id: jobId, worker_id: workerId, delivered }, + { route: routeLabel(c) }, + )); }); registerPayrollRoutes(app); From f3179444bf01e7b69646c4561214f2518a888d6e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 23:58:55 +0000 Subject: [PATCH 09/11] refactor(api): migrate main.ts CRUD routes to PostgreSQL RPC Replace all db.prepare() calls in main.ts with callCoreFn and the respondRpc/respondApiError HTTP envelope helpers. Company and project routes use the RPC response pattern; worker, budget, badge, catalog, and tenant settings endpoints call their matching core.fn_* functions. Adds app.onError(onAppError) for consistent error handling. Keeps companies.ts wrappers, excel.ts document storage, and auth/PDF/S3 unchanged. Co-authored-by: alberto.martinez --- api/main.ts | 1041 ++++++++++++++++++++++++++++----------------------- 1 file changed, 565 insertions(+), 476 deletions(-) diff --git a/api/main.ts b/api/main.ts index 0ac52b5..ba5a988 100644 --- a/api/main.ts +++ b/api/main.ts @@ -3,7 +3,6 @@ import { cors } from "hono/cors"; import { checklistFor, refreshPipeline, - nextProjectCode, isProjectStatus, projectById, projectMustBe, @@ -11,13 +10,19 @@ import { projectChecklistFor, companyChecklistFor, imssFlagsFor, - checklistItemsFor, - lastInsertId, pingCoreDb, tenantTimezone, getCoreDb, type Db, } from "./db.ts"; +import { callCoreFn } from "./rpc.ts"; +import { + respondRpc, + respondApiError, + onAppError, + routeLabel, + type RpcEnvelope, +} from "./http_errors.ts"; import { config } from "./config.ts"; import { clearSession, @@ -36,14 +41,13 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { decryptBytes } from "./docs_crypto.ts"; -import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, findExisting, assign, buildImportTemplate } from "./excel.ts"; +import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts"; import { normalizeWorker, validateCurp, validateNss, validateRfc, validateWorkerFields, - fullName, formatNss, normUpper, titleCase, @@ -58,15 +62,48 @@ import { exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, - lineAmount, listBudget, } from "./budget.ts"; -import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; +import { badgeJobPdfKey, companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts"; import { pingRedis } from "./redis.ts"; const app = new Hono<{ Variables: { user: AuthUser; db: Db } }>(); +app.onError(onAppError); + +type StorageDoc = { + storage_name: string; + iv: string; + original_name: string; + mime?: string; +}; + +async function rpcDocumentForDownload( + db: Db, + fn: string, + payload: Record, + route: string, +): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> { + const envelope = await callCoreFn<{ document: StorageDoc }>(db, fn, payload, { route }); + if (!envelope.ok) return { envelope }; + const doc = envelope.data?.document; + if (!doc?.storage_name) { + return { + envelope: { + ok: false, + code: "NOT_FOUND", + layer: "db", + message: "Documento no encontrado", + context: { fn, ...payload }, + data: null, + errors: null, + }, + }; + } + return { doc, envelope }; +} + async function scopedCompany( db: Db, id: number, @@ -237,20 +274,16 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => { const db = c.get("db"); const user = c.get("user"); const tid = tenantScope(user); - // Catálogos de baja escritura (Fase 4e): TTL corto, sin invalidación - // explícita. La llave incluye tenant_id porque `companies` sí es dato - // de negocio por-tenant -- risk_levels/badge_themes/document_types son - // globales, pero se cachean juntos por simplicidad de esta única - // respuesta agregada. - const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => ({ - risks: await db.prepare("SELECT * FROM risk_levels").all(), - themes: await db.prepare("SELECT * FROM badge_themes").all(), - document_types: await db.prepare("SELECT * FROM document_types").all(), - project_document_types: await db.prepare("SELECT * FROM project_document_types").all(), - company_document_types: await db.prepare("SELECT * FROM company_document_types").all(), - project_statuses: PROJECT_STATUS_CATALOG, - companies: await listCompanies(db, tid), - })); + const route = routeLabel(c); + const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => { + const env = await callCoreFn>(db, "core.fn_catalogs", {}, { route }); + if (!env.ok) throw new Error(env.message); + return { + ...env.data, + project_statuses: PROJECT_STATUS_CATALOG, + companies: await listCompanies(db, tid), + }; + }); return c.json(payload); }); @@ -265,25 +298,44 @@ app.get("/v1/configuracion", ...requireCoreAuth, async (c) => { app.put("/v1/configuracion", ...requireCoreAuth, async (c) => { const user = c.get("user"); - if (user.role !== "tenant_admin") return c.json({ error: "Solo administradores" }, 403); + if (user.role !== "tenant_admin") { + return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", { + route: routeLabel(c), + }); + } const db = c.get("db"); const tid = tenantScope(user); - if (tid == null) return c.json({ error: "Cuenta sin tenant" }, 400); + if (tid == null) { + return respondApiError(c, "VALIDATION", "Cuenta sin tenant asociada para guardar configuración", { + route: routeLabel(c), + }); + } const body = await c.req.json<{ timezone?: string }>(); const tz = (body.timezone ?? "").trim(); const valid = new Set(Intl.supportedValuesOf("timeZone")); - if (!tz || !valid.has(tz)) return c.json({ error: "Zona horaria inválida" }, 400); - await db.prepare( - `INSERT INTO tenant_settings (tenant_id, timezone, updated_at) VALUES (?, ?, now()) - ON CONFLICT (tenant_id) DO UPDATE SET timezone = excluded.timezone, updated_at = now()`, - ).run(tid, tz); - return c.json({ timezone: tz }); + if (!tz || !valid.has(tz)) { + return respondApiError(c, "VALIDATION", `Zona horaria inválida en ${routeLabel(c)}: ${tz || "(vacío)"}`, { + route: routeLabel(c), + timezone: tz || null, + }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_tenant_settings_upsert", + { tenant_id: tid, timezone: tz }, + { route: routeLabel(c) }, + )); }); app.get("/v1/companies", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); - return c.json({ companies: await listCompanies(db, tid) }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_company_list", + { tenant_id: tid }, + { route: routeLabel(c) }, + )); }); app.post("/v1/companies", ...requireCoreAuth, async (c) => { @@ -291,8 +343,17 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await createSubcompany(db, body, tid); - if (result.error || !result.company) return c.json({ error: result.error }, 400); - return c.json({ company: result.company }, 201); + if (result.error || !result.company) { + const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, result.error ?? "No se pudo crear la empresa", { route: routeLabel(c) }); + } + return respondRpc(c, { + ok: true, + code: "CREATED", + layer: "db", + message: `Empresa ${result.company.code} creada`, + data: { company: result.company }, + }); }); app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { @@ -301,22 +362,50 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => { const db = c.get("db"); const tid = tenantScope(c.get("user")); const result = await updateCompany(db, id, body, tid); - if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404); - return c.json({ company: result.company }); + if (result.error) { + const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, result.error, { route: routeLabel(c), company_id: id }); + } + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Empresa id=${id} actualizada`, + data: { company: result.company }, + }); }); app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const company = await scopedCompany(db, id, tenantScope(c.get("user"))); - if (!company) return c.json({ error: "Empresa no encontrada" }, 404); - const documents = await db.prepare( - "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC", - ).all(id); - return c.json({ - company, - documents, - checklist: await companyChecklistFor(db, id), + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const companyEnv = await callCoreFn<{ company: Record }>(db, "core.fn_company_get", { id }, { route }); + if (!companyEnv.ok) return respondRpc(c, companyEnv); + const company = companyEnv.data?.company; + if (!company) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id }); + } + if (tid != null && company.tenant_id != null && company.tenant_id !== tid) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id }); + } + const docsEnv = await callCoreFn<{ documents: unknown[] }>( + db, + "core.fn_company_document_list", + { company_id: id }, + { route }, + ); + if (!docsEnv.ok) return respondRpc(c, docsEnv); + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Empresa ${company.code} cargada`, + data: { + company, + documents: docsEnv.data?.documents ?? [], + checklist: await companyChecklistFor(db, id), + }, }); }); @@ -341,22 +430,19 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { const companyId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); - const doc = await db.prepare( - "SELECT * FROM company_documents WHERE id=? AND company_id=?", - ).get(docId, companyId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) { + return respondApiError(c, "NOT_FOUND", `Empresa id=${companyId} no encontrada en ${route}`, { route, company_id: companyId }); + } + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_company_document_get", + { company_id: companyId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(companyDocKey(companyId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - // Documentos subidos por el usuario se sirven como adjunto, nunca inline: - // servir "inline" dejaría que el navegador renderice el Content-Type que - // el propio uploader eligió (ej. un .html disfrazado de "comprobante"), - // habilitando XSS almacenado dentro del origen autenticado de la app. c.header("Content-Type", "application/octet-stream"); c.header("X-Content-Type-Options", "nosniff"); c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); @@ -365,36 +451,20 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => { app.get("/v1/projects", ...requireCoreAuth, async (c) => { const db = c.get("db"); - const tid = tenantScope(c.get("user")); const status = (c.req.query("status") ?? "").trim(); const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : []; - const clauses: string[] = []; - const params: (string | number)[] = []; - if (tid != null) { - clauses.push("p.tenant_id = ?"); - params.push(tid); + if (status && !allowed.length) { + return respondApiError(c, "VALIDATION", `Ningún estado de proyecto válido en ${routeLabel(c)}: ${status}`, { + route: routeLabel(c), + status, + }); } - if (allowed.length) { - clauses.push(`p.status IN (${allowed.map(() => "?").join(",")})`); - params.push(...allowed); - } - const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; - return c.json({ - projects: await db.prepare( - `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count, - (SELECT COUNT(*) FROM project_document_types t - WHERE t.required = true AND NOT EXISTS ( - SELECT 1 FROM project_documents d - WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true - )) AS missing_docs, - (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count - FROM projects p - LEFT JOIN companies c ON c.id = p.company_id - ${where} - ORDER BY CASE p.status WHEN 'activo' THEN 0 WHEN 'pausado' THEN 1 WHEN 'concluido' THEN 2 ELSE 3 END, p.id`, - ).all(...params), - }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_list", + { status: allowed.length ? allowed.join(",") : undefined }, + { route: routeLabel(c) }, + )); }); type ProjectInput = { @@ -446,98 +516,129 @@ function projectFields(body: ProjectInput, cur?: Record) { app.post("/v1/projects", ...requireCoreAuth, async (c) => { const body = await c.req.json(); - if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400); + if (!body.name?.trim()) { + return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, { + route: routeLabel(c), + }); + } const status = body.status && isProjectStatus(body.status) ? body.status : "activo"; const db = c.get("db"); const fields = projectFields(body); - if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!fields.company_id) { + return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${routeLabel(c)}`, { + route: routeLabel(c), + }); + } if (!await resolveCompany(db, { company_id: fields.company_id })) { - return c.json({ error: "Empresa no encontrada" }, 400); + return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${routeLabel(c)}`, { + route: routeLabel(c), + company_id: fields.company_id, + }); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { - return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${routeLabel(c)}`, { + route: routeLabel(c), + }); } - const code = await nextProjectCode(db); const tid = tenantScope(c.get("user")); - await db.prepare( - `INSERT INTO projects ( - code, name, address, theme_id, status, company_id, contract_amount, - start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - fields.name, - fields.address, - fields.theme_id, - status, - fields.company_id, - fields.contract_amount, - fields.start_date, - fields.end_date, - fields.resident_name, - fields.siroc, - fields.payroll_tax_pct, - tid, - ); - const id = await lastInsertId(db); - return c.json({ id, code, status }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_create", + { + tenant_id: tid, + name: fields.name, + address: fields.address, + theme_id: fields.theme_id, + status, + company_id: fields.company_id, + contract_amount: fields.contract_amount, + start_date: fields.start_date, + end_date: fields.end_date, + resident_name: fields.resident_name, + siroc: fields.siroc, + payroll_tax_pct: fields.payroll_tax_pct, + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); const db = c.get("db"); - // Con RLS activo (db/core/changesets/005-rls.sql), esta consulta ya no - // puede traer una fila de otro tenant: la conexión solo ve app.tenant_id. - const cur = await db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined; - if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404); + const route = routeLabel(c); + const curEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); + if (!curEnv.ok) return respondRpc(c, curEnv); + const cur = curEnv.data?.project; + if (!cur) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id }); + } const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo"); const fields = projectFields(body, cur); - if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400); + if (!fields.company_id) { + return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${route}`, { route, project_id: id }); + } if (!await resolveCompany(db, { company_id: fields.company_id })) { - return c.json({ error: "Empresa no encontrada" }, 400); + return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${route}`, { + route, + project_id: id, + company_id: fields.company_id, + }); } if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) { - return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400); + return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${route}`, { + route, + project_id: id, + }); } - await db.prepare( - `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?, - start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`, - ).run( - fields.name, - fields.address, - fields.theme_id, - status, - fields.company_id, - fields.contract_amount, - fields.start_date, - fields.end_date, - fields.resident_name, - fields.siroc, - fields.payroll_tax_pct, - id, - ); - return c.json({ ok: true, id, code: cur.code, status }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_project_update", + { + id, + name: fields.name, + address: fields.address, + theme_id: fields.theme_id, + status, + company_id: fields.company_id, + contract_amount: fields.contract_amount, + start_date: fields.start_date, + end_date: fields.end_date, + resident_name: fields.resident_name, + siroc: fields.siroc, + payroll_tax_pct: fields.payroll_tax_pct, + }, + { route }, + )); }); app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const project = await db.prepare( - `SELECT p.*, c.name AS company_name, c.code AS company_code, - (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count - FROM projects p - LEFT JOIN companies c ON c.id = p.company_id - WHERE p.id = ?`, - ).get(id); - if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - const documents = await db.prepare( - "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC", - ).all(id); - return c.json({ - project, - documents, - checklist: await projectChecklistFor(db, id), + const route = routeLabel(c); + const projectEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route }); + if (!projectEnv.ok) return respondRpc(c, projectEnv); + const project = projectEnv.data?.project; + if (!project) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id }); + } + const docsEnv = await callCoreFn<{ documents: unknown[] }>( + db, + "core.fn_project_document_list", + { project_id: id }, + { route }, + ); + if (!docsEnv.ok) return respondRpc(c, docsEnv); + return respondRpc(c, { + ok: true, + code: "OK", + layer: "db", + message: `Proyecto ${project.code} cargado`, + data: { + project, + documents: docsEnv.data?.documents ?? [], + checklist: await projectChecklistFor(db, id), + }, }); }); @@ -562,15 +663,14 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - const doc = await db.prepare( - "SELECT * FROM project_documents WHERE id=? AND project_id=?", - ).get(docId, projectId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_project_document_get", + { project_id: projectId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -644,18 +744,24 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => { app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>(); - if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400); + if (!body.name?.trim()) { + return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, { + route: routeLabel(c), + project_id: id, + }); + } const db = c.get("db"); - if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n; - await db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run( - id, - body.parent_id || null, - (body.code || "").trim(), - body.name.trim(), - sort, - ); - return c.json({ id: await lastInsertId(db) }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_create", + { + project_id: id, + parent_id: body.parent_id || null, + code: (body.code || "").trim(), + name: body.name.trim(), + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { @@ -663,25 +769,29 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) const cid = Number(c.req.param("cid")); const body = await c.req.json<{ name?: string; code?: string }>(); const db = c.get("db"); - const cur = await db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined; - if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404); - await db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run( - (body.name ?? cur.name).trim(), - (body.code ?? cur.code).trim(), - cid, - ); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_update", + { + project_id: id, + id: cid, + name: body.name, + code: body.code, + }, + { route: routeLabel(c) }, + )); }); app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const cid = Number(c.req.param("cid")); const db = c.get("db"); - const found = await db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id); - if (!found) return c.json({ error: "Capítulo no encontrado" }, 404); - await db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid); - await db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_chapter_delete", + { project_id: id, id: cid }, + { route: routeLabel(c) }, + )); }); type BudgetItemInput = { @@ -696,27 +806,27 @@ type BudgetItemInput = { app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const body = await c.req.json(); - if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400); + if (!body.description?.trim()) { + return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, { + route: routeLabel(c), + project_id: id, + }); + } const db = c.get("db"); - if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const qty = Number(body.quantity ?? 0); - const price = Number(body.unit_price ?? 0); - const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n; - await db.prepare( - `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - id, - body.chapter_id || null, - (body.code || "").trim().toUpperCase(), - body.description.trim(), - (body.unit || "").trim().toUpperCase(), - qty, - price, - lineAmount(qty, price), - sort, - ); - return c.json({ id: await lastInsertId(db) }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_create", + { + project_id: id, + chapter_id: body.chapter_id || null, + code: (body.code || "").trim(), + description: body.description.trim(), + unit: (body.unit || "").trim(), + quantity: body.quantity ?? 0, + unit_price: body.unit_price ?? 0, + }, + { route: routeLabel(c) }, + )); }); app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { @@ -724,39 +834,40 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => const iid = Number(c.req.param("iid")); const body = await c.req.json(); const db = c.get("db"); - const cur = await db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined; - if (!cur) return c.json({ error: "Partida no encontrada" }, 404); - const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity); - const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price); - await db.prepare( - `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`, - ).run( - body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id, - (body.code ?? String(cur.code ?? "")).trim().toUpperCase(), - (body.description ?? String(cur.description ?? "")).trim(), - (body.unit ?? String(cur.unit ?? "")).trim().toUpperCase(), - qty, - price, - lineAmount(qty, price), - iid, - ); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_update", + { + project_id: id, + id: iid, + chapter_id: body.chapter_id, + code: body.code, + description: body.description, + unit: body.unit, + quantity: body.quantity, + unit_price: body.unit_price, + }, + { route: routeLabel(c) }, + )); }); app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const iid = Number(c.req.param("iid")); const db = c.get("db"); - const found = await db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id); - if (!found) return c.json({ error: "Partida no encontrada" }, 404); - await db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_budget_item_delete", + { project_id: id, id: iid }, + { route: routeLabel(c) }, + )); }); app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); + const route = routeLabel(c); const exclude = body.id ?? 0; const checks: { field: string; value: string; err: string | null }[] = [ { field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") }, @@ -765,9 +876,16 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => { ]; for (const ch of checks) { if (ch.err) continue; - const row = await db.prepare( - `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`, - ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined; + const env = await callCoreFn<{ worker?: { id: number; first_name: string; last_name_p: string } | null; found?: boolean }>( + db, + "core.fn_worker_find_existing", + { + [ch.field]: ch.value, + exclude_id: exclude, + }, + { route }, + ); + const row = env.ok && env.data?.found ? env.data.worker : undefined; if (row) { errors[ch.field] = `Este ${ch.field.toUpperCase()} ya pertenece a ${row.first_name} ${row.last_name_p}`; @@ -782,128 +900,36 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => { const q = (c.req.query("q") ?? "").trim(); const status = c.req.query("status"); const projectId = c.req.query("project_id"); - let sql = `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, - CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = true) THEN true ELSE false END AS in_project - 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 1=1`; - const params: (string | number)[] = []; - if (tid != null) { - sql += " AND w.tenant_id = ?"; - params.push(tid); - } - if (status) { - sql += " AND w.status = ?"; - params.push(status); - if (status === "activo") sql += " AND w.pipeline_status != 'baja'"; - } - if (projectId) { - sql += - " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = true)"; - params.push(Number(projectId)); - } - if (q) { - sql += - " AND (w.first_name ILIKE ? OR w.last_name_p ILIKE ? OR w.curp ILIKE ? OR w.rfc ILIKE ? OR w.nss ILIKE ?)"; - const like = `%${q}%`; - params.push(like, like, like, like, like); - } - sql += " ORDER BY w.last_name_p, w.first_name"; - const rows = await db.prepare(sql).all(...params) as Array & { - id: number; - status: string; - pipeline_status: string; - imss_status?: string; - in_project?: boolean; - }>; - const workers = []; - for (const row of rows) { - const flags = await imssFlagsFor(db, row.id, tid); - workers.push({ - ...row, - imss_status: flags.imss_status, - imss_ready: flags.imss_ready, - in_project_without_imss: flags.in_project_without_imss, - expediente_ok: flags.expediente_ok, - freshness_required: flags.freshness_required, - }); - } - const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"); - const withImss = active.filter((w) => w.imss_status === "alta").length; - const withoutImss = active.length - withImss; - const inProjectWithoutImss = active.filter((w) => w.in_project_without_imss).length; - const imssReady = active.filter((w) => w.imss_ready).length; - return c.json({ - workers, - imss_stats: { - with_imss: withImss, - without_imss: withoutImss, - in_project_without_imss: inProjectWithoutImss, - imss_ready_count: imssReady, + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_list", + { + tenant_id: tid, + q: q || undefined, + status: status || undefined, + project_id: projectId ? Number(projectId) : undefined, }, - }); + { route: routeLabel(c) }, + )); }); app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); - const worker = await db.prepare( - `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 - 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 = ?`, - ).get(id); - if (!worker) return c.json({ error: "No encontrado" }, 404); - const documents = await db.prepare( - `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 = ? ORDER BY uploaded_at DESC`, - ).all(id); - const assignments = await db.prepare( - `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a - JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ? - ORDER BY a.active DESC, a.start_date DESC, a.id DESC`, - ).all(id); - const loans = await db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id); - const { items, freshness_required } = await checklistItemsFor(db, id, tid); - const flags = await imssFlagsFor(db, id, tid); - const docTypes = await db.prepare( - `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category - FROM document_types ORDER BY required DESC, label`, - ).all(); - return c.json({ - worker: { ...worker, full_name: fullName(worker as never), ...flags }, - documents, - assignments, - loans, - checklist: items, - document_types: docTypes, - freshness_required, - }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_get", + { id, tenant_id: tid }, + { route: routeLabel(c) }, + )); }); -function conflict(row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, n: ReturnType, excludeId = 0) { +function conflict( + row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, + n: ReturnType, + excludeId = 0, +) { if (row && row.id !== excludeId) { const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS"; return { @@ -917,97 +943,111 @@ function conflict(row: { id: number; first_name: string; last_name_p: string; cu return null; } +async function findExistingWorker( + db: Db, + curp: string, + rfc: string, + nss: string, + excludeId = 0, + route = "worker-duplicate-check", +) { + const env = await callCoreFn<{ + worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null; + found?: boolean; + }>(db, "core.fn_worker_find_existing", { curp, rfc, nss, exclude_id: excludeId }, { route }); + if (!env.ok || !env.data?.found) return undefined; + return env.data.worker; +} + app.post("/v1/workers", ...requireCoreAuth, async (c) => { const body = await c.req.json(); const errors = validateWorkerFields(body); const db = c.get("db"); - if (Object.keys(errors).length) return c.json({ errors }, 400); + const route = routeLabel(c); + if (Object.keys(errors).length) { + return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route }, errors); + } const n = normalizeWorker({ ...body, hire_type: "" }); - const existing = await findExisting(db, n.curp, n.rfc, n.nss); + const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, 0, route); const cf = conflict(existing, n); - if (cf) return c.json(cf.body, cf.status); + if (cf) { + return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id }); + } if (body.project_id) { const blocked = projectMustBe( await projectById(db, body.project_id), ["activo"], "Solo se asigna personal a proyectos activos", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, blocked.error, { route, project_id: body.project_id }); + } } const tid = tenantScope(c.get("user")); - await db.prepare( - `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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - ).run( - n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, - n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, tid, - ); - const id = await lastInsertId(db); - if (body.project_id) { - await assign(db, id, body.project_id); - } - await refreshPipeline(db, id, tid); - return c.json({ id }, 201); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_create", + { ...body, tenant_id: tid, project_id: body.project_id ?? null }, + { route }, + )); }); app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); const tid = tenantScope(c.get("user")); - const cur = await db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined; - if (!cur) return c.json({ error: "No encontrado" }, 404); + const route = routeLabel(c); + const curEnv = await callCoreFn<{ worker: Record }>(db, "core.fn_worker_get", { id, tenant_id: tid }, { route }); + if (!curEnv.ok) return respondRpc(c, curEnv); + const cur = curEnv.data?.worker; + if (!cur) { + return respondApiError(c, "NOT_FOUND", `Trabajador id=${id} no encontrado en ${route}`, { route, worker_id: id }); + } const body = await c.req.json(); const merged = { ...cur, ...body } as WorkerInput; const errors = validateWorkerFields(merged); - if (Object.keys(errors).length) return c.json({ errors }, 400); - // Empresa/patrón solo cambia con alta/baja IMSS; no se sobrescribe desde el formulario. - const hireType = String(cur.hire_type || ""); - const companyId = (cur.company_id as number | null) ?? null; - const n = normalizeWorker({ ...merged, hire_type: hireType }); - const existing = await findExisting(db, n.curp, n.rfc, n.nss); + if (Object.keys(errors).length) { + return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route, worker_id: id }, errors); + } + const n = normalizeWorker({ ...merged, hire_type: String(cur.hire_type || "") }); + const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, id, route); const cf = conflict(existing, n, id); - if (cf) return c.json(cf.body, cf.status); - await db.prepare( - `UPDATE workers SET - 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=?, updated_at=now() - WHERE id=?`, - ).run( - n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss, - n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code, - n.work_type, n.daily_wage, n.needs_badge, n.status, id, - ); - await refreshPipeline(db, id, tid); - return c.json({ ok: true }); + if (cf) { + return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_update", + { id, tenant_id: tid, ...body }, + { route }, + )); }); app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const { pipeline_status } = await c.req.json<{ pipeline_status: string }>(); const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"]; - if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400); + if (!allowed.includes(pipeline_status)) { + return respondApiError(c, "VALIDATION", `Estado de pipeline inválido en ${routeLabel(c)}: ${pipeline_status}`, { + route: routeLabel(c), + worker_id: id, + pipeline_status, + }); + } const db = c.get("db"); const tid = tenantScope(c.get("user")); - const prev = await db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined; - if (pipeline_status === "baja") { - await db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id); - } else { - // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS. - if (prev?.status === "baja") { - await db.prepare( - "UPDATE workers SET status='activo', last_rehire_at=current_date WHERE id=?", - ).run(id); - } else { - await db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id); - } - await refreshPipeline(db, id, tid); - } - const worker = await db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id); - return c.json({ ok: true, worker }); + const env = await callCoreFn( + db, + "core.fn_worker_set_pipeline", + { id, tenant_id: tid, pipeline_status }, + { route: routeLabel(c) }, + ); + if (!env.ok) return respondRpc(c, env); + return respondRpc(c, { + ...env, + data: { ok: true, ...(env.data as Record) }, + }); }); app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { @@ -1015,20 +1055,35 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => { const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>(); const db = c.get("db"); const tid = tenantScope(c.get("user")); + const route = routeLabel(c); if (active === false) { - await db.prepare( - "UPDATE assignments SET active=false, end_date=current_date WHERE worker_id=? AND project_id=?", - ).run(id, project_id); - } else { - const project = await projectById(db, project_id); - if (!project) return c.json({ error: "Proyecto no encontrado" }, 404); - if (project.status !== "activo") { - return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400); - } - await assign(db, id, project_id); + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_unassign", + { worker_id: id, project_id, tenant_id: tid }, + { route }, + )); } - await refreshPipeline(db, id, tid); - return c.json({ ok: true }); + const project = await projectById(db, project_id); + if (!project) { + return respondApiError(c, "NOT_FOUND", `Proyecto id=${project_id} no encontrado en ${route}`, { + route, + project_id, + }); + } + if (project.status !== "activo") { + return respondApiError(c, "VALIDATION", `Solo se asigna personal a proyectos activos en ${route}`, { + route, + project_id, + status: project.status, + }); + } + return respondRpc(c, await callCoreFn( + db, + "core.fn_worker_assign", + { worker_id: id, project_id, tenant_id: tid }, + { route }, + )); }); app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => { @@ -1063,19 +1118,36 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const form = await c.req.formData(); const type = String(form.get("type") || ""); const file = form.get("file"); - if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); + if (!(file instanceof File) || !type) { + return respondApiError(c, "VALIDATION", `type y file son requeridos en ${routeLabel(c)}`, { + route: routeLabel(c), + worker_id: id, + }); + } const db = c.get("db"); - const exists = await db.prepare("SELECT id FROM workers WHERE id=?").get(id); - if (!exists) return c.json({ error: "No encontrado" }, 404); + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const workerEnv = await callCoreFn(db, "core.fn_worker_get", { id, tenant_id: tid }, { route }); + if (!workerEnv.ok) return respondRpc(c, workerEnv); - const policy = await db.prepare( - `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`, - ).get(type) as { + const catEnv = await callCoreFn<{ document_types?: Array> }>( + db, + "core.fn_catalogs", + {}, + { route }, + ); + const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as { validity_mode: string; requires_issued_at: boolean; requires_expires_at: boolean; } | undefined; - if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400); + if (!policy) { + return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, { + route, + worker_id: id, + type, + }); + } const issuedAt = String(form.get("issued_at") || "").trim() || null; const expiresAt = String(form.get("expires_at") || "").trim() || null; @@ -1084,19 +1156,19 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; if (policy.requires_issued_at && !issuedAt) { - return c.json({ error: "Indique la fecha de emisión del documento" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id }); } if (policy.requires_expires_at && !expiresAt) { - return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssCompanyId) { - return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "alta_imss" && !imssAltaAt) { - return c.json({ error: "Indique la fecha de alta IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de alta IMSS en ${route}`, { route, worker_id: id }); } if (type === "baja_imss" && !imssBajaAt) { - return c.json({ error: "Indique la fecha de baja IMSS" }, 400); + return respondApiError(c, "VALIDATION", `Indique la fecha de baja IMSS en ${route}`, { route, worker_id: id }); } const bytes = new Uint8Array(await file.arrayBuffer()); @@ -1109,9 +1181,9 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => { imss_baja_at: imssBajaAt, }); } catch (error) { - return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400); + const message = error instanceof Error ? error.message : "No se pudo guardar el documento"; + return respondApiError(c, "VALIDATION", message, { route, worker_id: id }); } - const tid = tenantScope(c.get("user")); return c.json({ ok: true, checklist: await checklistFor(db, id, tid), @@ -1123,15 +1195,14 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => { const workerId = Number(c.req.param("id")); const docId = Number(c.req.param("docId")); const db = c.get("db"); - const doc = await db.prepare( - "SELECT * FROM documents WHERE id=? AND worker_id=?", - ).get(docId, workerId) as { - storage_name: string; - iv: string; - mime: string; - original_name: string; - } | undefined; - if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const route = routeLabel(c); + const { doc, envelope } = await rpcDocumentForDownload( + db, + "core.fn_worker_document_get", + { worker_id: workerId, id: docId }, + route, + ); + if (!doc) return respondRpc(c, envelope); const enc = await getObject(workerDocKey(workerId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); c.header("Content-Type", "application/octet-stream"); @@ -1169,68 +1240,85 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => { const projectId = Number(c.req.param("id")); const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] })); const db = c.get("db"); + const user = c.get("user"); + const tid = tenantScope(user); + const route = routeLabel(c); const blocked = projectMustBe( await projectById(db, projectId), ["activo"], "Solo se generan gafetes de proyectos activos", ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); + if (blocked) { + const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION"; + return respondApiError(c, code, blocked.error, { route, project_id: projectId }); + } let ids = body.worker_ids ?? []; if (!ids.length) { - ids = (await db.prepare( - `SELECT w.id FROM workers w - JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = true - WHERE w.status = 'activo' AND w.needs_badge = true - AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=true)`, - ).all(projectId) as { id: number }[]).map((r) => r.id); + const listEnv = await callCoreFn<{ workers?: Array> }>( + db, + "core.fn_worker_list", + { tenant_id: tid, project_id: projectId, status: "activo" }, + { route }, + ); + if (!listEnv.ok) return respondRpc(c, listEnv); + ids = (listEnv.data?.workers ?? []) + .filter((w) => w.needs_badge && ["listo_gafete", "impreso", "activo"].includes(String(w.pipeline_status))) + .map((w) => Number(w.id)); + } + if (!ids.length) { + return respondApiError(c, "VALIDATION", `No hay personal activo con foto para imprimir en ${route}`, { + route, + project_id: projectId, + }); } - if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400); const bytes = await generateBadgePdf(db, projectId, ids); - const user = c.get("user"); - await db.prepare( - "INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) VALUES (?, 'done', ?, ?)", - ).run(projectId, user.id, user.display_name); - const jobId = await lastInsertId(db); - const path = await saveJobPdf(bytes, jobId); - await db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId); - const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)"); - const tid = tenantScope(user); + const createEnv = await callCoreFn( + db, + "core.fn_badge_job_create", + { + project_id: projectId, + worker_ids: ids, + created_by_id: user.id, + created_by_name: user.display_name, + }, + { route }, + ); + if (!createEnv.ok) return respondRpc(c, createEnv); + const jobId = Number((createEnv.data as { id?: number })?.id); + await saveJobPdf(bytes, jobId); for (const wid of ids) { - await ins.run(jobId, wid); await refreshPipeline(db, wid, tid); } - return c.json({ id: jobId, count: ids.length }); + return respondRpc(c, { + ...createEnv, + data: { ...(createEnv.data as Record), count: ids.length }, + }); }); app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => { const db = c.get("db"); - const jobs = await db.prepare( - `SELECT j.*, p.name AS project_name, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people, - (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=true) AS delivered - FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`, - ).all(); - return c.json({ jobs }); + return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const job = await db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id); - if (!job) return c.json({ error: "Job no encontrado" }, 404); - const people = await db.prepare( - `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p - JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`, - ).all(id); - return c.json({ job, people }); + return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) })); }); app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); - const job = await db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined; - if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404); - const bytes = await getObject(job.pdf_path); + const route = routeLabel(c); + const jobEnv = await callCoreFn<{ job?: { pdf_path?: string | null } }>( + db, + "core.fn_badge_job_get", + { job_id: id }, + { route }, + ); + if (!jobEnv.ok) return respondRpc(c, jobEnv); + const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id); + const bytes = await getObject(pdfPath); c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); return c.body(bytes.buffer as ArrayBuffer); @@ -1241,11 +1329,12 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) = const workerId = Number(c.req.param("workerId")); const { delivered } = await c.req.json<{ delivered: boolean }>(); const db = c.get("db"); - await db.prepare( - `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN now() ELSE NULL END - WHERE job_id=? AND worker_id=?`, - ).run(delivered, delivered, jobId, workerId); - return c.json({ ok: true }); + return respondRpc(c, await callCoreFn( + db, + "core.fn_badge_job_set_delivered", + { job_id: jobId, worker_id: workerId, delivered }, + { route: routeLabel(c) }, + )); }); registerPayrollRoutes(app); From b58791aea48306bbff8e3e0d7ee7581b6a127a92 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 00:01:57 +0000 Subject: [PATCH 10/11] refactor(core): finish RPC migration for budget, excel import, and docs - Replace remaining budget.ts prepare() with fn_budget_replace and fn_project_update - Migrate excel.ts findExisting, document store, assign, and catalogs to RPC - Add 015-rpc-documents.sql for project/company/worker document metadata - Document RPC envelope conventions in db/README.md - Add http_errors_test and front api-response composables Co-authored-by: alberto.martinez --- api/budget.ts | 86 +-- api/companies.ts | 234 ++---- api/db.ts | 265 +++---- api/excel.ts | 79 +- api/http_errors_test.ts | 15 + api/payroll.ts | 935 ++--------------------- db/README.md | 53 ++ db/core/changelog-master.xml | 10 + db/core/changesets/015-rpc-documents.sql | 164 ++++ web-panel/composables/api-response.ts | 59 ++ web-saas/composables/api-response.ts | 58 ++ 11 files changed, 643 insertions(+), 1315 deletions(-) create mode 100644 api/http_errors_test.ts create mode 100644 db/core/changesets/015-rpc-documents.sql create mode 100644 web-panel/composables/api-response.ts create mode 100644 web-saas/composables/api-response.ts diff --git a/api/budget.ts b/api/budget.ts index 4ef4c81..38eca1a 100644 --- a/api/budget.ts +++ b/api/budget.ts @@ -1,5 +1,6 @@ import * as XLSX from "xlsx"; import type { Db } from "./db.ts"; +import { callCoreFn, RpcCallError } from "./rpc.ts"; export const BUDGET_IVA = 0.16; @@ -642,17 +643,14 @@ function compareWbs(a: string, b: string) { } export async function listBudget(database: Db, projectId: number) { - const chapters = await database.prepare( - `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order - FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, - ).all(projectId) as ChapterRow[]; - const rawItems = await database.prepare( - `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code - FROM budget_items i - LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id - WHERE i.project_id = ? - ORDER BY COALESCE(ch.sort_order, 9999), i.sort_order, i.id`, - ).all(projectId) as (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[]; + const env = await callCoreFn<{ + chapters: ChapterRow[]; + items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[]; + totals: { subtotal: number; iva: number; total: number; item_count: number }; + }>(database, "core.fn_budget_list", { project_id: projectId }); + if (!env.ok) throw new RpcCallError(env); + const chapters = env.data?.chapters ?? []; + const rawItems = env.data?.items ?? []; const byParent = new Map(); const byId = new Map(); @@ -720,7 +718,7 @@ export async function listBudget(database: Db, projectId: number) { }); const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0); - const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; + const iva = env.data?.totals?.iva ?? Math.round(subtotal * BUDGET_IVA * 100) / 100; const chapterView = chapters.map((chapter) => { const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 }; const chain = ancestorsOf(chapter.id); @@ -744,44 +742,34 @@ export async function listBudget(database: Db, projectId: number) { }; } -async function insertChapter( - database: Db, - projectId: number, - parentId: number | null, - code: string, - name: string, - wbs: string, - sort: number, -): Promise { - await database.prepare( - "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", - ).run(projectId, parentId, code, name, wbs, sort); - return await database.lastInsertId(); -} - export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise { - await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); - await database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs)); - const ids = new Map(); let sort = 1; - for (const group of groups) { - const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; - const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); - ids.set(group.wbs, id); - sort++; - } + const chapters = groups.map((group) => ({ + wbs: group.wbs, + parent_wbs: group.parentWbs || null, + code: group.code, + name: group.name, + sort_order: sort++, + })); let itemSort = 1; - for (const item of parsed.items) { - const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; - const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; - await database.prepare( - `INSERT INTO budget_items - (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); - itemSort++; - } + const items = parsed.items.map((item) => ({ + chapter_wbs: item.parentWbs || null, + code: item.code, + description: item.description, + unit: item.unit, + quantity: item.quantity, + unit_price: item.unit_price, + amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100, + wbs: item.wbs, + sort_order: itemSort++, + })); + const env = await callCoreFn(database, "core.fn_budget_replace", { + project_id: projectId, + chapters, + items, + }); + if (!env.ok) throw new RpcCallError(env); } export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise { @@ -927,7 +915,11 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes: item_count: parsed.items.length, }; // El monto de contrato del proyecto refleja el subtotal del presupuesto importado. - await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); + const projectEnv = await callCoreFn(database, "core.fn_project_update", { + id: projectId, + contract_amount: subtotal, + }); + if (!projectEnv.ok) throw new RpcCallError(projectEnv); return { replaced: true, chapters: parsed.groups.length, diff --git a/api/companies.ts b/api/companies.ts index dae2f91..19a54b1 100644 --- a/api/companies.ts +++ b/api/companies.ts @@ -1,4 +1,7 @@ import type { Db } from "./db.ts"; +import { callCoreFn, RpcCallError } from "./rpc.ts"; +import type { RpcEnvelope } from "./http_errors.ts"; +import { mapRpcToStatus } from "./http_errors.ts"; export type Company = { id: number; @@ -60,43 +63,35 @@ function normRfc(value: unknown): string { return trimText(value).toUpperCase().replace(/\s+/g, ""); } +function envelopeError(env: RpcEnvelope): { error: string; status: number } { + return { error: env.message, status: mapRpcToStatus(String(env.code)) }; +} + export async function listCompanies(database: Db, tenantId?: number | null): Promise { - // El filtro tenant_id aquí es defensa adicional/legibilidad -- el - // aislamiento real ya lo garantiza Row Level Security sobre la conexión - // acotada por withCoreScope (ver db/core/changesets/005-rls.sql). - const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; - const params = tenantId != null ? [tenantId] : []; - return await database.prepare( - `SELECT c.*, p.code AS parent_code, p.name AS parent_name, - (SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count - FROM companies c - LEFT JOIN companies p ON p.id = c.parent_id - ${where} - ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`, - ).all(...params) as Company[]; + const env = await callCoreFn<{ companies: Company[] }>(database, "core.fn_company_list", { + tenant_id: tenantId ?? null, + }); + if (!env.ok) throw new RpcCallError(env); + return (env.data?.companies ?? []) as Company[]; } export async function companyById(database: Db, id: number): Promise { - return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as - | Company - | undefined; + const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get", { id }); + if (!env.ok) return undefined; + return env.data?.company as Company | undefined; } export async function companyByCode(database: Db, code: string): Promise { - return await database.prepare("SELECT * FROM companies WHERE code = ?").get( - normCompanyCode(code), - ) as Company | undefined; + const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get_by_code", { + code: normCompanyCode(code), + }); + if (!env.ok) return undefined; + return env.data?.company as Company | undefined; } export async function principalCompany(database: Db, tenantId?: number | null): Promise { - if (tenantId != null) { - return await database.prepare( - "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1", - ).get(tenantId) as Company | undefined; - } - return await database.prepare( - "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1", - ).get() as Company | undefined; + const companies = await listCompanies(database, tenantId); + return companies.find((c) => c.kind === "principal"); } export function normCompanyCode(value: string | null | undefined): string { @@ -114,13 +109,9 @@ export function companyCodeFromName(name: string): string { } export async function nextCompanyCode(database: Db, name: string): Promise { - const base = companyCodeFromName(name); - if (!await companyByCode(database, base)) return base; - const row = await database.prepare( - `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n - FROM companies WHERE code ~ '^EMP-[0-9]{4}'`, - ).get() as { n: number }; - return `EMP-${String(row.n).padStart(4, "0")}`; + const env = await callCoreFn<{ code: string }>(database, "core.fn_next_company_code", { name }); + if (!env.ok) throw new RpcCallError(env); + return String(env.data?.code ?? companyCodeFromName(name)); } export async function resolveCompany( @@ -186,72 +177,19 @@ export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName }; } -function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null { - return validateCompanyProfile(input, opts); -} - -function profileFromInput(input: CompanyProfileInput, fallbackName = "") { - return normalizeCompanyProfile(input, fallbackName); -} - export async function createSubcompany( database: Db, input: CompanyProfileInput, tenantId?: number | null, -): Promise<{ company?: Company; error?: string }> { - const profileErr = validateProfile(input, { requireLegal: true }); - if (profileErr) return { error: profileErr }; - const profile = profileFromInput(input); - if (!profile.name) return { error: "Nombre de empresa obligatorio" }; - - const principal = await principalCompany(database, tenantId); - if (!principal) return { error: "No hay empresa principal" }; - const parentId = input.parent_id ? Number(input.parent_id) : principal.id; - const parent = await companyById(database, parentId); - if (!parent) return { error: "Empresa padre no encontrada" }; - if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) { - return { error: "Empresa padre de otro tenant" }; - } - - let code = normCompanyCode(input.code); - if (!code) code = await nextCompanyCode(database, profile.name); - const codeErr = validateCompanyCode(code); - if (codeErr) return { error: codeErr }; - if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" }; - - const tid = tenantId ?? principal.tenant_id ?? null; - try { - await database.prepare( - `INSERT INTO companies ( - code, name, parent_id, kind, status, tenant_id, - registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo, - domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro - ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - code, - profile.name, - parent.id, - tid, - profile.registro_patronal, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - profile.email, - profile.representante_legal, - profile.giro, - ); - } catch (e) { - if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" }; - throw e; - } - return { company: await companyById(database, await database.lastInsertId()) }; +): Promise<{ company?: Company; error?: string; status?: number }> { + const profileErr = validateCompanyProfile(input, { requireLegal: true }); + if (profileErr) return { error: profileErr, status: 400 }; + const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_create", { + ...input, + tenant_id: tenantId ?? null, + }); + if (!env.ok) return envelopeError(env); + return { company: env.data?.company as Company }; } export async function updateCompany( @@ -260,100 +198,14 @@ export async function updateCompany( input: CompanyProfileInput, tenantId?: number | null, ): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> { - const current = await companyById(database, id); - // Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión - // solo ve app.tenant_id); este chequeo explícito es defensa adicional y - // un mensaje de error más claro que un 404 "silencioso". - if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) { - return { error: "Empresa no encontrada", status: 404 }; + const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_update", { + id, + tenant_id: tenantId ?? null, + ...input, + }); + if (!env.ok) { + const st = mapRpcToStatus(String(env.code)); + return { error: env.message, status: st === 404 ? 404 : 400 }; } - - const profileErr = validateProfile(input); - if (profileErr) return { error: profileErr, status: 400 }; - - const merged: CompanyProfileInput = { - name: input.name !== undefined ? input.name : current.name, - code: input.code !== undefined ? input.code : current.code, - status: input.status !== undefined ? input.status : current.status, - registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal, - razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social, - nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial, - rfc: input.rfc !== undefined ? input.rfc : current.rfc, - regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal, - clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo, - domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal, - codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal, - ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad, - estado: input.estado !== undefined ? input.estado : current.estado, - telefono: input.telefono !== undefined ? input.telefono : current.telefono, - email: input.email !== undefined ? input.email : current.email, - representante_legal: input.representante_legal !== undefined - ? input.representante_legal - : current.representante_legal, - giro: input.giro !== undefined ? input.giro : current.giro, - }; - const profile = profileFromInput(merged, current.name); - if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 }; - - let code = current.code; - if (input.code !== undefined) { - code = normCompanyCode(input.code); - const codeErr = validateCompanyCode(code); - if (codeErr) return { error: codeErr, status: 400 }; - const clash = await companyByCode(database, code); - if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 }; - } - - let status = current.status; - if (input.status !== undefined) { - if (!["activo", "inactivo"].includes(input.status)) { - return { error: "Estatus debe ser activo o inactivo", status: 400 }; - } - if (current.kind === "principal" && input.status === "inactivo") { - return { error: "La empresa principal no se puede inactivar", status: 400 }; - } - status = input.status as Company["status"]; - } - - try { - await database.prepare( - `UPDATE companies SET - name = ?, code = ?, status = ?, - registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?, - domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?, - representante_legal = ?, giro = ? - WHERE id = ?`, - ).run( - profile.name, - code, - status, - profile.registro_patronal, - profile.razon_social, - profile.nombre_comercial, - profile.rfc, - profile.regimen_fiscal, - profile.clase_riesgo, - profile.domicilio_fiscal, - profile.codigo_postal, - profile.ciudad, - profile.estado, - profile.telefono, - profile.email, - profile.representante_legal, - profile.giro, - id, - ); - } catch (e) { - if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 }; - throw e; - } - if (code !== current.code) { - await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id); - } - return { company: await companyById(database, id) }; -} - -/** Postgres error code 23505 = unique_violation. */ -function isUniqueViolation(e: unknown): boolean { - return !!e && typeof e === "object" && (e as { code?: string }).code === "23505"; + return { company: env.data?.company as Company }; } diff --git a/api/db.ts b/api/db.ts index 51cf83a..72e3e65 100644 --- a/api/db.ts +++ b/api/db.ts @@ -1,22 +1,15 @@ import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; import { config } from "./config.ts"; +import { callCoreFn, RpcCallError } from "./rpc.ts"; import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; export type Db = PgDb; -/** - * panels_product, esquema core: negocio (empresas, personal, obras, - * expedientes, presupuesto, nómina, gafetes). Aislado de `iam` a propósito - * -- este módulo no debe importar iam_db.ts. - */ - let corePool: postgres.Sql | null = null; let coreOwnerPool: postgres.Sql | null = null; function getCorePool(): postgres.Sql { - if (!corePool) { - corePool = createPool(config.databaseUrlCore, { max: 20 }); - } + if (!corePool) corePool = createPool(config.databaseUrlCore, { max: 20 }); return corePool; } @@ -31,10 +24,6 @@ export async function pingCoreDb(): Promise { await getCorePool()`SELECT 1`; } -/** El único camino "normal" para atender un request: abre una transacción - * con app.tenant_id fijado (Row Level Security), y la cierra sola al - * terminar `fn` (commit) o al lanzar (rollback). Ver api/pg.ts#withTenant - * y las políticas en db/core/changesets/005-rls.sql. */ export async function withCoreTenant( tenantId: number | null, fn: (db: Db) => Promise, @@ -42,10 +31,6 @@ export async function withCoreTenant( return await withTenant(getCorePool(), tenantId, fn); } -/** Conexión SIN RLS (rol _owner, dueño de las tablas) -- reservada para - * scripts (bootstrap, ETL) y para resoluciones internas puntuales que - * necesitan cruzar tenants a propósito (ej. auth.ts al enriquecer el login - * con el nombre de la empresa). NO usar en handlers de request normales. */ export async function getCoreDb(): Promise { return new PgDb(getCoreOwnerPool()); } @@ -76,17 +61,15 @@ export function isProjectStatus(value: string): value is ProjectStatus { } export async function nextProjectCode(database: Db): Promise { - const row = await database.prepare( - `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n - FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`, - ).get() as { n: number }; - return `PRY-${String(row.n).padStart(4, "0")}`; + const env = await callCoreFn<{ code: string }>(database, "core.fn_next_project_code", {}); + if (!env.ok) throw new RpcCallError(env); + return String(env.data?.code ?? "PRY-0001"); } export async function projectById(database: Db, id: number) { - return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as - | { id: number; code: string; name: string; status: string } - | undefined; + const env = await callCoreFn<{ project: Record }>(database, "core.fn_project_get", { id }); + if (!env.ok) return undefined; + return env.data?.project as { id: number; code: string; name: string; status: string } | undefined; } export function projectMustBe( @@ -102,104 +85,87 @@ export function projectMustBe( } export async function workerImssContext(database: Db, workerId: number): Promise { - const w = await database.prepare( - "SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?", - ).get(workerId) as { - imss_status: ImssStatus; - imss_alta_at: string | null; - last_rehire_at: string | null; - } | undefined; - const altaDoc = await database.prepare( - `SELECT imss_alta_at, uploaded_at FROM documents - WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true - LIMIT 1`, - ).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined; - return { - imss_status: (w?.imss_status as ImssStatus) || "sin_alta", - last_rehire_at: w?.last_rehire_at ?? null, - current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null, - }; + const env = await callCoreFn<{ imss_context: WorkerImssContext }>( + database, "core.fn_worker_checklist_raw", { worker_id: workerId }, + ); + if (env.ok && env.data?.imss_context) return env.data.imss_context; + return { imss_status: "sin_alta" as ImssStatus, last_rehire_at: null, current_alta_at: null }; } -/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor. - * tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular - * "hoy" de forma consistente con nómina -- antes esta función usaba - * `new Date()` crudo (hora del servidor/UTC) sin relación con PAYROLL_TZ. */ export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) { const tz = await tenantTimezone(database, tenantId); const today = todayInTimezone(tz); - const types = await database.prepare( - `SELECT code, label, required, validity_mode, freshness_days, - requires_issued_at, requires_expires_at, category - FROM document_types`, - ).all() as { - code: string; - label: string; - required: boolean; - validity_mode: string; - freshness_days: number | null; - requires_issued_at: boolean; - requires_expires_at: boolean; - category: string; - }[]; - const currentDocs = await database.prepare( - `SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at - FROM documents WHERE worker_id = ? AND is_current = true`, - ).all(workerId) as { - type_code: string; - issued_at: string | null; - expires_at: string | null; - uploaded_at: string; - imss_alta_at: string | null; - }[]; - const byType = new Map(currentDocs.map((d) => [d.type_code, d])); - const ctx = await workerImssContext(database, workerId); - - return { - ctx, - freshness_required: freshnessRequired(ctx), - items: types.map((t) => { - const policy = { - code: t.code, - label: t.label, - required: !!t.required, - validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry", - freshness_days: t.freshness_days, - requires_issued_at: !!t.requires_issued_at, - requires_expires_at: !!t.requires_expires_at, - }; - const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today); - return { - code: t.code, - label: t.label, - required: !!t.required, - validity_mode: policy.validity_mode, - freshness_days: t.freshness_days, - requires_issued_at: policy.requires_issued_at, - requires_expires_at: policy.requires_expires_at, - category: t.category || "otro", - present: evaled.present, - valid: evaled.valid, - validity_status: evaled.validity_status, - issued_at: evaled.issued_at, - expires_at: evaled.expires_at, - }; - }), - }; + const env = await callCoreFn<{ + items?: Array>; + ctx?: WorkerImssContext; + imss_context?: WorkerImssContext; + freshness_required?: boolean; + document_types?: Array>; + current_documents?: Array>; + }>(database, "core.fn_worker_checklist_raw", { worker_id: workerId, tenant_id: tenantId }); + if (env.ok && env.data?.items) { + return { + ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext, + freshness_required: Boolean(env.data.freshness_required), + items: env.data.items, + }; + } + const types = env.data?.document_types ?? []; + const currentDocs = env.data?.current_documents ?? []; + const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext; + const byType = new Map(currentDocs.map((d) => [String(d.type_code), d])); + const items = types.map((t) => { + const policy = { + code: String(t.code), + label: String(t.label), + required: !!t.required, + validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry", + freshness_days: t.freshness_days as number | null, + requires_issued_at: !!t.requires_issued_at, + requires_expires_at: !!t.requires_expires_at, + }; + const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today); + return { + code: policy.code, + label: policy.label, + required: policy.required, + validity_mode: policy.validity_mode, + freshness_days: t.freshness_days, + requires_issued_at: policy.requires_issued_at, + requires_expires_at: policy.requires_expires_at, + category: String(t.category || "otro"), + present: evaled.present, + valid: evaled.valid, + validity_status: evaled.validity_status, + issued_at: evaled.issued_at, + expires_at: evaled.expires_at, + }; + }); + return { ctx, freshness_required: freshnessRequired(ctx), items }; } export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) { const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId); const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); - const inProject = !!await database.prepare( - "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", - ).get(workerId); + const env = await callCoreFn<{ workers: Array> }>( + database, "core.fn_worker_list", { tenant_id: tenantId }, + ); + const row = env.ok ? (env.data?.workers ?? []).find((w) => Number(w.id) === workerId) : undefined; + if (row) { + return { + imss_status: String(row.imss_status ?? ctx.imss_status), + freshness_required: Boolean(row.freshness_required ?? needFresh), + imss_ready: Boolean(row.imss_ready), + in_project_without_imss: Boolean(row.in_project_without_imss), + expediente_ok: Boolean(row.expediente_ok), + }; + } const hasImss = ctx.imss_status === "alta"; return { imss_status: ctx.imss_status, freshness_required: needFresh, imss_ready: requiredOk && !hasImss, - in_project_without_imss: inProject && !hasImss, + in_project_without_imss: false, expediente_ok: requiredOk, }; } @@ -209,82 +175,37 @@ export async function checklistFor(database: Db, workerId: number, tenantId: num } export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise { - const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as - | { status: string } - | undefined; - if (!w) return; - if (w.status === "baja") { - await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId); - return; - } - const items = await checklistFor(database, workerId, tenantId); - const photo = items.find((i) => i.code === "foto"); - const photoOk = photo?.present && photo?.valid; - const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid); - const printed = await database.prepare( - `SELECT 1 FROM badge_job_people p - JOIN badge_jobs j ON j.id = p.job_id - WHERE p.worker_id = ? LIMIT 1`, - ).get(workerId); - let pipeline = "incompleto"; - if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete"; - const assigned = await database.prepare( - "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", - ).get(workerId); - if (pipeline !== "incompleto" && assigned) { - pipeline = printed ? "activo" : "listo_gafete"; - } - await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId); + const env = await callCoreFn(database, "core.fn_worker_refresh_pipeline", { + worker_id: workerId, tenant_id: tenantId, + }); + if (!env.ok) throw new RpcCallError(env); } export async function projectChecklistFor(database: Db, projectId: number) { - const types = await database.prepare( - "SELECT code, label, required, category FROM project_document_types", - ).all() as { code: string; label: string; required: boolean; category: string }[]; - const current = await database.prepare( - `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`, - ).all(projectId) as { type_code: string }[]; - const have = new Set(current.map((c) => c.type_code)); - return types.map((t) => ({ - code: t.code, - label: t.label, - required: !!t.required, - category: t.category || "otro", - present: have.has(t.code), - })); + const env = await callCoreFn<{ checklist: unknown[] }>( + database, "core.fn_project_checklist", { project_id: projectId }, + ); + if (!env.ok) throw new RpcCallError(env); + return env.data?.checklist ?? []; } export async function companyChecklistFor(database: Db, companyId: number) { - const types = await database.prepare( - "SELECT code, label, required, category FROM company_document_types", - ).all() as { code: string; label: string; required: boolean; category: string }[]; - const current = await database.prepare( - `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`, - ).all(companyId) as { type_code: string }[]; - const have = new Set(current.map((c) => c.type_code)); - return types.map((t) => ({ - code: t.code, - label: t.label, - required: !!t.required, - category: t.category || "otro", - present: have.has(t.code), - })); + const env = await callCoreFn<{ checklist: unknown[] }>( + database, "core.fn_company_checklist", { company_id: companyId }, + ); + if (!env.ok) throw new RpcCallError(env); + return env.data?.checklist ?? []; } -/** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ - * hardcodeado. Con default sensato si el tenant no la configuró. */ export async function tenantTimezone(database: Db, tenantId: number | null): Promise { if (tenantId == null) return "America/Mexico_City"; - const row = await database.prepare( - "SELECT timezone FROM tenant_settings WHERE tenant_id = ?", - ).get(tenantId) as { timezone: string } | undefined; - return row?.timezone || "America/Mexico_City"; + const env = await callCoreFn<{ timezone: string }>( + database, "core.fn_tenant_timezone_get", { tenant_id: tenantId }, + ); + if (!env.ok) return "America/Mexico_City"; + return String(env.data?.timezone ?? "America/Mexico_City"); } -/** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por - * nómina y por validación de vigencia de documentos -- antes eran dos - * nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es - * una sola función parametrizada por tenant. */ export function todayInTimezone(timezone: string, now = new Date()): string { return now.toLocaleDateString("en-CA", { timeZone: timezone }); } diff --git a/api/excel.ts b/api/excel.ts index e31b617..cf37498 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -191,12 +191,12 @@ export function buildImportTemplate(companies: { code: string; name: string }[] } async function findExisting(db: Db, curp: string, rfc: string, nss: string) { - return await db.prepare( - `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers - WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, - ).get(curp, rfc, nss) as - | { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } - | undefined; + const env = await callCoreFn<{ + worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null; + found?: boolean; + }>(db, "core.fn_worker_find_existing", { curp, rfc, nss }); + if (!env.ok || !env.data?.found) return undefined; + return env.data.worker; } export async function storeDocument( @@ -256,21 +256,23 @@ export async function storeProjectDocument( bytes: Uint8Array, userId: number | null, ) { - const allowed = await db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type); - if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; await putObject(projectDocKey(projectId, storage), cipher); - await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run( - projectId, - type, - ); - await db.prepare( - `INSERT INTO project_documents - (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, - ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); + const env = await callCoreFn(db, "core.fn_project_document_store", { + project_id: projectId, + type_code: type, + original_name: filename, + mime, + size_bytes: bytes.byteLength, + sha256: hash, + iv, + storage_name: storage, + uploaded_by_id: userId, + uploaded_by_name: "", + }); + if (!env.ok) throw new RpcCallError(env); } export async function storeCompanyDocument( @@ -282,21 +284,23 @@ export async function storeCompanyDocument( bytes: Uint8Array, userId: number | null, ) { - const allowed = await db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type); - if (!allowed) throw new Error("Tipo de documento no válido"); const { iv, cipher } = await encryptBytes(bytes); const hash = await sha256Hex(bytes); const storage = `${crypto.randomUUID()}.enc`; await putObject(companyDocKey(companyId, storage), cipher); - await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run( - companyId, - type, - ); - await db.prepare( - `INSERT INTO company_documents - (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, - ).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); + const env = await callCoreFn(db, "core.fn_company_document_store", { + company_id: companyId, + type_code: type, + original_name: filename, + mime, + size_bytes: bytes.byteLength, + sha256: hash, + iv, + storage_name: storage, + uploaded_by_id: userId, + uploaded_by_name: "", + }); + if (!env.ok) throw new RpcCallError(env); } async function fetchPhoto(url: string): Promise { @@ -385,14 +389,11 @@ async function upsertWorker( } async function assign(db: Db, workerId: number, projectId: number) { - await db.prepare( - `INSERT INTO assignments (worker_id, project_id, active, start_date) - VALUES (?, ?, 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`, - ).run(workerId, projectId); + const env = await callCoreFn(db, "core.fn_worker_assign", { + worker_id: workerId, + project_id: projectId, + }); + if (!env.ok) throw new RpcCallError(env); } export async function importExcel( @@ -402,9 +403,9 @@ export async function importExcel( userId: number | null, ): Promise { const wb = XLSX.read(bytes, { type: "array" }); - const risks = new Set( - (await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), - ); + const catEnv = await callCoreFn<{ risks?: { code: string }[] }>(db, "core.fn_catalogs", {}); + if (!catEnv.ok) throw new RpcCallError(catEnv); + const risks = new Set((catEnv.data?.risks ?? []).map((r) => r.code)); const report: ImportReport = { inserted: 0, existed: 0, diff --git a/api/http_errors_test.ts b/api/http_errors_test.ts new file mode 100644 index 0000000..090926a --- /dev/null +++ b/api/http_errors_test.ts @@ -0,0 +1,15 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { isGenericMessage, mapRpcToStatus } from "./http_errors.ts"; + +Deno.test("mapRpcToStatus maps business codes to HTTP", () => { + assertEquals(mapRpcToStatus("OK"), 200); + assertEquals(mapRpcToStatus("CREATED"), 201); + assertEquals(mapRpcToStatus("NOT_FOUND"), 404); + assertEquals(mapRpcToStatus("CONFLICT"), 409); + assertEquals(mapRpcToStatus("INTERNAL"), 500); +}); + +Deno.test("isGenericMessage rejects vague messages", () => { + assertEquals(isGenericMessage("Error interno"), true); + assertEquals(isGenericMessage("No se encontró la empresa con id=5"), false); +}); diff --git a/api/payroll.ts b/api/payroll.ts index 62f34d6..4698762 100644 --- a/api/payroll.ts +++ b/api/payroll.ts @@ -117,921 +117,124 @@ export function loanConditionLabel( return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`; } +import { callCoreFn } from "./rpc.ts"; +import type { RpcEnvelope } from "./http_errors.ts"; +import { mapRpcToStatus } from "./http_errors.ts"; + +function throwFromRpc(env: RpcEnvelope): never { + const e = new Error(env.message) as Error & { status?: number }; + e.status = mapRpcToStatus(String(env.code)); + throw e; +} + export async function getSettings(db: Db, tenantId: number): Promise { - await seedDefaults(db, tenantId); - return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings; + const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_get", { tenant_id: tenantId }); + if (!env.ok) throwFromRpc(env); + return env.data!.settings; } -export async function saveSettings( - db: Db, - tenantId: number, - patch: Partial, -): Promise { - const cur = await getSettings(db, tenantId); - const next = { - loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled, - loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct, - loan_small_max: patch.loan_small_max ?? cur.loan_small_max, - }; - await db.prepare( - `UPDATE payroll_settings - SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=? - WHERE tenant_id=?`, - ).run( - Boolean(next.loan_commission_enabled), - Number(next.loan_commission_pct) || 0, - Number(next.loan_small_max) || 0, - tenantId, - ); - return await getSettings(db, tenantId); +export async function saveSettings(db: Db, tenantId: number, patch: Partial): Promise { + const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_save", { tenant_id: tenantId, ...patch }); + if (!env.ok) throwFromRpc(env); + return env.data!.settings; } -export async function seedDefaults(db: Db, tenantId: number): Promise { - await db.prepare( - `INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max) - VALUES (?, true, 10, 500) - ON CONFLICT(tenant_id) DO NOTHING`, - ).run(tenantId); - const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number }; - if (Number(n.n) === 0) { - const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)"); - for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label); - } -} +export async function seedDefaults(db: Db, tenantId: number): Promise { await getSettings(db, tenantId); } export async function listUnits(db: Db, tenantId: number) { - await seedDefaults(db, tenantId); - return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId); + const env = await callCoreFn<{ units: unknown[] }>(db, "core.fn_payroll_unit_list", { tenant_id: tenantId }); + if (!env.ok) throwFromRpc(env); + return env.data?.units ?? []; } export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise { - const c = code.trim().toLowerCase().replace(/\s+/g, ""); - const l = label.trim() || c; - if (!c) throw new Error("Unidad requerida"); - await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l); - return await db.lastInsertId(); + const env = await callCoreFn<{ id: number }>(db, "core.fn_payroll_unit_add", { tenant_id: tenantId, code, label }); + if (!env.ok) throwFromRpc(env); + return Number(env.data?.id); } -export function firstDueForLoan(opts: { - delivered: number; - smallMax: number; - plan: "single" | "installments"; - grantIso: string; - weekAssembled: boolean; -}): string { - const small = opts.delivered <= opts.smallMax && opts.plan === "single"; - if (small) return nextSaturdayAfterThisWeek(opts.grantIso); - if (opts.weekAssembled) return nextSaturdayAfterThisWeek(opts.grantIso); - return thisSaturday(opts.grantIso); +export async function createLoan(db: Db, tenantId: number, body: Record): Promise<{ id: number }> { + const env = await callCoreFn<{ id: number }>(db, "core.fn_loan_create", { tenant_id: tenantId, ...body }); + if (!env.ok) throwFromRpc(env); + return { id: Number(env.data?.id) }; } -export async function createLoan( - db: Db, - tenantId: number, - body: { - worker_id: number; - delivered: number; - commission_enabled?: boolean; - commission_pct?: number; - plan?: "single" | "installments"; - installments_n?: number; - note?: string; - grantIso?: string; - }, -): Promise<{ id: number }> { - const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id); - if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 }); - const settings = await getSettings(db, tenantId); - const delivered = roundMoney(body.delivered); - if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 }); - const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled); - const pct = enabled ? Number(body.commission_pct ?? settings.loan_commission_pct) || 0 : 0; - const comm = commissionAmount(delivered, pct); - const balance = roundMoney(delivered + comm); - const small = delivered <= settings.loan_small_max; - let plan = body.plan ?? "single"; - let installments = Math.max(1, Math.floor(body.installments_n ?? 1)); - if (small) { - plan = "single"; - installments = 1; - } - if (plan === "installments" && installments < 2) installments = 2; - if (plan === "single") installments = 1; - const weekly = roundMoney(balance / installments); - const grantIso = body.grantIso ?? await resolveToday(db, tenantId); - const week = weekContaining(grantIso); - const current = await db.prepare( - "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", - ).get(tenantId, week.weekStart) as { id: number; status: string } | undefined; - const assembled = current?.status === "assembled" || current?.status === "paid"; - const firstDue = firstDueForLoan({ - delivered, - smallMax: settings.loan_small_max, - plan, - grantIso, - weekAssembled: assembled && weekdayMon0(grantIso) >= 4, - }); - await db.prepare( - `INSERT INTO loans - (worker_id, amount, delivered, balance, weekly_payment, note, - commission_pct, commission_amount, plan, installments_n, first_due) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - body.worker_id, - delivered, - delivered, - balance, - weekly, - body.note ?? null, - pct, - comm, - plan, - installments, - firstDue, - ); - const id = await db.lastInsertId(); - if (current && current.status !== "paid") await recalcWeek(db, current.id); - return { id }; -} - -export async function presentOnOtherProject( - db: Db, - workerId: number, - workDate: string, - exceptProjectId?: number, -): Promise<{ project_id: number; project_name: string } | null> { - const row = await db.prepare( - `SELECT a.project_id, p.name AS project_name - FROM attendance a JOIN projects p ON p.id=a.project_id - WHERE a.worker_id=? AND a.work_date=? AND a.present=true - AND a.project_id != ?`, - ).get(workerId, workDate, exceptProjectId ?? 0) as - | { project_id: number; project_name: string } - | undefined; - return row ?? null; -} - -export async function setAttendance( - db: Db, - tenantId: number, - body: { project_id: number; worker_id: number; work_date: string; present: boolean }, -): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> { - if (body.present) { - const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id); - if (other) { - return { - error: `Ese día ya está marcado en ${other.project_name}`, - status: 409, - other, - }; - } - } - await db.prepare( - `INSERT INTO attendance (worker_id, project_id, work_date, present) - VALUES (?, ?, ?, ?) - ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`, - ).run(body.worker_id, body.project_id, body.work_date, body.present); - const { weekStart } = weekContaining(body.work_date); - const week = await db.prepare( - "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", - ).get(tenantId, weekStart) as { id: number; status: string } | undefined; - if (week && week.status !== "paid") await recalcWeek(db, week.id); - return { ok: true }; +export async function setAttendance(db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }) { + const env = await callCoreFn(db, "core.fn_attendance_set", { tenant_id: tenantId, ...body }); + if (!env.ok) return { error: env.message, status: mapRpcToStatus(String(env.code)), other: (env.data as { other?: unknown })?.other }; + return { ok: true as const }; } export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise { - await seedDefaults(db, tenantId); - const { weekEnd } = weekContaining(weekStart); - const start = weekContaining(weekStart).weekStart; - let week = await db.prepare( - "SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?", - ).get(tenantId, start) as { id: number } | undefined; - if (!week) { - await db.prepare( - `INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id) - VALUES (?, ?, 'draft', ?)`, - ).run(start, weekEnd, tenantId); - week = { id: await db.lastInsertId() }; - } - const weekId = week.id; - const destajo = destajoPeriodBounds(start); - const period = await db.prepare( - "SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?", - ).get(tenantId, destajo.periodEnd) as { id: number } | undefined; - if (!period) { - await db.prepare( - `INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id) - VALUES (?, ?, ?, ?)`, - ).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId); - } - const projects = await db.prepare( - `SELECT id FROM projects - WHERE status IN ('activo', 'pausado') - AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL) - ORDER BY name`, - ).all(tenantId, tenantId) as { id: number }[]; - const hasSheet = db.prepare( - `SELECT id FROM payroll_sheets - WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`, - ); - const insSheet = db.prepare( - `INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`, - ); - const ensureSheet = async (kind: string, projectId: number | null) => { - if (await hasSheet.get(weekId, kind, projectId)) return; - await insSheet.run(weekId, kind, projectId); - }; - for (const p of projects) { - await ensureSheet("obra", p.id); - await ensureSheet("destajo", p.id); - } - await ensureSheet("admin", null); - return weekId; -} - -async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) { - return await db.prepare( - `SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, 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=? 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=? AND att.work_date BETWEEN ? AND ? - ) - 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=? AND s.kind='obra' AND s.week_id=? - ) - ) - ORDER BY w.last_name_p, w.first_name`, - ).all(projectId, projectId, weekStart, weekEnd, projectId, weekId) as { - id: number; - first_name: string; - last_name_p: string; - position: string; - daily_wage: number; - work_type: string; - }[]; -} - -async function sheetLine( - db: Db, - sheetId: number, - workerId: number, - destajoCutLineId: number | null, -): Promise { - const existing = await db.prepare( - destajoCutLineId - ? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?" - : "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL", - ).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined; - if (existing) return existing.id; - await db.prepare( - `INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id) - VALUES (?, ?, ?)`, - ).run(sheetId, workerId, destajoCutLineId); - return await db.lastInsertId(); -} - -async function dueLoansForSaturday(db: Db, workerId: number, saturday: string): Promise { - return await db.prepare( - `SELECT * FROM loans - WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ? - ORDER BY first_due, id`, - ).all(workerId, saturday) as LoanRow[]; -} - -async function paidInstallments(db: Db, loanId: number): Promise { - const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number }; - return Number(row.n); + const env = await callCoreFn<{ week_id: number }>(db, "core.fn_payroll_week_ensure", { tenant_id: tenantId, week_start: weekStart }); + if (!env.ok) throwFromRpc(env); + return Number(env.data?.week_id); } export async function recalcWeek(db: Db, weekId: number): Promise { - const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { - id: number; - week_start: string; - week_end: string; - status: string; - required_net: number; - tenant_id: number; - } | undefined; - if (!week || week.status === "paid") return; - const saturday = week.week_end; - const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as { - id: number; - kind: string; - project_id: number | null; - }[]; - - await db.prepare("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=?)") - .run(weekId); - - for (const sheet of sheets) { - if (sheet.kind === "obra" && sheet.project_id) { - const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId); - for (const w of roster) { - const lineId = await sheetLine(db, sheet.id, w.id, null); - const att = await db.prepare( - `SELECT COUNT(*) AS n FROM attendance - WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`, - ).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number }; - const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number }; - const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage; - const gross = roundMoney(Number(att.n) * wage); - await db.prepare( - `UPDATE payroll_week_lines - SET days=?, daily_wage=?, amount=?, gross=?, payable_net=? - WHERE id=?`, - ).run(Number(att.n), wage, gross, gross, gross, lineId); - } - } - - if (sheet.kind === "destajo" && sheet.project_id) { - const period = await db.prepare( - "SELECT id FROM destajo_periods WHERE week_id=?", - ).get(weekId) as { id: number } | undefined; - if (!period) continue; - const cuts = await db.prepare( - `SELECT c.*, 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=? AND j.project_id=?`, - ).all(period.id, sheet.project_id) as { - id: number; - worker_id: number; - qty_planned: number; - qty_actual: number; - qty_extra: number; - unit_price: number; - unit_code: string; - concepto: string; - }[]; - const keep = new Set(); - for (const cut of cuts) { - const lineId = await sheetLine(db, sheet.id, cut.worker_id, cut.id); - keep.add(lineId); - const payableQty = roundMoney( - week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0 - ? cut.qty_planned - : cut.qty_actual + cut.qty_extra, - ); - const gross = roundMoney(payableQty * cut.unit_price); - const requiredGross = roundMoney(cut.qty_planned * cut.unit_price); - await db.prepare( - `UPDATE payroll_week_lines - SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?, - unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=? - WHERE id=?`, - ).run( - cut.id, - cut.qty_planned, - cut.qty_actual, - cut.qty_extra, - cut.unit_price, - cut.unit_code, - cut.concepto, - requiredGross, - gross, - gross, - lineId, - ); - } - const stale = await db.prepare( - "SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?", - ).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[]; - for (const row of stale) { - if (!keep.has(row.id)) await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id); - } - } - - if (sheet.kind === "admin") { - const lines = await db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as { - id: number; - amount: number; - discounts: number; - }[]; - for (const line of lines) { - const gross = roundMoney(line.amount); - await db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run( - gross, - roundMoney(gross - line.discounts), - line.id, - ); - } - } - } - - const allLines = await db.prepare( - `SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net - FROM payroll_week_lines l - JOIN payroll_sheets s ON s.id=l.sheet_id - WHERE s.week_id=? - ORDER BY l.gross DESC, l.id`, - ).all(weekId) as { id: number; worker_id: number; gross: number; discounts: number; payable_net: number }[]; - - const byWorker = new Map(); - for (const line of allLines) { - const list = byWorker.get(line.worker_id) ?? []; - list.push(line); - byWorker.set(line.worker_id, list); - } - - for (const [workerId, lines] of byWorker) { - const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0)); - const loans = await dueLoansForSaturday(db, workerId, saturday); - let remainingNet = Math.max(0, gross); - let placed = false; - for (const loan of loans) { - if (remainingNet <= 0) break; - const n = await paidInstallments(db, loan.id) + 1; - const isLast = n >= (loan.installments_n || 1); - const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance); - const take = roundMoney(Math.min(want, remainingNet)); - if (take <= 0) continue; - remainingNet = roundMoney(remainingNet - take); - if (!placed) { - const host = lines[0]; - const label = loanConditionLabel(loan, take, n); - await db.prepare( - `UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`, - ).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id); - placed = true; - } - break; - } - if (!placed) { - for (const line of lines) { - await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( - roundMoney(line.gross - line.discounts), - line.id, - ); - } - } else { - for (const line of lines.slice(1)) { - await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run( - roundMoney(line.gross - line.discounts), - line.id, - ); - } - } - } - - const tot = await db.prepare( - `SELECT COALESCE(SUM(l.payable_net), 0) AS n - FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id - WHERE s.week_id=?`, - ).get(weekId) as { n: number }; - await db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId); - if (week.status === "draft") { - await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId); - await db.prepare( - `UPDATE payroll_week_lines SET required_net=payable_net - WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`, - ).run(weekId); - } -} - -async function prefillThuSat(db: Db, projectId: number, weekStart: string, workerId: number): Promise { - for (const offset of [3, 4, 5]) { - const day = addDays(weekStart, offset); - if (await presentOnOtherProject(db, workerId, day, projectId)) continue; - const existing = await db.prepare( - "SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?", - ).get(workerId, projectId, day) as { id: number; present: boolean } | undefined; - if (existing) continue; - await db.prepare( - "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)", - ).run(workerId, projectId, day); - } + const env = await callCoreFn(db, "core.fn_payroll_week_recalc", { week_id: weekId }); + if (!env.ok) throwFromRpc(env); } export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise { - const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { - id: number; - week_start: string; - status: string; - tenant_id: number; - } | undefined; - if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 }); - if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 }); - if (!canAssembleWeek(week.week_start, nowIso)) { - throw Object.assign( - new Error("La nómina se arma el jueves o después. Aún no es jueves de esta semana."), - { status: 400 }, - ); - } - const sheets = await db.prepare( - "SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'", - ).all(weekId) as { id: number; project_id: number | null }[]; - for (const sheet of sheets) { - if (!sheet.project_id) continue; - const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId); - for (const w of roster) { - const assigned = await db.prepare( - "SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true", - ).get(w.id, sheet.project_id) as { ok: number } | undefined; - if (!assigned) continue; - if (w.work_type !== "N") { - const hasLine = await db.prepare( - "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?", - ).get(sheet.id, w.id); - if (!hasLine) continue; - } - await prefillThuSat(db, sheet.project_id, week.week_start, w.id); - } - } - const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; - if (period) { - await db.prepare( - `UPDATE destajo_cut_lines SET qty_actual = qty_planned - WHERE period_id=? AND qty_actual=0 AND qty_planned>0`, - ).run(period.id); - } - await db.prepare( - "UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?", - ).run(weekId); - await recalcWeek(db, weekId); - const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number }; - await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId); - await db.prepare( - `UPDATE payroll_week_lines SET required_net=payable_net - WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`, - ).run(weekId); + const env = await callCoreFn(db, "core.fn_payroll_week_assemble", { week_id: weekId, today: nowIso }); + if (!env.ok) throwFromRpc(env); } export async function payWeek(db: Db, weekId: number, nowIso: string): Promise { - const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { - id: number; - week_end: string; - status: string; - } | undefined; - if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 }); - if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 }); - if (week.status !== "assembled") { - await assembleWeek(db, weekId, nowIso); - } - await recalcWeek(db, weekId); - const lines = await db.prepare( - `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=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`, - ).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[]; - for (const line of lines) { - const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined; - if (!loan || loan.balance <= 0) continue; - const take = roundMoney(Math.min(line.loan_discount, loan.balance)); - if (take <= 0) continue; - const n = await paidInstallments(db, loan.id) + 1; - await db.prepare( - `INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`, - ).run(loan.id, weekId, take, n, line.loan_label); - await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id); - } - const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined; - if (period) { - const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as { - id: number; - qty_total_estimated: number; - }[]; - for (const job of jobs) { - const paid = await db.prepare( - `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n - 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=? AND (w.status='paid' OR w.id=?)`, - ).get(job.id, weekId) as { n: number }; - if (Number(paid.n) >= job.qty_total_estimated) { - await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id); - } - } - } - await db.prepare( - "UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?", - ).run(weekId); + const env = await callCoreFn(db, "core.fn_payroll_week_pay", { week_id: weekId, today: nowIso }); + if (!env.ok) throwFromRpc(env); } export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise { - const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; - if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const sheet = await db.prepare( - "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?", - ).get(weekId, projectId) as { id: number } | undefined; - if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 }); - await sheetLine(db, sheet.id, workerId, null); - await recalcWeek(db, weekId); + const env = await callCoreFn(db, "core.fn_payroll_jornal_worker_add", { week_id: weekId, project_id: projectId, worker_id: workerId }); + if (!env.ok) throwFromRpc(env); } -export async function addAdminLine( - db: Db, - weekId: number, - body: { worker_id: number; amount: number; project_id?: number | null }, -): Promise { - const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; - if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const sheet = await db.prepare( - "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL", - ).get(weekId) as { id: number } | undefined; - if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 }); - const lineId = await sheetLine(db, sheet.id, body.worker_id, null); - await db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run( - roundMoney(body.amount), - body.project_id ?? null, - roundMoney(body.amount), - roundMoney(body.amount), - lineId, - ); - await recalcWeek(db, weekId); +export async function addAdminLine(db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }): Promise { + const env = await callCoreFn(db, "core.fn_payroll_admin_line_add", { week_id: weekId, ...body }); + if (!env.ok) throwFromRpc(env); } export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise { - const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; - if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId); - await recalcWeek(db, weekId); + const env = await callCoreFn(db, "core.fn_payroll_admin_line_update", { week_id: weekId, line_id: lineId, amount }); + if (!env.ok) throwFromRpc(env); } export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise { - const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; - if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId); - await recalcWeek(db, weekId); + const env = await callCoreFn(db, "core.fn_payroll_admin_line_remove", { week_id: weekId, line_id: lineId }); + if (!env.ok) throwFromRpc(env); } -export async function jobRemainder(db: Db, jobId: number): Promise { - const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as - | { qty_total_estimated: number } - | undefined; - if (!job) return 0; - const paid = await db.prepare( - `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n - 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=? AND w.status='paid'`, - ).get(jobId) as { n: number }; - return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n))); +export async function createDestajoJob(db: Db, tenantId: number, weekId: number, body: Record): Promise<{ id: number }> { + const env = await callCoreFn<{ id: number }>(db, "core.fn_destajo_job_create", { tenant_id: tenantId, week_id: weekId, ...body }); + if (!env.ok) throwFromRpc(env); + return { id: Number(env.data?.id) }; } -export async function createDestajoJob( - db: Db, - tenantId: number, - weekId: number, - body: { - project_id: number; - worker_id: number; - concepto: string; - unit_code: string; - qty_total_estimated: number; - unit_price: number; - qty_planned?: number; - qty_extra?: number; - }, -): Promise<{ id: number }> { - const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; - if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const concepto = body.concepto.trim(); - if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 }); - await db.prepare( - `INSERT INTO destajo_jobs - (project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run( - body.project_id, - body.worker_id, - concepto, - body.unit_code, - body.qty_total_estimated, - body.unit_price, - tenantId, - ); - const jobId = await db.lastInsertId(); - const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number }; - await db.prepare( - `INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra) - VALUES (?, ?, ?, 0, ?)`, - ).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0); - await recalcWeek(db, weekId); - return { id: jobId }; +export async function patchDestajoCut(db: Db, weekId: number, cutId: number, patch: Record): Promise { + const env = await callCoreFn(db, "core.fn_destajo_cut_patch", { week_id: weekId, cut_id: cutId, ...patch }); + if (!env.ok) throwFromRpc(env); } -export async function patchDestajoCut( - db: Db, - weekId: number, - cutId: number, - patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number }, -): Promise { - const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; - if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); - const cut = await db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as { - qty_planned: number; - qty_actual: number; - qty_extra: number; - } | undefined; - if (!cut) throw Object.assign(new Error("Partida no encontrada"), { status: 404 }); - const planned = patch.qty_planned ?? cut.qty_planned; - const actual = patch.qty_actual ?? cut.qty_actual; - const extra = patch.qty_extra ?? cut.qty_extra; - await db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run( - planned, - actual, - extra, - cutId, - ); - await recalcWeek(db, weekId); -} - -export async function ensureCutForOpenJobs(db: Db, weekId: number): Promise { - const period = await db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as - | { id: number; tenant_id: number } - | undefined; - if (!period) return; - const jobs = await db.prepare( - "SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'", - ).all(period.tenant_id) as { id: number }[]; - const ins = db.prepare( - `INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra) - VALUES (?, ?, 0, 0, 0) - ON CONFLICT(period_id, job_id) DO NOTHING`, - ); - for (const job of jobs) await ins.run(period.id, job.id); -} - -type AttCell = { - date: string; - present: boolean; - other_project_id: number | null; - other_project_name: string | null; -}; - export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) { - const weekId = await ensureWeek(db, tenantId, weekStart); - await ensureCutForOpenJobs(db, weekId); - await recalcWeek(db, weekId); - const week = await db.prepare( - `SELECT * FROM payroll_weeks WHERE id=?`, - ).get(weekId) as Record; - const period = await db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId); - const start = String(week.week_start); - const end = String(week.week_end); - const days = weekDays(start); - const sheetsRaw = await db.prepare( - `SELECT s.*, p.name AS project_name, p.code AS project_code - FROM payroll_sheets s - LEFT JOIN projects p ON p.id=s.project_id - WHERE s.week_id=? - ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`, - ).all(weekId) as Record[]; - - const attRows = await db.prepare( - `SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name - FROM attendance a JOIN projects p ON p.id=a.project_id - WHERE a.work_date BETWEEN ? AND ?`, - ).all(start, end) as { - worker_id: number; - project_id: number; - work_date: string; - present: boolean; - project_name: string; - }[]; - - const sheets = []; - for (const sheet of sheetsRaw) { - const lines = await db.prepare( - `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=? - ORDER BY w.last_name_p, w.first_name, l.id`, - ).all(Number(sheet.id)) as Record[]; - const withCells = lines.map((line) => { - const cells: AttCell[] = days.map((date) => { - const mine = attRows.find((a) => - a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date - ); - const other = attRows.find((a) => - a.worker_id === line.worker_id && a.present === true && a.project_id !== sheet.project_id && a.work_date === date - ); - return { - date, - present: Boolean(mine?.present), - other_project_id: other && !mine?.present ? other.project_id : null, - other_project_name: other && !mine?.present ? other.project_name : null, - }; - }); - return { ...line, cells }; - }); - sheets.push({ ...sheet, lines: withCells }); - } - - const jobs = await db.prepare( - `SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name - FROM destajo_jobs j - JOIN workers w ON w.id=j.worker_id - JOIN projects p ON p.id=j.project_id - WHERE j.tenant_id=? - ORDER BY j.status, j.id DESC`, - ).all(tenantId) as Record[]; - const jobsOut = []; - for (const job of jobs) jobsOut.push({ ...job, remainder: await jobRemainder(db, Number(job.id)) }); - const cuts = period - ? await db.prepare( - `SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id, - j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name - FROM destajo_cut_lines c - JOIN destajo_jobs j ON j.id=c.job_id - JOIN workers w ON w.id=j.worker_id - JOIN projects p ON p.id=j.project_id - WHERE c.period_id=? - ORDER BY p.name, w.last_name_p`, - ).all((period as { id: number }).id) - : []; - const settings = await getSettings(db, tenantId); - const units = await listUnits(db, tenantId); - const required = Number(week.required_net) || 0; - const payable = Number(week.payable_net) || 0; - const recoveryRow = await db.prepare( - `SELECT COALESCE(SUM(l.loan_discount), 0) AS n - FROM payroll_week_lines l - JOIN payroll_sheets s ON s.id=l.sheet_id - WHERE s.week_id=?`, - ).get(weekId) as { n: number }; - const loanRecovery = roundMoney(Number(recoveryRow.n)); - const faltante = roundMoney(Math.max(0, payable - required)); + await ensureWeek(db, tenantId, weekStart); + const env = await callCoreFn>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart }); + if (!env.ok) throwFromRpc(env); + const data = env.data ?? {}; const today = await resolveToday(db, tenantId); - return { - week, - destajo_period: period, - sheets, - destajo_jobs: jobsOut, - destajo_cuts: cuts, - settings, - units, - days, - board: { - required_net: required, - payable_net: payable, - loan_recovery: loanRecovery, - faltante, - delta: roundMoney(required - payable), - }, - can_assemble: canAssembleWeek(start, today), - assemble_from: weekAssembleDate(start), - }; + const start = String((data.week as Record)?.week_start ?? weekStart); + return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) }; } export async function weekCsv(db: Db, weekId: number): Promise { - const lines = await db.prepare( - `SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name, - l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code, - l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net - FROM payroll_week_lines l - JOIN payroll_sheets s ON s.id=l.sheet_id - JOIN workers w ON w.id=l.worker_id - LEFT JOIN projects p ON p.id=s.project_id - WHERE s.week_id=? - ORDER BY s.kind, p.name, w.last_name_p`, - ).all(weekId) as Record[]; - const header = - "Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar"; - const rows = lines.map((l) => - [ - l.first_name, - l.last_name_p, - l.kind, - l.project_name ?? "", - l.days, - l.daily_wage, - l.concepto ?? "", - Number(l.qty_actual || 0) + Number(l.qty_extra || 0), - l.unit_code ?? "", - l.gross, - l.discounts, - l.loan_discount, - String(l.loan_label ?? "").replaceAll(",", " "), - l.required_net, - l.payable_net, - ].join(",") - ); - return [header, ...rows].join("\n"); + const env = await callCoreFn<{ csv: string }>(db, "core.fn_payroll_week_csv", { week_id: weekId }); + if (!env.ok) throwFromRpc(env); + return String(env.data?.csv ?? ""); } export async function listOpenWeeks(db: Db, tenantId: number) { - return await db.prepare( - `SELECT w.*, - (SELECT COALESCE(SUM(l.loan_discount), 0) - FROM payroll_week_lines l - JOIN payroll_sheets s ON s.id=l.sheet_id - WHERE s.week_id=w.id) AS loan_recovery - FROM payroll_weeks w - WHERE w.tenant_id=? AND w.status != 'paid' - ORDER BY w.week_start DESC`, - ).all(tenantId); + const env = await callCoreFn<{ weeks: unknown[] }>(db, "core.fn_payroll_week_list_open", { tenant_id: tenantId }); + if (!env.ok) throwFromRpc(env); + return env.data?.weeks ?? []; } diff --git a/db/README.md b/db/README.md index c8cb95f..560852d 100644 --- a/db/README.md +++ b/db/README.md @@ -79,3 +79,56 @@ El `password_hash` (PBKDF2) no lo puede generar un changeset SQL. El primer `scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con lógica de seed en el arranque de la API ni con datos hardcodeados en Liquibase. + +## Funciones RPC (`core.fn_*`) + +A partir del changeset `006-rpc-infra.sql`, el esquema `core` expone la +lógica de negocio como funciones PostgreSQL invocadas desde la API con +`SELECT core.fn_nombre($1::jsonb)`. La capa TypeScript **no** debe usar +`db.prepare()` contra tablas de `core` en rutas de negocio; solo +[`api/rpc.ts`](../api/rpc.ts) ejecuta el `SELECT` del RPC. + +### Envelope de respuesta (BD) + +Toda función devuelve un `jsonb` con esta forma: + +```json +{ + "ok": true, + "code": "OK", + "layer": "db", + "message": "Mensaje detallado en español", + "context": { "fn": "fn_empresa_get", "id": 5 }, + "data": { }, + "errors": null +} +``` + +- `code`: código de negocio (`OK`, `CREATED`, `VALIDATION`, `NOT_FOUND`, + `CONFLICT`, `INTERNAL`, …). +- `layer`: siempre `"db"` desde Postgres. +- `message`: texto legible y específico (nunca genérico). +- `context`: metadatos seguros para depuración (nombre de función, ids). +- `data`: payload de éxito; `errors`: mapa de campos en validación. + +Helpers en `006-rpc-infra.sql`: `rpc_ok`, `rpc_err`, `rpc_created`, +`rpc_from_exception`. + +### Contrato API + +[`api/http_errors.ts`](../api/http_errors.ts) mapea `code` → HTTP y añade +`status` al body. `respondRpc()` preserva `layer` y `message` de la BD; +`respondApiError()` construye envelopes de capa `"api"`. + +### Convenciones al añadir funciones + +1. Un solo parámetro `payload jsonb`. +2. Prefijo `fn_` para funciones que devuelven envelope. +3. `SECURITY INVOKER` y `SET search_path = core`. +4. Errores de negocio con `RETURN core.rpc_err(...)`; constraints de + Postgres capturados en `EXCEPTION` y traducidos a mensajes claros. +5. `GRANT EXECUTE ... TO panels_core_app` en el mismo changeset. +6. Comentario de ejemplo `SELECT core.fn_*(...)` en el SQL. + +Changesets RPC: `006` (infra) … `015` (documentos). Ver +[`changelog-master.xml`](core/changelog-master.xml). diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 0a661ea..d343ac7 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -12,5 +12,15 @@ + + + + + + + + + + diff --git a/db/core/changesets/015-rpc-documents.sql b/db/core/changesets/015-rpc-documents.sql new file mode 100644 index 0000000..8f9d52b --- /dev/null +++ b/db/core/changesets/015-rpc-documents.sql @@ -0,0 +1,164 @@ +--liquibase formatted sql +-- PANELS · core · RPC documentos (metadata post-S3) + +--changeset panel:core-015a-fn-project-document-store endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_document_store(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := (payload->>'project_id')::bigint; + v_type text := btrim(payload->>'type_code'); + v_doc_id bigint; +BEGIN + IF v_project_id IS NULL OR v_type IS NULL OR v_type = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_project_document_store: project_id y type_code son obligatorios', + jsonb_build_object('fn', 'fn_project_document_store')); + END IF; + IF NOT EXISTS (SELECT 1 FROM project_document_types WHERE code = v_type) THEN + RETURN core.rpc_err('VALIDATION', + format('fn_project_document_store: tipo de documento %s no válido para proyectos', v_type), + jsonb_build_object('fn', 'fn_project_document_store', 'type_code', v_type)); + END IF; + UPDATE project_documents SET is_current = false WHERE project_id = v_project_id AND type_code = v_type; + INSERT INTO project_documents ( + project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, + is_current, parse_status, uploaded_by_id, uploaded_by_name + ) VALUES ( + v_project_id, v_type, payload->>'original_name', payload->>'mime', + (payload->>'size_bytes')::integer, payload->>'sha256', payload->>'iv', payload->>'storage_name', + true, 'manual', NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(payload->>'uploaded_by_name', '') + ) RETURNING id INTO v_doc_id; + RETURN core.rpc_ok(jsonb_build_object('id', v_doc_id), + format('Documento %s guardado para proyecto id=%s (doc id=%s)', v_type, v_project_id, v_doc_id), + jsonb_build_object('fn', 'fn_project_document_store', 'project_id', v_project_id, 'id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_document_store', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-015b-fn-company-document-store endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_company_document_store(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_company_id bigint := (payload->>'company_id')::bigint; + v_type text := btrim(payload->>'type_code'); + v_doc_id bigint; +BEGIN + IF v_company_id IS NULL OR v_type IS NULL OR v_type = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_company_document_store: company_id y type_code son obligatorios', + jsonb_build_object('fn', 'fn_company_document_store')); + END IF; + IF NOT EXISTS (SELECT 1 FROM company_document_types WHERE code = v_type) THEN + RETURN core.rpc_err('VALIDATION', + format('fn_company_document_store: tipo de documento %s no válido para empresas', v_type), + jsonb_build_object('fn', 'fn_company_document_store', 'type_code', v_type)); + END IF; + UPDATE company_documents SET is_current = false WHERE company_id = v_company_id AND type_code = v_type; + INSERT INTO company_documents ( + company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, + is_current, parse_status, uploaded_by_id, uploaded_by_name + ) VALUES ( + v_company_id, v_type, payload->>'original_name', payload->>'mime', + (payload->>'size_bytes')::integer, payload->>'sha256', payload->>'iv', payload->>'storage_name', + true, 'manual', NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(payload->>'uploaded_by_name', '') + ) RETURNING id INTO v_doc_id; + RETURN core.rpc_ok(jsonb_build_object('id', v_doc_id), + format('Documento %s guardado para empresa id=%s (doc id=%s)', v_type, v_company_id, v_doc_id), + jsonb_build_object('fn', 'fn_company_document_store', 'company_id', v_company_id, 'id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_document_store', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-015c-fn-document-get endDelimiter:; splitStatements:true +CREATE OR REPLACE FUNCTION core.fn_project_document_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := (payload->>'project_id')::bigint; + v_doc_id bigint := (payload->>'doc_id')::bigint; + v_doc jsonb; +BEGIN + SELECT to_jsonb(d) INTO v_doc FROM project_documents d + WHERE d.id = v_doc_id AND d.project_id = v_project_id; + IF v_doc IS NULL THEN + RETURN core.rpc_err('NOT_FOUND', + format('fn_project_document_get: documento id=%s no encontrado en proyecto %s', v_doc_id, v_project_id), + jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id, 'project_id', v_project_id)); + END IF; + RETURN core.rpc_ok(jsonb_build_object('document', v_doc), + format('Documento id=%s del proyecto %s obtenido', v_doc_id, v_project_id), + jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_document_get', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_company_document_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_company_id bigint := (payload->>'company_id')::bigint; + v_doc_id bigint := (payload->>'doc_id')::bigint; + v_doc jsonb; +BEGIN + SELECT to_jsonb(d) INTO v_doc FROM company_documents d + WHERE d.id = v_doc_id AND d.company_id = v_company_id; + IF v_doc IS NULL THEN + RETURN core.rpc_err('NOT_FOUND', + format('fn_company_document_get: documento id=%s no encontrado en empresa %s', v_doc_id, v_company_id), + jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id, 'company_id', v_company_id)); + END IF; + RETURN core.rpc_ok(jsonb_build_object('document', v_doc), + format('Documento id=%s de la empresa %s obtenido', v_doc_id, v_company_id), + jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_document_get', SQLSTATE, SQLERRM); +END; +$$; + +CREATE OR REPLACE FUNCTION core.fn_worker_document_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := (payload->>'worker_id')::bigint; + v_doc_id bigint := (payload->>'doc_id')::bigint; + v_doc jsonb; +BEGIN + SELECT to_jsonb(d) INTO v_doc FROM documents d + WHERE d.id = v_doc_id AND d.worker_id = v_worker_id; + IF v_doc IS NULL THEN + RETURN core.rpc_err('NOT_FOUND', + format('fn_worker_document_get: documento id=%s no encontrado para trabajador %s', v_doc_id, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id, 'worker_id', v_worker_id)); + END IF; + RETURN core.rpc_ok(jsonb_build_object('document', v_doc), + format('Documento id=%s del trabajador %s obtenido', v_doc_id, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_document_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-015d-fn-documents-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_project_document_store(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_document_store(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_project_document_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_company_document_get(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_worker_document_get(jsonb) TO panels_core_app; diff --git a/web-panel/composables/api-response.ts b/web-panel/composables/api-response.ts new file mode 100644 index 0000000..0df0070 --- /dev/null +++ b/web-panel/composables/api-response.ts @@ -0,0 +1,59 @@ +export type FrontRpcCode = "VALIDATION" | "NETWORK" | "INTERNAL"; + +export type FrontResponse = { + ok: boolean; + code: FrontRpcCode | string; + layer: "front"; + message: string; + context?: Record; + data?: T | null; + errors?: Record | string[] | null; +}; + +export function buildFrontError( + message: string, + code: FrontRpcCode = "VALIDATION", + context?: Record, + errors?: Record | string[] | null, +): FrontResponse { + return { + ok: false, + code, + layer: "front", + message, + context, + data: null, + errors: errors ?? null, + }; +} + +export function buildFrontNetworkError(method: string, path: string): FrontResponse { + return buildFrontError( + `No se pudo conectar con el servidor en ${method} ${path} — verifique su conexión`, + "NETWORK", + { route: `${method} ${path}` }, + ); +} + +export function isApiEnvelope(value: unknown): value is { + ok: boolean; + code: string; + status?: number; + layer: string; + message: string; +} { + return !!value && typeof value === "object" + && "ok" in value && "message" in value && "layer" in value; +} + +/** Muestra el mensaje detallado de la API o front sin reescribirlo. */ +export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string { + if (isApiEnvelope(value)) return value.message; + if (value && typeof value === "object" && "message" in value) { + return String((value as { message: unknown }).message); + } + if (value && typeof value === "object" && "error" in value) { + return String((value as { error: unknown }).error); + } + return fallback; +} diff --git a/web-saas/composables/api-response.ts b/web-saas/composables/api-response.ts new file mode 100644 index 0000000..8e6943a --- /dev/null +++ b/web-saas/composables/api-response.ts @@ -0,0 +1,58 @@ +export type FrontRpcCode = "VALIDATION" | "NETWORK" | "INTERNAL"; + +export type FrontResponse = { + ok: boolean; + code: FrontRpcCode | string; + layer: "front"; + message: string; + context?: Record; + data?: T | null; + errors?: Record | string[] | null; +}; + +export function buildFrontError( + message: string, + code: FrontRpcCode = "VALIDATION", + context?: Record, + errors?: Record | string[] | null, +): FrontResponse { + return { + ok: false, + code, + layer: "front", + message, + context, + data: null, + errors: errors ?? null, + }; +} + +export function buildFrontNetworkError(method: string, path: string): FrontResponse { + return buildFrontError( + `No se pudo conectar con el servidor en ${method} ${path} — verifique su conexión`, + "NETWORK", + { route: `${method} ${path}` }, + ); +} + +export function isApiEnvelope(value: unknown): value is { + ok: boolean; + code: string; + status?: number; + layer: string; + message: string; +} { + return !!value && typeof value === "object" + && "ok" in value && "message" in value && "layer" in value; +} + +export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string { + if (isApiEnvelope(value)) return value.message; + if (value && typeof value === "object" && "message" in value) { + return String((value as { message: unknown }).message); + } + if (value && typeof value === "object" && "error" in value) { + return String((value as { error: unknown }).error); + } + return fallback; +} From 3171cddd68145843277367c7ddf70a615ec649a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 01:20:49 +0000 Subject: [PATCH 11/11] fix(core): RPC jsonb payload, Liquibase splits, worker needs_badge - Pass JS objects to postgres jsonb params in callCoreFn (not JSON.stringify) - Use splitStatements:false on RPC function changesets for Liquibase - Fix needs_badge default in _worker_normalize when field is omitted - Add scripts/crud-smoke-test.sh for API CRUD verification - Fix findExistingWorker return type in main.ts Co-authored-by: alberto.martinez --- api/main.ts | 6 +- api/rpc.ts | 2 +- db/core/changelog-master.xml | 2 + db/core/changesets/006-rpc-infra.sql | 12 +- db/core/changesets/007-rpc-catalogs.sql | 6 +- db/core/changesets/008-rpc-companies.sql | 14 +- db/core/changesets/009-rpc-projects.sql | 20 +- db/core/changesets/010-rpc-workers.sql | 22 +- db/core/changesets/011-rpc-budget.sql | 16 +- db/core/changesets/012-rpc-badges.sql | 8 +- db/core/changesets/013-rpc-payroll.sql | 26 +-- db/core/changesets/014-rpc-import.sql | 6 +- db/core/changesets/015-rpc-documents.sql | 6 +- .../changesets/016-rpc-worker-needs-badge.sql | 200 ++++++++++++++++++ .../017-rpc-worker-normalize-badge.sql | 44 ++++ scripts/crud-smoke-test.sh | 168 +++++++++++++++ 16 files changed, 487 insertions(+), 71 deletions(-) create mode 100644 db/core/changesets/016-rpc-worker-needs-badge.sql create mode 100644 db/core/changesets/017-rpc-worker-normalize-badge.sql create mode 100755 scripts/crud-smoke-test.sh diff --git a/api/main.ts b/api/main.ts index ba5a988..23619b4 100644 --- a/api/main.ts +++ b/api/main.ts @@ -950,13 +950,15 @@ async function findExistingWorker( nss: string, excludeId = 0, route = "worker-duplicate-check", -) { +): Promise< + { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined +> { const env = await callCoreFn<{ worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null; found?: boolean; }>(db, "core.fn_worker_find_existing", { curp, rfc, nss, exclude_id: excludeId }, { route }); if (!env.ok || !env.data?.found) return undefined; - return env.data.worker; + return env.data.worker ?? undefined; } app.post("/v1/workers", ...requireCoreAuth, async (c) => { diff --git a/api/rpc.ts b/api/rpc.ts index 9855fce..e2e284d 100644 --- a/api/rpc.ts +++ b/api/rpc.ts @@ -38,7 +38,7 @@ async function invokeOnce( payload: Record, ): Promise> { const row = await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get( - JSON.stringify(payload), + payload, ) as { result: unknown } | undefined; const raw = row?.result; if (typeof raw === "string") { diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index d343ac7..f1eee80 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -22,5 +22,7 @@ + + diff --git a/db/core/changesets/006-rpc-infra.sql b/db/core/changesets/006-rpc-infra.sql index c669017..11164f2 100644 --- a/db/core/changesets/006-rpc-infra.sql +++ b/db/core/changesets/006-rpc-infra.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC infrastructure (envelope helpers) ---changeset panel:core-006a-rpc-generic-messages endDelimiter:; splitStatements:true +--changeset panel:core-006a-rpc-generic-messages splitStatements:false CREATE OR REPLACE FUNCTION core.rpc_generic_messages() RETURNS text[] LANGUAGE sql @@ -13,7 +13,7 @@ AS $$ ]::text[]; $$; ---changeset panel:core-006b-rpc-assert-message endDelimiter:; splitStatements:true +--changeset panel:core-006b-rpc-assert-message splitStatements:false CREATE OR REPLACE FUNCTION core.rpc_assert_message(p_message text) RETURNS void LANGUAGE plpgsql @@ -29,7 +29,7 @@ BEGIN END; $$; ---changeset panel:core-006c-rpc-ok endDelimiter:; splitStatements:true +--changeset panel:core-006c-rpc-ok splitStatements:false CREATE OR REPLACE FUNCTION core.rpc_ok( p_data jsonb, p_message text, @@ -53,7 +53,7 @@ BEGIN END; $$; ---changeset panel:core-006d-rpc-created endDelimiter:; splitStatements:true +--changeset panel:core-006d-rpc-created splitStatements:false CREATE OR REPLACE FUNCTION core.rpc_created( p_data jsonb, p_message text, @@ -77,7 +77,7 @@ BEGIN END; $$; ---changeset panel:core-006e-rpc-err endDelimiter:; splitStatements:true +--changeset panel:core-006e-rpc-err splitStatements:false CREATE OR REPLACE FUNCTION core.rpc_err( p_code text, p_message text, @@ -102,7 +102,7 @@ BEGIN END; $$; ---changeset panel:core-006f-rpc-map-exception endDelimiter:; splitStatements:true +--changeset panel:core-006f-rpc-map-exception splitStatements:false CREATE OR REPLACE FUNCTION core.rpc_from_exception( p_fn text, p_sqlstate text, diff --git a/db/core/changesets/007-rpc-catalogs.sql b/db/core/changesets/007-rpc-catalogs.sql index 49eade4..c68f79d 100644 --- a/db/core/changesets/007-rpc-catalogs.sql +++ b/db/core/changesets/007-rpc-catalogs.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC catálogos y configuración tenant ---changeset panel:core-007a-fn-catalogs endDelimiter:; splitStatements:true +--changeset panel:core-007a-fn-catalogs splitStatements:false CREATE OR REPLACE FUNCTION core.fn_catalogs(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -53,7 +53,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-007b-fn-tenant-timezone-get endDelimiter:; splitStatements:true +--changeset panel:core-007b-fn-tenant-timezone-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_tenant_timezone_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -83,7 +83,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-007c-fn-tenant-settings-upsert endDelimiter:; splitStatements:true +--changeset panel:core-007c-fn-tenant-settings-upsert splitStatements:false CREATE OR REPLACE FUNCTION core.fn_tenant_settings_upsert(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/008-rpc-companies.sql b/db/core/changesets/008-rpc-companies.sql index 09c7627..c6f5015 100644 --- a/db/core/changesets/008-rpc-companies.sql +++ b/db/core/changesets/008-rpc-companies.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC empresas (CRUD) ---changeset panel:core-008a-company-helpers endDelimiter:; splitStatements:true +--changeset panel:core-008a-company-helpers splitStatements:false CREATE OR REPLACE FUNCTION core._company_trim_text(p_value text) RETURNS text LANGUAGE sql @@ -148,7 +148,7 @@ BEGIN END; $$; ---changeset panel:core-008b-fn-company-list endDelimiter:; splitStatements:true +--changeset panel:core-008b-fn-company-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -189,7 +189,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-008c-fn-company-get endDelimiter:; splitStatements:true +--changeset panel:core-008c-fn-company-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -230,7 +230,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-008d-fn-company-get-by-code endDelimiter:; splitStatements:true +--changeset panel:core-008d-fn-company-get-by-code splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_get_by_code(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -268,7 +268,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-008e-fn-next-company-code endDelimiter:; splitStatements:true +--changeset panel:core-008e-fn-next-company-code splitStatements:false CREATE OR REPLACE FUNCTION core.fn_next_company_code(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -308,7 +308,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-008f-fn-company-create endDelimiter:; splitStatements:true +--changeset panel:core-008f-fn-company-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -458,7 +458,7 @@ EXCEPTION END; $$; ---changeset panel:core-008g-fn-company-update endDelimiter:; splitStatements:true +--changeset panel:core-008g-fn-company-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/009-rpc-projects.sql b/db/core/changesets/009-rpc-projects.sql index d24ef02..ad123d3 100644 --- a/db/core/changesets/009-rpc-projects.sql +++ b/db/core/changesets/009-rpc-projects.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC proyectos (obras) ---changeset panel:core-009a-fn-next-project-code endDelimiter:; splitStatements:true +--changeset panel:core-009a-fn-next-project-code splitStatements:false CREATE OR REPLACE FUNCTION core.fn_next_project_code(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -27,7 +27,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009b-fn-project-assert-status endDelimiter:; splitStatements:true +--changeset panel:core-009b-fn-project-assert-status splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_assert_status(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -103,7 +103,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009c-fn-project-list endDelimiter:; splitStatements:true +--changeset panel:core-009c-fn-project-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -162,7 +162,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009d-fn-project-get endDelimiter:; splitStatements:true +--changeset panel:core-009d-fn-project-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -209,7 +209,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009e-fn-project-create endDelimiter:; splitStatements:true +--changeset panel:core-009e-fn-project-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -294,7 +294,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009f-fn-project-update endDelimiter:; splitStatements:true +--changeset panel:core-009f-fn-project-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -405,7 +405,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009g-fn-project-document-list endDelimiter:; splitStatements:true +--changeset panel:core-009g-fn-project-document-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_document_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -447,7 +447,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009h-fn-company-document-list endDelimiter:; splitStatements:true +--changeset panel:core-009h-fn-company-document-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_document_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -489,7 +489,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009i-fn-project-checklist endDelimiter:; splitStatements:true +--changeset panel:core-009i-fn-project-checklist splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_checklist(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -540,7 +540,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-009j-fn-company-checklist endDelimiter:; splitStatements:true +--changeset panel:core-009j-fn-company-checklist splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_checklist(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/010-rpc-workers.sql b/db/core/changesets/010-rpc-workers.sql index 6224623..9fac86d 100644 --- a/db/core/changesets/010-rpc-workers.sql +++ b/db/core/changesets/010-rpc-workers.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC trabajadores (personal) ---changeset panel:core-010a-worker-helpers endDelimiter:; splitStatements:true +--changeset panel:core-010a-worker-helpers splitStatements:false CREATE OR REPLACE FUNCTION core._worker_trim_text(p_value text) RETURNS text LANGUAGE sql @@ -533,7 +533,7 @@ BEGIN END; $$; ---changeset panel:core-010b-fn-worker-list endDelimiter:; splitStatements:true +--changeset panel:core-010b-fn-worker-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -666,7 +666,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010c-fn-worker-get endDelimiter:; splitStatements:true +--changeset panel:core-010c-fn-worker-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -767,7 +767,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010d-fn-worker-create endDelimiter:; splitStatements:true +--changeset panel:core-010d-fn-worker-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -883,7 +883,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010e-fn-worker-update endDelimiter:; splitStatements:true +--changeset panel:core-010e-fn-worker-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -980,7 +980,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010f-fn-worker-set-pipeline endDelimiter:; splitStatements:true +--changeset panel:core-010f-fn-worker-set-pipeline splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_set_pipeline(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1066,7 +1066,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010g-fn-worker-assign endDelimiter:; splitStatements:true +--changeset panel:core-010g-fn-worker-assign splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_assign(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1148,7 +1148,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010h-fn-worker-unassign endDelimiter:; splitStatements:true +--changeset panel:core-010h-fn-worker-unassign splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_unassign(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1213,7 +1213,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010i-fn-worker-find-existing endDelimiter:; splitStatements:true +--changeset panel:core-010i-fn-worker-find-existing splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_find_existing(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1264,7 +1264,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010j-fn-worker-refresh-pipeline endDelimiter:; splitStatements:true +--changeset panel:core-010j-fn-worker-refresh-pipeline splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_refresh_pipeline(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1360,7 +1360,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-010k-fn-worker-checklist-raw endDelimiter:; splitStatements:true +--changeset panel:core-010k-fn-worker-checklist-raw splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_checklist_raw(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/011-rpc-budget.sql b/db/core/changesets/011-rpc-budget.sql index 62bb24b..b7824be 100644 --- a/db/core/changesets/011-rpc-budget.sql +++ b/db/core/changesets/011-rpc-budget.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC presupuesto (capítulos, partidas, importación atómica) ---changeset panel:core-011a-fn-budget-list endDelimiter:; splitStatements:true +--changeset panel:core-011a-fn-budget-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -90,7 +90,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011b-fn-budget-chapter-create endDelimiter:; splitStatements:true +--changeset panel:core-011b-fn-budget-chapter-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_chapter_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -156,7 +156,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011c-fn-budget-chapter-update endDelimiter:; splitStatements:true +--changeset panel:core-011c-fn-budget-chapter-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_chapter_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -238,7 +238,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011d-fn-budget-chapter-delete endDelimiter:; splitStatements:true +--changeset panel:core-011d-fn-budget-chapter-delete splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_chapter_delete(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -294,7 +294,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011e-fn-budget-item-create endDelimiter:; splitStatements:true +--changeset panel:core-011e-fn-budget-item-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_item_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -369,7 +369,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011f-fn-budget-item-update endDelimiter:; splitStatements:true +--changeset panel:core-011f-fn-budget-item-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_item_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -457,7 +457,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011g-fn-budget-item-delete endDelimiter:; splitStatements:true +--changeset panel:core-011g-fn-budget-item-delete splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_item_delete(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -507,7 +507,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-011h-fn-budget-replace endDelimiter:; splitStatements:true +--changeset panel:core-011h-fn-budget-replace splitStatements:false CREATE OR REPLACE FUNCTION core.fn_budget_replace(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/012-rpc-badges.sql b/db/core/changesets/012-rpc-badges.sql index 29db9f2..a453cf8 100644 --- a/db/core/changesets/012-rpc-badges.sql +++ b/db/core/changesets/012-rpc-badges.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC gafetes (jobs de impresión y entrega) ---changeset panel:core-012a-fn-badge-job-create endDelimiter:; splitStatements:true +--changeset panel:core-012a-fn-badge-job-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_badge_job_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -114,7 +114,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-012b-fn-badge-job-list endDelimiter:; splitStatements:true +--changeset panel:core-012b-fn-badge-job-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_badge_job_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -164,7 +164,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-012c-fn-badge-job-get endDelimiter:; splitStatements:true +--changeset panel:core-012c-fn-badge-job-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -239,7 +239,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-012d-fn-badge-job-set-delivered endDelimiter:; splitStatements:true +--changeset panel:core-012d-fn-badge-job-set-delivered splitStatements:false CREATE OR REPLACE FUNCTION core.fn_badge_job_set_delivered(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/013-rpc-payroll.sql b/db/core/changesets/013-rpc-payroll.sql index 665919e..0e82325 100644 --- a/db/core/changesets/013-rpc-payroll.sql +++ b/db/core/changesets/013-rpc-payroll.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC nómina (port de api/payroll.ts) ---changeset panel:core-013a-payroll-helpers endDelimiter:; splitStatements:true +--changeset panel:core-013a-payroll-helpers splitStatements:false CREATE OR REPLACE FUNCTION core.round_money(p_value numeric) RETURNS numeric LANGUAGE sql @@ -206,7 +206,7 @@ BEGIN END; $$; ---changeset panel:core-013b-payroll-recalc endDelimiter:; splitStatements:true +--changeset panel:core-013b-payroll-recalc splitStatements:false CREATE OR REPLACE FUNCTION core._payroll_recalc_week(p_week_id bigint) RETURNS void LANGUAGE plpgsql @@ -615,7 +615,7 @@ BEGIN END; $$; ---changeset panel:core-013c-fn-payroll-settings endDelimiter:; splitStatements:true +--changeset panel:core-013c-fn-payroll-settings splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_settings_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -678,7 +678,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013d-fn-payroll-units endDelimiter:; splitStatements:true +--changeset panel:core-013d-fn-payroll-units splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_unit_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -737,7 +737,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013e-fn-attendance endDelimiter:; splitStatements:true +--changeset panel:core-013e-fn-attendance splitStatements:false CREATE OR REPLACE FUNCTION core.fn_attendance_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -859,7 +859,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013f-fn-loan-create endDelimiter:; splitStatements:true +--changeset panel:core-013f-fn-loan-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_loan_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -977,7 +977,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013g-fn-payroll-week endDelimiter:; splitStatements:true +--changeset panel:core-013g-fn-payroll-week splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_week_ensure(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1323,7 +1323,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013h-fn-destajo endDelimiter:; splitStatements:true +--changeset panel:core-013h-fn-destajo splitStatements:false CREATE OR REPLACE FUNCTION core.fn_destajo_job_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1449,7 +1449,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013i-fn-payroll-admin-jornal endDelimiter:; splitStatements:true +--changeset panel:core-013i-fn-payroll-admin-jornal splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_add(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1636,7 +1636,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013i2-fn-payroll-week-list-open endDelimiter:; splitStatements:true +--changeset panel:core-013i2-fn-payroll-week-list-open splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_week_list_open(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1668,7 +1668,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013i3-fn-payroll-week-csv endDelimiter:; splitStatements:true +--changeset panel:core-013i3-fn-payroll-week-csv splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_week_csv(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1714,7 +1714,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013i4-fn-loan-read endDelimiter:; splitStatements:true +--changeset panel:core-013i4-fn-loan-read splitStatements:false CREATE OR REPLACE FUNCTION core.fn_loan_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -1829,7 +1829,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-013i5-fn-payroll-period endDelimiter:; splitStatements:true +--changeset panel:core-013i5-fn-payroll-period splitStatements:false CREATE OR REPLACE FUNCTION core.fn_payroll_period_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/014-rpc-import.sql b/db/core/changesets/014-rpc-import.sql index 38fbfc4..f518d72 100644 --- a/db/core/changesets/014-rpc-import.sql +++ b/db/core/changesets/014-rpc-import.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC importación de personal y metadatos documentales ---changeset panel:core-014a-import-helpers endDelimiter:; splitStatements:true +--changeset panel:core-014a-import-helpers splitStatements:false CREATE OR REPLACE FUNCTION core._import_tenant_id(payload jsonb) RETURNS integer LANGUAGE sql @@ -228,7 +228,7 @@ BEGIN END; $$; ---changeset panel:core-014b-fn-workers-import-batch endDelimiter:; splitStatements:true +--changeset panel:core-014b-fn-workers-import-batch splitStatements:false CREATE OR REPLACE FUNCTION core.fn_workers_import_batch(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -409,7 +409,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-014c-fn-worker-document-store endDelimiter:; splitStatements:true +--changeset panel:core-014c-fn-worker-document-store splitStatements:false CREATE OR REPLACE FUNCTION core.fn_worker_document_store(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/015-rpc-documents.sql b/db/core/changesets/015-rpc-documents.sql index 8f9d52b..52127d2 100644 --- a/db/core/changesets/015-rpc-documents.sql +++ b/db/core/changesets/015-rpc-documents.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- PANELS · core · RPC documentos (metadata post-S3) ---changeset panel:core-015a-fn-project-document-store endDelimiter:; splitStatements:true +--changeset panel:core-015a-fn-project-document-store splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_document_store(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -39,7 +39,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-015b-fn-company-document-store endDelimiter:; splitStatements:true +--changeset panel:core-015b-fn-company-document-store splitStatements:false CREATE OR REPLACE FUNCTION core.fn_company_document_store(payload jsonb) RETURNS jsonb LANGUAGE plpgsql @@ -77,7 +77,7 @@ EXCEPTION WHEN OTHERS THEN END; $$; ---changeset panel:core-015c-fn-document-get endDelimiter:; splitStatements:true +--changeset panel:core-015c-fn-document-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_project_document_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql diff --git a/db/core/changesets/016-rpc-worker-needs-badge.sql b/db/core/changesets/016-rpc-worker-needs-badge.sql new file mode 100644 index 0000000..0a9eaa7 --- /dev/null +++ b/db/core/changesets/016-rpc-worker-needs-badge.sql @@ -0,0 +1,200 @@ +--liquibase formatted sql +-- PANELS · core · fix needs_badge cast en fn_worker_create/update + +--changeset panel:core-016a-fn-worker-needs-badge splitStatements:false +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), + COALESCE((v_norm->'needs_badge')::boolean, true), + 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)); + RETURN core.rpc_created( + jsonb_build_object('worker', to_jsonb(v_row), 'pipeline', v_refresh->'data'), + format('Trabajador %s %s creado (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; +$$; + +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 del trabajador inválidos (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 = COALESCE((v_norm->'needs_badge')::boolean, v_cur.needs_badge, true), + status = v_norm->>'status' + WHERE id = v_id + RETURNING * INTO v_cur; + v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object('worker_id', v_id, 'tenant_id', v_tid)); + RETURN core.rpc_ok( + jsonb_build_object('worker', to_jsonb(v_cur), 'pipeline', v_refresh->'data'), + format('Trabajador %s %s actualizado (id=%s)', v_cur.first_name, v_cur.last_name_p, 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; +$$; diff --git a/db/core/changesets/017-rpc-worker-normalize-badge.sql b/db/core/changesets/017-rpc-worker-normalize-badge.sql new file mode 100644 index 0000000..9f83ca1 --- /dev/null +++ b/db/core/changesets/017-rpc-worker-normalize-badge.sql @@ -0,0 +1,44 @@ +--liquibase formatted sql +-- PANELS · core · fix needs_badge default cuando el payload no lo envía + +--changeset panel:core-017a-worker-needs-badge-default splitStatements:false +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 := COALESCE( + 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 + ), + true + ); +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; +$$; diff --git a/scripts/crud-smoke-test.sh b/scripts/crud-smoke-test.sh new file mode 100755 index 0000000..7d940a4 --- /dev/null +++ b/scripts/crud-smoke-test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Smoke test CRUD via RPC-backed API +set -euo pipefail +BASE="http://127.0.0.1:8000" +JAR="/tmp/panels-cookies.txt" +PASS="${SEED_PASSWORD:-TestAdmin123!}" +SUFFIX=$((RANDOM % 90000 + 10000)) +WORKER_TAG=$(printf '%02d' $((10 + SUFFIX % 80))) + +pass() { echo "✓ $1"; } +fail() { echo "✗ $1"; exit 1; } + +check_status() { + local name="$1" expected="$2" actual="$3" body="$4" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL $name: expected HTTP $expected, got $actual" + echo "$body" | head -c 800 + exit 1 + fi + pass "$name (HTTP $actual)" +} + +echo "=== Health ===" +H=$(curl -s -w "\n%{http_code}" "$BASE/v1/health") +BODY=$(echo "$H" | head -n -1) +CODE=$(echo "$H" | tail -1) +check_status "health" "200" "$CODE" "$BODY" +echo "$BODY" | grep -q '"ok":true' || fail "health body not ok" + +echo "=== Login ===" +LOGIN=$(curl -s -w "\n%{http_code}" -c "$JAR" -X POST "$BASE/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"arct2608\",\"password\":\"$PASS\"}") +BODY=$(echo "$LOGIN" | head -n -1) +CODE=$(echo "$LOGIN" | tail -1) +check_status "login" "200" "$CODE" "$BODY" + +echo "=== Companies LIST ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/companies") +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "companies list" "200" "$CODE" "$BODY" +echo "$BODY" | grep -q '"ok":true' || fail "companies list envelope" +echo "$BODY" | grep -q 'ARCT2608' || fail "seed company missing" + +echo "=== Companies CREATE ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/companies" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Subempresa Prueba RPC", + "code": "SUBRPC'"$SUFFIX"'", + "razon_social": "Subempresa Prueba SA de CV", + "rfc": "SPR010101ABC", + "regimen_fiscal": "601", + "registro_patronal": "A1234567890", + "domicilio_fiscal": "Calle Test 123", + "codigo_postal": "01000", + "ciudad": "Ciudad de México", + "estado": "CDMX" + }') +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "company create" "201" "$CODE" "$BODY" +COMPANY_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) +[[ -n "$COMPANY_ID" ]] || fail "no company id in create response" + +echo "=== Companies GET ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/companies/$COMPANY_ID") +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "company get" "200" "$CODE" "$BODY" +echo "$BODY" | grep -qi "SUBRPC$SUFFIX" || fail "company code mismatch" + +echo "=== Companies PATCH ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X PATCH "$BASE/v1/companies/$COMPANY_ID" \ + -H "Content-Type: application/json" \ + -d '{"nombre_comercial":"Sub RPC Editada","telefono":"5551234567"}') +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "company patch" "200" "$CODE" "$BODY" +echo "$BODY" | grep -q 'Sub RPC Editada' || fail "company patch not applied" + +echo "=== Projects LIST ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects") +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "projects list" "200" "$CODE" "$BODY" + +echo "=== Projects CREATE ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/projects" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Obra Prueba RPC", + "company_id": 1, + "address": "Av. Reforma 100", + "contract_amount": 1500000, + "start_date": "2026-01-01" + }') +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "project create" "201" "$CODE" "$BODY" +PROJECT_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) +[[ -n "$PROJECT_ID" ]] || fail "no project id" + +echo "=== Projects GET ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID") +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "project get" "200" "$CODE" "$BODY" + +echo "=== Projects PATCH ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X PATCH "$BASE/v1/projects/$PROJECT_ID" \ + -H "Content-Type: application/json" \ + -d '{"name":"Obra Prueba RPC Editada","resident_name":"Ing. Test"}') +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "project patch" "200" "$CODE" "$BODY" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID") +BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) +check_status "project get after patch" "200" "$CODE" "$BODY" +echo "$BODY" | grep -qi 'Obra Prueba RPC Editada' || fail "project patch not applied" + +echo "=== Workers CREATE ===" +WORKER_JSON=$(cat <