--liquibase formatted sql -- PANELS · core · RPC programa de obra --changeset panel:core-030a-wp-generate-periods splitStatements:false CREATE OR REPLACE FUNCTION core._wp_generate_periods(p_start date, p_end date) RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE v_periods jsonb := '[]'::jsonb; v_idx integer := 0; v_cur date := p_start; v_end date; BEGIN IF p_start IS NULL OR p_end IS NULL OR p_end < p_start THEN RETURN '[]'::jsonb; END IF; WHILE v_cur <= p_end LOOP v_idx := v_idx + 1; v_end := LEAST(v_cur + 6, p_end); v_periods := v_periods || jsonb_build_array(jsonb_build_object( 'period_index', v_idx, 'label', format('Sem %s', v_idx), 'start_date', v_cur, 'end_date', v_end )); v_cur := v_cur + 7; END LOOP; RETURN v_periods; END; $$; --changeset panel:core-030b-wp-assert-project splitStatements:false CREATE OR REPLACE FUNCTION core._wp_assert_project_ready(p_project_id bigint) RETURNS jsonb LANGUAGE plpgsql STABLE SET search_path = core AS $$ DECLARE v_start date; v_end date; v_item_count integer; BEGIN SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = p_project_id; IF NOT FOUND THEN RETURN core.rpc_err('NOT_FOUND', format('Proyecto id=%s no encontrado', p_project_id), jsonb_build_object('project_id', p_project_id)); END IF; IF v_start IS NULL OR v_end IS NULL THEN RETURN core.rpc_err('VALIDATION', 'El proyecto debe tener fecha de inicio y término para el programa de obra', jsonb_build_object('project_id', p_project_id, 'field', 'dates')); END IF; IF v_end < v_start THEN RETURN core.rpc_err('VALIDATION', 'La fecha de término debe ser posterior al inicio', jsonb_build_object('project_id', p_project_id)); END IF; SELECT COUNT(*)::integer INTO v_item_count FROM budget_items WHERE project_id = p_project_id; IF v_item_count = 0 THEN RETURN core.rpc_err('VALIDATION', 'El proyecto debe tener presupuesto cargado antes del programa de obra', jsonb_build_object('project_id', p_project_id, 'field', 'budget')); END IF; RETURN NULL; END; $$; --changeset panel:core-030c-wp-slot-weight splitStatements:false CREATE OR REPLACE FUNCTION core._wp_slot_weight(p_start_pct numeric, p_end_pct numeric) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT GREATEST(0, COALESCE(p_end_pct, 0) - COALESCE(p_start_pct, 0)) / 100.0; $$; --changeset panel:core-030d-fn-work-program-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_program jsonb; v_periods jsonb; v_budget_total numeric(14,2); v_scheduled jsonb; v_weekly jsonb; v_cumulative numeric := 0; v_row jsonb; v_arr jsonb := '[]'::jsonb; v_pct_period numeric; v_pct_cum numeric; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_get: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_get')); END IF; SELECT to_jsonb(wp) INTO v_program FROM work_programs wp WHERE wp.project_id = v_project_id; IF v_program IS NULL THEN SELECT COALESCE(SUM(amount), 0) INTO v_budget_total FROM budget_items WHERE project_id = v_project_id; RETURN core.rpc_ok( jsonb_build_object( 'program', NULL, 'periods', '[]'::jsonb, 'budget_subtotal', v_budget_total, 'curve', '[]'::jsonb ), format('Proyecto %s sin programa de obra', v_project_id), jsonb_build_object('fn', 'fn_work_program_get', 'project_id', v_project_id) ); END IF; SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods FROM work_program_periods p WHERE p.program_id = (v_program->>'id')::bigint; SELECT COALESCE(SUM(amount), 0) INTO v_budget_total FROM budget_items WHERE project_id = v_project_id; SELECT COALESCE(jsonb_agg(jsonb_build_object( 'period_index', pr.period_index, 'label', pr.label, 'amount', round(COALESCE(SUM(pa.amount), 0), 2) ) ORDER BY pr.period_index), '[]'::jsonb) INTO v_scheduled FROM work_program_periods pr LEFT JOIN work_program_partida_amounts pa ON pa.period_id = pr.id WHERE pr.program_id = (v_program->>'id')::bigint GROUP BY pr.period_index, pr.label; FOR v_row IN SELECT value FROM jsonb_array_elements(v_scheduled) LOOP v_pct_period := CASE WHEN v_budget_total > 0 THEN round((v_row.value->>'amount')::numeric / v_budget_total, 6) ELSE 0 END; v_cumulative := v_cumulative + (v_row.value->>'amount')::numeric; v_pct_cum := CASE WHEN v_budget_total > 0 THEN round(v_cumulative / v_budget_total, 6) ELSE 0 END; v_arr := v_arr || jsonb_build_array(jsonb_build_object( 'period_index', v_row.value->'period_index', 'label', v_row.value->'label', 'amount', v_row.value->'amount', 'pct_period', v_pct_period, 'pct_cumulative', v_pct_cum )); END LOOP; RETURN core.rpc_ok( jsonb_build_object( 'program', v_program, 'periods', v_periods, 'budget_subtotal', v_budget_total, 'curve', v_arr ), format('Programa de obra del proyecto %s', v_project_id), jsonb_build_object('fn', 'fn_work_program_get', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_get', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030e-fn-work-program-replace splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_replace(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_source text := COALESCE(nullif(btrim(payload->>'source'), ''), 'import'); v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'active'); v_start date; v_end date; v_program_id bigint; v_period jsonb; v_period_id bigint; v_period_map jsonb := '{}'::jsonb; v_slot jsonb; v_partida jsonb; v_chapter jsonb; v_ch jsonb; v_slot_count integer := 0; v_partida_count integer := 0; v_err jsonb; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_replace: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_replace')); END IF; v_err := core._wp_assert_project_ready(v_project_id); IF v_err IS NOT NULL THEN RETURN v_err; END IF; SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = v_project_id; DELETE FROM work_programs WHERE project_id = v_project_id; INSERT INTO work_programs (project_id, start_date, end_date, duration_days, source, status) VALUES (v_project_id, v_start, v_end, (v_end - v_start + 1), v_source, v_status) RETURNING id INTO v_program_id; FOR v_period IN SELECT * FROM jsonb_array_elements(core._wp_generate_periods(v_start, v_end)) LOOP INSERT INTO work_program_periods (program_id, period_index, label, start_date, end_date) VALUES ( v_program_id, (v_period->>'period_index')::integer, v_period->>'label', (v_period->>'start_date')::date, (v_period->>'end_date')::date ) RETURNING id INTO v_period_id; v_period_map := v_period_map || jsonb_build_object(v_period->>'period_index', v_period_id); END LOOP; FOR v_slot IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'concept_slots', '[]'::jsonb)) LOOP v_period_id := (v_period_map->>(v_slot->>'period_index'))::bigint; IF v_period_id IS NULL THEN CONTINUE; END IF; IF NOT EXISTS ( SELECT 1 FROM budget_items bi WHERE bi.id = (v_slot->>'budget_item_id')::bigint AND bi.project_id = v_project_id ) THEN CONTINUE; END IF; IF core._wp_slot_weight((v_slot->>'start_pct')::numeric, (v_slot->>'end_pct')::numeric) <= 0 THEN CONTINUE; END IF; INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct) VALUES ( v_program_id, (v_slot->>'budget_item_id')::bigint, v_period_id, COALESCE((v_slot->>'start_pct')::numeric, 0), COALESCE((v_slot->>'end_pct')::numeric, 0) ); v_slot_count := v_slot_count + 1; END LOOP; FOR v_ch IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'chapters_to_create', '[]'::jsonb)) LOOP INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES ( v_project_id, NULLIF(v_ch->>'parent_id', '')::bigint, upper(COALESCE(nullif(btrim(v_ch->>'code'), ''), '')), COALESCE(nullif(btrim(v_ch->>'name'), ''), 'Partida'), COALESCE(nullif(btrim(v_ch->>'wbs'), ''), ''), COALESCE((v_ch->>'sort_order')::integer, 0) ); END LOOP; FOR v_partida IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'partida_amounts', '[]'::jsonb)) LOOP v_period_id := (v_period_map->>(v_partida->>'period_index'))::bigint; IF v_period_id IS NULL THEN CONTINUE; END IF; IF NOT EXISTS ( SELECT 1 FROM budget_chapters bc WHERE bc.id = (v_partida->>'budget_chapter_id')::bigint AND bc.project_id = v_project_id ) THEN CONTINUE; END IF; IF COALESCE((v_partida->>'amount')::numeric, 0) = 0 THEN CONTINUE; END IF; INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount) VALUES ( v_program_id, (v_partida->>'budget_chapter_id')::bigint, v_period_id, round(COALESCE((v_partida->>'amount')::numeric, 0), 2) ); v_partida_count := v_partida_count + 1; END LOOP; RETURN core.rpc_ok( jsonb_build_object( 'program_id', v_program_id, 'concept_slots', v_slot_count, 'partida_amounts', v_partida_count, 'period_count', jsonb_object_length(v_period_map) ), format('Programa de obra del proyecto %s reemplazado (%s slots, %s erogaciones)', v_project_id, v_slot_count, v_partida_count), jsonb_build_object('fn', 'fn_work_program_replace', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_replace', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030f-fn-work-program-list-concepts splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_list_concepts(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_program_id bigint; v_periods jsonb; v_rows jsonb; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_list_concepts: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_list_concepts')); END IF; SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_ok(jsonb_build_object('periods', '[]'::jsonb, 'items', '[]'::jsonb), format('Proyecto %s sin programa', v_project_id), jsonb_build_object('fn', 'fn_work_program_list_concepts', 'project_id', v_project_id)); END IF; SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods FROM work_program_periods p WHERE p.program_id = v_program_id; SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.ch_sort, x.sort_order, x.id), '[]'::jsonb) INTO v_rows FROM ( SELECT bi.id, bi.code, bi.description, bi.unit, bi.amount, bi.chapter_id, ch.code AS chapter_code, ch.name AS chapter_name, ch.sort_order AS ch_sort, bi.sort_order, COALESCE(( SELECT jsonb_agg(jsonb_build_object( 'period_id', pr.id, 'period_index', pr.period_index, 'start_pct', COALESCE(cs.start_pct, 0), 'end_pct', COALESCE(cs.end_pct, 0) ) ORDER BY pr.period_index) FROM work_program_periods pr LEFT JOIN work_program_concept_slots cs ON cs.period_id = pr.id AND cs.budget_item_id = bi.id AND cs.program_id = v_program_id WHERE pr.program_id = v_program_id ), '[]'::jsonb) AS periods, COALESCE(( SELECT jsonb_agg(jsonb_build_object( 'period_id', pr.id, 'period_index', pr.period_index, 'pct_complete', COALESCE(pg.pct_complete, 0) ) ORDER BY pr.period_index) FROM work_program_periods pr LEFT JOIN work_program_progress pg ON pg.period_id = pr.id AND pg.budget_item_id = bi.id AND pg.program_id = v_program_id WHERE pr.program_id = v_program_id ), '[]'::jsonb) AS progress FROM budget_items bi LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id WHERE bi.project_id = v_project_id ) x; RETURN core.rpc_ok( jsonb_build_object('periods', v_periods, 'items', v_rows), format('Conceptos del programa del proyecto %s', v_project_id), jsonb_build_object('fn', 'fn_work_program_list_concepts', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_list_concepts', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030g-fn-work-program-list-partidas splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_list_partidas(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_program_id bigint; v_periods jsonb; v_rows jsonb; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_list_partidas: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_list_partidas')); END IF; SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_ok(jsonb_build_object('periods', '[]'::jsonb, 'chapters', '[]'::jsonb), format('Proyecto %s sin programa', v_project_id), jsonb_build_object('fn', 'fn_work_program_list_partidas', 'project_id', v_project_id)); END IF; SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods FROM work_program_periods p WHERE p.program_id = v_program_id; SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.sort_order, x.id), '[]'::jsonb) INTO v_rows FROM ( SELECT bc.id, bc.code, bc.name, bc.sort_order, COALESCE(SUM(bi.amount), 0) AS budget_amount, COALESCE(( SELECT jsonb_agg(jsonb_build_object( 'period_id', pr.id, 'period_index', pr.period_index, 'amount', round(COALESCE(pa.amount, 0), 2) ) ORDER BY pr.period_index) FROM work_program_periods pr LEFT JOIN work_program_partida_amounts pa ON pa.period_id = pr.id AND pa.budget_chapter_id = bc.id AND pa.program_id = v_program_id WHERE pr.program_id = v_program_id ), '[]'::jsonb) AS periods FROM budget_chapters bc LEFT JOIN budget_items bi ON bi.chapter_id = bc.id WHERE bc.project_id = v_project_id GROUP BY bc.id, bc.code, bc.name, bc.sort_order ) x; RETURN core.rpc_ok( jsonb_build_object('periods', v_periods, 'chapters', v_rows), format('Partidas del programa del proyecto %s', v_project_id), jsonb_build_object('fn', 'fn_work_program_list_partidas', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_list_partidas', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030h-fn-work-program-update-concept splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_update_concept(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; v_period_id bigint := NULLIF(payload->>'period_id', '')::bigint; v_program_id bigint; v_start numeric := COALESCE((payload->>'start_pct')::numeric, 0); v_end numeric := COALESCE((payload->>'end_pct')::numeric, 0); BEGIN IF v_project_id IS NULL OR v_item_id IS NULL OR v_period_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'project_id, budget_item_id y period_id son obligatorios', jsonb_build_object('fn', 'fn_work_program_update_concept')); END IF; SELECT wp.id INTO v_program_id FROM work_programs wp WHERE wp.project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto', jsonb_build_object('fn', 'fn_work_program_update_concept', 'project_id', v_project_id)); END IF; IF core._wp_slot_weight(v_start, v_end) <= 0 THEN DELETE FROM work_program_concept_slots WHERE program_id = v_program_id AND budget_item_id = v_item_id AND period_id = v_period_id; ELSE INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct) VALUES (v_program_id, v_item_id, v_period_id, v_start, v_end) ON CONFLICT (program_id, budget_item_id, period_id) DO UPDATE SET start_pct = EXCLUDED.start_pct, end_pct = EXCLUDED.end_pct; END IF; UPDATE work_programs SET updated_at = now(), source = 'manual' WHERE id = v_program_id; RETURN core.rpc_ok( jsonb_build_object('budget_item_id', v_item_id, 'period_id', v_period_id, 'start_pct', v_start, 'end_pct', v_end), format('Slot de concepto %s actualizado', v_item_id), jsonb_build_object('fn', 'fn_work_program_update_concept', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_update_concept', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030i-fn-work-program-update-partida splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_update_partida(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint; v_period_id bigint := NULLIF(payload->>'period_id', '')::bigint; v_program_id bigint; v_amount numeric := round(COALESCE((payload->>'amount')::numeric, 0), 2); BEGIN IF v_project_id IS NULL OR v_chapter_id IS NULL OR v_period_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'project_id, budget_chapter_id y period_id son obligatorios', jsonb_build_object('fn', 'fn_work_program_update_partida')); END IF; SELECT wp.id INTO v_program_id FROM work_programs wp WHERE wp.project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto', jsonb_build_object('fn', 'fn_work_program_update_partida', 'project_id', v_project_id)); END IF; IF v_amount = 0 THEN DELETE FROM work_program_partida_amounts WHERE program_id = v_program_id AND budget_chapter_id = v_chapter_id AND period_id = v_period_id; ELSE INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount) VALUES (v_program_id, v_chapter_id, v_period_id, v_amount) ON CONFLICT (program_id, budget_chapter_id, period_id) DO UPDATE SET amount = EXCLUDED.amount; END IF; UPDATE work_programs SET updated_at = now(), source = 'manual' WHERE id = v_program_id; RETURN core.rpc_ok( jsonb_build_object('budget_chapter_id', v_chapter_id, 'period_id', v_period_id, 'amount', v_amount), format('Erogación de partida %s actualizada', v_chapter_id), jsonb_build_object('fn', 'fn_work_program_update_partida', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_update_partida', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030j-fn-work-program-update-progress splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_update_progress(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; v_period_id bigint := NULLIF(payload->>'period_id', '')::bigint; v_program_id bigint; v_pct numeric := COALESCE((payload->>'pct_complete')::numeric, 0); BEGIN IF v_project_id IS NULL OR v_item_id IS NULL OR v_period_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'project_id, budget_item_id y period_id son obligatorios', jsonb_build_object('fn', 'fn_work_program_update_progress')); END IF; SELECT wp.id INTO v_program_id FROM work_programs wp WHERE wp.project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto', jsonb_build_object('fn', 'fn_work_program_update_progress', 'project_id', v_project_id)); END IF; IF v_pct <= 0 THEN DELETE FROM work_program_progress WHERE program_id = v_program_id AND budget_item_id = v_item_id AND period_id = v_period_id; ELSE INSERT INTO work_program_progress (program_id, budget_item_id, period_id, pct_complete) VALUES (v_program_id, v_item_id, v_period_id, LEAST(100, GREATEST(0, v_pct))) ON CONFLICT (program_id, budget_item_id, period_id) DO UPDATE SET pct_complete = EXCLUDED.pct_complete; END IF; RETURN core.rpc_ok( jsonb_build_object('budget_item_id', v_item_id, 'period_id', v_period_id, 'pct_complete', v_pct), format('Avance físico del concepto %s actualizado', v_item_id), jsonb_build_object('fn', 'fn_work_program_update_progress', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_update_progress', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030k-fn-work-program-recalculate splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_recalculate_partidas(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_program_id bigint; v_updated integer := 0; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_recalculate_partidas: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_recalculate_partidas')); END IF; SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto', jsonb_build_object('fn', 'fn_work_program_recalculate_partidas', 'project_id', v_project_id)); END IF; DELETE FROM work_program_partida_amounts WHERE program_id = v_program_id; WITH item_weights AS ( SELECT cs.budget_item_id, cs.period_id, bi.chapter_id, bi.amount AS item_amount, core._wp_slot_weight(cs.start_pct, cs.end_pct) AS slot_weight, SUM(core._wp_slot_weight(cs.start_pct, cs.end_pct)) OVER (PARTITION BY cs.budget_item_id) AS total_weight FROM work_program_concept_slots cs JOIN budget_items bi ON bi.id = cs.budget_item_id WHERE cs.program_id = v_program_id AND bi.chapter_id IS NOT NULL ), allocations AS ( SELECT iw.chapter_id, iw.period_id, round(SUM( CASE WHEN iw.total_weight > 0 THEN iw.item_amount * iw.slot_weight / iw.total_weight ELSE 0 END ), 2) AS amount FROM item_weights iw GROUP BY iw.chapter_id, iw.period_id HAVING SUM( CASE WHEN iw.total_weight > 0 THEN iw.item_amount * iw.slot_weight / iw.total_weight ELSE 0 END ) > 0 ) INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount) SELECT v_program_id, a.chapter_id, a.period_id, a.amount FROM allocations a; GET DIAGNOSTICS v_updated = ROW_COUNT; UPDATE work_programs SET updated_at = now() WHERE id = v_program_id; RETURN core.rpc_ok( jsonb_build_object('updated', v_updated), format('Recalculadas %s erogaciones por partida desde conceptos', v_updated), jsonb_build_object('fn', 'fn_work_program_recalculate_partidas', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_recalculate_partidas', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030l-fn-work-program-generate splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_generate(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_strategy text := COALESCE(nullif(btrim(payload->>'strategy'), ''), 'wbs'); v_start date; v_end date; v_period_count integer; v_program_id bigint; v_period jsonb; v_period_id bigint; v_period_map jsonb := '{}'::jsonb; v_item record; v_ch record; v_idx integer; v_start_week integer; v_end_week integer; v_name text; v_slots jsonb := '[]'::jsonb; v_err jsonb; v_total_items integer; v_item_idx integer := 0; BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_generate: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_generate')); END IF; v_err := core._wp_assert_project_ready(v_project_id); IF v_err IS NOT NULL THEN RETURN v_err; END IF; SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = v_project_id; v_period_count := GREATEST(1, CEIL((v_end - v_start + 1)::numeric / 7)::integer); SELECT COUNT(*)::integer INTO v_total_items FROM budget_items WHERE project_id = v_project_id; FOR v_item IN SELECT bi.id, bi.code, bi.description, bi.amount, COALESCE(ch.sort_order, 9999) AS ch_sort, bi.sort_order FROM budget_items bi LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id WHERE bi.project_id = v_project_id ORDER BY COALESCE(ch.sort_order, 9999), bi.sort_order, bi.id LOOP v_item_idx := v_item_idx + 1; v_name := upper(COALESCE(v_item.description, '') || ' ' || COALESCE((SELECT name FROM budget_chapters WHERE id = ( SELECT chapter_id FROM budget_items WHERE id = v_item.id )), '')); IF v_strategy = 'amount' THEN v_start_week := GREATEST(1, LEAST(v_period_count, CEIL(v_item_idx::numeric / v_total_items * v_period_count)::integer)); v_end_week := GREATEST(v_start_week, LEAST(v_period_count, CEIL((v_item_idx + 1)::numeric / v_total_items * v_period_count)::integer)); ELSIF v_strategy = 'keyword' THEN IF v_name ~ 'PRELIMIN|TRAZO|DEMOL|ALBA' THEN v_start_week := 1; v_end_week := GREATEST(1, LEAST(2, v_period_count)); ELSIF v_name ~ 'HIDRO|SANIT|ELECT|CONTRA|INTERNET|TV|DATOS' THEN v_start_week := GREATEST(1, LEAST(2, v_period_count)); v_end_week := GREATEST(v_start_week, LEAST(CEIL(v_period_count * 0.7)::integer, v_period_count)); ELSIF v_name ~ 'TABLAR|PLAFON|MURO' THEN v_start_week := GREATEST(1, LEAST(CEIL(v_period_count * 0.35)::integer, v_period_count)); v_end_week := GREATEST(v_start_week, LEAST(CEIL(v_period_count * 0.85)::integer, v_period_count)); ELSIF v_name ~ 'PINTUR|ACABAD|WPC|PASTA' THEN v_start_week := GREATEST(1, LEAST(CEIL(v_period_count * 0.55)::integer, v_period_count)); v_end_week := GREATEST(v_start_week, LEAST(CEIL(v_period_count * 0.95)::integer, v_period_count)); ELSIF v_name ~ 'CARPINT|PUERT|ZOCLO' THEN v_start_week := GREATEST(1, LEAST(CEIL(v_period_count * 0.75)::integer, v_period_count)); v_end_week := v_period_count; ELSE v_start_week := GREATEST(1, LEAST(v_period_count, CEIL(v_item_idx::numeric / v_total_items * v_period_count)::integer)); v_end_week := GREATEST(v_start_week, LEAST(v_period_count, v_start_week + 1)); END IF; ELSE v_start_week := GREATEST(1, LEAST(v_period_count, CEIL(v_item_idx::numeric / v_total_items * v_period_count)::integer)); v_end_week := GREATEST(v_start_week, LEAST(v_period_count, v_start_week + GREATEST(0, CEIL(v_period_count::numeric / v_total_items)::integer - 1))); END IF; FOR v_idx IN v_start_week..v_end_week LOOP v_slots := v_slots || jsonb_build_array(jsonb_build_object( 'budget_item_id', v_item.id, 'period_index', v_idx, 'start_pct', CASE WHEN v_idx = v_start_week AND v_start_week = v_end_week THEN 0 WHEN v_idx = v_start_week THEN 14.29 ELSE 0 END, 'end_pct', CASE WHEN v_idx = v_end_week THEN 100 ELSE 100 END )); END LOOP; END LOOP; PERFORM core.fn_work_program_replace(jsonb_build_object( 'project_id', v_project_id, 'source', 'wizard', 'status', 'active', 'concept_slots', v_slots, 'partida_amounts', '[]'::jsonb )); SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id; PERFORM core.fn_work_program_recalculate_partidas(jsonb_build_object('project_id', v_project_id)); RETURN core.rpc_ok( jsonb_build_object('program_id', v_program_id, 'strategy', v_strategy, 'period_count', v_period_count), format('Programa generado automáticamente para proyecto %s (%s)', v_project_id, v_strategy), jsonb_build_object('fn', 'fn_work_program_generate', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_generate', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030m-fn-work-program-vs-cost splitStatements:false CREATE OR REPLACE FUNCTION core.fn_work_program_vs_cost(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_program_id bigint; v_periods jsonb; v_by_period jsonb; v_by_chapter jsonb; v_budget_total numeric(14,2); BEGIN IF v_project_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_work_program_vs_cost: project_id es obligatorio', jsonb_build_object('fn', 'fn_work_program_vs_cost')); END IF; SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id; IF v_program_id IS NULL THEN RETURN core.rpc_ok(jsonb_build_object('periods', '[]'::jsonb, 'by_period', '[]'::jsonb, 'by_chapter', '[]'::jsonb), format('Proyecto %s sin programa', v_project_id), jsonb_build_object('fn', 'fn_work_program_vs_cost', 'project_id', v_project_id)); END IF; SELECT COALESCE(SUM(amount), 0) INTO v_budget_total FROM budget_items WHERE project_id = v_project_id; SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods FROM work_program_periods p WHERE p.program_id = v_program_id; SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.period_index), '[]'::jsonb) INTO v_by_period FROM ( SELECT pr.period_index, pr.label, pr.start_date, pr.end_date, round(COALESCE(SUM(pa.amount), 0), 2) AS scheduled_amount, round(COALESCE(( SELECT SUM(e.subtotal) FROM expense_entries e WHERE e.project_id = v_project_id AND e.status = 'confirmed' AND e.counts_toward_budget = true AND e.expense_date BETWEEN pr.start_date AND pr.end_date ), 0) + COALESCE(( SELECT SUM(m.total_cost) FROM warehouse_movements m WHERE m.project_id = v_project_id AND m.movement_type = 'exit' AND m.movement_date BETWEEN pr.start_date AND pr.end_date ), 0), 2) AS executed_amount FROM work_program_periods pr LEFT JOIN work_program_partida_amounts pa ON pa.period_id = pr.id AND pa.program_id = v_program_id WHERE pr.program_id = v_program_id GROUP BY pr.id, pr.period_index, pr.label, pr.start_date, pr.end_date ) x; SELECT COALESCE(jsonb_agg(to_jsonb(y) ORDER BY y.chapter_code), '[]'::jsonb) INTO v_by_chapter FROM ( SELECT bc.id AS chapter_id, bc.code AS chapter_code, bc.name AS chapter_name, round(COALESCE(SUM(pa.amount), 0), 2) AS scheduled_amount, round(COALESCE(SUM(core._cost_executed_amount(bi.id)), 0), 2) AS executed_amount, round(COALESCE(SUM(bi.amount), 0), 2) AS budget_amount FROM budget_chapters bc LEFT JOIN budget_items bi ON bi.chapter_id = bc.id LEFT JOIN work_program_partida_amounts pa ON pa.budget_chapter_id = bc.id AND pa.program_id = v_program_id WHERE bc.project_id = v_project_id GROUP BY bc.id, bc.code, bc.name ) y; RETURN core.rpc_ok( jsonb_build_object( 'periods', v_periods, 'budget_subtotal', v_budget_total, 'by_period', v_by_period, 'by_chapter', v_by_chapter ), format('Programado vs ejecutado del proyecto %s', v_project_id), jsonb_build_object('fn', 'fn_work_program_vs_cost', 'project_id', v_project_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_work_program_vs_cost', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-030n-wp-grants splitStatements:false GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_programs TO panels_core_app; GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_periods TO panels_core_app; GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_concept_slots TO panels_core_app; GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_partida_amounts TO panels_core_app; GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_progress TO panels_core_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;