-- ============================================================ -- 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 (1–3 al año)'), count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces (4–10 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 (4–10 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 #>> '{}' = 'Sí') 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; $$;