mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 23:03:18 +00:00
<!-- 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> <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> </div>
835 lines
33 KiB
PL/PgSQL
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;
|