panels-origin/api/work_program_excel.ts
Cursor Agent dc510236c5
feat: programa de obra por proyecto (import, wizard, Gantt, vs costos)
Implementa el módulo completo de programa de obra vinculado al presupuesto:

- Schema PostgreSQL: work_programs, periods, concept_slots, partida_amounts, progress
- RPC: CRUD, import replace, generate wizard, recalculate partidas, vs-cost
- API: parser Excel Neodata (concepto Barra= + partida erogaciones), endpoints REST
- UI: página /programa-obra con cuadrículas, Gantt, comparativo programado/ejecutado
- Integración: menú, ficha proyecto, banner en presupuesto, permiso manage_budget

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-05 05:17:18 +00:00

395 lines
13 KiB
TypeScript

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