panels-origin/db/core/changesets/012-rpc-badges.sql
Cursor Agent 7f2e84e47c
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 <alberto.martinez@mrdev.mx>
2026-09-03 23:50:46 +00:00

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