diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 83712ea..9bf67ee 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -47,5 +47,6 @@ + diff --git a/db/core/changesets/038-fn-work-program-vs-cost.sql b/db/core/changesets/038-fn-work-program-vs-cost.sql new file mode 100644 index 0000000..1299501 --- /dev/null +++ b/db/core/changesets/038-fn-work-program-vs-cost.sql @@ -0,0 +1,103 @@ +--liquibase formatted sql +-- PANELS · core · fn_work_program_vs_cost: warehouse_movements no tiene project_id + +--changeset panel:core-038-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 + JOIN warehouses w ON w.id = m.warehouse_id + WHERE w.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(( + SELECT SUM(pa.amount) + FROM work_program_partida_amounts pa + WHERE pa.budget_chapter_id = bc.id AND pa.program_id = v_program_id + ), 0), 2) AS scheduled_amount, + round(COALESCE(( + SELECT SUM(core._cost_executed_amount(bi.id)) + FROM budget_items bi + WHERE bi.chapter_id = bc.id + ), 0), 2) AS executed_amount, + round(COALESCE(( + SELECT SUM(bi.amount) + FROM budget_items bi + WHERE bi.chapter_id = bc.id + ), 0), 2) AS budget_amount + FROM budget_chapters bc + WHERE bc.project_id = v_project_id + ) 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; +$$; diff --git a/scripts/verify-work-program-load-seed.sql b/scripts/verify-work-program-load-seed.sql new file mode 100644 index 0000000..354a996 --- /dev/null +++ b/scripts/verify-work-program-load-seed.sql @@ -0,0 +1,91 @@ +SET search_path = core; + +INSERT INTO projects (name, start_date, end_date) VALUES ('Obra test', '2026-09-07', '2026-09-19'); +-- id = 1 + +INSERT INTO budget_chapters (project_id, code, name, sort_order) VALUES (1, 'A001', 'PRELIMINARES', 1); +INSERT INTO budget_items (project_id, chapter_id, code, description, amount, sort_order) +VALUES (1, 1, '10301-001', 'Trazo', 1000, 1); + +INSERT INTO work_programs (project_id, start_date, end_date, duration_days, source, status) +VALUES (1, '2026-09-07', '2026-09-19', 13, 'import', 'active'); + +INSERT INTO work_program_periods (program_id, period_index, label, start_date, end_date) VALUES + (1, 1, 'Sem 1', '2026-09-07', '2026-09-13'), + (1, 2, 'Sem 2', '2026-09-14', '2026-09-19'); + +INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount) VALUES + (1, 1, 1, 400.00), + (1, 1, 2, 600.00); + +INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct) +VALUES (1, 1, 1, 0, 50); + +INSERT INTO expense_entries (project_id, expense_date, subtotal, status, counts_toward_budget, budget_item_id) +VALUES (1, '2026-09-08', 50.00, 'confirmed', true, 1); + +INSERT INTO warehouses (tenant_id, kind, project_id) VALUES (1, 'project', 1); +INSERT INTO warehouse_materials (tenant_id, code, name) VALUES (1, 'CEM', 'Cemento'); +INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, movement_date, quantity, total_cost, budget_item_id) +VALUES (1, 1, 1, 'exit', '2026-09-09', 1, 25.00, 1); + +DO $$ +DECLARE + v_get jsonb; + v_concepts jsonb; + v_partidas jsonb; + v_vs jsonb; + v_broken jsonb; +BEGIN + v_get := fn_work_program_get(jsonb_build_object('project_id', 1)); + IF NOT COALESCE((v_get->>'ok')::boolean, false) THEN + RAISE EXCEPTION 'fn_work_program_get failed: %', v_get->>'message'; + END IF; + IF jsonb_array_length(v_get->'data'->'curve') <> 2 THEN + RAISE EXCEPTION 'curve expected 2 periods, got %', v_get->'data'->'curve'; + END IF; + IF (v_get->'data'->'curve'->0->>'amount')::numeric <> 400 THEN + RAISE EXCEPTION 'curve week1 amount %, expected 400', v_get->'data'->'curve'->0->>'amount'; + END IF; + IF (v_get->'data'->'curve'->1->>'amount')::numeric <> 600 THEN + RAISE EXCEPTION 'curve week2 amount %, expected 600', v_get->'data'->'curve'->1->>'amount'; + END IF; + + v_concepts := fn_work_program_list_concepts(jsonb_build_object('project_id', 1)); + IF NOT COALESCE((v_concepts->>'ok')::boolean, false) THEN + RAISE EXCEPTION 'fn_work_program_list_concepts failed: %', v_concepts->>'message'; + END IF; + IF jsonb_array_length(v_concepts->'data'->'items') < 1 THEN + RAISE EXCEPTION 'concepts empty: %', v_concepts; + END IF; + + v_partidas := fn_work_program_list_partidas(jsonb_build_object('project_id', 1)); + IF NOT COALESCE((v_partidas->>'ok')::boolean, false) THEN + RAISE EXCEPTION 'fn_work_program_list_partidas failed: %', v_partidas->>'message'; + END IF; + IF (v_partidas->'data'->'chapters'->0->>'code') <> 'A001' THEN + RAISE EXCEPTION 'partida code %, expected A001', v_partidas->'data'->'chapters'->0->>'code'; + END IF; + + v_vs := fn_work_program_vs_cost(jsonb_build_object('project_id', 1)); + IF NOT COALESCE((v_vs->>'ok')::boolean, false) THEN + RAISE EXCEPTION 'fn_work_program_vs_cost failed: %', v_vs->>'message'; + END IF; + -- Sem 1: gasto 50 + salida almacén 25 = 75 + IF (v_vs->'data'->'by_period'->0->>'executed_amount')::numeric <> 75 THEN + RAISE EXCEPTION 'vs-cost week1 executed %, expected 75. payload=%', + v_vs->'data'->'by_period'->0->>'executed_amount', v_vs; + END IF; + IF (v_vs->'data'->'by_period'->0->>'scheduled_amount')::numeric <> 400 THEN + RAISE EXCEPTION 'vs-cost week1 scheduled %, expected 400', v_vs->'data'->'by_period'->0->>'scheduled_amount'; + END IF; + IF (v_vs->'data'->'by_chapter'->0->>'executed_amount')::numeric <> 75 THEN + RAISE EXCEPTION 'vs-cost chapter executed %, expected 75', v_vs->'data'->'by_chapter'->0->>'executed_amount'; + END IF; + IF (v_vs->'data'->'by_chapter'->0->>'scheduled_amount')::numeric <> 1000 THEN + RAISE EXCEPTION 'vs-cost chapter scheduled %, expected 1000', v_vs->'data'->'by_chapter'->0->>'scheduled_amount'; + END IF; + + RAISE NOTICE 'OK get+concepts+partidas+vs-cost'; +END; +$$; diff --git a/scripts/verify-work-program-load.sh b/scripts/verify-work-program-load.sh new file mode 100755 index 0000000..8f20b2e --- /dev/null +++ b/scripts/verify-work-program-load.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Corre las 4 RPCs de carga de Programa de obra contra Postgres local. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PSQL="${PSQL:-psql}" +URL="${1:-}" + +if [[ -z "$URL" ]]; then + echo "uso: $0 postgresql://user@host:port/db" >&2 + exit 2 +fi + +TMP="$(mktemp /tmp/wp-load-XXXXXX.sql)" +{ + cat "$ROOT/scripts/verify-work-program-load.sql" + sed -n '/^CREATE OR REPLACE FUNCTION core.fn_work_program_list_concepts/,/^\$\$;$/p' \ + "$ROOT/db/core/changesets/030-rpc-work-program.sql" + sed -n '/^CREATE OR REPLACE FUNCTION core.fn_work_program_list_partidas/,/^\$\$;$/p' \ + "$ROOT/db/core/changesets/030-rpc-work-program.sql" + sed -n '/^CREATE OR REPLACE FUNCTION core.fn_work_program_get/,/^\$\$;$/p' \ + "$ROOT/db/core/changesets/037-fn-work-program-get-curve-vrow.sql" + sed -n '/^CREATE OR REPLACE FUNCTION core.fn_work_program_vs_cost/,/^\$\$;$/p' \ + "$ROOT/db/core/changesets/038-fn-work-program-vs-cost.sql" + cat "$ROOT/scripts/verify-work-program-load-seed.sql" +} > "$TMP" +chmod 644 "$TMP" + +"$PSQL" "$URL" -v ON_ERROR_STOP=1 -f "$TMP" +rm -f "$TMP" +echo "verify-work-program-load: OK" diff --git a/scripts/verify-work-program-load.sql b/scripts/verify-work-program-load.sql new file mode 100644 index 0000000..bb5ad04 --- /dev/null +++ b/scripts/verify-work-program-load.sql @@ -0,0 +1,114 @@ +-- Verifica las 4 lecturas que carga Programa de obra (get, concepts, partidas, vs-cost). +-- Esquema mínimo con las columnas reales. No es Liquibase. + +DROP SCHEMA IF EXISTS core CASCADE; +CREATE SCHEMA core; +SET search_path = core; + +CREATE FUNCTION rpc_generic_messages() RETURNS text[] LANGUAGE sql IMMUTABLE AS $$ + SELECT ARRAY['OK']::text[]; +$$; +CREATE FUNCTION rpc_assert_message(p_message text) RETURNS void LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF p_message IS NULL OR btrim(p_message) = '' THEN RAISE EXCEPTION 'empty'; END IF; +END; +$$; +CREATE FUNCTION rpc_ok(p_data jsonb, p_message text, p_ctx jsonb DEFAULT '{}'::jsonb) RETURNS jsonb +LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + PERFORM rpc_assert_message(p_message); + RETURN jsonb_build_object('ok', true, 'code', 'OK', 'message', p_message, 'data', p_data); +END; +$$; +CREATE FUNCTION rpc_err(p_code text, p_message text, p_ctx jsonb DEFAULT '{}'::jsonb, p_errors jsonb DEFAULT NULL) RETURNS jsonb +LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + PERFORM rpc_assert_message(p_message); + RETURN jsonb_build_object('ok', false, 'code', p_code, 'message', p_message, 'data', NULL); +END; +$$; +CREATE FUNCTION rpc_from_exception(p_fn text, p_sqlstate text, p_message text, p_detail text DEFAULT NULL) RETURNS jsonb +LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + RETURN rpc_err('INTERNAL', format('Error inesperado en %s (sqlstate=%s): %s', p_fn, p_sqlstate, p_message), + jsonb_build_object('fn', p_fn, 'sqlstate', p_sqlstate)); +END; +$$; + +CREATE TABLE companies (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text); +CREATE TABLE projects ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text, status text DEFAULT 'activo', tenant_id int, start_date date, end_date date +); +CREATE TABLE budget_chapters ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id BIGINT REFERENCES projects(id), + code text, name text, sort_order int DEFAULT 0 +); +CREATE TABLE budget_items ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id BIGINT REFERENCES projects(id), + chapter_id BIGINT REFERENCES budget_chapters(id), + code text, description text, unit text DEFAULT '', amount numeric(14,2) DEFAULT 0, sort_order int DEFAULT 0 +); +CREATE TABLE work_programs ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id BIGINT UNIQUE REFERENCES projects(id), + start_date date, end_date date, duration_days int, source text, status text +); +CREATE TABLE work_program_periods ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + program_id BIGINT REFERENCES work_programs(id), + period_index int, label text, start_date date, end_date date +); +CREATE TABLE work_program_partida_amounts ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + program_id BIGINT REFERENCES work_programs(id), + budget_chapter_id BIGINT REFERENCES budget_chapters(id), + period_id BIGINT REFERENCES work_program_periods(id), + amount numeric(14,2) +); +CREATE TABLE work_program_concept_slots ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + program_id BIGINT, budget_item_id BIGINT, period_id BIGINT, start_pct numeric, end_pct numeric +); +CREATE TABLE work_program_progress ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + program_id BIGINT, budget_item_id BIGINT, period_id BIGINT, pct_complete numeric +); +CREATE TABLE expense_entries ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id BIGINT, expense_date date, subtotal numeric(14,2), + status text, counts_toward_budget boolean, budget_item_id BIGINT +); +CREATE TABLE warehouses ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id int, kind text, project_id BIGINT +); +CREATE TABLE warehouse_materials ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_id int, code text, name text +); +CREATE TABLE warehouse_movements ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id int, + warehouse_id BIGINT REFERENCES warehouses(id), + material_id BIGINT, + movement_type text, + movement_date date, + quantity numeric, + total_cost numeric(14,2), + budget_item_id BIGINT +); + +CREATE FUNCTION _cost_executed_amount(p_budget_item_id bigint) RETURNS numeric +LANGUAGE sql STABLE SET search_path = core AS $$ + SELECT round(COALESCE( + (SELECT SUM(e.subtotal) FROM expense_entries e + WHERE e.budget_item_id = p_budget_item_id AND e.status = 'confirmed' AND e.counts_toward_budget = true), + 0 + ) + COALESCE( + (SELECT SUM(m.total_cost) FROM warehouse_movements m + WHERE m.budget_item_id = p_budget_item_id AND m.movement_type = 'exit'), + 0 + ), 2); +$$;