Pre-computed merge for change #13

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit e12f3dd14e
6 changed files with 286 additions and 43 deletions

View file

@ -1,6 +1,6 @@
import { assertEquals, assertGreater } from "jsr:@std/assert@1"; import { assertEquals, assertGreater } from "jsr:@std/assert@1";
import { readFileSync } from "node:fs"; 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", () => { 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"); 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); 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"; Deno.test("detecta programa por partida con columna vacía antes de Sem (Neodata)", () => {
const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_18ff.xlsx"; 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", () => { Deno.test("lee Excel real por concepto del usuario", () => {
try { try {

View file

@ -3,8 +3,9 @@ import type { AuthUser } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts"; import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { projectById } 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 { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { import {
createEmptyWorkProgram, createEmptyWorkProgram,
exportWorkProgram, exportWorkProgram,
@ -29,11 +30,38 @@ async function ensureProject(c: Context, id: number): Promise<boolean> {
return await denyUnlessProjectScope(c, id); 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 form = await c.req.formData();
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File)) throw new Error("Archivo Excel requerido (campo file)"); if (!(file instanceof File)) {
return new Uint8Array(await file.arrayBuffer()); 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) { export function registerWorkProgramRoutes(app: App) {
@ -42,7 +70,7 @@ export function registerWorkProgramRoutes(app: App) {
if (!await ensureProject(c, id)) return; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const data = await getWorkProgram(db, id); 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) => { 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; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const data = await listConcepts(db, id); 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) => { 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; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const data = await listPartidas(db, id); 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) => { 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; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const data = await getVsCost(db, id); 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) => { app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const bytes = await readUpload(c); const upload = await readUpload(c);
const preview = await previewWorkProgramExcel(bytes, id, db); if ("response" in upload) return upload.response;
return c.json({ ok: true, data: preview }); 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) => { app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
if (!await ensureProject(c, id)) return; if (!await ensureProject(c, id)) return;
const db = c.get("db"); 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 createChapters = c.req.query("create_chapters") !== "false";
const data = await importWorkProgramExcel(db, id, bytes, { createChapters }); const data = await importWorkProgramExcel(db, id, upload.bytes, { createChapters });
return c.json({ ok: true, data }); return respondOk(c, data, "Programa de obra importado");
}); });
app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { 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; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const data = await createEmptyWorkProgram(db, id); 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) => { 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 db = c.get("db");
const body = await c.req.json().catch(() => ({})) as { strategy?: string }; const body = await c.req.json().catch(() => ({})) as { strategy?: string };
const data = await generateWorkProgram(db, id, body.strategy || "keyword"); 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) => { 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; if (!await ensureProject(c, id)) return;
const db = c.get("db"); const db = c.get("db");
const env = await recalculatePartidas(db, id); 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) => { 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.start_pct ?? 0),
Number(body.end_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) => { 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(c.req.param("periodId")),
Number(body.amount ?? 0), 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) => { 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(c.req.param("periodId")),
Number(body.pct_complete ?? 0), 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) => { app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => {

View file

@ -38,5 +38,6 @@
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/> <include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/> <include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/> <include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -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;
$$;

View file

@ -20,28 +20,67 @@
<template v-if="preview"> <template v-if="preview">
<div class="import-summary mt-3"> <div class="import-summary mt-3">
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div> <div><strong>{{ preview.format_label || "—" }}</strong><span>Formato</span></div>
<div><strong>{{ preview.period_count }}</strong><span>Semanas</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 === '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 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><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
</div> </div>
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message> <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" severity="warn" class="mt-2" :closable="false"> <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(", ") }} Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
</Message> </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> </template>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message> <Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer> <template #footer>
<Button label="Cerrar" text @click="visibleModel = false" /> <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> </template>
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <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 props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>(); const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
@ -56,36 +95,48 @@ const visibleModel = computed({
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const fileName = ref(""); const fileName = ref("");
const pending = ref<File | null>(null); 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 busy = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(""); 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() { function pickFile() {
fileInput.value?.click(); 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) { async function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
if (!file || !props.projectId) return; if (!file || !props.projectId) return;
pending.value = file; pending.value = file;
fileName.value = file.name; fileName.value = file.name;
error.value = ""; error.value = "";
preview.value = null;
busy.value = true; busy.value = true;
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); 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) { } catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel"; error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
preview.value = null; preview.value = null;
} finally { } finally {
busy.value = false; busy.value = false;
(e.target as HTMLInputElement).value = "";
} }
} }
async function confirm() { async function confirm() {
if (!pending.value || !props.projectId) return; if (!pending.value || !props.projectId || !canImport.value) return;
saving.value = true; saving.value = true;
error.value = ""; error.value = "";
try { try {

View file

@ -47,22 +47,25 @@ export function isApiEnvelope(value: unknown): value is {
&& "ok" in value && "message" in value && "layer" in value; && "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 { export function unwrapApiPayload<T>(body: unknown): T {
if (!isApiEnvelope(body)) return body as T; if (!body || typeof body !== "object") return body as T;
const env = body; const rec = body as Record<string, unknown>;
const data = env.data; const data = rec.data;
const spread = data && typeof data === "object" && !Array.isArray(data) const canSpread = "ok" in rec
? data as Record<string, unknown> && 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 { return {
...spread, ...spread,
data, data,
ok: env.ok, ok: rec.ok,
code: env.code, code: rec.code,
message: env.message, message: rec.message,
status: env.status, status: rec.status,
layer: env.layer, layer: rec.layer,
} as T; } as T;
} }