From b0a1a3147c38c069ef09c975a29b684bd68751aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:44:35 +0000 Subject: [PATCH 1/2] fix: mostrar preview del programa de obra al importar Excel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El Excel Neodata por partida sí se leía (A001–A009, 9 semanas), pero la API devolvía { ok, data } sin envelope y el panel no desembalaba data: el diálogo mostraba 0 y «Sin match». Ahora se aplana la respuesta y el preview lista las partidas encontradas. Co-authored-by: alberto.martinez --- api/work_program_excel_test.ts | 34 +++++++- api/work_program_http.ts | 77 ++++++++++++++----- .../components/WorkProgramImportDialog.vue | 67 ++++++++++++++-- web-panel/composables/api-response.ts | 27 ++++--- 4 files changed, 162 insertions(+), 43 deletions(-) diff --git a/api/work_program_excel_test.ts b/api/work_program_excel_test.ts index 4e2f990..bcc9b50 100644 --- a/api/work_program_excel_test.ts +++ b/api/work_program_excel_test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertGreater } from "jsr:@std/assert@1"; import { readFileSync } from "node:fs"; -import { parseBarString, parseWorkProgramRows, parseWorkProgramWorkbook } from "./work_program_excel.ts"; +import { parseBarString, parseWorkProgramRows, parseWorkProgramWorkbook, matchWorkProgramImport } from "./work_program_excel.ts"; Deno.test("parseBarString decodifica formato Neodata", () => { const slots = parseBarString("Barra=14.29-100,0-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0"); @@ -36,8 +36,36 @@ Deno.test("detecta programa por concepto con Barra=", () => { assertEquals(parsed.concepts[0].slots.length, 3); }); -const conceptoPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Concepto_a_Est_ndar__E__4-9-2026_Hr18Mn46_85cd.xlsx"; -const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_18ff.xlsx"; +Deno.test("detecta programa por partida con columna vacía antes de Sem (Neodata)", () => { + const parsed = parseWorkProgramRows([ + ["PARTIDA", "PARTIDA", "", "Sem 1", "Sem 2", "Sem 3", "Total"], + ["A", "A", "", 0, 0, 0, 0], + ["A001", "PRELIMINARES Y ALBAÑILERÍA", "", 19493.27, 32531.38, 1161.31, 123219.19], + ["", "", "", "Barra=14.29-100,0-100,0-14.29"], + ["A002", "INSTALACION HIDROSANITARIA", "", 1889.2, 19822.48, 3668.97, 48630.1], + ]); + assertEquals(parsed.format, "partida"); + assertEquals(parsed.period_count, 3); + assertEquals(parsed.partidas.length, 2); + assertEquals(parsed.partidas[0].code, "A001"); + assertEquals(parsed.partidas[0].amounts[0], 19493.27); +}); + +Deno.test("match de partidas por código A001 del presupuesto", () => { + const parsed = parseWorkProgramRows([ + ["PARTIDA", "PARTIDA", "Sem 1", "Sem 2", "Total"], + ["A001", "PRELIMINARES Y ALBAÑILERÍA", 100, 200, 300], + ]); + const matched = matchWorkProgramImport({ ...parsed, sheet: "Por partida" }, { + items: [], + chapters: [{ id: 11, code: "A001", name: "Preliminares y albañilería", amount: 300 }], + }); + assertEquals(matched.preview.unmatched_codes, []); + assertEquals(matched.payload.partida_amounts.length, 2); +}); + +const conceptoPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Concepto_a_Est_ndar__E__4-9-2026_Hr18Mn46.xlsx"; +const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_da4d.xlsx"; Deno.test("lee Excel real por concepto del usuario", () => { try { diff --git a/api/work_program_http.ts b/api/work_program_http.ts index 9e45b4c..5a56e30 100644 --- a/api/work_program_http.ts +++ b/api/work_program_http.ts @@ -3,8 +3,9 @@ import type { AuthUser } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { projectById } from "./db.ts"; -import { requireAnyPermission, requirePermission } from "./permissions.ts"; +import { requirePermission } from "./permissions.ts"; import { denyUnlessProjectScope } from "./scope_enforcement.ts"; +import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; import { createEmptyWorkProgram, exportWorkProgram, @@ -29,11 +30,38 @@ async function ensureProject(c: Context, id: number): Promise { return await denyUnlessProjectScope(c, id); } -async function readUpload(c: { req: { formData: () => Promise } }): Promise { +function respondOk(c: Context, data: T, message: string) { + return respondRpc(c, { + ok: true, + code: "OK", + layer: "api", + message, + data, + }); +} + +async function readUpload(c: Context): Promise<{ bytes: Uint8Array } | { response: Response }> { const form = await c.req.formData(); const file = form.get("file"); - if (!(file instanceof File)) throw new Error("Archivo Excel requerido (campo file)"); - return new Uint8Array(await file.arrayBuffer()); + if (!(file instanceof File)) { + return { + response: respondApiError(c, "VALIDATION", "Seleccione un archivo Excel (.xlsx o .xls) de programa de obra", { + route: routeLabel(c), + }), + }; + } + const name = file.name.trim().toLowerCase(); + if (!name.endsWith(".xlsx") && !name.endsWith(".xls")) { + return { + response: respondApiError( + c, + "VALIDATION", + `Solo se aceptan archivos Excel (.xlsx o .xls). «${file.name}» no es un Excel de programa de obra.`, + { route: routeLabel(c), filename: file.name }, + ), + }; + } + return { bytes: new Uint8Array(await file.arrayBuffer()) }; } export function registerWorkProgramRoutes(app: App) { @@ -42,7 +70,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await getWorkProgram(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, data.program ? "Programa de obra cargado" : "La obra no tiene programa de obra"); }); app.get("/v1/projects/:id/work-program/concepts", ...viewAuth, async (c) => { @@ -50,7 +78,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await listConcepts(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Conceptos del programa de obra"); }); app.get("/v1/projects/:id/work-program/partidas", ...viewAuth, async (c) => { @@ -58,7 +86,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await listPartidas(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Partidas del programa de obra"); }); app.get("/v1/projects/:id/work-program/vs-cost", ...requireCoreAuth, requirePermission("cost_control.view"), async (c) => { @@ -66,26 +94,35 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await getVsCost(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Comparativo programado vs costos"); }); app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { const id = Number(c.req.param("id")); if (!await ensureProject(c, id)) return; const db = c.get("db"); - const bytes = await readUpload(c); - const preview = await previewWorkProgramExcel(bytes, id, db); - return c.json({ ok: true, data: preview }); + const upload = await readUpload(c); + if ("response" in upload) return upload.response; + const preview = await previewWorkProgramExcel(upload.bytes, id, db); + const found = preview.format === "concepto" ? preview.concepts.length : preview.partidas.length; + return respondOk( + c, + preview, + preview.format === "none" + ? "No se reconoció el formato del programa de obra" + : `${preview.format_label}: ${found} renglón(es), ${preview.period_count} semana(s)`, + ); }); app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { const id = Number(c.req.param("id")); if (!await ensureProject(c, id)) return; const db = c.get("db"); - const bytes = await readUpload(c); + const upload = await readUpload(c); + if ("response" in upload) return upload.response; const createChapters = c.req.query("create_chapters") !== "false"; - const data = await importWorkProgramExcel(db, id, bytes, { createChapters }); - return c.json({ ok: true, data }); + const data = await importWorkProgramExcel(db, id, upload.bytes, { createChapters }); + return respondOk(c, data, "Programa de obra importado"); }); app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { @@ -93,7 +130,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await createEmptyWorkProgram(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Programa de obra creado en blanco"); }); app.post("/v1/projects/:id/work-program/generate", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { @@ -102,7 +139,7 @@ export function registerWorkProgramRoutes(app: App) { const db = c.get("db"); const body = await c.req.json().catch(() => ({})) as { strategy?: string }; const data = await generateWorkProgram(db, id, body.strategy || "keyword"); - return c.json({ ok: true, data }); + return respondOk(c, data, "Programa de obra generado"); }); app.post("/v1/projects/:id/work-program/recalculate-partidas", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -110,7 +147,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const env = await recalculatePartidas(db, id); - return c.json({ ok: true, data: env }); + return respondOk(c, env, "Erogaciones por partida recalculadas"); }); app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -126,7 +163,7 @@ export function registerWorkProgramRoutes(app: App) { Number(body.start_pct ?? 0), Number(body.end_pct ?? 0), ); - return c.json({ ok: true, data }); + return respondOk(c, data, "Avance de concepto actualizado"); }); app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -141,7 +178,7 @@ export function registerWorkProgramRoutes(app: App) { Number(c.req.param("periodId")), Number(body.amount ?? 0), ); - return c.json({ ok: true, data }); + return respondOk(c, data, "Erogación de partida actualizada"); }); app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -156,7 +193,7 @@ export function registerWorkProgramRoutes(app: App) { Number(c.req.param("periodId")), Number(body.pct_complete ?? 0), ); - return c.json({ ok: true, data }); + return respondOk(c, data, "Porcentaje de avance actualizado"); }); app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => { diff --git a/web-panel/components/WorkProgramImportDialog.vue b/web-panel/components/WorkProgramImportDialog.vue index 43a94da..012b26f 100644 --- a/web-panel/components/WorkProgramImportDialog.vue +++ b/web-panel/components/WorkProgramImportDialog.vue @@ -20,28 +20,67 @@ {{ error }}