diff --git a/TECH_DEBT.md b/TECH_DEBT.md index 3a48c62..628069f 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -85,3 +85,28 @@ mínimo viable). - **Impacto actual:** ninguno en producción — la URL no está publicada. - **Bloqueante para:** ampliar visibilidad de datos en la vista de control. + +--- + +## [TECH-DEBT-010] E9 lee modalidad por JOIN a `answers` en vez de `qi_modalidad` + +- **Estado:** ABIERTO — decisión consciente, no un descuido. +- **Descripción:** `rpc_me_segmentacion` (E9, Mirada Empresa) segmenta por nivel + organizacional y por modalidad de trabajo. El nivel (`2.6`) sí se promovió a + `responses.qi_sector` en el submit (Etapa 4G) — E9 lee de ahí directo. La + modalidad (`2.7`) NO tiene una columna `qi_modalidad` equivalente — promoverla + hubiera significado reabrir `rpc_submit_response`, ya verificada contra + Postgres real en 4G. Para no reabrirla, E9 resuelve modalidad con un JOIN a + `answers`/`questions` por `code='2.7'` en tiempo de consulta. +- **Costo del JOIN vs. la columna:** una pasada extra por `answers`/`questions` + en cada llamada a E9 — aceptable para el volumen actual (decenas/cientos de + respuestas por encuesta), pero más lento que leer una columna denormalizada + si el dataset crece mucho o si la segmentación por modalidad se usa con + frecuencia en varios widgets. +- **Acción si se decide promover:** agregar `qi_modalidad text` a `responses`, + poblarla en `rpc_submit_response` (requiere re-verificar esa RPC contra + Postgres real, igual que se hizo en 4G), y migrar E9 a leerla directo — + mismo patrón que se usó para `qi_sector`. +- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por + modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene + promoverla de una vez en lugar de cada RPC reimplementando el JOIN. diff --git a/supabase/migrations/20260625000001_fix_dash_responses_v3_codes.sql b/supabase/migrations/20260625000001_fix_dash_responses_v3_codes.sql new file mode 100644 index 0000000..5fb5f85 --- /dev/null +++ b/supabase/migrations/20260625000001_fix_dash_responses_v3_codes.sql @@ -0,0 +1,46 @@ +-- Sprint 4 (v3.0.0) — Fix puntual: _dash_responses filtraba género/modalidad +-- contra códigos v2 ('A6'/'A7') que no existen en el esquema v3. El filtro +-- devolvía 0 filas SIN error — silencioso, indistinguible de "no hay nadie +-- con ese filtro". Es una trampa: alguien podría leer "ningún hombre es +-- cuidador" cuando en realidad el filtro nunca encontró la pregunta. +-- +-- Mapeo: A6 (género, v2) → 2.5 (género, v3) · A7 (modalidad, v2) → 2.7 (modalidad, v3). +-- +-- Alcance: SOLO este helper compartido. Las 9 RPCs viejas de v2 (migración +-- 20260605140001_012_dashboard_rpc_v2.sql) NO se tocan — sus propias +-- comparaciones de código (1.1, 1.6, 1.13, 4.3, 6.1, 6.2, 6.5, etc.) siguen +-- sin migrar (Etapa 4H, pendiente de confirmación aparte, no se resuelve +-- acá). Este fix no las rompe: ninguna de esas 9 RPCs compara 'A6'/'A7' +-- directamente, solo le pasan arrays de filtro (p_genero/p_modalidad) a esta +-- función — que ahora los evalúa contra preguntas que SÍ existen en v3. +CREATE OR REPLACE FUNCTION public._dash_responses( + p_survey_id uuid, + p_depto text DEFAULT NULL, + p_genero text[] DEFAULT NULL, + p_etario text[] DEFAULT NULL, + p_modalidad text[] DEFAULT NULL +) +RETURNS TABLE(response_id uuid) +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT r.id + FROM public.responses r + WHERE (p_survey_id = '00000000-0000-0000-0000-000000000000' + OR r.survey_id = p_survey_id) + AND (p_depto IS NULL OR r.qi_zone = p_depto) + AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = r.id AND q.code = '2.5' + AND a.value #>> '{}' = ANY(p_genero) + )) + AND (p_etario IS NULL OR cardinality(p_etario) = 0 + OR r.qi_age_bucket = ANY(p_etario)) + AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = r.id AND q.code = '2.7' + AND a.value #>> '{}' = ANY(p_modalidad) + )); +$$; diff --git a/supabase/migrations/20260625000002_dashboard_mirada_empresa.sql b/supabase/migrations/20260625000002_dashboard_mirada_empresa.sql new file mode 100644 index 0000000..91f35bd --- /dev/null +++ b/supabase/migrations/20260625000002_dashboard_mirada_empresa.sql @@ -0,0 +1,716 @@ +-- ============================================================ +-- 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; +$$; diff --git a/supabase/tests/02_dashboard_mirada_empresa.test.sql b/supabase/tests/02_dashboard_mirada_empresa.test.sql new file mode 100644 index 0000000..ec08fb0 --- /dev/null +++ b/supabase/tests/02_dashboard_mirada_empresa.test.sql @@ -0,0 +1,327 @@ +-- ============================================================ +-- Tests pgTAP: Dashboard v3 — RPCs de la Mirada Empresa +-- +-- Ejecutar con: supabase test db +-- Requiere: Docker + supabase start + extensión pgtap +-- +-- Cubre: +-- 1. Existencia de las 11 RPCs + el helper _dash_es_cuidador (firma correcta) +-- 2. _dash_es_cuidador: las 4 combinaciones de la definición UNFPA +-- (solo Ninguna / Ninguna+otro / solo "hijo sin discapacidad" / sin responder) +-- 3. k≥5 en dos niveles: n=4 → suppressed, n=5 → ok con pct correcto (E1) +-- 4. Fix de _dash_responses: el filtro de género ya encuentra '2.5' (no '"A6"') +-- 5. Umbral parametrizable de la métrica estrella: mismo dataset, dos +-- resultados distintos según p_umbral_senales (prueba que el parámetro +-- realmente cambia el comportamiento, no es decorativo) +-- 6. E9 (rpc_me_segmentacion) — bug real encontrado en VPS: por_nivel leía +-- answers/2.6 en vez de responses.qi_sector. Prueba que ahora lee +-- qi_sector, que por_modalidad sigue leyendo por JOIN a 2.7, y que el +-- k≥5 por segmento sigue omitiendo filas chicas. +-- +-- NO escrito por mí pero sí ejecutable por el director: no hay Docker en el +-- entorno donde se generó este archivo — no se corrió contra Postgres real. +-- ============================================================ + +BEGIN; + +SELECT plan(28); + +-- ============================================================ +-- SECCIÓN 1: las funciones existen con la firma esperada +-- ============================================================ +SELECT has_function('public', '_dash_es_cuidador', ARRAY['uuid'], + '1.1 _dash_es_cuidador(uuid) existe'); +SELECT has_function('public', 'rpc_me_prevalencia_cuidadores', ARRAY['uuid','jsonb'], + '1.2 rpc_me_prevalencia_cuidadores existe'); +SELECT has_function('public', 'rpc_me_riesgo_rotacion', ARRAY['uuid','jsonb'], + '1.3 rpc_me_riesgo_rotacion existe'); +SELECT has_function('public', 'rpc_me_ausentismo', ARRAY['uuid','jsonb'], + '1.4 rpc_me_ausentismo existe'); +SELECT has_function('public', 'rpc_me_presentismo', ARRAY['uuid','jsonb'], + '1.5 rpc_me_presentismo existe'); +SELECT has_function('public', 'rpc_me_punto_ciego', ARRAY['uuid','jsonb'], + '1.6 rpc_me_punto_ciego existe'); +SELECT has_function('public', 'rpc_me_apoyos_demandados', ARRAY['uuid','jsonb'], + '1.7 rpc_me_apoyos_demandados existe'); +SELECT has_function('public', 'rpc_me_talento_en_riesgo', ARRAY['uuid','jsonb','int4'], + '1.8 rpc_me_talento_en_riesgo(uuid,jsonb,int) existe — confirma el parámetro de umbral'); +SELECT has_function('public', 'rpc_me_carrera_congelada', ARRAY['uuid','jsonb'], + '1.9 rpc_me_carrera_congelada existe'); +SELECT has_function('public', 'rpc_me_intensidad_carga', ARRAY['uuid','jsonb'], + '1.10 rpc_me_intensidad_carga existe'); +SELECT has_function('public', 'rpc_me_segmentacion', ARRAY['uuid','jsonb'], + '1.11 rpc_me_segmentacion existe'); +SELECT has_function('public', 'rpc_me_disposicion_red', ARRAY['uuid','jsonb'], + '1.12 rpc_me_disposicion_red existe'); + +-- ============================================================ +-- SECCIÓN 2: fixture — 1 org, 1 survey v3, preguntas mínimas necesarias +-- ============================================================ +DO $$ +DECLARE + v_org_id uuid := '10000000-1111-0000-0000-000000000001'; + v_cv_id uuid := '20000000-1111-0000-0000-000000000001'; + v_survey_id uuid := '30000000-1111-0000-0000-000000000001'; +BEGIN + INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_me_org'); + INSERT INTO public.consent_versions (id, version, body, scopes) + VALUES (v_cv_id, '_test-cv-me', 'body', '["operativo"]'::jsonb); + INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, consent_version_id) + VALUES (v_survey_id, v_org_id, '_test_me_survey', true, 'sensible', 'confidencial', v_cv_id); + + INSERT INTO public.questions (id, survey_id, code, type, prompt, position) VALUES + ('40000000-1111-0000-0000-000000000041', v_survey_id, '4.1', 'multiple_choice', 'vínculos', 41), + ('40000000-1111-0000-0000-000000000072', v_survey_id, '7.2', 'single_choice', 'presentismo', 72), + ('40000000-1111-0000-0000-000000000073', v_survey_id, '7.3', 'single_choice', 'ausentismo', 73), + ('40000000-1111-0000-0000-000000000075', v_survey_id, '7.5', 'multiple_choice', 'carrera', 75), + ('40000000-1111-0000-0000-000000000076', v_survey_id, '7.6', 'single_choice', 'riesgo rotación', 76), + ('40000000-1111-0000-0000-000000000091', v_survey_id, '9.1', 'single_choice', 'conciencia', 91), + ('40000000-1111-0000-0000-000000000025', v_survey_id, '2.5', 'single_choice', 'género', 25); +END; +$$; + +-- ============================================================ +-- SECCIÓN 3: _dash_es_cuidador — las 4 combinaciones de la definición UNFPA +-- ============================================================ +DO $$ +DECLARE + v_survey_id uuid := '30000000-1111-0000-0000-000000000001'; + v_q41 uuid := '40000000-1111-0000-0000-000000000041'; + v_cr_id uuid := '50000000-1111-0000-0000-000000000001'; + v_r_ninguna uuid := '60000000-1111-0000-0000-000000000001'; -- solo "Ninguna..." + v_r_mixto uuid := '60000000-1111-0000-0000-000000000002'; -- "Ninguna..." + otra opción + v_r_solonin uuid := '60000000-1111-0000-0000-000000000003'; -- solo "Hijo menor sin discapacidad" + v_r_sinresp uuid := '60000000-1111-0000-0000-000000000004'; -- no respondió 4.1 +BEGIN + INSERT INTO public.consent_records (id, consent_version_id, granted_scopes) + VALUES (v_cr_id, '20000000-1111-0000-0000-000000000001', '{"operativo":true}'::jsonb); + + INSERT INTO public.responses (id, survey_id, company_id, consent_record_id) VALUES + (v_r_ninguna, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r_mixto, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r_solonin, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r_sinresp, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id); + + INSERT INTO public.answers (response_id, question_id, instance_id, value) VALUES + (v_r_ninguna, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado"]'::jsonb), + (v_r_mixto, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado","Otro / Especificar"]'::jsonb), + (v_r_solonin, v_q41, 'default', '["Hijo menor sin discapacidad"]'::jsonb); + -- v_r_sinresp: sin fila en answers para 4.1 (a propósito) +END; +$$; + +SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000001'), false, + '3.1 solo "Ninguna..." → NO es cuidador'); +SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000002'), true, + '3.2 "Ninguna..." + otra opción → SÍ es cuidador (la otra opción cuenta)'); +SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000003'), true, + '3.3 solo "Hijo menor sin discapacidad" → SÍ es cuidador (criterio UNFPA)'); +SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000004'), false, + '3.4 sin respuesta a 4.1 → NO es cuidador'); + +-- ============================================================ +-- SECCIÓN 4: k≥5 en rpc_me_prevalencia_cuidadores (E1) +-- 4 cuidadores → suppressed; al agregar 1 más (5) → ok, pct correcto +-- ============================================================ +SELECT is( + (public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'status'), + 'suppressed', + '4.1 con 4 responses (n<5) → status=suppressed' +); + +DO $$ +DECLARE + v_survey_id uuid := '30000000-1111-0000-0000-000000000001'; + v_q41 uuid := '40000000-1111-0000-0000-000000000041'; + v_cr_id uuid := '50000000-1111-0000-0000-000000000001'; + v_r5 uuid := '60000000-1111-0000-0000-000000000005'; -- 5ta response, NO cuidador +BEGIN + INSERT INTO public.responses (id, survey_id, company_id, consent_record_id) + VALUES (v_r5, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id); + INSERT INTO public.answers (response_id, question_id, instance_id, value) + VALUES (v_r5, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado"]'::jsonb); +END; +$$; + +-- Ahora hay 5 responses: 3 cuidadores (mixto, solonin) + ... v_r_sinresp NO es +-- cuidador (sin 4.1), v_r_ninguna NO es cuidador, v_r5 NO es cuidador. +-- Cuidadores reales = {v_r_mixto, v_r_solonin} = 2 de 5 → 40.0% +SELECT is( + (public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'status'), + 'ok', + '4.2 con 5 responses (n>=5) → status=ok' +); +SELECT is( + ((public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'pct_cuidadores')::numeric), + 40.0, + '4.3 pct_cuidadores = 2/5 = 40.0%' +); + +-- ============================================================ +-- SECCIÓN 5: fix de _dash_responses — filtro de género contra 2.5 (no A6) +-- ============================================================ +DO $$ +DECLARE + v_survey_id uuid := '30000000-1111-0000-0000-000000000001'; + v_q25 uuid := '40000000-1111-0000-0000-000000000025'; + v_cr_id uuid := '50000000-1111-0000-0000-000000000001'; + v_rg uuid := '60000000-1111-0000-0000-000000000006'; +BEGIN + INSERT INTO public.responses (id, survey_id, company_id, consent_record_id) + VALUES (v_rg, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id); + INSERT INTO public.answers (response_id, question_id, instance_id, value) + VALUES (v_rg, v_q25, 'default', '"Femenino"'::jsonb); +END; +$$; + +SELECT is( + (SELECT count(*)::int FROM public._dash_responses( + '30000000-1111-0000-0000-000000000001'::uuid, NULL, ARRAY['Femenino'], NULL, NULL + ) WHERE response_id = '60000000-1111-0000-0000-000000000006'), + 1, + '5.1 filtro p_genero=[Femenino] encuentra la response con 2.5=Femenino (post-fix)' +); +SELECT is( + (SELECT count(*)::int FROM public._dash_responses( + '30000000-1111-0000-0000-000000000001'::uuid, NULL, ARRAY['Masculino'], NULL, NULL + ) WHERE response_id = '60000000-1111-0000-0000-000000000006'), + 0, + '5.2 filtro p_genero=[Masculino] NO encuentra esa response (control negativo)' +); + +-- ============================================================ +-- SECCIÓN 6: umbral parametrizable — métrica estrella +-- 1 cuidador con exactamente 1 de 3 señales (riesgo de rotación, vía 7.6) +-- ============================================================ +DO $$ +DECLARE + v_survey_id uuid := '30000000-1111-0000-0000-000000000001'; + v_q41 uuid := '40000000-1111-0000-0000-000000000041'; + v_q76 uuid := '40000000-1111-0000-0000-000000000076'; + v_q73 uuid := '40000000-1111-0000-0000-000000000073'; + v_q75 uuid := '40000000-1111-0000-0000-000000000075'; + v_cr_id uuid := '50000000-1111-0000-0000-000000000001'; + v_r1 uuid := '60000000-2222-0000-0000-000000000001'; + v_r2 uuid := '60000000-2222-0000-0000-000000000002'; + v_r3 uuid := '60000000-2222-0000-0000-000000000003'; + v_r4 uuid := '60000000-2222-0000-0000-000000000004'; + v_r5 uuid := '60000000-2222-0000-0000-000000000005'; +BEGIN + INSERT INTO public.responses (id, survey_id, company_id, consent_record_id) + VALUES (v_r1, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r2, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r3, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r4, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id), + (v_r5, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id); + + -- Las 5 son cuidadoras (4.1 con un vínculo real) y respondieron 7.6 + -- (ancla de la población), pero NINGUNA cumple 2+ señales — cada una + -- cumple EXACTAMENTE 1 (riesgo de rotación), 0 en ausentismo/carrera. + INSERT INTO public.answers (response_id, question_id, instance_id, value) + SELECT r, v_q41, 'default', '["Otro / Especificar"]'::jsonb + FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r; + + INSERT INTO public.answers (response_id, question_id, instance_id, value) + SELECT r, v_q76, 'default', '"Muy probable"'::jsonb + FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r; + + INSERT INTO public.answers (response_id, question_id, instance_id, value) + SELECT r, v_q73, 'default', '"Nunca"'::jsonb + FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r; + + INSERT INTO public.answers (response_id, question_id, instance_id, value) + SELECT r, v_q75, 'default', '["Ninguna de las anteriores"]'::jsonb + FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r; +END; +$$; + +-- Con el umbral default (2 de 3): nadie llega → 0% +SELECT is( + ((public.rpc_me_talento_en_riesgo('30000000-1111-0000-0000-000000000001'::uuid)->>'pct_talento_riesgo')::numeric), + 0.0, + '6.1 umbral default (2 de 3): 0% — nadie acumula 2 señales' +); +-- Bajando el umbral a 1 de 3: las 5 cumplen (todas tienen la señal de riesgo) → 100% +SELECT is( + ((public.rpc_me_talento_en_riesgo( + '30000000-1111-0000-0000-000000000001'::uuid, '{}'::jsonb, 1 + )->>'pct_talento_riesgo')::numeric), + 100.0, + '6.2 umbral=1 (parámetro explícito): 100% — el parámetro SÍ cambia el resultado' +); +SELECT is( + ((public.rpc_me_talento_en_riesgo('30000000-1111-0000-0000-000000000001'::uuid)->>'criterios_promedio')::numeric), + 1.00, + '6.3 criterios_promedio = 1.00 (cada cuidadora cumple exactamente 1 de 3 señales)' +); + +-- ============================================================ +-- SECCIÓN 7: E9 (rpc_me_segmentacion) — por_nivel via qi_sector, +-- por_modalidad via JOIN a 2.7, k≥5 por segmento +-- ============================================================ +DO $$ +DECLARE + v_survey_id uuid := '30000000-1111-0000-0000-000000000001'; + v_q27 uuid := '40000000-1111-0000-0000-000000000027'; + v_cr_id uuid := '50000000-1111-0000-0000-000000000001'; + v_r uuid; + i int; +BEGIN + INSERT INTO public.questions (id, survey_id, code, type, prompt, position) + VALUES (v_q27, v_survey_id, '2.7', 'single_choice', 'modalidad', 27); + + -- 6 responses "Operativo"/"Presencial" (n=6 >= 5 → visible) + FOR i IN 1..6 LOOP + v_r := ('60000000-3333-0000-0000-' || lpad(i::text, 12, '0'))::uuid; + INSERT INTO public.responses (id, survey_id, company_id, consent_record_id, qi_sector) + VALUES (v_r, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id, 'Operativo'); + INSERT INTO public.answers (response_id, question_id, instance_id, value) + VALUES (v_r, v_q27, 'default', '"Presencial"'::jsonb); + END LOOP; + + -- 3 responses "Gerencial"/"Remoto (home office)" (n=3 < 5 → omitido) + FOR i IN 1..3 LOOP + v_r := ('60000000-4444-0000-0000-' || lpad(i::text, 12, '0'))::uuid; + INSERT INTO public.responses (id, survey_id, company_id, consent_record_id, qi_sector) + VALUES (v_r, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id, 'Gerencial'); + INSERT INTO public.answers (response_id, question_id, instance_id, value) + VALUES (v_r, v_q27, 'default', '"Remoto (home office)"'::jsonb); + END LOOP; +END; +$$; + +SELECT is( + jsonb_path_query_first( + public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid), + '$.por_nivel[*] ? (@.nivel == "Operativo")' + )->>'n', + '6', + '7.1 por_nivel lee qi_sector — "Operativo" aparece con n=6 (post-fix)' +); +SELECT is( + jsonb_path_query_first( + public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid), + '$.por_nivel[*] ? (@.nivel == "Gerencial")' + ), + NULL::jsonb, + '7.2 "Gerencial" (n=3 < 5) está omitido de por_nivel — k-anonimato por segmento' +); +SELECT is( + jsonb_path_query_first( + public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid), + '$.por_modalidad[*] ? (@.modalidad == "Presencial")' + )->>'n', + '6', + '7.3 por_modalidad lee 2.7 por JOIN a answers — "Presencial" aparece con n=6' +); +SELECT is( + jsonb_path_query_first( + public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid), + '$.por_modalidad[*] ? (@.modalidad == "Remoto (home office)")' + ), + NULL::jsonb, + '7.4 "Remoto (home office)" (n=3 < 5) está omitido de por_modalidad' +); + +SELECT * FROM finish(); +ROLLBACK;