mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:53:18 +00:00
merge: #13 preview programa de obra
Incluye ambos changesets 032 (document-get y work-program) en el changelog. Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
commit
2f2d23004e
6 changed files with 286 additions and 43 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -39,5 +39,6 @@
|
||||||
<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-rpc-document-get-id.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/032-rpc-document-get-id.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</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">
|
<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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue