Pre-computed merge for change #5

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit 31f67d40b7
17 changed files with 2644 additions and 1 deletions

View file

@ -59,6 +59,7 @@ import { registerPayrollRoutes } from "./payroll_http.ts";
import { registerExpenseRoutes } from "./expenses_http.ts"; import { registerExpenseRoutes } from "./expenses_http.ts";
import { registerWarehouseRoutes } from "./warehouse_http.ts"; import { registerWarehouseRoutes } from "./warehouse_http.ts";
import { registerCostControlRoutes } from "./cost_control_http.ts"; import { registerCostControlRoutes } from "./cost_control_http.ts";
import { registerWorkProgramRoutes } from "./work_program_http.ts";
import { registerIamRoutes } from "./iam_http.ts"; import { registerIamRoutes } from "./iam_http.ts";
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
import { import {
@ -1347,6 +1348,7 @@ registerPayrollRoutes(app);
registerExpenseRoutes(app); registerExpenseRoutes(app);
registerWarehouseRoutes(app); registerWarehouseRoutes(app);
registerCostControlRoutes(app); registerCostControlRoutes(app);
registerWorkProgramRoutes(app);
registerIamRoutes(app); registerIamRoutes(app);
const port = config.port; const port = config.port;

245
api/work_program.ts Normal file
View file

@ -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<string, unknown> | null;
periods: Record<string, unknown>[];
budget_subtotal: number;
curve: { period_index: number; label: string; amount: number; pct_period: number; pct_cumulative: number }[];
};
async function rpc<T>(db: Db, fn: string, payload: Record<string, unknown>): Promise<T> {
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<WorkProgramSummary> {
return rpc(db, "core.fn_work_program_get", { project_id: projectId });
}
export async function listConcepts(db: Db, projectId: number) {
return rpc<{ periods: Record<string, unknown>[]; items: Record<string, unknown>[] }>(
db,
"core.fn_work_program_list_concepts",
{ project_id: projectId },
);
}
export async function listPartidas(db: Db, projectId: number) {
return rpc<{ periods: Record<string, unknown>[]; chapters: Record<string, unknown>[] }>(
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<WorkProgramExcelPreview & { match?: ReturnType<typeof matchWorkProgramImport> }> {
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<Record<string, unknown>> {
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<Uint8Array> {
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";

395
api/work_program_excel.ts Normal file
View file

@ -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<WorkProgramExcelPreview, "sheet"> {
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<unknown[]>(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<typeof buildConceptExportRows>[0],
partidaChapters: Parameters<typeof buildPartidaExportRows>[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;
}

View file

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

145
api/work_program_http.ts Normal file
View file

@ -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<FormData> } }): Promise<Uint8Array> {
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());
});
}

View file

@ -35,5 +35,7 @@
<include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/> <include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/>
<include file="changesets/027-iam-rpc-cross-grants.sql" relativeToChangelogFile="true"/> <include file="changesets/027-iam-rpc-cross-grants.sql" relativeToChangelogFile="true"/>
<include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/> <include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/>
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

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

View file

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

View file

@ -17,6 +17,8 @@
</div> </div>
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full mt-3" @click="emit('edit')" /> <Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full mt-3" @click="emit('edit')" />
<Button label="Ver presupuesto" icon="pi pi-calculator" outlined class="w-full mt-2" @click="navigateTo(`/presupuesto?project=${projectId}`)" /> <Button label="Ver presupuesto" icon="pi pi-calculator" outlined class="w-full mt-2" @click="navigateTo(`/presupuesto?project=${projectId}`)" />
<Button label="Programa de obra" icon="pi pi-calendar" outlined class="w-full mt-2" @click="navigateTo(`/programa-obra?project=${projectId}`)" />
<Tag v-if="workProgramLabel" class="mt-2" :value="workProgramLabel" :severity="workProgramSeverity" />
<Tabs v-model:value="tab" class="mt-3 detail-tabs"> <Tabs v-model:value="tab" class="mt-3 detail-tabs">
<TabList> <TabList>
@ -77,6 +79,8 @@ const docsReady = computed(() => {
return required.length > 0 && required.every((item) => item.present); return required.length > 0 && required.every((item) => item.present);
}); });
const docType = computed(() => props.doc || "contrato"); const docType = computed(() => props.doc || "contrato");
const workProgramLabel = ref("");
const workProgramSeverity = ref<"success" | "warn" | "secondary">("secondary");
const rows = computed(() => { const rows = computed(() => {
const p = view.value; const p = view.value;
if (!p) return []; if (!p) return [];
@ -111,6 +115,18 @@ async function load() {
loading.value = true; loading.value = true;
try { try {
detail.value = await api<DetailData>(`/v1/projects/${projectId.value}`); detail.value = await api<DetailData>(`/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 { } finally {
loading.value = false; loading.value = false;
} }

View file

@ -0,0 +1,84 @@
<template>
<div>
<div v-if="summary.length" class="kpi-row mb-3">
<div class="kpi-card">
<span class="kpi-label">Programado total</span>
<strong>{{ money(totalScheduled) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Ejecutado total</span>
<strong>{{ money(totalExecuted) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Δ acumulado</span>
<strong :class="deltaClass">{{ money(totalExecuted - totalScheduled) }}</strong>
</div>
</div>
<DataTable :value="summary" scrollable scroll-height="40vh" size="small" table-style="min-width: 52rem">
<Column field="label" header="Semana" />
<Column field="scheduled_amount" header="Programado">
<template #body="{ data }">{{ money(data.scheduled_amount) }}</template>
</Column>
<Column field="executed_amount" header="Ejecutado">
<template #body="{ data }">{{ money(data.executed_amount) }}</template>
</Column>
<Column header="Δ">
<template #body="{ data }">
<span :class="Number(data.executed_amount) > Number(data.scheduled_amount) ? 'cost-critical-text' : ''">
{{ money(Number(data.executed_amount) - Number(data.scheduled_amount)) }}
</span>
</template>
</Column>
</DataTable>
<div class="section-title mt-4 mb-2">Por partida</div>
<DataTable :value="byChapter" size="small" scrollable scroll-height="28vh">
<Column field="chapter_code" header="Código" style="width: 6rem" />
<Column field="chapter_name" header="Partida" />
<Column field="scheduled_amount" header="Programado">
<template #body="{ data }">{{ money(data.scheduled_amount) }}</template>
</Column>
<Column field="executed_amount" header="Ejecutado">
<template #body="{ data }">{{ money(data.executed_amount) }}</template>
</Column>
<Column field="budget_amount" header="Presupuesto">
<template #body="{ data }">{{ money(data.budget_amount) }}</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
byPeriod: { label: string; scheduled_amount: number; executed_amount: number }[];
byChapter: { chapter_code: string; chapter_name: string; scheduled_amount: number; executed_amount: number; budget_amount: number }[];
}>();
const summary = computed(() => props.byPeriod || []);
const byChapter = computed(() => props.byChapter || []);
const totalScheduled = computed(() => summary.value.reduce((s, r) => s + Number(r.scheduled_amount || 0), 0));
const totalExecuted = computed(() => summary.value.reduce((s, r) => s + Number(r.executed_amount || 0), 0));
const deltaClass = computed(() => (totalExecuted.value > totalScheduled.value ? "cost-critical-text" : ""));
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
</script>
<style scoped>
.kpi-row { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.kpi-card {
flex: 1;
min-width: 9rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.kpi-label { font-size: 0.75rem; color: var(--text-color-secondary); }
.section-title { font-weight: 600; font-size: 0.9rem; }
</style>

View file

@ -0,0 +1,98 @@
<template>
<div class="gantt-panel">
<div class="gantt-header">
<span v-for="p in periods" :key="p.id" class="gantt-col-head">{{ p.label }}</span>
</div>
<div v-for="row in bars" :key="row.id" class="gantt-row">
<div class="gantt-row-label">
<span class="identifier">{{ row.code }}</span>
<span>{{ row.label }}</span>
</div>
<div class="gantt-track">
<div
v-for="seg in row.segments"
:key="`${row.id}-${seg.period_index}`"
class="gantt-bar"
:style="barStyle(seg)"
:title="`${row.code} · ${seg.label}`"
/>
</div>
</div>
<Message v-if="!bars.length" severity="info" :closable="false">Sin partidas programadas.</Message>
</div>
</template>
<script setup lang="ts">
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
type ChapterRow = {
id: number;
code: string;
name: string;
periods: { period_id: number; period_index: number; amount: number }[];
};
const props = defineProps<{
periods: Period[];
chapters: ChapterRow[];
drillConcepts?: { id: number; code: string; description: string; chapter_id: number | null; periods: { period_index: number; start_pct: number; end_pct: number }[] }[];
drillChapterId?: number | null;
}>();
const periodCount = computed(() => props.periods.length || 1);
const bars = computed(() => {
if (props.drillChapterId && props.drillConcepts?.length) {
return props.drillConcepts
.filter((c) => c.chapter_id === props.drillChapterId)
.map((c) => ({
id: c.id,
code: c.code,
label: c.description,
segments: props.periods
.map((p) => {
const slot = c.periods.find((x) => x.period_index === p.period_index);
if (!slot || (slot.start_pct === 0 && slot.end_pct === 0)) return null;
return { period_index: p.period_index, label: p.label, start_pct: slot.start_pct, end_pct: slot.end_pct };
})
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
}));
}
return props.chapters.map((ch) => ({
id: ch.id,
code: ch.code,
label: ch.name,
segments: props.periods
.map((p) => {
const slot = ch.periods.find((x) => x.period_index === p.period_index);
if (!slot || Number(slot.amount) <= 0) return null;
return { period_index: p.period_index, label: p.label, start_pct: 0, end_pct: 100 };
})
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
}));
});
function barStyle(seg: { period_index: number; start_pct: number; end_pct: number }) {
const n = periodCount.value;
const colW = 100 / n;
const left = (seg.period_index - 1) * colW + (seg.start_pct / 100) * colW;
const width = Math.max(1, ((seg.end_pct - seg.start_pct) / 100) * colW);
return { left: `${left}%`, width: `${width}%` };
}
</script>
<style scoped>
.gantt-panel { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem 0; }
.gantt-header { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; font-size: 0.7rem; color: var(--text-color-secondary); }
.gantt-col-head { text-align: center; }
.gantt-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; min-height: 2rem; }
.gantt-row-label { display: flex; flex-direction: column; font-size: 0.8rem; overflow: hidden; }
.gantt-track { position: relative; height: 1.25rem; background: var(--surface-100); border-radius: 4px; }
.gantt-bar {
position: absolute;
top: 2px;
bottom: 2px;
background: var(--primary-color);
border-radius: 3px;
opacity: 0.85;
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<Dialog v-model:visible="visibleModel" header="Generar programa automático" modal :style="{ width: '28rem' }">
<Message severity="info" :closable="false">
Crea un borrador distribuyendo los conceptos del presupuesto según la estrategia elegida.
</Message>
<FormField label="Estrategia" class="mt-3">
<Select v-model="strategy" :options="options" option-label="label" option-value="value" class="w-full" />
</FormField>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cancelar" text @click="visibleModel = false" />
<Button label="Generar" icon="pi pi-bolt" :loading="busy" @click="run" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
const props = defineProps<{ visible: boolean; projectId: number }>();
const emit = defineEmits<{ "update:visible": [boolean]; generated: [] }>();
const { api } = useApi();
const toast = useToast();
const visibleModel = computed({
get: () => props.visible,
set: (v) => emit("update:visible", v),
});
const strategy = ref("keyword");
const busy = ref(false);
const error = ref("");
const options = [
{ value: "keyword", label: "Por tipo de partida (preliminares → acabados)" },
{ value: "wbs", label: "Por orden WBS del presupuesto" },
{ value: "amount", label: "Por peso económico" },
];
async function run() {
if (!props.projectId) return;
busy.value = true;
error.value = "";
try {
await api(`/v1/projects/${props.projectId}/work-program/generate`, {
method: "POST",
json: { strategy: strategy.value },
});
toast.add({ severity: "success", summary: "Programa generado", life: 2500 });
emit("generated");
visibleModel.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo generar";
} finally {
busy.value = false;
}
}
</script>

View file

@ -0,0 +1,121 @@
<template>
<div class="wp-grid-wrap">
<DataTable :value="rows" scrollable scroll-height="flex" table-style="min-width: 48rem" size="small">
<Column frozen :header="rowLabel" style="min-width: 14rem">
<template #body="{ data }">
<div class="wp-row-label">
<span class="identifier">{{ data.code || "—" }}</span>
<span class="wp-desc">{{ shortDesc(data.label) }}</span>
</div>
</template>
</Column>
<Column v-for="p in periods" :key="p.id" :header="p.label" style="width: 7rem">
<template #body="{ data }">
<div v-if="mode === 'concept'" class="wp-cell" :class="{ active: cellActive(data, p.id) }">
<InputNumber
:model-value="cellStart(data, p.id)"
:min="0"
:max="100"
:min-fraction-digits="0"
:max-fraction-digits="2"
class="wp-mini"
@update:model-value="(v: number | null) => patchConcept(data, p.id, v ?? 0, cellEnd(data, p.id))"
/>
<span class="wp-sep">–</span>
<InputNumber
:model-value="cellEnd(data, p.id)"
:min="0"
:max="100"
:min-fraction-digits="0"
:max-fraction-digits="2"
class="wp-mini"
@update:model-value="(v: number | null) => patchConcept(data, p.id, cellStart(data, p.id), v ?? 0)"
/>
</div>
<div v-else class="wp-cell" :class="{ active: cellAmount(data, p.id) > 0 }">
<InputNumber
:model-value="cellAmount(data, p.id)"
mode="currency"
currency="MXN"
locale="es-MX"
:min="0"
class="w-full"
@update:model-value="(v: number | null) => patchPartida(data, p.id, v ?? 0)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
type Period = { id: number; period_index: number; label: string };
type GridRow = {
id: number;
code: string;
label: string;
periods: { period_id: number; start_pct?: number; end_pct?: number; amount?: number }[];
};
const props = defineProps<{
mode: "concept" | "partida";
periods: Period[];
rows: GridRow[];
projectId: number;
}>();
const emit = defineEmits<{ updated: [] }>();
const { api } = useApi();
const rowLabel = computed(() => (props.mode === "concept" ? "Concepto" : "Partida"));
function shortDesc(v: string) {
const t = String(v || "").trim();
return t.length > 48 ? `${t.slice(0, 45)}…` : t;
}
function periodData(row: GridRow, periodId: number) {
return row.periods.find((p) => p.period_id === periodId);
}
function cellStart(row: GridRow, periodId: number) {
return Number(periodData(row, periodId)?.start_pct ?? 0);
}
function cellEnd(row: GridRow, periodId: number) {
return Number(periodData(row, periodId)?.end_pct ?? 0);
}
function cellAmount(row: GridRow, periodId: number) {
return Number(periodData(row, periodId)?.amount ?? 0);
}
function cellActive(row: GridRow, periodId: number) {
return cellStart(row, periodId) > 0 || cellEnd(row, periodId) > 0;
}
async function patchConcept(row: GridRow, periodId: number, start: number, end: number) {
await api(`/v1/projects/${props.projectId}/work-program/concepts/${row.id}/periods/${periodId}`, {
method: "PATCH",
json: { start_pct: start, end_pct: end },
});
emit("updated");
}
async function patchPartida(row: GridRow, periodId: number, amount: number) {
await api(`/v1/projects/${props.projectId}/work-program/partidas/${row.id}/periods/${periodId}`, {
method: "PATCH",
json: { amount },
});
emit("updated");
}
</script>
<style scoped>
.wp-row-label { display: flex; flex-direction: column; gap: 0.15rem; }
.wp-desc { font-size: 0.75rem; color: var(--text-color-secondary); }
.wp-cell { padding: 0.15rem; border-radius: 4px; }
.wp-cell.active { background: color-mix(in srgb, var(--primary-color) 12%, transparent); }
.wp-cell { display: flex; align-items: center; gap: 0.15rem; }
.wp-mini { width: 3rem; }
.wp-sep { opacity: 0.5; font-size: 0.7rem; }
.wp-grid-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; }
</style>

View file

@ -0,0 +1,127 @@
<template>
<Dialog
v-model:visible="visibleModel"
header="Importar programa de obra (Excel Neodata/Opus)"
modal
maximizable
:style="{ width: 'min(96vw, 88rem)' }"
>
<Message severity="info" :closable="false">
Suba el Excel <strong>por concepto</strong> (Barra=) o <strong>por partida</strong> (erogaciones semanales).
Debe existir presupuesto cargado en <strong>{{ projectName }}</strong>.
</Message>
<div class="flex flex-wrap gap-2 mt-3">
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="hidden-file" @change="onFile" />
<Button label="Seleccionar Excel" icon="pi pi-upload" :disabled="busy" @click="pickFile" />
<span v-if="fileName" class="muted">{{ fileName }}</span>
</div>
<ProgressBar v-if="busy" mode="indeterminate" class="mt-2" style="height: 4px" />
<template v-if="preview">
<div class="import-summary mt-3">
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
<div><strong>{{ preview.period_count }}</strong><span>Semanas</span></div>
<div v-if="preview.format === 'concepto'"><strong>{{ preview.concepts.length }}</strong><span>Conceptos</span></div>
<div v-if="preview.format === 'partida'"><strong>{{ preview.partidas.length }}</strong><span>Partidas</span></div>
<div><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
</div>
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
<Message v-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
</Message>
</template>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cerrar" text @click="visibleModel = false" />
<Button label="Importar" icon="pi pi-check" :disabled="!preview || preview.format === 'none'" :loading="saving" @click="confirm" />
</template>
</Dialog>
</template>
<script setup lang="ts">
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
const { api } = useApi();
const toast = useToast();
const visibleModel = computed({
get: () => props.visible,
set: (v) => emit("update:visible", v),
});
const fileInput = ref<HTMLInputElement | null>(null);
const fileName = ref("");
const pending = ref<File | null>(null);
const preview = ref<Record<string, unknown> | null>(null);
const busy = ref(false);
const saving = ref(false);
const error = ref("");
function pickFile() {
fileInput.value?.click();
}
async function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file || !props.projectId) return;
pending.value = file;
fileName.value = file.name;
error.value = "";
busy.value = true;
try {
const fd = new FormData();
fd.append("file", file);
preview.value = await api(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
} catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
preview.value = null;
} finally {
busy.value = false;
}
}
async function confirm() {
if (!pending.value || !props.projectId) return;
saving.value = true;
error.value = "";
try {
const fd = new FormData();
fd.append("file", pending.value);
await api(`/v1/projects/${props.projectId}/work-program/import?create_chapters=true`, { method: "POST", body: fd });
toast.add({ severity: "success", summary: "Programa importado", life: 2500 });
emit("imported");
visibleModel.value = false;
preview.value = null;
pending.value = null;
fileName.value = "";
} catch (err) {
error.value = err instanceof Error ? err.message : "Error al importar";
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.import-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem;
}
.import-summary div {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
}
.import-summary span {
font-size: 0.75rem;
color: var(--text-color-secondary);
}
.hidden-file { display: none; }
</style>

View file

@ -144,6 +144,7 @@ const menu = [
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") }, { label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") }, { label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") }, { 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: "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: "Gastos", icon: "pi pi-money-bill", to: "/gastos", command: () => navigateTo("/gastos") },
{ label: "Almacén", icon: "pi pi-box", to: "/almacen", command: () => navigateTo("/almacen") }, { label: "Almacén", icon: "pi pi-box", to: "/almacen", command: () => navigateTo("/almacen") },

View file

@ -17,6 +17,21 @@
</div> </div>
</Message> </Message>
<Message
v-else-if="currentProject?.start_date && currentProject?.end_date && items.length"
severity="success"
:closable="true"
>
Presupuesto listo. Puede crear el programa de obra para esta obra.
<Button
label="Ir a Programa de obra"
icon="pi pi-calendar"
class="mt-2"
size="small"
@click="navigateTo({ path: '/programa-obra', query: { project: currentProject?.id } })"
/>
</Message>
<DataTable <DataTable
v-model:selection="selected" v-model:selection="selected"
:value="listed" :value="listed"
@ -106,7 +121,7 @@
import FormField from "~/components/padron/FormField.vue"; import FormField from "~/components/padron/FormField.vue";
import BudgetImportDialog from "~/components/BudgetImportDialog.vue"; import BudgetImportDialog from "~/components/BudgetImportDialog.vue";
type Project = { id: number; code: string; name: string; status: string; budget_count?: number }; type Project = { id: number; code: string; name: string; status: string; budget_count?: number; start_date?: string | null; end_date?: string | null };
type Chapter = { type Chapter = {
id: number; id: number;
name: string; name: string;

View file

@ -0,0 +1,353 @@
<template>
<div class="programa-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar concepto o partida">
<Chip :label="projectName" icon="pi pi-building" />
<Chip v-if="program" :label="programStatus" icon="pi pi-calendar" />
<Chip v-if="curve.length" :label="`${periods.length} semanas`" icon="pi pi-clock" />
</DeskTableBar>
<Message v-if="!currentProject" severity="info" :closable="false">Seleccione una obra en el árbol.</Message>
<Message v-else-if="!hasBudget" severity="warn" :closable="false">
Esta obra no tiene presupuesto. Cárguelo primero en Presupuesto.
<Button label="Ir a Presupuesto" class="mt-2" size="small" @click="goPresupuesto" />
</Message>
<Message v-else-if="!hasDates" severity="warn" :closable="false">
Defina fecha de inicio y término en la ficha del proyecto.
<Button label="Ir a Proyectos" class="mt-2" size="small" @click="navigateTo('/proyectos')" />
</Message>
<Message v-else-if="!program" severity="info" :closable="false">
Presupuesto listo. Cree el programa importando Excel Neodata, generándolo automáticamente o en blanco.
<div class="flex flex-wrap gap-2 mt-3">
<Button label="Importar Excel" icon="pi pi-upload" @click="showImport = true" />
<Button label="Generar automático" icon="pi pi-bolt" outlined @click="showGenerate = true" />
<Button label="Crear vacío" icon="pi pi-plus" outlined :loading="creating" @click="createEmpty" />
</div>
</Message>
<template v-else>
<TabView v-model:active-index="activeTab" class="programa-tabs">
<TabPanel header="Conceptos">
<WorkProgramGrid
mode="concept"
:periods="periods"
:rows="conceptRows"
:project-id="loadedProjectId"
@updated="reload"
/>
</TabPanel>
<TabPanel header="Partidas">
<WorkProgramGrid
mode="partida"
:periods="periods"
:rows="partidaRows"
:project-id="loadedProjectId"
@updated="reload"
/>
<div v-if="curve.length" class="curve-row mt-3">
<div v-for="c in curve" :key="c.period_index" class="curve-cell">
<span class="curve-label">{{ c.label }}</span>
<strong>{{ money(c.amount) }}</strong>
<span class="curve-pct">{{ pct(c.pct_period) }} · acum {{ pct(c.pct_cumulative) }}</span>
</div>
</div>
</TabPanel>
<TabPanel header="Gantt">
<div class="gantt-toolbar mb-2">
<Select
v-model="ganttChapterId"
:options="ganttChapterOptions"
option-label="label"
option-value="value"
placeholder="Todas las partidas"
show-clear
class="gantt-select"
/>
</div>
<WorkProgramGantt
:periods="periods"
:chapters="ganttChapters"
:drill-chapter-id="ganttChapterId"
:drill-concepts="ganttConcepts"
/>
</TabPanel>
<TabPanel header="Programado vs costos">
<WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" />
</TabPanel>
</TabView>
</template>
<WorkProgramImportDialog
v-model:visible="showImport"
:project-id="loadedProjectId"
:project-name="projectName"
@imported="reload"
/>
<WorkProgramGenerateDialog
v-model:visible="showGenerate"
:project-id="loadedProjectId"
@generated="reload"
/>
</div>
</template>
<script setup lang="ts">
import WorkProgramGrid from "~/components/WorkProgramGrid.vue";
import WorkProgramGantt from "~/components/WorkProgramGantt.vue";
import WorkProgramCostCompare from "~/components/WorkProgramCostCompare.vue";
import WorkProgramImportDialog from "~/components/WorkProgramImportDialog.vue";
import WorkProgramGenerateDialog from "~/components/WorkProgramGenerateDialog.vue";
type Project = {
id: number;
name: string;
code: string;
start_date?: string | null;
end_date?: string | null;
budget_count?: number;
};
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
type CurvePoint = { period_index: number; label: string; amount: number; pct_period: number; pct_cumulative: number };
const desk = useDesktop();
const route = useRoute();
const { api, download } = useApi();
const toast = useToast();
const projects = ref<Project[]>([]);
const loadedProjectId = ref(0);
const program = ref<Record<string, unknown> | null>(null);
const periods = ref<Period[]>([]);
const curve = ref<CurvePoint[]>([]);
const conceptItems = ref<Record<string, unknown>[]>([]);
const partidaChapters = ref<Record<string, unknown>[]>([]);
const vsCostPeriods = ref<Record<string, unknown>[]>([]);
const vsCostChapters = ref<Record<string, unknown>[]>([]);
const activeTab = ref(0);
const showImport = ref(false);
const showGenerate = ref(false);
const creating = ref(false);
const ganttChapterId = ref<number | null>(null);
const { tableQ, matches } = useTableSearch();
const currentProject = computed(() => projects.value.find((p) => p.id === loadedProjectId.value) || null);
const projectName = computed(() => currentProject.value?.name || "Sin proyecto");
const hasBudget = computed(() => Number(currentProject.value?.budget_count || 0) > 0);
const hasDates = computed(() => !!(currentProject.value?.start_date && currentProject.value?.end_date));
const programStatus = computed(() => {
const s = String(program.value?.status || "draft");
return s === "active" ? "Programa activo" : "Borrador";
});
const conceptRows = computed(() =>
conceptItems.value
.filter((i) => matches(i.code, i.description, i.chapter_name))
.map((i) => ({
id: Number(i.id),
code: String(i.code || ""),
label: String(i.description || ""),
periods: (i.periods as { period_id: number; start_pct?: number; end_pct?: number }[]) || [],
})),
);
const partidaRows = computed(() =>
partidaChapters.value
.filter((c) => matches(c.code, c.name))
.map((c) => ({
id: Number(c.id),
code: String(c.code || ""),
label: String(c.name || ""),
periods: (c.periods as { period_id: number; amount?: number }[]) || [],
})),
);
const ganttChapterOptions = computed(() =>
partidaChapters.value.map((c) => ({
value: Number(c.id),
label: [c.code, c.name].filter(Boolean).join(" "),
})),
);
const ganttChapters = computed(() =>
partidaChapters.value.map((c) => ({
id: Number(c.id),
code: String(c.code || ""),
name: String(c.name || ""),
periods: ((c.periods as { period_id: number; period_index: number; amount: number }[]) || []),
})),
);
const ganttConcepts = computed(() =>
conceptItems.value.map((c) => ({
id: Number(c.id),
code: String(c.code || ""),
description: String(c.description || ""),
chapter_id: c.chapter_id != null ? Number(c.chapter_id) : null,
periods: ((c.periods as { period_index: number; start_pct: number; end_pct: number }[]) || []),
})),
);
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function pct(v: number) {
return `${(Number(v || 0) * 100).toFixed(1)}%`;
}
function goPresupuesto() {
navigateTo({ path: "/presupuesto", query: { project: loadedProjectId.value } });
}
async function loadProjects() {
const res = await api<{ projects: Project[] }>("/v1/projects");
projects.value = res.projects;
}
async function loadProgram(id: number) {
try {
const wp = await api<{ program: Record<string, unknown> | null; periods: Period[]; curve: CurvePoint[] }>(
`/v1/projects/${id}/work-program`,
);
program.value = wp.program;
periods.value = wp.periods || [];
curve.value = wp.curve || [];
if (wp.program) {
const [concepts, partidas, vs] = await Promise.all([
api<{ items: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/concepts`),
api<{ chapters: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/partidas`),
api<{ by_period: Record<string, unknown>[]; by_chapter: Record<string, unknown>[] }>(
`/v1/projects/${id}/work-program/vs-cost`,
),
]);
conceptItems.value = concepts.items || [];
partidaChapters.value = partidas.chapters || [];
vsCostPeriods.value = vs.by_period || [];
vsCostChapters.value = vs.by_chapter || [];
} else {
conceptItems.value = [];
partidaChapters.value = [];
vsCostPeriods.value = [];
vsCostChapters.value = [];
}
} catch (err) {
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "Error al cargar programa", life: 3500 });
}
}
async function reload() {
if (loadedProjectId.value) await loadProgram(loadedProjectId.value);
syncChrome();
}
async function createEmpty() {
creating.value = true;
try {
await api(`/v1/projects/${loadedProjectId.value}/work-program`, { method: "POST" });
await reload();
} catch (err) {
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "No se pudo crear", life: 3000 });
} finally {
creating.value = false;
}
}
async function recalculate() {
await api(`/v1/projects/${loadedProjectId.value}/work-program/recalculate-partidas`, { method: "POST" });
toast.add({ severity: "success", summary: "Partidas recalculadas desde conceptos", life: 2000 });
await reload();
}
async function exportExcel(kind: "concepto" | "partida") {
const name = kind === "partida" ? "programa-partidas.xlsx" : "programa-conceptos.xlsx";
await download(`/v1/projects/${loadedProjectId.value}/work-program/export?kind=${kind}`, name);
}
function syncChrome() {
const project = currentProject.value;
desk.configure({
tab: "Programa de obra",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: desk.treeKey.value || (project ? `p:${project.id}` : ""),
countLabel: program.value ? `${periods.value.length} semanas` : "",
tree: projects.value.map((item) => ({
key: `p:${item.id}`,
label: item.name,
icon: "pi pi-building",
data: { count: item.id === project?.id ? conceptItems.value.length : 0 },
})),
ribbon: [
{
key: "programa",
label: "Programa",
items: [
{ key: "import", label: "Importar", icon: "pi pi-upload", disabled: !project || !hasBudget.value, command: () => { showImport.value = true; } },
{ key: "generate", label: "Generar", icon: "pi pi-bolt", disabled: !project || !hasBudget.value, command: () => { showGenerate.value = true; } },
{ key: "recalc", label: "Recalcular partidas", icon: "pi pi-calculator", disabled: !program.value, command: recalculate },
],
},
{
key: "datos",
label: "Datos",
items: [
{ key: "exp-c", label: "Export conceptos", icon: "pi pi-download", disabled: !program.value, command: () => exportExcel("concepto") },
{ key: "exp-p", label: "Export partidas", icon: "pi pi-download", disabled: !program.value, command: () => exportExcel("partida") },
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !project, command: reload },
],
},
],
});
}
watch(
() => desk.treeKey.value,
async (key) => {
if (key?.startsWith("p:")) {
const id = Number(key.split(":")[1]);
if (id && id !== loadedProjectId.value) {
loadedProjectId.value = id;
await loadProgram(id);
syncChrome();
}
}
},
);
onMounted(async () => {
await loadProjects();
const fromQuery = Number(route.query.project || 0);
const initial = fromQuery || projects.value[0]?.id;
if (initial) {
loadedProjectId.value = initial;
desk.treeKey.value = `p:${initial}`;
await loadProgram(initial);
}
syncChrome();
});
watch([program, periods, conceptItems], syncChrome);
</script>
<style scoped>
.programa-page { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.programa-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.programa-tabs :deep(.p-tabview-panels) { flex: 1; min-height: 0; overflow: auto; }
.curve-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.curve-cell {
flex: 1;
min-width: 7rem;
padding: 0.5rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
display: flex;
flex-direction: column;
font-size: 0.8rem;
}
.curve-label { color: var(--text-color-secondary); font-size: 0.7rem; }
.curve-pct { font-size: 0.65rem; color: var(--text-color-secondary); }
.gantt-toolbar { display: flex; gap: 0.5rem; }
.gantt-select { min-width: 16rem; }
</style>