mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 19:43:18 +00:00
<!-- 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> <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> </div>
333 lines
11 KiB
PL/PgSQL
333 lines
11 KiB
PL/PgSQL
--liquibase formatted sql
|
|
-- PANELS · core · RPC gafetes (jobs de impresión y entrega)
|
|
|
|
--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
|
|
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 splitStatements:false
|
|
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 splitStatements:false
|
|
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 splitStatements:false
|
|
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;
|