From de07975d4a8a1ac99af3502f6c5b07bfc1e925c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:27:24 +0000 Subject: [PATCH 01/15] fix: abrir documentos de proyecto enviando doc_id a la RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La API pedía el PDF con { id } y fn_project_document_get leía payload->>'doc_id', así que el id llegaba vacío aunque el archivo ya estuviera en el bucket. Mismo desajuste en empresa y trabajador. Co-authored-by: alberto.martinez --- api/document_rpc.ts | 10 ++ api/document_rpc_test.ts | 13 +++ api/main.ts | 14 ++- db/core/changelog-master.xml | 1 + .../changesets/032-rpc-document-get-id.sql | 99 +++++++++++++++++++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 api/document_rpc.ts create mode 100644 api/document_rpc_test.ts create mode 100644 db/core/changesets/032-rpc-document-get-id.sql diff --git a/api/document_rpc.ts b/api/document_rpc.ts new file mode 100644 index 0000000..7634a94 --- /dev/null +++ b/api/document_rpc.ts @@ -0,0 +1,10 @@ +/** + * Payload para core.fn_*_document_get. + * Las RPC leen `doc_id`; historicamente la API enviaba `id` y el documento + * nunca se encontraba (mensaje "documento id= no encontrado"). + */ +export function normalizeDocumentGetPayload( + payload: Record, +): Record { + return { ...payload, doc_id: payload.doc_id ?? payload.id }; +} diff --git a/api/document_rpc_test.ts b/api/document_rpc_test.ts new file mode 100644 index 0000000..8bcec8d --- /dev/null +++ b/api/document_rpc_test.ts @@ -0,0 +1,13 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { normalizeDocumentGetPayload } from "./document_rpc.ts"; + +Deno.test("document get payload usa doc_id cuando la API envía id", () => { + const payload = normalizeDocumentGetPayload({ project_id: 1, id: 42 }); + assertEquals(payload.doc_id, 42); + assertEquals(payload.project_id, 1); +}); + +Deno.test("document get payload respeta doc_id si ya viene", () => { + const payload = normalizeDocumentGetPayload({ company_id: 3, doc_id: 7, id: 99 }); + assertEquals(payload.doc_id, 7); +}); diff --git a/api/main.ts b/api/main.ts index b400b4f..4617023 100644 --- a/api/main.ts +++ b/api/main.ts @@ -16,6 +16,7 @@ import { type Db, } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; +import { normalizeDocumentGetPayload } from "./document_rpc.ts"; import { respondRpc, respondApiError, @@ -100,7 +101,12 @@ async function rpcDocumentForDownload( payload: Record, route: string, ): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> { - const envelope = await callCoreFn<{ document: StorageDoc }>(db, fn, payload, { route }); + const envelope = await callCoreFn<{ document: StorageDoc }>( + db, + fn, + normalizeDocumentGetPayload(payload), + { route }, + ); if (!envelope.ok) return { envelope }; const doc = envelope.data?.document; if (!doc?.storage_name) { @@ -447,7 +453,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermiss const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_company_document_get", - { company_id: companyId, id: docId }, + { company_id: companyId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); @@ -689,7 +695,7 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_project_document_get", - { project_id: projectId, id: docId }, + { project_id: projectId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); @@ -1237,7 +1243,7 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermissio const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_worker_document_get", - { worker_id: workerId, id: docId }, + { worker_id: workerId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index ecb60c8..175385f 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -38,5 +38,6 @@ + diff --git a/db/core/changesets/032-rpc-document-get-id.sql b/db/core/changesets/032-rpc-document-get-id.sql new file mode 100644 index 0000000..e7e394f --- /dev/null +++ b/db/core/changesets/032-rpc-document-get-id.sql @@ -0,0 +1,99 @@ +--liquibase formatted sql +-- PANELS · core · fn_*_document_get acepta doc_id o id +-- La API HTTP enviaba { id } y las RPC leían payload->>'doc_id', dejando el id vacío. + +--changeset panel:core-032a-fn-project-document-get splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_project_document_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_doc_id bigint := NULLIF(COALESCE(payload->>'doc_id', payload->>'id'), '')::bigint; + v_doc jsonb; +BEGIN + IF v_project_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_project_document_get: project_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_project_document_get', 'project_id', v_project_id, 'doc_id', v_doc_id)); + END IF; + SELECT to_jsonb(d) INTO v_doc FROM project_documents d + WHERE d.id = v_doc_id AND d.project_id = v_project_id; + IF v_doc IS NULL THEN + RETURN core.rpc_err('NOT_FOUND', + format('fn_project_document_get: documento id=%s no encontrado en proyecto %s', v_doc_id, v_project_id), + jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id, 'project_id', v_project_id)); + END IF; + RETURN core.rpc_ok(jsonb_build_object('document', v_doc), + format('Documento id=%s del proyecto %s obtenido', v_doc_id, v_project_id), + jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_project_document_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-032b-fn-company-document-get splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_company_document_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint; + v_doc_id bigint := NULLIF(COALESCE(payload->>'doc_id', payload->>'id'), '')::bigint; + v_doc jsonb; +BEGIN + IF v_company_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_company_document_get: company_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_company_document_get', 'company_id', v_company_id, 'doc_id', v_doc_id)); + END IF; + SELECT to_jsonb(d) INTO v_doc FROM company_documents d + WHERE d.id = v_doc_id AND d.company_id = v_company_id; + IF v_doc IS NULL THEN + RETURN core.rpc_err('NOT_FOUND', + format('fn_company_document_get: documento id=%s no encontrado en empresa %s', v_doc_id, v_company_id), + jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id, 'company_id', v_company_id)); + END IF; + RETURN core.rpc_ok(jsonb_build_object('document', v_doc), + format('Documento id=%s de la empresa %s obtenido', v_doc_id, v_company_id), + jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_company_document_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-032c-fn-worker-document-get splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_worker_document_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; + v_doc_id bigint := NULLIF(COALESCE(payload->>'doc_id', payload->>'id'), '')::bigint; + v_doc jsonb; +BEGIN + IF v_worker_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_worker_document_get: worker_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_worker_document_get', 'worker_id', v_worker_id, 'doc_id', v_doc_id)); + END IF; + SELECT to_jsonb(d) INTO v_doc FROM documents d + WHERE d.id = v_doc_id AND d.worker_id = v_worker_id; + IF v_doc IS NULL THEN + RETURN core.rpc_err('NOT_FOUND', + format('fn_worker_document_get: documento id=%s no encontrado para trabajador %s', v_doc_id, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id, 'worker_id', v_worker_id)); + END IF; + RETURN core.rpc_ok(jsonb_build_object('document', v_doc), + format('Documento id=%s del trabajador %s obtenido', v_doc_id, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_document_get', SQLSTATE, SQLERRM); +END; +$$; From fa3656d142f161c13db32168a7d13a95c23954bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:34:57 +0000 Subject: [PATCH 02/15] fix: mensajes claros al subir presupuesto que no es Excel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/budget.ts | 47 ++++++++++++-- api/budget_test.ts | 31 +++++++++- api/main.ts | 68 +++++++++++++++------ web-panel/components/BudgetImportDialog.vue | 38 +++++++++--- web-panel/composables/api-response.ts | 41 +++++++++++-- web-panel/pages/proyectos.vue | 35 +++++++---- web-panel/utils/excelFile.ts | 13 ++++ 7 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 web-panel/utils/excelFile.ts 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 b400b4f..f4f5043 100644 --- a/api/main.ts +++ b/api/main.ts @@ -72,6 +72,8 @@ import { registerIamRoutes } from "./iam_http.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { buildBudgetTemplate, + budgetReadErrorMessage, + excelFileTypeError, exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, @@ -119,6 +121,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, @@ -732,40 +745,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.