panels-origin/api/work_program_excel.ts
Alberto Martinez 496273b41c feat: Programa de obra por proyecto
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary

Implementa el módulo **Programa de obra** por proyecto, vinculado al presupuesto ya existente.

### Flujo
1. Cargar presupuesto (requisito)
2. Definir fechas de inicio/término en el proyecto
3. Crear programa: **importar Excel Neodata/Opus**, **generar automático** o **crear vacío**
4. Editar en cuadrícula (conceptos con `Barra=` y partidas con erogaciones semanales)
5. Visualizar Gantt resumido y comparativo **programado vs ejecutado**

### Backend
- Tablas: `work_programs`, `work_program_periods`, `work_program_concept_slots`, `work_program_partida_amounts`, `work_program_progress`
- RPC: list/get, replace (import), generate (wizard), update celdas, recalculate partidas, vs-cost
- Parser Excel para formatos **por concepto** (`Barra=`) y **por partida** (montos semanales)
- Tests con fixtures reales del usuario

### Frontend
- Nueva página `/programa-obra` con pestañas: Conceptos, Partidas, Gantt, Programado vs costos
- Diálogos de importación y generación automática
- Menú principal, enlace en ficha de proyecto, banner en presupuesto

### Permisos
Reutiliza `manage_budget` en MVP.

## Test plan
- [x] `deno test api/work_program_excel_test.ts` (5 tests)
- [x] `deno check` API modules
- [x] `npm run build` web-panel
- [ ] Migrar DB (`029`, `030`) en entorno con Postgres
- [ ] Importar Excel por concepto y por partida en obra con presupuesto
- [ ] Generar automático con estrategia keyword
- [ ] Recalcular partidas desde conceptos
- [ ] Verificar tab Programado vs costos con gastos registrados
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-3985fe74-d26b-4506-bd4d-f9ad57374141?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-3985fe74-d26b-4506-bd4d-f9ad57374141&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-08 04:51:09 +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;
}