--liquibase formatted sql -- PANELS · core · RPC proyectos (obras) --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 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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 splitStatements:false 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;