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