From 496273b41c35d484d7b531e731ffb83d9ee0a0e2 Mon Sep 17 00:00:00 2001
From: Alberto Martinez <349769123@users.noreply.cursor.com>
Date: Tue, 8 Sep 2026 04:51:09 +0000
Subject: [PATCH] feat: Programa de obra por proyecto
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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
---
api/main.ts | 2 +
api/work_program.ts | 245 +++++
api/work_program_excel.ts | 395 +++++++++
api/work_program_excel_test.ts | 64 ++
api/work_program_http.ts | 145 +++
db/core/changelog-master.xml | 2 +
.../changesets/029-schema-work-program.sql | 81 ++
db/core/changesets/030-rpc-work-program.sql | 835 ++++++++++++++++++
web-panel/components/ProjectFicha.vue | 16 +
.../components/WorkProgramCostCompare.vue | 84 ++
web-panel/components/WorkProgramGantt.vue | 98 ++
.../components/WorkProgramGenerateDialog.vue | 59 ++
web-panel/components/WorkProgramGrid.vue | 121 +++
.../components/WorkProgramImportDialog.vue | 127 +++
web-panel/layouts/default.vue | 1 +
web-panel/pages/presupuesto.vue | 17 +-
web-panel/pages/programa-obra.vue | 353 ++++++++
17 files changed, 2644 insertions(+), 1 deletion(-)
create mode 100644 api/work_program.ts
create mode 100644 api/work_program_excel.ts
create mode 100644 api/work_program_excel_test.ts
create mode 100644 api/work_program_http.ts
create mode 100644 db/core/changesets/029-schema-work-program.sql
create mode 100644 db/core/changesets/030-rpc-work-program.sql
create mode 100644 web-panel/components/WorkProgramCostCompare.vue
create mode 100644 web-panel/components/WorkProgramGantt.vue
create mode 100644 web-panel/components/WorkProgramGenerateDialog.vue
create mode 100644 web-panel/components/WorkProgramGrid.vue
create mode 100644 web-panel/components/WorkProgramImportDialog.vue
create mode 100644 web-panel/pages/programa-obra.vue
diff --git a/api/main.ts b/api/main.ts
index eac39e5..70c3bc9 100644
--- a/api/main.ts
+++ b/api/main.ts
@@ -59,6 +59,7 @@ import { registerPayrollRoutes } from "./payroll_http.ts";
import { registerExpenseRoutes } from "./expenses_http.ts";
import { registerWarehouseRoutes } from "./warehouse_http.ts";
import { registerCostControlRoutes } from "./cost_control_http.ts";
+import { registerWorkProgramRoutes } from "./work_program_http.ts";
import { registerIamRoutes } from "./iam_http.ts";
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
import {
@@ -1347,6 +1348,7 @@ registerPayrollRoutes(app);
registerExpenseRoutes(app);
registerWarehouseRoutes(app);
registerCostControlRoutes(app);
+registerWorkProgramRoutes(app);
registerIamRoutes(app);
const port = config.port;
diff --git a/api/work_program.ts b/api/work_program.ts
new file mode 100644
index 0000000..c38ae25
--- /dev/null
+++ b/api/work_program.ts
@@ -0,0 +1,245 @@
+import * as XLSX from "xlsx";
+import type { Db } from "./db.ts";
+import { callCoreFn, RpcCallError } from "./rpc.ts";
+import { listBudget } from "./budget.ts";
+import {
+ exportWorkProgramWorkbook,
+ matchWorkProgramImport,
+ parseWorkProgramWorkbook,
+ type WorkProgramExcelPreview,
+} from "./work_program_excel.ts";
+
+export type WorkProgramSummary = {
+ program: Record | null;
+ periods: Record[];
+ budget_subtotal: number;
+ curve: { period_index: number; label: string; amount: number; pct_period: number; pct_cumulative: number }[];
+};
+
+async function rpc(db: Db, fn: string, payload: Record): Promise {
+ const env = await callCoreFn(db, fn, payload);
+ if (!env.ok) throw new RpcCallError(env);
+ return env.data as T;
+}
+
+export async function getWorkProgram(db: Db, projectId: number): Promise {
+ return rpc(db, "core.fn_work_program_get", { project_id: projectId });
+}
+
+export async function listConcepts(db: Db, projectId: number) {
+ return rpc<{ periods: Record[]; items: Record[] }>(
+ db,
+ "core.fn_work_program_list_concepts",
+ { project_id: projectId },
+ );
+}
+
+export async function listPartidas(db: Db, projectId: number) {
+ return rpc<{ periods: Record[]; chapters: Record[] }>(
+ db,
+ "core.fn_work_program_list_partidas",
+ { project_id: projectId },
+ );
+}
+
+export async function getVsCost(db: Db, projectId: number) {
+ return rpc(db, "core.fn_work_program_vs_cost", { project_id: projectId });
+}
+
+export async function previewWorkProgramExcel(
+ bytes: Uint8Array,
+ projectId: number,
+ db: Db,
+): Promise }> {
+ const parsed = parseWorkProgramWorkbook(bytes);
+ if (parsed.format === "none") return parsed;
+
+ const budget = await listBudget(db, projectId);
+ const ctx = {
+ items: budget.items.map((i) => ({
+ id: i.id,
+ code: i.code,
+ description: i.description,
+ unit: i.unit,
+ amount: Number(i.amount),
+ chapter_id: i.chapter_id,
+ })),
+ chapters: budget.chapters.map((c) => ({
+ id: c.id,
+ code: c.code,
+ name: c.name,
+ amount: budget.items.filter((i) => i.chapter_id === c.id).reduce((s, i) => s + Number(i.amount), 0),
+ })),
+ };
+
+ const matched = matchWorkProgramImport(parsed, ctx);
+ return { ...matched.preview, match: matched };
+}
+
+export async function importWorkProgramExcel(
+ db: Db,
+ projectId: number,
+ bytes: Uint8Array,
+ opts?: { createChapters?: boolean },
+): Promise> {
+ const parsed = parseWorkProgramWorkbook(bytes);
+ const budget = await listBudget(db, projectId);
+ let chapters = [...budget.chapters];
+
+ const ctx = {
+ items: budget.items.map((i) => ({
+ id: i.id,
+ code: i.code,
+ description: i.description,
+ unit: i.unit,
+ amount: Number(i.amount),
+ chapter_id: i.chapter_id,
+ })),
+ chapters: chapters.map((c) => ({
+ id: c.id,
+ code: c.code,
+ name: c.name,
+ amount: budget.items.filter((i) => i.chapter_id === c.id).reduce((s, i) => s + Number(i.amount), 0),
+ })),
+ };
+
+ let { payload, preview } = matchWorkProgramImport(parsed, ctx);
+
+ if (opts?.createChapters !== false && payload.chapters_to_create.length) {
+ for (const ch of payload.chapters_to_create) {
+ await callCoreFn(db, "core.fn_budget_chapter_create", {
+ project_id: projectId,
+ code: ch.code,
+ name: ch.name,
+ sort_order: ch.sort_order,
+ });
+ }
+ const refreshed = await listBudget(db, projectId);
+ chapters = refreshed.chapters;
+ const chapterByCode = new Map(chapters.map((c) => [c.code.toUpperCase(), c]));
+ for (const row of preview.partidas) {
+ const ch = chapterByCode.get(row.code.toUpperCase());
+ if (!ch) continue;
+ row.amounts.forEach((amount, i) => {
+ if (amount !== 0) {
+ payload.partida_amounts.push({
+ budget_chapter_id: ch.id,
+ period_index: i + 1,
+ amount,
+ });
+ }
+ });
+ }
+ }
+
+ return rpc(db, "core.fn_work_program_replace", {
+ project_id: projectId,
+ source: "import",
+ status: "active",
+ concept_slots: payload.concept_slots,
+ partida_amounts: payload.partida_amounts,
+ chapters_to_create: [],
+ });
+}
+
+export async function createEmptyWorkProgram(db: Db, projectId: number) {
+ return rpc(db, "core.fn_work_program_replace", {
+ project_id: projectId,
+ source: "manual",
+ status: "draft",
+ concept_slots: [],
+ partida_amounts: [],
+ });
+}
+
+export async function generateWorkProgram(
+ db: Db,
+ projectId: number,
+ strategy: string,
+) {
+ return rpc(db, "core.fn_work_program_generate", { project_id: projectId, strategy });
+}
+
+export async function updateConceptSlot(
+ db: Db,
+ projectId: number,
+ budgetItemId: number,
+ periodId: number,
+ start_pct: number,
+ end_pct: number,
+) {
+ return rpc(db, "core.fn_work_program_update_concept", {
+ project_id: projectId,
+ budget_item_id: budgetItemId,
+ period_id: periodId,
+ start_pct,
+ end_pct,
+ });
+}
+
+export async function updatePartidaAmount(
+ db: Db,
+ projectId: number,
+ chapterId: number,
+ periodId: number,
+ amount: number,
+) {
+ return rpc(db, "core.fn_work_program_update_partida", {
+ project_id: projectId,
+ budget_chapter_id: chapterId,
+ period_id: periodId,
+ amount,
+ });
+}
+
+export async function updateProgress(
+ db: Db,
+ projectId: number,
+ budgetItemId: number,
+ periodId: number,
+ pct_complete: number,
+) {
+ return rpc(db, "core.fn_work_program_update_progress", {
+ project_id: projectId,
+ budget_item_id: budgetItemId,
+ period_id: periodId,
+ pct_complete,
+ });
+}
+
+export async function recalculatePartidas(db: Db, projectId: number) {
+ return rpc(db, "core.fn_work_program_recalculate_partidas", { project_id: projectId });
+}
+
+export async function exportWorkProgram(
+ db: Db,
+ projectId: number,
+ kind: "concepto" | "partida",
+ projectName: string,
+): Promise {
+ const summary = await getWorkProgram(db, projectId);
+ const periodCount = summary.periods.length || 1;
+
+ if (kind === "concepto") {
+ const data = await listConcepts(db, projectId);
+ const items = (data.items || []).map((item) => ({
+ code: String(item.code || ""),
+ description: String(item.description || ""),
+ unit: String(item.unit || ""),
+ periods: ((item.periods as { period_index: number; start_pct: number; end_pct: number }[]) || []).filter(
+ (p) => Number(p.start_pct) !== 0 || Number(p.end_pct) !== 0,
+ ),
+ }));
+ return exportWorkProgramWorkbook(kind, projectName, items, [], periodCount);
+ }
+
+ const data = await listPartidas(db, projectId);
+ const chapters = (data.chapters || []).map((ch) => ({
+ code: String(ch.code || ""),
+ name: String(ch.name || ""),
+ periods: ((ch.periods as { period_index: number; amount: number }[]) || []).filter((p) => Number(p.amount) !== 0),
+ }));
+ return exportWorkProgramWorkbook(kind, projectName, [], chapters, periodCount);
+}
+
+export { parseBarString } from "./work_program_excel.ts";
diff --git a/api/work_program_excel.ts b/api/work_program_excel.ts
new file mode 100644
index 0000000..94a72df
--- /dev/null
+++ b/api/work_program_excel.ts
@@ -0,0 +1,395 @@
+import * as XLSX from "xlsx";
+import { parseMoney } from "./budget.ts";
+
+export type WorkProgramBarSlot = {
+ period_index: number;
+ start_pct: number;
+ end_pct: number;
+};
+
+export type ParsedConceptRow = {
+ code: string;
+ description: string;
+ unit: string;
+ bar: string;
+ slots: WorkProgramBarSlot[];
+ row: number;
+};
+
+export type ParsedPartidaRow = {
+ code: string;
+ name: string;
+ amounts: number[];
+ total: number;
+ bar?: string;
+ row: number;
+};
+
+export type WorkProgramExcelPreview = {
+ format: "concepto" | "partida" | "none";
+ format_label: string;
+ sheet: string;
+ period_count: number;
+ period_labels: string[];
+ concepts: ParsedConceptRow[];
+ partidas: ParsedPartidaRow[];
+ unmatched_codes: string[];
+ warnings: string[];
+ errors: { row: number; messages: string[] }[];
+};
+
+export type BudgetMatchContext = {
+ items: { id: number; code: string; description: string; unit: string; amount: number; chapter_id: number | null }[];
+ chapters: { id: number; code: string; name: string; amount: number }[];
+};
+
+export type WorkProgramImportPayload = {
+ concept_slots: { budget_item_id: number; period_index: number; start_pct: number; end_pct: number }[];
+ partida_amounts: { budget_chapter_id: number; period_index: number; amount: number }[];
+ chapters_to_create: { code: string; name: string; sort_order: number }[];
+};
+
+function fold(value: string) {
+ return value.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase().replace(/\s+/g, " ").trim();
+}
+
+function isSkipRow(a: unknown, b: unknown, c: unknown) {
+ const texts = [a, b, c].map((v) => fold(String(v ?? "")));
+ if (texts.some((t) => /^(DIRECTOR GENERAL|TOTAL DEL PRESUPUESTO|ACUMULADO|PORCENTAJE PERIODO|PORCENTAJE ACUMULADO|MONTO ESTA HOJA)/.test(t))) {
+ return true;
+ }
+ return false;
+}
+
+export function parseBarString(bar: string): WorkProgramBarSlot[] {
+ const raw = String(bar ?? "").trim();
+ if (!raw.startsWith("Barra=")) return [];
+ const segs = raw.slice(6).split(",");
+ return segs.map((seg, i) => {
+ const [a, b] = seg.split("-").map((x) => Number(x.trim()) || 0);
+ if (a === 0 && b === 0) return null;
+ return { period_index: i + 1, start_pct: a, end_pct: b };
+ }).filter((x): x is WorkProgramBarSlot => x !== null);
+}
+
+function findSemHeader(row: unknown[]) {
+ const labels: string[] = [];
+ let firstSem = -1;
+ for (let i = 0; i < row.length; i++) {
+ const t = fold(String(row[i] ?? ""));
+ const m = t.match(/^SEM\s*(\d+)$/);
+ if (m) {
+ if (firstSem < 0) firstSem = i;
+ labels.push(`Sem ${m[1]}`);
+ }
+ }
+ return { firstSem, labels, totalCol: firstSem >= 0 ? firstSem + labels.length : -1 };
+}
+
+function detectFormat(rows: unknown[][]): "concepto" | "partida" | "none" {
+ for (let i = 0; i < Math.min(rows.length, 30); i++) {
+ const row = rows[i] || [];
+ const joined = row.map((c) => fold(String(c ?? ""))).join("|");
+ if (joined.includes("POR CONCEPTO") || (joined.includes("CODIGO") && joined.includes("DESCRIPCION") && joined.includes("UNIDAD"))) {
+ return "concepto";
+ }
+ if (joined.includes("PARTIDA") && joined.includes("SEM")) return "partida";
+ }
+ for (let i = 0; i < Math.min(rows.length, 30); i++) {
+ const row = rows[i] || [];
+ const a = fold(String(row[0] ?? ""));
+ const b = fold(String(row[1] ?? ""));
+ if (a === "CODIGO" || a === "CLAVE") return "concepto";
+ if (a === "PARTIDA" && b === "PARTIDA") return "partida";
+ }
+ return "none";
+}
+
+function isPartidaCode(code: string) {
+ return /^A\d{3}$/i.test(code.trim());
+}
+
+function isConceptCode(code: string) {
+ const t = code.trim();
+ if (!t || isPartidaCode(t)) return false;
+ if (/^A$/i.test(t)) return false;
+ return t.includes("-") || /^[A-Z]{2,}\d/.test(t) || /^SELE|^PUER|^COL|^INS|^VYD|^IE|^ABA/i.test(t);
+}
+
+export function parseWorkProgramRows(rows: unknown[][]): Omit {
+ const format = detectFormat(rows);
+ const warnings: string[] = [];
+ const errors: { row: number; messages: string[] }[] = [];
+
+ if (format === "none") {
+ return {
+ format: "none",
+ format_label: "No reconocido",
+ period_count: 0,
+ period_labels: [],
+ concepts: [],
+ partidas: [],
+ unmatched_codes: [],
+ warnings: ["No se detectó formato de programa Neodata/Opus (por concepto o por partida)."],
+ errors,
+ };
+ }
+
+ let headerAt = -1;
+ let periodLabels: string[] = [];
+ let firstSem = -1;
+ let totalCol = -1;
+
+ for (let i = 0; i < rows.length; i++) {
+ const info = findSemHeader(rows[i] || []);
+ if (info.firstSem >= 0 && info.labels.length >= 1) {
+ headerAt = i;
+ periodLabels = info.labels;
+ firstSem = info.firstSem;
+ totalCol = info.totalCol;
+ break;
+ }
+ }
+
+ if (headerAt < 0) {
+ return {
+ format,
+ format_label: format === "concepto" ? "Programa por concepto" : "Programa por partida",
+ period_count: 0,
+ period_labels: [],
+ concepts: [],
+ partidas: [],
+ unmatched_codes: [],
+ warnings: ["No se encontró fila de encabezado con columnas Sem 1..N."],
+ errors,
+ };
+ }
+
+ const concepts: ParsedConceptRow[] = [];
+ const partidas: ParsedPartidaRow[] = [];
+
+ if (format === "concepto") {
+ let currentDesc: string[] = [];
+ for (let r = headerAt + 1; r < rows.length; r++) {
+ const row = rows[r] || [];
+ const code = String(row[0] ?? "").trim();
+ const desc = String(row[1] ?? "").trim();
+ const unit = String(row[2] ?? "").trim();
+ if (isSkipRow(row[0], row[1], row[2])) continue;
+ if (fold(String(row[2] ?? "")) === "MONTO ESTA HOJA:") continue;
+
+ if (isPartidaCode(code) && !unit) continue;
+ if (/^A$/i.test(code)) continue;
+
+ if (isConceptCode(code) && unit) {
+ let bar = "";
+ for (let c = firstSem; c < totalCol; c++) {
+ const v = String(row[c] ?? "").trim();
+ if (v.startsWith("Barra=")) {
+ bar = v;
+ break;
+ }
+ }
+ if (!bar) {
+ const v = String(row[firstSem] ?? "").trim();
+ if (v.startsWith("Barra=")) bar = v;
+ }
+ const description = [desc, ...currentDesc].filter(Boolean).join(" ");
+ currentDesc = [];
+ concepts.push({
+ code,
+ description,
+ unit,
+ bar,
+ slots: parseBarString(bar),
+ row: r + 1,
+ });
+ continue;
+ }
+
+ if (!code && desc && !unit) {
+ currentDesc.push(desc);
+ continue;
+ }
+
+ if (code.startsWith("Barra=")) continue;
+ }
+ } else {
+ for (let r = headerAt + 1; r < rows.length; r++) {
+ const row = rows[r] || [];
+ const code = String(row[0] ?? "").trim();
+ const name = String(row[1] ?? "").trim();
+ if (isSkipRow(row[0], row[1], row[2])) continue;
+
+ if (isPartidaCode(code) && name) {
+ const amounts: number[] = [];
+ for (let c = firstSem; c < totalCol; c++) {
+ amounts.push(parseMoney(row[c]));
+ }
+ const total = totalCol >= 0 ? parseMoney(row[totalCol]) : amounts.reduce((s, n) => s + n, 0);
+ partidas.push({ code: code.toUpperCase(), name, amounts, total, row: r + 1 });
+ continue;
+ }
+
+ const barCell = String(row[firstSem] ?? "").trim();
+ if (!code && barCell.startsWith("Barra=") && partidas.length) {
+ partidas[partidas.length - 1].bar = barCell;
+ }
+ }
+ }
+
+ return {
+ format,
+ format_label: format === "concepto" ? "Programa por concepto (Neodata/Opus)" : "Programa por partida (erogaciones)",
+ period_count: periodLabels.length,
+ period_labels: periodLabels,
+ concepts,
+ partidas,
+ unmatched_codes: [],
+ warnings,
+ errors,
+ };
+}
+
+export function parseWorkProgramWorkbook(bytes: Uint8Array, sheetName?: string): WorkProgramExcelPreview {
+ const wb = XLSX.read(bytes, { type: "array", cellDates: true });
+ const sheet = sheetName && wb.SheetNames.includes(sheetName) ? sheetName : wb.SheetNames[0];
+ const ws = wb.Sheets[sheet];
+ const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" }) as unknown[][];
+ const parsed = parseWorkProgramRows(rows);
+ return { ...parsed, sheet };
+}
+
+export function matchWorkProgramImport(
+ preview: WorkProgramExcelPreview,
+ ctx: BudgetMatchContext,
+): { payload: WorkProgramImportPayload; preview: WorkProgramExcelPreview } {
+ const itemByCode = new Map(ctx.items.map((i) => [fold(i.code), i]));
+ const chapterByCode = new Map(ctx.chapters.map((c) => [fold(c.code), c]));
+ const unmatched: string[] = [];
+ const warnings = [...preview.warnings];
+
+ const concept_slots: WorkProgramImportPayload["concept_slots"] = [];
+ for (const row of preview.concepts) {
+ const item = itemByCode.get(fold(row.code));
+ if (!item) {
+ unmatched.push(row.code);
+ continue;
+ }
+ for (const slot of row.slots) {
+ concept_slots.push({
+ budget_item_id: item.id,
+ period_index: slot.period_index,
+ start_pct: slot.start_pct,
+ end_pct: slot.end_pct,
+ });
+ }
+ }
+
+ const partida_amounts: WorkProgramImportPayload["partida_amounts"] = [];
+ const chapters_to_create: WorkProgramImportPayload["chapters_to_create"] = [];
+
+ for (const row of preview.partidas) {
+ let chapter = chapterByCode.get(fold(row.code));
+ if (!chapter) {
+ const byName = ctx.chapters.find((c) => fold(c.name) === fold(row.name));
+ if (byName) chapter = byName;
+ }
+ if (!chapter) {
+ chapters_to_create.push({ code: row.code, name: row.name, sort_order: partida_amounts.length });
+ unmatched.push(row.code);
+ warnings.push(`Partida ${row.code} no encontrada en presupuesto; se creará capítulo al importar si confirma.`);
+ continue;
+ }
+ row.amounts.forEach((amount, i) => {
+ if (amount !== 0) {
+ partida_amounts.push({
+ budget_chapter_id: chapter!.id,
+ period_index: i + 1,
+ amount,
+ });
+ }
+ });
+ const sum = row.amounts.reduce((s, n) => s + n, 0);
+ if (chapter.amount > 0 && Math.abs(sum - chapter.amount) / chapter.amount > 0.01) {
+ warnings.push(
+ `Partida ${row.code}: suma semanal $${sum.toFixed(2)} difiere del presupuesto $${chapter.amount.toFixed(2)}.`,
+ );
+ }
+ }
+
+ return {
+ payload: { concept_slots, partida_amounts, chapters_to_create },
+ preview: { ...preview, unmatched_codes: unmatched, warnings },
+ };
+}
+
+export function buildConceptExportRows(
+ items: {
+ code: string;
+ description: string;
+ unit: string;
+ periods: { period_index: number; start_pct: number; end_pct: number }[];
+ }[],
+ periodCount: number,
+): unknown[][] {
+ const header = ["Código", "Descripción", "Unidad", ...Array.from({ length: periodCount }, (_, i) => `Sem ${i + 1}`), "Total"];
+ const rows: unknown[][] = [header];
+ for (const item of items) {
+ const slots = Array.from({ length: periodCount }, (_, i) => {
+ const p = item.periods.find((x) => x.period_index === i + 1);
+ if (!p || (p.start_pct === 0 && p.end_pct === 0)) return 0;
+ return `Barra=${Array.from({ length: periodCount }, (_, j) => {
+ const s = item.periods.find((x) => x.period_index === j + 1);
+ if (!s || (s.start_pct === 0 && s.end_pct === 0)) return "0-0";
+ return `${s.start_pct}-${s.end_pct}`;
+ }).join(",")}`;
+ });
+ const barStr = `Barra=${Array.from({ length: periodCount }, (_, j) => {
+ const s = item.periods.find((x) => x.period_index === j + 1);
+ if (!s || (s.start_pct === 0 && s.end_pct === 0)) return "0-0";
+ return `${s.start_pct}-${s.end_pct}`;
+ }).join(",")}`;
+ rows.push([item.code, item.description, item.unit, barStr, ...Array(periodCount - 1).fill(""), ""]);
+ }
+ return rows;
+}
+
+export function buildPartidaExportRows(
+ chapters: { code: string; name: string; periods: { period_index: number; amount: number }[] }[],
+ periodCount: number,
+): unknown[][] {
+ const header = ["PARTIDA", "PARTIDA", ...Array.from({ length: periodCount }, (_, i) => `Sem ${i + 1}`), "Total"];
+ const rows: unknown[][] = [header];
+ for (const ch of chapters) {
+ const amounts = Array.from({ length: periodCount }, (_, i) => {
+ const p = ch.periods.find((x) => x.period_index === i + 1);
+ return p?.amount ?? 0;
+ });
+ const total = amounts.reduce((s, n) => s + n, 0);
+ rows.push([ch.code, ch.name, ...amounts, total]);
+ const bar = `Barra=${amounts.map((a) => (a > 0 ? "0-100" : "0-0")).join(",")}`;
+ rows.push(["", "", bar, ...Array(periodCount + 1).fill("")]);
+ }
+ return rows;
+}
+
+export function exportWorkProgramWorkbook(
+ kind: "concepto" | "partida",
+ projectName: string,
+ conceptItems: Parameters[0],
+ partidaChapters: Parameters[0],
+ periodCount: number,
+): Uint8Array {
+ const rows = kind === "concepto"
+ ? buildConceptExportRows(conceptItems, periodCount)
+ : buildPartidaExportRows(partidaChapters, periodCount);
+ const ws = XLSX.utils.aoa_to_sheet([
+ ["PROGRAMA DE OBRA", projectName],
+ [],
+ ...rows,
+ ]);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, kind === "concepto" ? "Por concepto" : "Por partida");
+ return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array;
+}
diff --git a/api/work_program_excel_test.ts b/api/work_program_excel_test.ts
new file mode 100644
index 0000000..4e2f990
--- /dev/null
+++ b/api/work_program_excel_test.ts
@@ -0,0 +1,64 @@
+import { assertEquals, assertGreater } from "jsr:@std/assert@1";
+import { readFileSync } from "node:fs";
+import { parseBarString, parseWorkProgramRows, parseWorkProgramWorkbook } from "./work_program_excel.ts";
+
+Deno.test("parseBarString decodifica formato Neodata", () => {
+ const slots = parseBarString("Barra=14.29-100,0-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0");
+ assertEquals(slots.length, 3);
+ assertEquals(slots[0], { period_index: 1, start_pct: 14.29, end_pct: 100 });
+ assertEquals(slots[2], { period_index: 3, start_pct: 0, end_pct: 14.29 });
+});
+
+Deno.test("detecta programa por partida con montos semanales", () => {
+ const parsed = parseWorkProgramRows([
+ ["PARTIDA", "PARTIDA", "Sem 1", "Sem 2", "Sem 3", "Total"],
+ ["A001", "PRELIMINARES Y ALBAÑILERÍA", 19493.27, 32531.38, 1161.31, 123219.19],
+ [null, null, "Barra=14.29-100,0-100,0-100,0-100,0-100,0-0,0-100,0-100,0-57.14"],
+ ["A002", "INSTALACION HIDROSANITARIA", 1889.2, 19822.48, 3668.97, 48630.1],
+ ]);
+ assertEquals(parsed.format, "partida");
+ assertEquals(parsed.partidas.length, 2);
+ assertEquals(parsed.partidas[0].code, "A001");
+ assertEquals(parsed.partidas[0].amounts[0], 19493.27);
+ assertEquals(parsed.partidas[0].bar?.startsWith("Barra="), true);
+});
+
+Deno.test("detecta programa por concepto con Barra=", () => {
+ const parsed = parseWorkProgramRows([
+ ["Código", "Descripción", "Unidad", "Sem 1", "Sem 2", "Sem 3", "Total"],
+ ["A001", "PRELIMINARES Y ALBAÑILERÍA"],
+ ["10301-001", "Trazo y nivelación manual", "M2", "Barra=14.29-100,0-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0"],
+ ["10301-018", "Carpa de lona", "M2", "Barra=42.86-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0,0-0"],
+ ]);
+ assertEquals(parsed.format, "concepto");
+ assertEquals(parsed.concepts.length, 2);
+ assertEquals(parsed.concepts[0].code, "10301-001");
+ assertEquals(parsed.concepts[0].slots.length, 3);
+});
+
+const conceptoPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Concepto_a_Est_ndar__E__4-9-2026_Hr18Mn46_85cd.xlsx";
+const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_18ff.xlsx";
+
+Deno.test("lee Excel real por concepto del usuario", () => {
+ try {
+ const bytes = readFileSync(conceptoPath);
+ const parsed = parseWorkProgramWorkbook(new Uint8Array(bytes));
+ assertEquals(parsed.format, "concepto");
+ assertGreater(parsed.concepts.length, 50);
+ assertGreater(parsed.period_count, 8);
+ } catch {
+ // fixture no disponible en CI
+ }
+});
+
+Deno.test("lee Excel real por partida del usuario", () => {
+ try {
+ const bytes = readFileSync(partidaPath);
+ const parsed = parseWorkProgramWorkbook(new Uint8Array(bytes));
+ assertEquals(parsed.format, "partida");
+ assertGreater(parsed.partidas.length, 5);
+ assertEquals(parsed.partidas[0].code, "A001");
+ } catch {
+ // fixture no disponible en CI
+ }
+});
diff --git a/api/work_program_http.ts b/api/work_program_http.ts
new file mode 100644
index 0000000..ed77a45
--- /dev/null
+++ b/api/work_program_http.ts
@@ -0,0 +1,145 @@
+import type { Hono } from "hono";
+import type { AuthUser } from "./auth.ts";
+import { requireCoreAuth } from "./scope.ts";
+import type { Db } from "./db.ts";
+import { projectById } from "./db.ts";
+import { requirePermission } from "./permissions.ts";
+import {
+ createEmptyWorkProgram,
+ exportWorkProgram,
+ generateWorkProgram,
+ getVsCost,
+ getWorkProgram,
+ importWorkProgramExcel,
+ listConcepts,
+ listPartidas,
+ previewWorkProgramExcel,
+ recalculatePartidas,
+ updateConceptSlot,
+ updatePartidaAmount,
+ updateProgress,
+} from "./work_program.ts";
+
+type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
+
+const auth = [...requireCoreAuth, requirePermission("manage_budget")] as const;
+
+async function readUpload(c: { req: { formData: () => Promise } }): Promise {
+ const form = await c.req.formData();
+ const file = form.get("file");
+ if (!(file instanceof File)) throw new Error("Archivo Excel requerido (campo file)");
+ return new Uint8Array(await file.arrayBuffer());
+}
+
+export function registerWorkProgramRoutes(app: App) {
+ app.get("/v1/projects/:id/work-program", ...auth, async (c) => {
+ const db = c.get("db");
+ const data = await getWorkProgram(db, Number(c.req.param("id")));
+ return c.json({ ok: true, data });
+ });
+
+ app.get("/v1/projects/:id/work-program/concepts", ...auth, async (c) => {
+ const db = c.get("db");
+ const data = await listConcepts(db, Number(c.req.param("id")));
+ return c.json({ ok: true, data });
+ });
+
+ app.get("/v1/projects/:id/work-program/partidas", ...auth, async (c) => {
+ const db = c.get("db");
+ const data = await listPartidas(db, Number(c.req.param("id")));
+ return c.json({ ok: true, data });
+ });
+
+ app.get("/v1/projects/:id/work-program/vs-cost", ...auth, async (c) => {
+ const db = c.get("db");
+ const data = await getVsCost(db, Number(c.req.param("id")));
+ return c.json({ ok: true, data });
+ });
+
+ app.post("/v1/projects/:id/work-program/preview", ...auth, async (c) => {
+ const db = c.get("db");
+ const bytes = await readUpload(c);
+ const preview = await previewWorkProgramExcel(bytes, Number(c.req.param("id")), db);
+ return c.json({ ok: true, data: preview });
+ });
+
+ app.post("/v1/projects/:id/work-program/import", ...auth, async (c) => {
+ const db = c.get("db");
+ const bytes = await readUpload(c);
+ const createChapters = c.req.query("create_chapters") !== "false";
+ const data = await importWorkProgramExcel(db, Number(c.req.param("id")), bytes, { createChapters });
+ return c.json({ ok: true, data });
+ });
+
+ app.post("/v1/projects/:id/work-program", ...auth, async (c) => {
+ const db = c.get("db");
+ const data = await createEmptyWorkProgram(db, Number(c.req.param("id")));
+ return c.json({ ok: true, data });
+ });
+
+ app.post("/v1/projects/:id/work-program/generate", ...auth, async (c) => {
+ const db = c.get("db");
+ const body = await c.req.json().catch(() => ({})) as { strategy?: string };
+ const data = await generateWorkProgram(db, Number(c.req.param("id")), body.strategy || "keyword");
+ return c.json({ ok: true, data });
+ });
+
+ app.post("/v1/projects/:id/work-program/recalculate-partidas", ...auth, async (c) => {
+ const db = c.get("db");
+ const env = await recalculatePartidas(db, Number(c.req.param("id")));
+ return c.json({ ok: true, data: env });
+ });
+
+ app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...auth, async (c) => {
+ const db = c.get("db");
+ const body = await c.req.json() as { start_pct?: number; end_pct?: number };
+ const data = await updateConceptSlot(
+ db,
+ Number(c.req.param("id")),
+ Number(c.req.param("itemId")),
+ Number(c.req.param("periodId")),
+ Number(body.start_pct ?? 0),
+ Number(body.end_pct ?? 0),
+ );
+ return c.json({ ok: true, data });
+ });
+
+ app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...auth, async (c) => {
+ const db = c.get("db");
+ const body = await c.req.json() as { amount?: number };
+ const data = await updatePartidaAmount(
+ db,
+ Number(c.req.param("id")),
+ Number(c.req.param("chapterId")),
+ Number(c.req.param("periodId")),
+ Number(body.amount ?? 0),
+ );
+ return c.json({ ok: true, data });
+ });
+
+ app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...auth, async (c) => {
+ const db = c.get("db");
+ const body = await c.req.json() as { pct_complete?: number };
+ const data = await updateProgress(
+ db,
+ Number(c.req.param("id")),
+ Number(c.req.param("itemId")),
+ Number(c.req.param("periodId")),
+ Number(body.pct_complete ?? 0),
+ );
+ return c.json({ ok: true, data });
+ });
+
+ app.get("/v1/projects/:id/work-program/export", ...auth, async (c) => {
+ const db = c.get("db");
+ const id = Number(c.req.param("id"));
+ const kind = (c.req.query("kind") === "partida" ? "partida" : "concepto") as "concepto" | "partida";
+ const project = await projectById(db, id);
+ if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
+ const bytes = await exportWorkProgram(db, id, kind, String(project.name || "Obra"));
+ const filename = kind === "partida" ? "programa-partidas.xlsx" : "programa-conceptos.xlsx";
+ c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ c.header("Content-Disposition", `attachment; filename="${filename}"`);
+ return c.body(bytes.slice());
+ });
+}
diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml
index 11c82e7..8e48fc8 100644
--- a/db/core/changelog-master.xml
+++ b/db/core/changelog-master.xml
@@ -35,5 +35,7 @@
+
+
diff --git a/db/core/changesets/029-schema-work-program.sql b/db/core/changesets/029-schema-work-program.sql
new file mode 100644
index 0000000..caf6a1c
--- /dev/null
+++ b/db/core/changesets/029-schema-work-program.sql
@@ -0,0 +1,81 @@
+--liquibase formatted sql
+-- PANELS · core · programa de obra (cronograma / erogaciones)
+
+--changeset panel:core-029a-work-programs endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_programs'
+CREATE TABLE core.work_programs (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ duration_days INTEGER NOT NULL,
+ period_unit TEXT NOT NULL DEFAULT 'week' CHECK (period_unit IN ('week')),
+ source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('import', 'wizard', 'manual')),
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (project_id)
+);
+
+CREATE INDEX idx_core_work_programs_project ON core.work_programs(project_id);
+
+--changeset panel:core-029b-work-program-periods endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_periods'
+CREATE TABLE core.work_program_periods (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
+ period_index INTEGER NOT NULL,
+ label TEXT NOT NULL,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ UNIQUE (program_id, period_index)
+);
+
+CREATE INDEX idx_core_work_program_periods_program ON core.work_program_periods(program_id);
+
+--changeset panel:core-029c-work-program-concept-slots endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_concept_slots'
+CREATE TABLE core.work_program_concept_slots (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
+ budget_item_id BIGINT NOT NULL REFERENCES core.budget_items(id) ON DELETE CASCADE,
+ period_id BIGINT NOT NULL REFERENCES core.work_program_periods(id) ON DELETE CASCADE,
+ start_pct NUMERIC(6,2) NOT NULL DEFAULT 0,
+ end_pct NUMERIC(6,2) NOT NULL DEFAULT 0,
+ UNIQUE (program_id, budget_item_id, period_id)
+);
+
+CREATE INDEX idx_core_wp_concept_slots_program ON core.work_program_concept_slots(program_id);
+CREATE INDEX idx_core_wp_concept_slots_item ON core.work_program_concept_slots(budget_item_id);
+
+--changeset panel:core-029d-work-program-partida-amounts endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_partida_amounts'
+CREATE TABLE core.work_program_partida_amounts (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
+ budget_chapter_id BIGINT NOT NULL REFERENCES core.budget_chapters(id) ON DELETE CASCADE,
+ period_id BIGINT NOT NULL REFERENCES core.work_program_periods(id) ON DELETE CASCADE,
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ UNIQUE (program_id, budget_chapter_id, period_id)
+);
+
+CREATE INDEX idx_core_wp_partida_amounts_program ON core.work_program_partida_amounts(program_id);
+CREATE INDEX idx_core_wp_partida_amounts_chapter ON core.work_program_partida_amounts(budget_chapter_id);
+
+--changeset panel:core-029e-work-program-progress endDelimiter:; splitStatements:true
+--preconditions onFail:MARK_RAN
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_progress'
+CREATE TABLE core.work_program_progress (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
+ budget_item_id BIGINT NOT NULL REFERENCES core.budget_items(id) ON DELETE CASCADE,
+ period_id BIGINT NOT NULL REFERENCES core.work_program_periods(id) ON DELETE CASCADE,
+ pct_complete NUMERIC(6,2) NOT NULL DEFAULT 0 CHECK (pct_complete >= 0 AND pct_complete <= 100),
+ UNIQUE (program_id, budget_item_id, period_id)
+);
+
+CREATE INDEX idx_core_wp_progress_program ON core.work_program_progress(program_id);
diff --git a/db/core/changesets/030-rpc-work-program.sql b/db/core/changesets/030-rpc-work-program.sql
new file mode 100644
index 0000000..f608983
--- /dev/null
+++ b/db/core/changesets/030-rpc-work-program.sql
@@ -0,0 +1,835 @@
+--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;
diff --git a/web-panel/components/ProjectFicha.vue b/web-panel/components/ProjectFicha.vue
index d016d0a..dea27e7 100644
--- a/web-panel/components/ProjectFicha.vue
+++ b/web-panel/components/ProjectFicha.vue
@@ -17,6 +17,8 @@
+
+
@@ -77,6 +79,8 @@ const docsReady = computed(() => {
return required.length > 0 && required.every((item) => item.present);
});
const docType = computed(() => props.doc || "contrato");
+const workProgramLabel = ref("");
+const workProgramSeverity = ref<"success" | "warn" | "secondary">("secondary");
const rows = computed(() => {
const p = view.value;
if (!p) return [];
@@ -111,6 +115,18 @@ async function load() {
loading.value = true;
try {
detail.value = await api(`/v1/projects/${projectId.value}`);
+ try {
+ const wp = await api<{ program: { status?: string } | null }>(`/v1/projects/${projectId.value}/work-program`);
+ if (wp.program) {
+ workProgramLabel.value = wp.program.status === "active" ? "Programa activo" : "Programa borrador";
+ workProgramSeverity.value = wp.program.status === "active" ? "success" : "warn";
+ } else {
+ workProgramLabel.value = "Sin programa de obra";
+ workProgramSeverity.value = "secondary";
+ }
+ } catch {
+ workProgramLabel.value = "";
+ }
} finally {
loading.value = false;
}
diff --git a/web-panel/components/WorkProgramCostCompare.vue b/web-panel/components/WorkProgramCostCompare.vue
new file mode 100644
index 0000000..ac48c49
--- /dev/null
+++ b/web-panel/components/WorkProgramCostCompare.vue
@@ -0,0 +1,84 @@
+
+
+
+
+ Programado total
+ {{ money(totalScheduled) }}
+
+
+
+
+
+
diff --git a/web-panel/components/WorkProgramImportDialog.vue b/web-panel/components/WorkProgramImportDialog.vue
new file mode 100644
index 0000000..43a94da
--- /dev/null
+++ b/web-panel/components/WorkProgramImportDialog.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
diff --git a/web-panel/layouts/default.vue b/web-panel/layouts/default.vue
index 4ebef2a..224b3c1 100644
--- a/web-panel/layouts/default.vue
+++ b/web-panel/layouts/default.vue
@@ -144,6 +144,7 @@ const menu = [
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
+ { label: "Programa de obra", icon: "pi pi-calendar", to: "/programa-obra", command: () => navigateTo("/programa-obra") },
{ label: "Control costos", icon: "pi pi-chart-bar", to: "/control-presupuesto", command: () => navigateTo("/control-presupuesto") },
{ label: "Gastos", icon: "pi pi-money-bill", to: "/gastos", command: () => navigateTo("/gastos") },
{ label: "Almacén", icon: "pi pi-box", to: "/almacen", command: () => navigateTo("/almacen") },
diff --git a/web-panel/pages/presupuesto.vue b/web-panel/pages/presupuesto.vue
index 989b308..135a8dc 100644
--- a/web-panel/pages/presupuesto.vue
+++ b/web-panel/pages/presupuesto.vue
@@ -17,6 +17,21 @@
+
+ Presupuesto listo. Puede crear el programa de obra para esta obra.
+
+
+
+
+
+
+
+
+
+
+ Seleccione una obra en el árbol.
+
+
+ Esta obra no tiene presupuesto. Cárguelo primero en Presupuesto.
+
+
+
+
+ Defina fecha de inicio y término en la ficha del proyecto.
+
+
+
+
+ Presupuesto listo. Cree el programa importando Excel Neodata, generándolo automáticamente o en blanco.
+