mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
fix: mostrar preview del programa de obra al importar Excel
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 <alberto.martinez@mrdev.mx>
This commit is contained in:
parent
9ea4706954
commit
b0a1a3147c
4 changed files with 162 additions and 43 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
|||
return await denyUnlessProjectScope(c, id);
|
||||
}
|
||||
|
||||
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
|
||||
function respondOk<T>(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) => {
|
||||
|
|
|
|||
|
|
@ -20,28 +20,67 @@
|
|||
|
||||
<template v-if="preview">
|
||||
<div class="import-summary mt-3">
|
||||
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
|
||||
<div><strong>{{ preview.period_count }}</strong><span>Semanas</span></div>
|
||||
<div><strong>{{ preview.format_label || "—" }}</strong><span>Formato</span></div>
|
||||
<div><strong>{{ preview.period_count || 0 }}</strong><span>Semanas</span></div>
|
||||
<div v-if="preview.format === 'concepto'"><strong>{{ preview.concepts.length }}</strong><span>Conceptos</span></div>
|
||||
<div v-if="preview.format === 'partida'"><strong>{{ preview.partidas.length }}</strong><span>Partidas</span></div>
|
||||
<div><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
|
||||
</div>
|
||||
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
|
||||
<Message v-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
|
||||
<Message v-for="(w, i) in preview.warnings" :key="`w-${i}`" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
|
||||
<Message v-if="preview.unmatched_codes?.length && preview.format === 'partida'" severity="warn" class="mt-2" :closable="false">
|
||||
Partidas no encontradas en presupuesto ({{ preview.unmatched_codes.join(", ") }}). Al importar se crearán como capítulos.
|
||||
</Message>
|
||||
<Message v-else-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
|
||||
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
|
||||
</Message>
|
||||
<DataTable
|
||||
v-if="preview.format === 'partida' && preview.partidas.length"
|
||||
:value="preview.partidas"
|
||||
size="small"
|
||||
class="mt-3"
|
||||
scrollable
|
||||
scroll-height="16rem"
|
||||
>
|
||||
<Column field="code" header="Partida" style="width: 7rem" />
|
||||
<Column field="name" header="Nombre" />
|
||||
<Column header="Total">
|
||||
<template #body="{ data }">{{ money(data.total) }}</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
<DataTable
|
||||
v-else-if="preview.format === 'concepto' && preview.concepts.length"
|
||||
:value="preview.concepts"
|
||||
size="small"
|
||||
class="mt-3"
|
||||
scrollable
|
||||
scroll-height="16rem"
|
||||
>
|
||||
<Column field="code" header="Código" style="width: 9rem" />
|
||||
<Column field="description" header="Concepto" />
|
||||
<Column field="unit" header="Un." style="width: 4rem" />
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cerrar" text @click="visibleModel = false" />
|
||||
<Button label="Importar" icon="pi pi-check" :disabled="!preview || preview.format === 'none'" :loading="saving" @click="confirm" />
|
||||
<Button label="Importar" icon="pi pi-check" :disabled="!canImport" :loading="saving" @click="confirm" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Preview = {
|
||||
format: "concepto" | "partida" | "none";
|
||||
format_label: string;
|
||||
period_count: number;
|
||||
concepts: { code: string; description: string; unit: string }[];
|
||||
partidas: { code: string; name: string; total: number }[];
|
||||
unmatched_codes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
|
||||
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
|
||||
|
||||
|
|
@ -56,36 +95,48 @@ const visibleModel = computed({
|
|||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const fileName = ref("");
|
||||
const pending = ref<File | null>(null);
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const preview = ref<Preview | null>(null);
|
||||
const busy = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const canImport = computed(() => {
|
||||
if (!preview.value || preview.value.format === "none") return false;
|
||||
return preview.value.format === "concepto"
|
||||
? preview.value.concepts.length > 0
|
||||
: preview.value.partidas.length > 0;
|
||||
});
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function money(value: number) {
|
||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
|
||||
}
|
||||
|
||||
async function onFile(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file || !props.projectId) return;
|
||||
pending.value = file;
|
||||
fileName.value = file.name;
|
||||
error.value = "";
|
||||
preview.value = null;
|
||||
busy.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
preview.value = await api(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
|
||||
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
|
||||
preview.value = null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!pending.value || !props.projectId) return;
|
||||
if (!pending.value || !props.projectId || !canImport.value) return;
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -47,22 +47,25 @@ export function isApiEnvelope(value: unknown): value is {
|
|||
&& "ok" in value && "message" in value && "layer" in value;
|
||||
}
|
||||
|
||||
/** Normaliza respuestas RPC `{ ok, data, message }` al shape plano que esperan las pantallas. */
|
||||
/** Normaliza respuestas `{ ok, data }` (con o sin envelope RPC) al shape plano que esperan las pantallas. */
|
||||
export function unwrapApiPayload<T>(body: unknown): T {
|
||||
if (!isApiEnvelope(body)) return body as T;
|
||||
const env = body;
|
||||
const data = env.data;
|
||||
const spread = data && typeof data === "object" && !Array.isArray(data)
|
||||
? data as Record<string, unknown>
|
||||
: {};
|
||||
if (!body || typeof body !== "object") return body as T;
|
||||
const rec = body as Record<string, unknown>;
|
||||
const data = rec.data;
|
||||
const canSpread = "ok" in rec
|
||||
&& data != null
|
||||
&& typeof data === "object"
|
||||
&& !Array.isArray(data);
|
||||
if (!isApiEnvelope(body) && !canSpread) return body as T;
|
||||
const spread = canSpread ? data as Record<string, unknown> : {};
|
||||
return {
|
||||
...spread,
|
||||
data,
|
||||
ok: env.ok,
|
||||
code: env.code,
|
||||
message: env.message,
|
||||
status: env.status,
|
||||
layer: env.layer,
|
||||
ok: rec.ok,
|
||||
code: rec.code,
|
||||
message: rec.message,
|
||||
status: rec.status,
|
||||
layer: rec.layer,
|
||||
} as T;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue