mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:43:17 +00:00
Pre-computed merge for change #13
This commit is contained in:
commit
e12f3dd14e
6 changed files with 286 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) => {
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
<include file="changesets/029-schema-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/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
123
db/core/changesets/032-fix-work-program-jsonb-object-length.sql
Normal file
123
db/core/changesets/032-fix-work-program-jsonb-object-length.sql
Normal 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;
|
||||
$$;
|
||||
|
|
@ -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