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/db/core/changelog-master.xml b/db/core/changelog-master.xml index ecb60c8..fa34d98 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -38,5 +38,6 @@ + diff --git a/db/core/changesets/032-fix-work-program-jsonb-object-length.sql b/db/core/changesets/032-fix-work-program-jsonb-object-length.sql new file mode 100644 index 0000000..b2cb279 --- /dev/null +++ b/db/core/changesets/032-fix-work-program-jsonb-object-length.sql @@ -0,0 +1,123 @@ +--liquibase formatted sql +-- PANELS · core · fn_work_program_replace: jsonb_object_length no existe en PostgreSQL + +--changeset panel:core-032a-fn-work-program-replace-period-count splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_work_program_replace(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_source text := COALESCE(nullif(btrim(payload->>'source'), ''), 'import'); + v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'active'); + v_start date; + v_end date; + v_program_id bigint; + v_period jsonb; + v_period_id bigint; + v_period_map jsonb := '{}'::jsonb; + v_period_count integer := 0; + v_slot jsonb; + v_partida jsonb; + v_chapter jsonb; + v_ch jsonb; + v_slot_count integer := 0; + v_partida_count integer := 0; + v_err jsonb; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_work_program_replace: project_id es obligatorio', + jsonb_build_object('fn', 'fn_work_program_replace')); + END IF; + + v_err := core._wp_assert_project_ready(v_project_id); + IF v_err IS NOT NULL THEN RETURN v_err; END IF; + + SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = v_project_id; + + DELETE FROM work_programs WHERE project_id = v_project_id; + + INSERT INTO work_programs (project_id, start_date, end_date, duration_days, source, status) + VALUES (v_project_id, v_start, v_end, (v_end - v_start + 1), v_source, v_status) + RETURNING id INTO v_program_id; + + FOR v_period IN SELECT * FROM jsonb_array_elements(core._wp_generate_periods(v_start, v_end)) LOOP + INSERT INTO work_program_periods (program_id, period_index, label, start_date, end_date) + VALUES ( + v_program_id, + (v_period->>'period_index')::integer, + v_period->>'label', + (v_period->>'start_date')::date, + (v_period->>'end_date')::date + ) + RETURNING id INTO v_period_id; + v_period_map := v_period_map || jsonb_build_object(v_period->>'period_index', v_period_id); + v_period_count := v_period_count + 1; + END LOOP; + + FOR v_slot IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'concept_slots', '[]'::jsonb)) LOOP + v_period_id := (v_period_map->>(v_slot->>'period_index'))::bigint; + IF v_period_id IS NULL THEN CONTINUE; END IF; + IF NOT EXISTS ( + SELECT 1 FROM budget_items bi + WHERE bi.id = (v_slot->>'budget_item_id')::bigint AND bi.project_id = v_project_id + ) THEN CONTINUE; END IF; + IF core._wp_slot_weight((v_slot->>'start_pct')::numeric, (v_slot->>'end_pct')::numeric) <= 0 THEN CONTINUE; END IF; + INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct) + VALUES ( + v_program_id, + (v_slot->>'budget_item_id')::bigint, + v_period_id, + COALESCE((v_slot->>'start_pct')::numeric, 0), + COALESCE((v_slot->>'end_pct')::numeric, 0) + ); + v_slot_count := v_slot_count + 1; + END LOOP; + + FOR v_ch IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'chapters_to_create', '[]'::jsonb)) LOOP + INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) + VALUES ( + v_project_id, + NULLIF(v_ch->>'parent_id', '')::bigint, + upper(COALESCE(nullif(btrim(v_ch->>'code'), ''), '')), + COALESCE(nullif(btrim(v_ch->>'name'), ''), 'Partida'), + COALESCE(nullif(btrim(v_ch->>'wbs'), ''), ''), + COALESCE((v_ch->>'sort_order')::integer, 0) + ); + END LOOP; + + FOR v_partida IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'partida_amounts', '[]'::jsonb)) LOOP + v_period_id := (v_period_map->>(v_partida->>'period_index'))::bigint; + IF v_period_id IS NULL THEN CONTINUE; END IF; + IF NOT EXISTS ( + SELECT 1 FROM budget_chapters bc + WHERE bc.id = (v_partida->>'budget_chapter_id')::bigint AND bc.project_id = v_project_id + ) THEN CONTINUE; END IF; + IF COALESCE((v_partida->>'amount')::numeric, 0) = 0 THEN CONTINUE; END IF; + INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount) + VALUES ( + v_program_id, + (v_partida->>'budget_chapter_id')::bigint, + v_period_id, + round(COALESCE((v_partida->>'amount')::numeric, 0), 2) + ); + v_partida_count := v_partida_count + 1; + END LOOP; + + RETURN core.rpc_ok( + jsonb_build_object( + 'program_id', v_program_id, + 'concept_slots', v_slot_count, + 'partida_amounts', v_partida_count, + 'period_count', v_period_count + ), + format('Programa de obra del proyecto %s reemplazado (%s slots, %s erogaciones)', + v_project_id, v_slot_count, v_partida_count), + jsonb_build_object('fn', 'fn_work_program_replace', 'project_id', v_project_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_work_program_replace', SQLSTATE, SQLERRM); +END; +$$; 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 }}