panels-origin/api/budget_test.ts
Cursor Agent fa3656d142
fix: mensajes claros al subir presupuesto que no es Excel
El toast de error salía vacío porque el preview 400 no traía message.
Ahora solo se aceptan .xlsx/.xls, y si el Excel no tiene el formato
de presupuesto se explica qué columnas o plantilla usar.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 19:34:57 +00:00

127 lines
5.8 KiB
TypeScript

import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
import * as XLSX from "xlsx";
import {
BUDGET_FORMAT_MESSAGE,
buildBudgetTemplate,
excelFileTypeError,
parseBudgetRows,
parseMoney,
previewBudgetExcel,
} from "./budget.ts";
Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => {
const parsed = parseBudgetRows([
["Obra:", "Barda perimetral"],
["BARDAS PERIMETRALES", "", "", "", "", ""],
["CLAVE", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO U", "IMPORTE"],
["PRE01", "TRAZO Y NIVELACION", "ml", 92, 10, 920],
["EXC-001", "EXCAVACION", "M3", 8, 850, 6800],
["", "", "", "", "SUB TOTAL", 398545.97],
["", "", "", "", "16% IVA", 63767.36],
["", "", "", "", "TOTAL", 462313.33],
]);
assertEquals(parsed.items.length, 2);
assertEquals(parsed.items[0].code, "PRE01");
assertEquals(parsed.items[0].quantity, 92);
assertEquals(parsed.items[0].amount, 920);
assertEquals(parsed.items[0].chapter, "BARDAS PERIMETRALES");
assertEquals(parsed.items[1].code, "EXC-001");
});
Deno.test("parsea plantilla con columna CAPITULO", () => {
const parsed = parseBudgetRows([
["CAPITULO", "CLAVE", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO", "IMPORTE"],
["Acabados", "ACA01", "Aplanado", "M2", 10, 20, 200],
["Cimentacion", "CIM01", "Zapata", "PZA", 2, 100, 200],
]);
assertEquals(parsed.items.map((item) => item.chapter), ["Acabados", "Cimentacion"]);
assertEquals(parsed.items[0].amount, 200);
});
Deno.test("lee importes con coma o signo", () => {
assertEquals(parseMoney("$ 1,500.00"), 1500);
assertEquals(parseMoney("201.47"), 201.47);
});
Deno.test("parsea Opus/Neodata: agrupadores, continuaciones y totales ignorados", () => {
const parsed = parseBudgetRows([
["Código", "Concepto", "Unidad", "Cantidad", "P. Unitario", "Importe", "%"],
["A", "LOCAL L224", "", "", "", "", ""],
["A001", "PRELIMINARES Y ALBAÑILERÍA", "", "", "", "", ""],
["10301-001", "Trazo y nivelación manual para establecer ejes, banco", "M2", 61.9, 15.53, 961.31, 0.000847],
["", "de nivel y referencias, incluye: materiales, mano de", "", 0, 0, 0, 0],
["", "obra, equipo y herramienta.", "", 0, 0, 0, 0],
["A001", "TOTAL PRELIMINARES Y ALBAÑILERÍA", "", "", "", 123219.19, 0.1],
["", "IVA 16.00%", "", "", "", 181433.44, ""],
["", "TOTAL DEL PRESUPUESTO MOSTRADO:", "", "", "", 1315392.47, ""],
]);
assertEquals(parsed.items.length, 1);
assertEquals(parsed.groups.map((group) => group.code), ["A", "A001"]);
assertEquals(parsed.groups[0].kind, "lote");
assertEquals(parsed.groups[1].parentWbs, parsed.groups[0].wbs);
assertEquals(parsed.items[0].code, "10301-001");
assertEquals(parsed.items[0].chapter, "PRELIMINARES Y ALBAÑILERÍA");
assertEquals(parsed.items[0].parentWbs, parsed.groups[1].wbs);
assertStringIncludes(parsed.items[0].description, "Trazo y nivelación manual");
assertStringIncludes(parsed.items[0].description, "banco de nivel y referencias");
assertStringIncludes(parsed.items[0].description, "equipo y herramienta");
});
Deno.test("parsea plantilla WBS lote-capitulo-concepto", () => {
const parsed = parseBudgetRows([
["WBS", "TIPO", "CODIGO", "DESCRIPCION", "UNIDAD", "CANTIDAD", "PRECIO"],
["1", "lote", "A", "LOCAL L224", "", "", ""],
["1.01", "capitulo", "A001", "Preliminares y albañilería", "", "", ""],
["1.01.01", "concepto", "10301-001", "Trazo y nivelación manual", "M2", 61.9, 15.53],
["1.02", "capitulo", "A002", "Instalación hidrosanitaria", "", "", ""],
["1.02.01", "concepto", "11402-001", "Inodoro", "PZA", 1, 100],
]);
assertEquals(parsed.groups.length, 3);
assertEquals(parsed.groups[0].kind, "lote");
assertEquals(parsed.groups[1].parentWbs, "1");
assertEquals(parsed.items.length, 2);
assertEquals(parsed.items[0].parentWbs, "1.01");
assertEquals(parsed.items[0].chapter, "Preliminares y albañilería");
assertEquals(parsed.items[1].parentWbs, "1.02");
assertEquals(parsed.items[0].amount, 961.31);
});
Deno.test("la plantilla es un xlsx valido", () => {
const bytes = buildBudgetTemplate();
assertEquals(bytes[0], 0x50);
assertEquals(bytes[1], 0x4b);
const wb = XLSX.read(bytes, { type: "array" });
assertEquals(wb.SheetNames.includes("PRESUPUESTO"), true);
assertEquals(wb.SheetNames.includes("INSTRUCCIONES"), true);
});
Deno.test("preview de plantilla no guarda y arma arbol", () => {
const preview = previewBudgetExcel(buildBudgetTemplate());
assertEquals(preview.format, "wbs");
assertEquals(preview.errors.length, 0);
assertEquals(preview.tree[0]?.code, "A");
assertEquals(preview.tree[0]?.children.length, 2);
assertEquals(preview.totals.item_count, 4);
});
Deno.test("rechaza archivos que no son Excel por nombre", () => {
assertEquals(excelFileTypeError("obra.pdf")?.includes(".xlsx"), true);
assertEquals(excelFileTypeError("notas.docx")?.includes("«notas.docx»"), true);
assertEquals(excelFileTypeError("presupuesto.xlsx"), null);
assertEquals(excelFileTypeError("PRESUPUESTO.XLS"), null);
});
Deno.test("preview de PDF explica que no es Excel", () => {
const preview = previewBudgetExcel(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]));
assertEquals(preview.totals.item_count, 0);
assertStringIncludes(preview.errors[0]?.messages[0] || "", "PDF");
});
Deno.test("preview de Excel sin columnas de presupuesto explica el formato", () => {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([["Nombre", "Notas"], ["Juan", "sin partidas"]]), "Hoja1");
const bytes = XLSX.write(wb, { type: "array", bookType: "xlsx" });
const preview = previewBudgetExcel(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
assertEquals(preview.totals.item_count, 0);
assertEquals(preview.errors[0]?.messages[0], BUDGET_FORMAT_MESSAGE);
});