panels-origin/db/core/changesets/030-rpc-work-program.sql
Alberto Martinez 496273b41c feat: Programa de obra por proyecto
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Implementa el módulo **Programa de obra** por proyecto, vinculado al presupuesto ya existente.

### Flujo
1. Cargar presupuesto (requisito)
2. Definir fechas de inicio/término en el proyecto
3. Crear programa: **importar Excel Neodata/Opus**, **generar automático** o **crear vacío**
4. Editar en cuadrícula (conceptos con `Barra=` y partidas con erogaciones semanales)
5. Visualizar Gantt resumido y comparativo **programado vs ejecutado**

### Backend
- Tablas: `work_programs`, `work_program_periods`, `work_program_concept_slots`, `work_program_partida_amounts`, `work_program_progress`
- RPC: list/get, replace (import), generate (wizard), update celdas, recalculate partidas, vs-cost
- Parser Excel para formatos **por concepto** (`Barra=`) y **por partida** (montos semanales)
- Tests con fixtures reales del usuario

### Frontend
- Nueva página `/programa-obra` con pestañas: Conceptos, Partidas, Gantt, Programado vs costos
- Diálogos de importación y generación automática
- Menú principal, enlace en ficha de proyecto, banner en presupuesto

### Permisos
Reutiliza `manage_budget` en MVP.

## Test plan
- [x] `deno test api/work_program_excel_test.ts` (5 tests)
- [x] `deno check` API modules
- [x] `npm run build` web-panel
- [ ] Migrar DB (`029`, `030`) en entorno con Postgres
- [ ] Importar Excel por concepto y por partida en obra con presupuesto
- [ ] Generar automático con estrategia keyword
- [ ] Recalcular partidas desde conceptos
- [ ] Verificar tab Programado vs costos con gastos registrados
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-3985fe74-d26b-4506-bd4d-f9ad57374141?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-3985fe74-d26b-4506-bd4d-f9ad57374141&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-08 04:51:09 +00:00

835 lines
33 KiB
PL/PgSQL

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