panels-origin/db/core/changesets/009-rpc-projects.sql
Alberto Martinez b9bf7044b2 refactor(core): migrate core CRUD to PostgreSQL RPC + unified HTTP errors
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.

### Database (Liquibase changesets 006–017)

- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets

### API

- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`

### Envelope contract

DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.

### Out of scope

`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.

## Test plan

- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-04 02:34:43 +00:00

604 lines
21 KiB
PL/PgSQL

--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;