From 0045ccd8ed3731289a96f1807260a21c5e6293e2 Mon Sep 17 00:00:00 2001
From: Alberto Martinez <349769123@users.noreply.cursor.com>
Date: Tue, 8 Sep 2026 21:55:34 +0000
Subject: [PATCH] fix: programado vs costo (warehouse_movements.project_id no
existe)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Problema
Tras cargar el programa, la pantalla pide en paralelo get / concepts / partidas / **vs-cost**. El vs-cost fallaba:
`fn_work_program_vs_cost (sqlstate=42703): column m.project_id does not exist`
`warehouse_movements` no tiene `project_id`. El proyecto está en `warehouses.project_id`.
## Cambio (changeset 038)
- Salidas de almacén por `JOIN warehouses w ON w.id = m.warehouse_id WHERE w.project_id = …`
- Ejecutado por partida **sin duplicar** al cruzar varias semanas (el join anterior multiplicaba el monto)
## Prueba local (hecha antes de pedir merge)
En Postgres 16 corrí las **4 RPCs** que usa la página, con presupuesto, 2 semanas, gasto $50 y salida de almacén $25:
- `fn_work_program_get` — curva 400 / 600
- `fn_work_program_list_concepts` — items
- `fn_work_program_list_partidas` — A001
- `fn_work_program_vs_cost` — ejecutado semanal **75**, por partida **75** (no 150)
Script: `scripts/verify-work-program-load.sh`
## Cómo probar en tu ambiente
1. Mergear y aplicar Liquibase (038).
2. Recargar Programa de obra en la obra ya importada. Sin toast rojo.
3. La pestaña / bloque de programado vs costo debe abrir.
---
db/core/changelog-master.xml | 1 +
.../038-fn-work-program-vs-cost.sql | 103 ++++++++++++++++
scripts/verify-work-program-load-seed.sql | 91 ++++++++++++++
scripts/verify-work-program-load.sh | 30 +++++
scripts/verify-work-program-load.sql | 114 ++++++++++++++++++
5 files changed, 339 insertions(+)
create mode 100644 db/core/changesets/038-fn-work-program-vs-cost.sql
create mode 100644 scripts/verify-work-program-load-seed.sql
create mode 100755 scripts/verify-work-program-load.sh
create mode 100644 scripts/verify-work-program-load.sql
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);
+$$;