Files
motor-de-encuestas/supabase/migrations/20260625000002_dashboard_mirada_empresa.sql
markosbenitez 83782ab6f8 feat(dashboard): RPCs Mirada Empresa v3 + fix códigos v2 en helper [dashboard]
11 RPCs de Mirada Empresa (E1-E10 + estrella Talento en riesgo) sobre
instrumento v3, k≥5 en dos niveles, helper _dash_es_cuidador (criterio
UNFPA). Fix de _dash_responses: A6/A7 → 2.5/2.7. Verificado contra 50
respuestas reales: métricas núcleo exactas, k-anonimato suprime segmentos
n<5, umbral de la estrella parametrizable. Modalidad por JOIN (TECH-DEBT-010).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:06:49 -03:00

717 lines
30 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================
-- Sprint 4 (v3.0.0) — Dashboard: RPCs de la Mirada Empresa
-- Fuente: docs/dashboard/SPEC_MIRADA_EMPRESA.md (contrato validado)
--
-- Patrón: idéntico al de la migración 012 (rpc_dashboard_kpis, etc.) —
-- SECURITY DEFINER, filtro survey_id + flag p_all delegado a
-- _dash_responses (sin tocar), k≥5 en dos niveles, shape {status, n, ...}.
-- No se modifican las 9 RPCs viejas de v2 (quedan como están).
--
-- Mapeo métrica de la spec → función:
-- E1 prevalencia de cuidadores → rpc_me_prevalencia_cuidadores
-- E2 riesgo de rotación → rpc_me_riesgo_rotacion
-- E3 ausentismo → rpc_me_ausentismo
-- E4 presentismo → rpc_me_presentismo
-- E6 punto ciego organizacional → rpc_me_punto_ciego
-- E7 demanda de apoyos → rpc_me_apoyos_demandados
-- ★ talento en riesgo → rpc_me_talento_en_riesgo
-- E5 carrera congelada → rpc_me_carrera_congelada
-- E8 intensidad de carga → rpc_me_intensidad_carga
-- E9 segmentación nivel/modalidad → rpc_me_segmentacion
-- E10 disposición a red de apoyo → rpc_me_disposicion_red
-- ============================================================
-- ════════════════════════════════════════════════════════════
-- HELPER — CUIDADOR_ACTIVO (único punto de la definición, ver spec)
-- "Hijo menor sin discapacidad" SÍ cuenta como cuidador (criterio UNFPA,
-- validado por Maria, jun 2026) — ensancha la población respecto a v2.
-- Toda métrica que use "cuidador" como población llama a este helper;
-- si la definición cambia, se cambia ACÁ y nada más.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public._dash_es_cuidador(p_response_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
-- CASE (no AND) a propósito: Postgres no garantiza orden de evaluación en
-- AND/OR, solo en CASE — esto asegura que el operador jsonb "-" (solo
-- válido sobre arrays) nunca se ejecute sobre un valor que no lo sea.
SELECT EXISTS (
SELECT 1
FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = p_response_id
AND q.code = '4.1'
AND CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(
a.value - 'Ninguna de las anteriores / No tengo vinculación de cuidado'
) > 0
ELSE false
END
)
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E1 — Prevalencia de cuidadores en la plantilla
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_prevalencia_cuidadores(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_cuid bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT count(*) INTO v_cuid
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
WHERE public._dash_es_cuidador(dr.response_id);
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'pct_cuidadores', ROUND(v_cuid::numeric / v_total * 100, 1)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E2 — Riesgo de rotación por cuidado
-- Denominador: cuidadores que RESPONDIERON 7.6 — no "todos los cuidadores",
-- porque 7.6 vive en Sección 7, oculta para soloNino (solo "Hijo menor sin
-- discapacidad") y para "Ninguna". Si el denominador fuera "todos los
-- cuidadores" sin este matiz, se diluiría el % con gente a la que nunca se
-- le mostró la pregunta — no es lo mismo "no está en riesgo" que "no se le
-- preguntó". El JOIN a la pregunta concreta resuelve esto solo.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_riesgo_rotacion(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n_cuid bigint;
v_algo bigint;
v_muy bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Algo probable'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Muy probable')
INTO v_n_cuid, v_algo, v_muy
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.6'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n_cuid < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_cuidadores',v_n_cuid);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n_cuidadores', v_n_cuid,
'pct_riesgo', ROUND((v_algo + v_muy)::numeric / v_n_cuid * 100, 1),
'desglose', jsonb_build_object(
'algo', ROUND(v_algo::numeric / v_n_cuid * 100, 1),
'muy', ROUND(v_muy::numeric / v_n_cuid * 100, 1)
)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E3 — Ausentismo atribuible al cuidado
-- Mismo matiz de denominador que E2 (7.3 vive en Sección 7, oculta para
-- soloNino/Ninguna) — denominador = cuidadores que respondieron 7.3.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_ausentismo(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_nunca bigint;
v_pocas bigint;
v_varias bigint;
v_frec bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Nunca'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Pocas veces (13 al año)'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces (410 al año)'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Con frecuencia (más de 10 al año)')
INTO v_n, v_nunca, v_pocas, v_varias, v_frec
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.3'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_ausentismo_recurrente', ROUND((v_varias + v_frec)::numeric / v_n * 100, 1),
'desglose', jsonb_build_object(
'nunca', ROUND(v_nunca::numeric / v_n * 100, 1),
'pocas', ROUND(v_pocas::numeric / v_n * 100, 1),
'varias', ROUND(v_varias::numeric / v_n * 100, 1),
'frecuente', ROUND(v_frec::numeric / v_n * 100, 1)
)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E4 — Presentismo (productividad reducida)
-- Mismo matiz de denominador (7.2, Sección 7) que E2/E3.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_presentismo(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_nunca bigint;
v_algunas bigint;
v_varias bigint;
v_casi bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Nunca o casi nunca'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Algunas veces al mes'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces por semana'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Casi todos los días')
INTO v_n, v_nunca, v_algunas, v_varias, v_casi
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.2'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_presentismo', ROUND((v_varias + v_casi)::numeric / v_n * 100, 1),
'desglose', jsonb_build_object(
'nunca_o_casi_nunca', ROUND(v_nunca::numeric / v_n * 100, 1),
'algunas_al_mes', ROUND(v_algunas::numeric / v_n * 100, 1),
'varias_por_semana', ROUND(v_varias::numeric / v_n * 100, 1),
'casi_todos_los_dias',ROUND(v_casi::numeric / v_n * 100, 1)
)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E6 — Punto ciego organizacional
-- carga_real_pct: sobre TODA la plantilla encuestada (= E1).
-- apoyo_percibido_pct: sobre quienes respondieron 9.1 (oculta solo para
-- "Ninguna" — soloNino SÍ la ve, a diferencia de 7.x). Son denominadores
-- distintos a propósito (carga real de TODOS vs. percepción de quienes
-- tienen vínculo) — es justamente la brecha que la métrica busca mostrar.
-- Cada población se suprime de forma independiente si n<5.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_punto_ciego(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_cuid bigint;
v_n_apoyo bigint;
v_apoyo_si bigint;
v_carga_pct numeric;
v_apoyo_pct numeric;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT count(*) INTO v_cuid
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
WHERE public._dash_es_cuidador(dr.response_id);
v_carga_pct := ROUND(v_cuid::numeric / v_total * 100, 1);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Sí, me siento acompañado/a')
INTO v_n_apoyo, v_apoyo_si
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '9.1';
IF v_n_apoyo >= 5 THEN
v_apoyo_pct := ROUND(v_apoyo_si::numeric / v_n_apoyo * 100, 1);
ELSE
v_apoyo_pct := NULL;
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'carga_real_pct', v_carga_pct,
'apoyo_percibido_pct', v_apoyo_pct,
'apoyo_percibido_suppressed', (v_n_apoyo < 5),
'brecha_pp', CASE WHEN v_apoyo_pct IS NOT NULL THEN ROUND(v_carga_pct - v_apoyo_pct, 1) ELSE NULL END
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E7 — Demanda de apoyos priorizada
-- Denominador = TODA la plantilla encuestada (igual molde que
-- rpc_apoyos_demandados de v2: divide por v_total, no por quienes
-- respondieron 9.2). 9.2 ya está oculta para Ninguna/soloNino, así que en
-- la práctica solo cuidadores "completos" la responden — coherente con
-- cómo se comportaba 6.2 en v2.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_apoyos_demandados(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object(
'apoyo', opt,
'n', cnt,
'pct', ROUND(cnt::numeric / v_total * 100, 1)
) AS row
FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '9.2' AND jsonb_typeof(a.value) = 'array'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY cnt DESC
) t;
RETURN jsonb_build_object('status','ok','n',v_total,'apoyos', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] ★ Métrica estrella — "Talento en riesgo"
-- Umbral PARAMETRIZABLE (p_umbral_senales, default 2 de 3) — cambiarlo no
-- requiere tocar el cuerpo de la función. ADVERTENCIA: esto resuelve
-- "cambiar el número de corte"; NO resuelve "pasar a un esquema ponderado"
-- (eso sería una fórmula distinta — pesos por señal, no un conteo — y
-- necesitaría otra firma/cuerpo de función, no solo este parámetro).
--
-- Población: cuidadores que respondieron 7.6 (ancla la Sección 7 visible;
-- 7.3/7.5 se traen con LEFT JOIN porque comparten la misma regla de
-- ocultamiento que 7.6 — en la práctica siempre van juntas, pero LEFT JOIN
-- es defensivo ante una respuesta parcial real).
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_talento_en_riesgo(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb,
p_umbral_senales int DEFAULT 2
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_riesgo bigint;
v_avg numeric;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
WITH base AS (
SELECT
dr.response_id,
CASE WHEN s76.value #>> '{}' IN ('Algo probable','Muy probable') THEN 1 ELSE 0 END
AS sig_riesgo,
CASE WHEN s73.value #>> '{}' IN ('Varias veces (410 al año)','Con frecuencia (más de 10 al año)') THEN 1 ELSE 0 END
AS sig_ausentismo,
CASE WHEN jsonb_typeof(s75.value) = 'array' THEN
CASE WHEN jsonb_array_length(s75.value - 'Ninguna de las anteriores') > 0
THEN 1 ELSE 0 END
ELSE 0
END AS sig_carrera
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers s76 ON s76.response_id = dr.response_id
JOIN public.questions q76 ON q76.id = s76.question_id AND q76.code = '7.6'
LEFT JOIN public.questions q73 ON q73.code = '7.3' AND q73.survey_id = q76.survey_id
LEFT JOIN public.answers s73 ON s73.response_id = dr.response_id AND s73.question_id = q73.id
LEFT JOIN public.questions q75 ON q75.code = '7.5' AND q75.survey_id = q76.survey_id
LEFT JOIN public.answers s75 ON s75.response_id = dr.response_id AND s75.question_id = q75.id
WHERE public._dash_es_cuidador(dr.response_id)
)
SELECT
count(*),
count(*) FILTER (WHERE (sig_riesgo + sig_ausentismo + sig_carrera) >= p_umbral_senales),
avg(sig_riesgo + sig_ausentismo + sig_carrera)
INTO v_n, v_riesgo, v_avg
FROM base;
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_cuidadores',v_n);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n_cuidadores', v_n,
'umbral_senales', p_umbral_senales,
'pct_talento_riesgo', ROUND(v_riesgo::numeric / v_n * 100, 1),
'criterios_promedio', ROUND(v_avg, 2)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E5 — Carrera congelada
-- Mismo matiz de denominador (7.5, Sección 7) que E2/E3/E4.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_cong bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
ELSE false
END
)
INTO v_n, v_cong
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row
FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id)
AND jsonb_typeof(a.value) = 'array'
AND a.value #>> '{}' != 'Ninguna de las anteriores'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY cnt DESC
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E8 — Intensidad de carga
-- Población: cuidadores que respondieron 6.1 (ancla Sección 6, misma regla
-- de ocultamiento que 6.2/6.4 — comparten r_hide_ning_sn).
-- Son rangos categóricos (no números) — se reporta distribución por
-- combinación, no un promedio, tal como pide la spec.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_intensidad_carga(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_guardia bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_n
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '6.1'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row) INTO v_rows FROM (
SELECT jsonb_build_object(
'dias_semana', dias_bin,
'horas_dia', horas_bin,
'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END,
'suppressed', (cnt < 5)
) AS row
FROM (
SELECT
ad.value #>> '{}' AS dias_bin,
ah.value #>> '{}' AS horas_bin,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers ad ON ad.response_id = dr.response_id
JOIN public.questions qd ON qd.id = ad.question_id AND qd.code = '6.1'
JOIN public.answers ah ON ah.response_id = dr.response_id
JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '6.2'
WHERE public._dash_es_cuidador(dr.response_id)
GROUP BY 1, 2
) sub
ORDER BY dias_bin, horas_bin
) t;
SELECT count(*) INTO v_guardia
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '6.4'
WHERE public._dash_es_cuidador(dr.response_id)
AND a.value #>> '{}' = 'De forma permanente / sin horario';
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'distribucion_intensidad', COALESCE(v_rows, '[]'::jsonb),
'pct_guardia_permanente', ROUND(v_guardia::numeric / v_n * 100, 1)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E9 — Segmentación por nivel y modalidad
-- pct_cuidadores cruzado con 2.6 (nivel organizacional) y 2.7 (modalidad).
-- k≥5 por fila — segmento con n<5 se omite (no se devuelve null disfrazado
-- de 0%, se OMITE la fila, igual criterio que rpc_prevalencia_por_depto).
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_segmentacion(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_por_nivel jsonb;
v_por_modal jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
-- Nivel organizacional (2.6) ya se promueve a responses.qi_sector en el
-- submit (Etapa 4G) — se lee directo de ahí, mismo patrón que
-- rpc_prevalencia_por_depto (v2) lee qi_zone. NO se lee de answers/2.6:
-- esa fue la causa del bug original (la RPC buscaba la respuesta cruda
-- en vez de la columna ya denormalizada).
SELECT jsonb_agg(row) INTO v_por_nivel FROM (
SELECT jsonb_build_object(
'nivel', nivel_val,
'n', cnt,
'pct', ROUND(cnt_cuid::numeric / cnt * 100, 1)
) AS row
FROM (
SELECT
r.qi_sector AS nivel_val,
count(DISTINCT dr.response_id) AS cnt,
count(DISTINCT dr.response_id) FILTER (WHERE public._dash_es_cuidador(dr.response_id)) AS cnt_cuid
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.responses r ON r.id = dr.response_id
WHERE r.qi_sector IS NOT NULL
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY nivel_val
) t;
-- Modalidad (2.7) NO tiene columna qi_modalidad — decisión consciente
-- para no reabrir rpc_submit_response, ya verificada en 4G. Se lee por
-- JOIN a answers/questions en tiempo de consulta. Ver TECH-DEBT-010.
SELECT jsonb_agg(row) INTO v_por_modal FROM (
SELECT jsonb_build_object(
'modalidad', modal_val,
'n', cnt,
'pct', ROUND(cnt_cuid::numeric / cnt * 100, 1)
) AS row
FROM (
SELECT
a.value #>> '{}' AS modal_val,
count(DISTINCT dr.response_id) AS cnt,
count(DISTINCT dr.response_id) FILTER (WHERE public._dash_es_cuidador(dr.response_id)) AS cnt_cuid
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '2.7'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY modal_val
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'por_nivel', COALESCE(v_por_nivel, '[]'::jsonb),
'por_modalidad', COALESCE(v_por_modal, '[]'::jsonb)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E10 — Disposición a red de apoyo interna
-- 9.3 (oculta para Ninguna Y soloNino) y 9.4 (oculta solo para Ninguna)
-- tienen reglas de ocultamiento DISTINTAS → poblaciones distintas →
-- cada porcentaje se calcula y se suprime de forma independiente,
-- mismo criterio que E6.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_disposicion_red(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n_grupo bigint;
v_grupo_si bigint;
v_n_vol bigint;
v_vol_si bigint;
v_pct_grupo numeric;
v_pct_vol numeric;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' IN ('Sí, definitivamente','Tal vez, si hay más personas en la misma situación'))
INTO v_n_grupo, v_grupo_si
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '9.3';
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = '')
INTO v_n_vol, v_vol_si
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '9.4';
IF v_n_grupo < 5 AND v_n_vol < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_grupo_apoyo',v_n_grupo,'n_voluntariado',v_n_vol);
END IF;
v_pct_grupo := CASE WHEN v_n_grupo >= 5 THEN ROUND(v_grupo_si::numeric / v_n_grupo * 100, 1) ELSE NULL END;
v_pct_vol := CASE WHEN v_n_vol >= 5 THEN ROUND(v_vol_si::numeric / v_n_vol * 100, 1) ELSE NULL END;
RETURN jsonb_build_object(
'status', 'ok',
'n_grupo_apoyo', v_n_grupo,
'pct_grupo_apoyo', v_pct_grupo,
'n_voluntariado', v_n_vol,
'pct_voluntariado', v_pct_vol
);
END;
$$;