fix: programado vs costo usa el almacén de la obra

warehouse_movements no tiene project_id; el ejecutado semanal se
suma por warehouses.project_id. El ejecutado por partida ya no se
duplica al cruzar varias semanas. Verificado en Postgres con las
cuatro lecturas de la pantalla (get, concepts, partidas, vs-cost).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 21:54:32 +00:00
parent 7ffdd9f7bf
commit 2d661d7a81
No known key found for this signature in database
5 changed files with 339 additions and 0 deletions

View file

@ -47,5 +47,6 @@
<include file="changesets/035-warehouse-materials-catalog.sql" relativeToChangelogFile="true"/>
<include file="changesets/036-fn-work-program-get-curve.sql" relativeToChangelogFile="true"/>
<include file="changesets/037-fn-work-program-get-curve-vrow.sql" relativeToChangelogFile="true"/>
<include file="changesets/038-fn-work-program-vs-cost.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

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

View file

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

View file

@ -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"

View file

@ -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);
$$;