mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:03:16 +00:00
<!-- 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> <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> </div>
395 lines
13 KiB
TypeScript
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;
|
|
}
|