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
Open in Web Open in Cursor 
--- 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 @@