diff --git a/api/budget.ts b/api/budget.ts index 38eca1a..8dc7311 100644 --- a/api/budget.ts +++ b/api/budget.ts @@ -801,10 +801,49 @@ const FORMAT_LABEL: Record = { none: "No reconocido", }; +export const EXCEL_ONLY_MESSAGE = "Solo se aceptan archivos Excel (.xlsx o .xls)."; +export const BUDGET_FORMAT_MESSAGE = + "El Excel no tiene el formato de presupuesto esperado. Use la plantilla del panel o un archivo Opus/Neodata con columnas CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."; + +export function excelFileTypeError(filename: string): string | null { + const name = filename.trim(); + if (/\.(xlsx|xls)$/i.test(name)) return null; + const shown = name || "sin nombre"; + return `${EXCEL_ONLY_MESSAGE} «${shown}» no es un Excel.`; +} + +export function budgetReadErrorMessage(preview: { errors: { messages: string[] }[] }): string { + return preview.errors[0]?.messages?.[0] || BUDGET_FORMAT_MESSAGE; +} + +function startsWith(bytes: Uint8Array, sig: number[]): boolean { + if (bytes.length < sig.length) return false; + return sig.every((value, i) => bytes[i] === value); +} + +function unrecognizedBinaryMessage(bytes: Uint8Array): string | null { + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46])) { + return "El archivo es un PDF, no un Excel. Suba un .xlsx o .xls con el formato de presupuesto."; + } + if (startsWith(bytes, [0xff, 0xd8, 0xff]) || startsWith(bytes, [0x89, 0x50, 0x4e, 0x47])) { + return "El archivo es una imagen, no un Excel. Suba un .xlsx o .xls."; + } + return null; +} + function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } { - const wb = XLSX.read(bytes, { type: "array" }); + const binaryError = unrecognizedBinaryMessage(bytes); + if (binaryError) return { error: binaryError }; + let wb: XLSX.WorkBook; + try { + wb = XLSX.read(bytes, { type: "array" }); + } catch { + return { + error: "No se pudo abrir el archivo. Suba un Excel .xlsx o .xls con el formato de presupuesto (plantilla del panel u Opus/Neodata).", + }; + } const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0]; - if (!sheet) return { error: "El archivo no tiene hojas" }; + if (!sheet) return { error: "El archivo Excel no tiene hojas. Revise que no esté vacío o dañado." }; return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] }; } @@ -863,7 +902,7 @@ export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview { const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; const errors = parsed.items.length ? [] - : [{ row: 0, messages: ["No se encontraron partidas. Revise que el Excel tenga Código/Concepto o la plantilla WBS."] }]; + : [{ row: 0, messages: [BUDGET_FORMAT_MESSAGE] }]; return { format: parsed.format, format_label: FORMAT_LABEL[parsed.format], @@ -902,7 +941,7 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes: chapters: 0, inserted: 0, skipped: parsed.skipped, - errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }], + errors: [{ row: 0, messages: [BUDGET_FORMAT_MESSAGE] }], }; } await replaceBudgetFromParsed(database, projectId, parsed); diff --git a/api/budget_test.ts b/api/budget_test.ts index b728114..a946ec1 100644 --- a/api/budget_test.ts +++ b/api/budget_test.ts @@ -1,6 +1,13 @@ import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; import * as XLSX from "xlsx"; -import { buildBudgetTemplate, parseBudgetRows, parseMoney, previewBudgetExcel } from "./budget.ts"; +import { + BUDGET_FORMAT_MESSAGE, + buildBudgetTemplate, + excelFileTypeError, + parseBudgetRows, + parseMoney, + previewBudgetExcel, +} from "./budget.ts"; Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => { const parsed = parseBudgetRows([ @@ -96,3 +103,25 @@ Deno.test("preview de plantilla no guarda y arma arbol", () => { 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); +}); diff --git a/api/main.ts b/api/main.ts index 4617023..3800628 100644 --- a/api/main.ts +++ b/api/main.ts @@ -73,6 +73,8 @@ import { registerIamRoutes } from "./iam_http.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { buildBudgetTemplate, + budgetReadErrorMessage, + excelFileTypeError, exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, @@ -125,6 +127,17 @@ async function rpcDocumentForDownload( return { doc, envelope }; } +async function readExcelUpload( + c: { req: { formData: () => Promise } }, +): Promise<{ file: File; bytes: Uint8Array } | { error: string }> { + const form = await c.req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return { error: "Seleccione un archivo Excel (.xlsx o .xls)." }; + const typeError = excelFileTypeError(file.name); + if (typeError) return { error: typeError }; + return { file, bytes: new Uint8Array(await file.arrayBuffer()) }; +} + async function scopedCompany( db: Db, id: number, @@ -738,40 +751,59 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission( }); app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { - const form = await c.req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); - const bytes = new Uint8Array(await file.arrayBuffer()); - const preview = previewBudgetExcel(bytes); - if (preview.errors.length && !preview.items.length) return c.json(preview, 400); + const upload = await readExcelUpload(c); + if ("error" in upload) { + return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c) }); + } + const preview = previewBudgetExcel(upload.bytes); + if (preview.errors.length && !preview.items.length) { + return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), { + route: routeLabel(c), + filename: upload.file.name, + format: preview.format, + }, preview.errors.flatMap((row) => row.messages)); + } return c.json(preview); }); app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; - const form = await c.req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const upload = await readExcelUpload(c); + if ("error" in upload) { + return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id }); + } const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const bytes = new Uint8Array(await file.arrayBuffer()); - const preview = previewBudgetExcel(bytes); - if (preview.errors.length && !preview.items.length) return c.json(preview, 400); + const preview = previewBudgetExcel(upload.bytes); + if (preview.errors.length && !preview.items.length) { + return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), { + route: routeLabel(c), + project_id: id, + filename: upload.file.name, + format: preview.format, + }, preview.errors.flatMap((row) => row.messages)); + } return c.json(preview); }); app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; - const form = await c.req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const upload = await readExcelUpload(c); + if ("error" in upload) { + return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id }); + } const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const bytes = new Uint8Array(await file.arrayBuffer()); - const report = await importBudgetExcel(db, id, bytes); - if (report.errors.length && !report.inserted) return c.json(report, 400); + const report = await importBudgetExcel(db, id, upload.bytes); + if (report.errors.length && !report.inserted) { + return respondApiError(c, "VALIDATION", budgetReadErrorMessage(report), { + route: routeLabel(c), + project_id: id, + filename: upload.file.name, + }, report.errors.flatMap((row) => row.messages)); + } return c.json(report); }); diff --git a/web-panel/components/BudgetImportDialog.vue b/web-panel/components/BudgetImportDialog.vue index 146d63d..87f9845 100644 --- a/web-panel/components/BudgetImportDialog.vue +++ b/web-panel/components/BudgetImportDialog.vue @@ -9,16 +9,17 @@ :content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }" > - Suba el Excel de Opus/Neodata, uno plano o la plantilla del panel. Primero se muestra lo encontrado; + Suba un Excel .xlsx o .xls (Opus/Neodata, plano o la plantilla del panel). Primero se muestra lo encontrado; nada se guarda hasta que confirme. Reemplazará el presupuesto de {{ projectName }}.
+
Solo archivos Excel (.xlsx o .xls). Otros tipos se rechazan antes de subirlos.