panels-origin/api/budget.ts
Cursor Agent b58791aea4
refactor(core): finish RPC migration for budget, excel import, and docs
- Replace remaining budget.ts prepare() with fn_budget_replace and fn_project_update
- Migrate excel.ts findExisting, document store, assign, and catalogs to RPC
- Add 015-rpc-documents.sql for project/company/worker document metadata
- Document RPC envelope conventions in db/README.md
- Add http_errors_test and front api-response composables

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 00:01:57 +00:00

932 lines
30 KiB
TypeScript

import * as XLSX from "xlsx";
import type { Db } from "./db.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
export const BUDGET_IVA = 0.16;
export const BUDGET_COLUMNS = [
"WBS",
"TIPO",
"CODIGO",
"DESCRIPCION",
"UNIDAD",
"CANTIDAD",
"PRECIO",
] as const;
const GROUP_TYPES = new Set(["lote", "capitulo", "capítulo", "partida", "titulo", "título", "group", "capitulo"]);
const ITEM_TYPES = new Set(["concepto", "item", "partida-concepto"]);
const HEADER_ALIASES: Record<string, string> = {
WBS: "WBS",
NIVEL: "WBS",
TIPO: "TIPO",
TYPE: "TIPO",
KIND: "TIPO",
CAPITULO: "CAPITULO",
CAPÍTULO: "CAPITULO",
CAP: "CAPITULO",
CLAVE: "CLAVE",
CODIGO: "CLAVE",
CÓDIGO: "CLAVE",
CODE: "CLAVE",
DESCRIPCION: "DESCRIPCION",
DESCRIPCIÓN: "DESCRIPCION",
CONCEPT: "DESCRIPCION",
CONCEPTO: "DESCRIPCION",
PARTIDA: "DESCRIPCION",
UNIDAD: "UNIDAD",
UD: "UNIDAD",
U: "UNIDAD",
CANTIDAD: "CANTIDAD",
CANT: "CANTIDAD",
QTY: "CANTIDAD",
PRECIO: "PRECIO",
"PRECIO U": "PRECIO",
"PRECIO U.": "PRECIO",
"P.U.": "PRECIO",
"P.U": "PRECIO",
PU: "PRECIO",
"PRECIO UNITARIO": "PRECIO",
"P. UNITARIO": "PRECIO",
"P UNITARIO": "PRECIO",
IMPORTE: "IMPORTE",
TOTAL: "IMPORTE",
MONTO: "IMPORTE",
};
export type ParsedGroup = {
kind: "lote" | "capitulo" | "partida";
wbs: string;
parentWbs: string;
code: string;
name: string;
};
export type ParsedBudgetItem = {
chapter: string;
chapterCode: string;
wbs: string;
parentWbs: string;
code: string;
description: string;
unit: string;
quantity: number;
unit_price: number;
amount: number;
};
export type ParsedBudget = {
format: "wbs" | "opus" | "flat" | "none";
groups: ParsedGroup[];
items: ParsedBudgetItem[];
skipped: number;
};
export type BudgetPreviewNode = {
wbs: string;
code: string;
name: string;
kind: ParsedGroup["kind"];
amount: number;
item_count: number;
children: BudgetPreviewNode[];
};
export type BudgetPreview = {
format: ParsedBudget["format"];
format_label: string;
sheet: string;
tree: BudgetPreviewNode[];
items: {
code: string;
description: string;
unit: string;
quantity: number;
unit_price: number;
amount: number;
chapter: string;
parentWbs: string;
}[];
totals: { subtotal: number; iva: number; total: number; item_count: number };
skipped: number;
errors: { row: number; messages: string[] }[];
};
export type BudgetImportReport = {
replaced: boolean;
chapters: number;
inserted: number;
skipped: number;
errors: { row: number; messages: string[] }[];
totals?: { subtotal: number; iva: number; total: number; item_count: number };
contract_amount?: number;
};
export type BudgetTreeNode = {
id: number;
parent_id: number | null;
code: string;
name: string;
wbs: string;
amount: number;
item_count: number;
children: BudgetTreeNode[];
};
export type BudgetItemRow = {
id: number;
project_id: number;
chapter_id: number | null;
code: string;
description: string;
unit: string;
quantity: number;
unit_price: number;
amount: number;
sort_order: number;
wbs: string;
chapter_name: string | null;
chapter_code: string | null;
parent_path: string;
ancestor_ids: number[];
};
function normHeader(raw: unknown): string {
const k = String(raw ?? "")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toUpperCase()
.replace(/\s+/g, " ")
.replace(/\.$/, "")
.trim();
return HEADER_ALIASES[k] || k;
}
function fold(value: string) {
return value.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase().replace(/\s+/g, " ").trim();
}
export function parseMoney(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
const raw = String(value ?? "").trim();
if (!raw) return 0;
const cleaned = raw.replace(/[$%\s]/g, "");
if (cleaned.includes(",") && cleaned.includes(".")) {
return Number(cleaned.replace(/,/g, "")) || 0;
}
if (cleaned.includes(",")) return Number(cleaned.replace(",", ".")) || 0;
return Number(cleaned) || 0;
}
function isSummaryLabel(value: string) {
const t = fold(value);
if (!t) return false;
if (/^\(.*\*.*\)$/.test(t) && /(PESOS|MILLON|M\.?\s*N\.?)/.test(t)) return true;
return /(SUB\s*TOTAL|^TOTAL\b|GRAN TOTAL|\bIVA\b|SIN IVA)/.test(t);
}
function rowHasNumbers(row: unknown[]) {
return row.slice(0, 6).some((cell, i) => i >= 2 && parseMoney(cell) !== 0);
}
function isGroupCode(code: string) {
const t = fold(code);
return /^[A-Z]$/.test(t) || /^[A-Z]\d{2,4}$/.test(t);
}
function padChild(parent: string, index: number) {
const part = String(index).padStart(parent ? 2 : 1, "0");
if (!parent) return String(index);
return `${parent}.${part}`;
}
function parentOfWbs(wbs: string) {
const i = wbs.lastIndexOf(".");
return i < 0 ? "" : wbs.slice(0, i);
}
function tipoOf(raw: string): "group" | "item" | "" {
const t = fold(raw).toLowerCase();
if (!t) return "";
if (ITEM_TYPES.has(t) || t === "concepto") return "item";
if (GROUP_TYPES.has(t) || t === "lote" || t === "capitulo" || t === "partida") return "group";
return "";
}
function groupKind(raw: string, depth: number): ParsedGroup["kind"] {
const t = fold(raw).toLowerCase();
if (t === "lote") return "lote";
if (t === "partida") return "partida";
if (depth <= 1) return "lote";
if (depth === 2) return "capitulo";
return "partida";
}
type ColMap = Record<string, number>;
function findHeader(rows: unknown[][]): { headerAt: number; map: ColMap } {
for (let i = 0; i < rows.length; i++) {
const headers = rows[i].map(normHeader);
const map: ColMap = {
WBS: headers.indexOf("WBS"),
TIPO: headers.indexOf("TIPO"),
CAPITULO: headers.indexOf("CAPITULO"),
CLAVE: headers.indexOf("CLAVE"),
DESCRIPCION: headers.indexOf("DESCRIPCION"),
UNIDAD: headers.indexOf("UNIDAD"),
CANTIDAD: headers.indexOf("CANTIDAD"),
PRECIO: headers.indexOf("PRECIO"),
IMPORTE: headers.indexOf("IMPORTE"),
};
if (map.WBS >= 0 && (map.TIPO >= 0 || map.DESCRIPCION >= 0)) return { headerAt: i, map };
if (map.DESCRIPCION >= 0 && (map.CLAVE >= 0 || map.CANTIDAD >= 0 || map.PRECIO >= 0)) {
return { headerAt: i, map };
}
}
return { headerAt: -1, map: {} };
}
function cell(row: unknown[], map: ColMap, key: string) {
const i = map[key];
return i >= 0 ? row[i] : "";
}
function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) {
for (let r = headerAt + 1; r < Math.min(rows.length, headerAt + 40); r++) {
const code = String(cell(rows[r] || [], map, "CLAVE") ?? "").trim();
const unit = String(cell(rows[r] || [], map, "UNIDAD") ?? "").trim();
const qty = parseMoney(cell(rows[r] || [], map, "CANTIDAD"));
const price = parseMoney(cell(rows[r] || [], map, "PRECIO"));
if (isGroupCode(code) && !unit && qty === 0 && price === 0) return true;
}
return false;
}
function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
const groups: ParsedGroup[] = [];
const items: ParsedBudgetItem[] = [];
let skipped = 0;
const names = new Map<string, { name: string; code: string }>();
for (let r = headerAt + 1; r < rows.length; r++) {
const row = rows[r] || [];
const values = row.map((v) => String(v ?? "").trim());
if (values.every((v) => !v)) continue;
if (values.some((value) => isSummaryLabel(value))) {
skipped++;
continue;
}
const wbs = String(cell(row, map, "WBS") ?? "").trim();
const tipoRaw = String(cell(row, map, "TIPO") ?? "").trim();
const code = String(cell(row, map, "CLAVE") ?? "").trim();
const description = String(cell(row, map, "DESCRIPCION") ?? "").trim();
const unit = String(cell(row, map, "UNIDAD") ?? "").trim();
const qty = parseMoney(cell(row, map, "CANTIDAD"));
const price = parseMoney(cell(row, map, "PRECIO"));
const importe = parseMoney(cell(row, map, "IMPORTE"));
if (!wbs && !code && !description) {
skipped++;
continue;
}
const depth = wbs ? wbs.split(".").length : 1;
let kind = tipoOf(tipoRaw);
if (!kind) kind = unit || qty || price ? "item" : "group";
const parentWbs = wbs ? parentOfWbs(wbs) : "";
if (kind === "group") {
const name = description || code || "Capítulo";
const node: ParsedGroup = {
kind: groupKind(tipoRaw, depth),
wbs: wbs || String(groups.length + 1),
parentWbs,
code,
name,
};
groups.push(node);
names.set(node.wbs, { name, code });
continue;
}
const parent = names.get(parentWbs);
items.push({
chapter: parent?.name || "General",
chapterCode: parent?.code || "",
wbs: wbs || padChild(parentWbs, items.length + 1),
parentWbs,
code,
description: description || code,
unit,
quantity: qty,
unit_price: price,
amount: Math.round((qty * price || importe) * 100) / 100,
});
}
return { groups, items, skipped };
}
function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
const groups: ParsedGroup[] = [];
const items: ParsedBudgetItem[] = [];
let skipped = 0;
let loteWbs = "";
let chapterWbs = "";
let loteIndex = 0;
let chapterIndex = 0;
let itemIndex = 0;
let lastItem: ParsedBudgetItem | null = null;
const groupNames = new Map<string, { name: string; code: string }>();
for (let r = headerAt + 1; r < rows.length; r++) {
const row = rows[r] || [];
const values = row.map((v) => String(v ?? "").trim());
if (values.every((v) => !v)) continue;
const code = String(cell(row, map, "CLAVE") ?? "").trim();
const description = String(cell(row, map, "DESCRIPCION") ?? "").trim();
const unit = String(cell(row, map, "UNIDAD") ?? "").trim();
const qty = parseMoney(cell(row, map, "CANTIDAD"));
const price = parseMoney(cell(row, map, "PRECIO"));
const importe = parseMoney(cell(row, map, "IMPORTE"));
if ([code, description].some((value) => isSummaryLabel(value)) || values.some((value) => isSummaryLabel(value))) {
skipped++;
lastItem = null;
continue;
}
if (!code && description && !unit && qty === 0 && price === 0 && importe === 0) {
if (lastItem) {
lastItem.description = `${lastItem.description} ${description}`.replace(/\s+/g, " ").trim();
} else {
skipped++;
}
continue;
}
if (code && !unit && qty === 0 && price === 0 && importe === 0 && (isGroupCode(code) || !rowHasNumbers(row))) {
lastItem = null;
if (isGroupCode(code) && /^[A-Z]$/i.test(fold(code))) {
loteIndex += 1;
chapterIndex = 0;
itemIndex = 0;
loteWbs = String(loteIndex);
chapterWbs = loteWbs;
const node: ParsedGroup = {
kind: "lote",
wbs: loteWbs,
parentWbs: "",
code,
name: description || code,
};
groups.push(node);
groupNames.set(node.wbs, { name: node.name, code: node.code });
} else {
chapterIndex += 1;
itemIndex = 0;
const parent = loteWbs || "";
if (!loteWbs) {
loteIndex += 1;
loteWbs = String(loteIndex);
}
chapterWbs = padChild(parent || loteWbs, chapterIndex);
const node: ParsedGroup = {
kind: "capitulo",
wbs: chapterWbs,
parentWbs: loteWbs && chapterWbs !== loteWbs ? loteWbs : "",
code,
name: description || code,
};
groups.push(node);
groupNames.set(node.wbs, { name: node.name, code: node.code });
}
continue;
}
if (!description && !code) {
skipped++;
continue;
}
if (!code && !unit && qty === 0 && price === 0) {
skipped++;
continue;
}
itemIndex += 1;
const parentWbs = chapterWbs || loteWbs;
const parent = groupNames.get(parentWbs);
const item: ParsedBudgetItem = {
chapter: parent?.name || "General",
chapterCode: parent?.code || "",
wbs: padChild(parentWbs, itemIndex),
parentWbs,
code,
description: description || code,
unit,
quantity: qty,
unit_price: price,
amount: Math.round((qty * price || importe) * 100) / 100,
};
items.push(item);
lastItem = item;
}
return { groups, items, skipped };
}
function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
const groups: ParsedGroup[] = [];
const items: ParsedBudgetItem[] = [];
let skipped = 0;
let chapter = "";
let chapterCode = "";
let chapterWbs = "";
let chapterIndex = 0;
let itemIndex = 0;
const cache = new Map<string, ParsedGroup>();
function ensureChapter(name: string, code = "") {
const key = name.trim() || "General";
const hit = cache.get(key.toLowerCase());
if (hit) {
chapter = hit.name;
chapterCode = hit.code;
chapterWbs = hit.wbs;
return hit;
}
chapterIndex += 1;
itemIndex = 0;
const node: ParsedGroup = {
kind: "capitulo",
wbs: String(chapterIndex),
parentWbs: "",
code,
name: key,
};
groups.push(node);
cache.set(key.toLowerCase(), node);
chapter = node.name;
chapterCode = node.code;
chapterWbs = node.wbs;
return node;
}
for (let r = 0; r < headerAt; r++) {
const first = String(rows[r]?.[0] ?? "").trim();
const second = String(rows[r]?.[1] ?? "").trim();
if (/^obra:?$/i.test(first) && second) ensureChapter(second);
const restEmpty = (rows[r] || []).slice(1).every((value) => !String(value ?? "").trim());
if (first && restEmpty && !/^(cliente|concurso|obra|lugar|ciudad|fecha):?$/i.test(first) && !isSummaryLabel(first)) {
ensureChapter(first);
}
}
for (let r = headerAt + 1; r < rows.length; r++) {
const row = rows[r] || [];
const values = row.map((v) => String(v ?? "").trim());
if (values.every((v) => !v)) continue;
if (values.some((value) => isSummaryLabel(value))) {
skipped++;
continue;
}
const code = String(cell(row, map, "CLAVE") ?? "").trim();
const description = String(cell(row, map, "DESCRIPCION") ?? "").trim();
const unit = String(cell(row, map, "UNIDAD") ?? "").trim();
const chapterCell = String(cell(row, map, "CAPITULO") ?? "").trim();
const qty = parseMoney(cell(row, map, "CANTIDAD"));
const price = parseMoney(cell(row, map, "PRECIO"));
const importe = parseMoney(cell(row, map, "IMPORTE"));
const first = values[0];
const label = chapterCell || description || first;
const looksChapter = !code && !unit && qty === 0 && price === 0 && !rowHasNumbers(row) && !!label;
if (looksChapter) {
ensureChapter(label);
continue;
}
if (chapterCell) ensureChapter(chapterCell);
if (!chapterWbs) ensureChapter("General");
if (!description && !code) {
skipped++;
continue;
}
itemIndex += 1;
items.push({
chapter: chapter || "General",
chapterCode,
wbs: padChild(chapterWbs, itemIndex),
parentWbs: chapterWbs,
code,
description: description || code,
unit,
quantity: qty,
unit_price: price,
amount: Math.round((qty * price || importe) * 100) / 100,
});
}
return { groups, items, skipped };
}
export function parseBudgetRows(rows: unknown[][]): ParsedBudget {
const empty: ParsedBudget = { format: "none", groups: [], items: [], skipped: 0 };
const { headerAt, map } = findHeader(rows);
if (headerAt < 0) return empty;
if (map.WBS >= 0) return { format: "wbs", ...parseWbsRows(rows, headerAt, map) };
if (looksOpus(rows, headerAt, map)) return { format: "opus", ...parseOpusRows(rows, headerAt, map) };
return { format: "flat", ...parseFlatRows(rows, headerAt, map) };
}
function xlsxBytes(wb: XLSX.WorkBook): Uint8Array {
const out = XLSX.write(wb, { type: "array", bookType: "xlsx" });
const bytes = out instanceof Uint8Array ? out : new Uint8Array(out);
return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
? bytes
: bytes.slice();
}
export function buildBudgetTemplate(): Uint8Array {
const wb = XLSX.utils.book_new();
const instructions = [
["Plantilla de presupuesto — Panel de proyectos"],
[""],
["1. Llene la hoja PRESUPUESTO. No cambie los nombres de las columnas."],
["2. WBS es el árbol: 1 = lote, 1.01 = capítulo, 1.01.01 = concepto."],
["3. TIPO: lote, capitulo o partida para agrupadores; concepto para renglones con unidad y precio."],
["4. El padre se infiere del WBS (1.01.01 cuelga de 1.01). No capture importes, IVA ni totales."],
["5. Una fila = un nodo. Ponga la descripción completa en una sola celda."],
["6. También se acepta un Excel Opus/Neodata (Código, Concepto, Unidad, Cantidad, P. Unitario) o uno plano con CLAVE y DESCRIPCION."],
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(instructions), "INSTRUCCIONES");
const sheet = [
[...BUDGET_COLUMNS],
["1", "lote", "A", "LOCAL L224", "", "", ""],
["1.01", "capitulo", "A001", "Preliminares y albañilería", "", "", ""],
["1.01.01", "concepto", "10301-001", "Trazo y nivelación manual para establecer ejes, banco de nivel y referencias.", "M2", 61.9, 15.53],
["1.01.02", "concepto", "10606-002", "Firme de 5 cm acabado común, de concreto F'c= 150 kg/cm2.", "M2", 61.9, 342.55],
["1.02", "capitulo", "A002", "Instalación hidrosanitaria", "", "", ""],
["1.02.01", "concepto", "11402-2996C200MX.020", "Inodoro alargado de una pieza, cerámica porcelanizada.", "PZA", 1, 6422.87],
["1.02.02", "concepto", "11403-E928-1.9", "Monomando de lavabo, negro mate.", "PZA", 2, 4272.4],
];
const ws = XLSX.utils.aoa_to_sheet(sheet);
ws["!cols"] = [{ wch: 10 }, { wch: 12 }, { wch: 24 }, { wch: 70 }, { wch: 10 }, { wch: 12 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, ws, "PRESUPUESTO");
return xlsxBytes(wb);
}
type ExportChapter = { id: number; parent_id: number | null; code: string; name: string; wbs: string; sort_order: number };
type ExportItem = {
chapter_id?: number | null;
wbs?: string;
code: string;
description: string;
unit: string;
quantity: number;
unit_price: number;
};
export function exportBudgetWorkbook(
projectName: string,
budget: { tree: BudgetTreeNode[]; chapters?: ExportChapter[]; items: ExportItem[] },
): Uint8Array {
const wb = XLSX.utils.book_new();
const rows: unknown[][] = [
["Obra", projectName],
[],
[...BUDGET_COLUMNS],
];
const itemsByChapter = new Map<number, ExportItem[]>();
for (const item of budget.items) {
const id = Number(item.chapter_id || 0);
const list = itemsByChapter.get(id) || [];
list.push(item);
itemsByChapter.set(id, list);
}
function walk(nodes: BudgetTreeNode[], depth: number) {
for (const node of nodes) {
const tipo = depth <= 0 ? "lote" : depth === 1 ? "capitulo" : "partida";
rows.push([node.wbs || "", tipo, node.code || "", node.name, "", "", ""]);
for (const item of itemsByChapter.get(node.id) || []) {
rows.push([
item.wbs || "",
"concepto",
item.code,
item.description,
item.unit,
item.quantity,
item.unit_price,
]);
}
if (node.children?.length) walk(node.children, depth + 1);
}
}
walk(budget.tree, 0);
const unassigned = itemsByChapter.get(0) || [];
for (const item of unassigned) {
rows.push([item.wbs || "", "concepto", item.code, item.description, item.unit, item.quantity, item.unit_price]);
}
const ws = XLSX.utils.aoa_to_sheet(rows);
ws["!cols"] = [{ wch: 10 }, { wch: 12 }, { wch: 24 }, { wch: 70 }, { wch: 10 }, { wch: 12 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, ws, "PRESUPUESTO");
return xlsxBytes(wb);
}
type ChapterRow = {
id: number;
project_id: number;
parent_id: number | null;
code: string;
name: string;
wbs: string;
sort_order: number;
};
function compareWbs(a: string, b: string) {
const as = a.split(".").map(Number);
const bs = b.split(".").map(Number);
const n = Math.max(as.length, bs.length);
for (let i = 0; i < n; i++) {
const d = (as[i] || 0) - (bs[i] || 0);
if (d) return d;
}
return a.localeCompare(b, undefined, { numeric: true });
}
export async function listBudget(database: Db, projectId: number) {
const env = await callCoreFn<{
chapters: ChapterRow[];
items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
totals: { subtotal: number; iva: number; total: number; item_count: number };
}>(database, "core.fn_budget_list", { project_id: projectId });
if (!env.ok) throw new RpcCallError(env);
const chapters = env.data?.chapters ?? [];
const rawItems = env.data?.items ?? [];
const byParent = new Map<number | null, ChapterRow[]>();
const byId = new Map<number, ChapterRow>();
for (const chapter of chapters) {
byId.set(chapter.id, chapter);
const key = chapter.parent_id || null;
const list = byParent.get(key) || [];
list.push(chapter);
byParent.set(key, list);
}
function ancestorsOf(id: number | null): ChapterRow[] {
const chain: ChapterRow[] = [];
let cur = id ? byId.get(id) : undefined;
const seen = new Set<number>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
chain.unshift(cur);
cur = cur.parent_id ? byId.get(cur.parent_id) : undefined;
}
return chain;
}
function descendantIds(id: number): number[] {
const ids = [id];
for (const child of byParent.get(id) || []) ids.push(...descendantIds(child.id));
return ids;
}
const amounts = new Map<number, { amount: number; item_count: number }>();
for (const chapter of chapters) {
const ids = new Set(descendantIds(chapter.id));
const owned = rawItems.filter((item) => item.chapter_id && ids.has(Number(item.chapter_id)));
amounts.set(chapter.id, {
amount: Math.round(owned.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100,
item_count: owned.length,
});
}
function toTree(nodes: ChapterRow[]): BudgetTreeNode[] {
return nodes.map((chapter) => {
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
return {
id: chapter.id,
parent_id: chapter.parent_id,
code: chapter.code || "",
name: chapter.name,
wbs: chapter.wbs || "",
amount: stats.amount,
item_count: stats.item_count,
children: toTree(byParent.get(chapter.id) || []),
};
});
}
const tree = toTree(byParent.get(null) || chapters.filter((chapter) => !chapter.parent_id));
const items: BudgetItemRow[] = rawItems.map((item) => {
const chain = ancestorsOf(item.chapter_id);
return {
...item,
wbs: item.wbs || "",
parent_path: chain.map((chapter) => [chapter.code, chapter.name].filter(Boolean).join(" ").trim()).join(" > "),
ancestor_ids: chain.map((chapter) => chapter.id),
};
});
const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const iva = env.data?.totals?.iva ?? Math.round(subtotal * BUDGET_IVA * 100) / 100;
const chapterView = chapters.map((chapter) => {
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
const chain = ancestorsOf(chapter.id);
return {
...chapter,
...stats,
path: chain.map((node) => [node.code, node.name].filter(Boolean).join(" ").trim()).join(" > "),
};
});
return {
tree,
chapters: chapterView,
items,
totals: {
subtotal: Math.round(subtotal * 100) / 100,
iva,
total: Math.round((subtotal + iva) * 100) / 100,
item_count: items.length,
},
};
}
export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise<void> {
const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
let sort = 1;
const chapters = groups.map((group) => ({
wbs: group.wbs,
parent_wbs: group.parentWbs || null,
code: group.code,
name: group.name,
sort_order: sort++,
}));
let itemSort = 1;
const items = parsed.items.map((item) => ({
chapter_wbs: item.parentWbs || null,
code: item.code,
description: item.description,
unit: item.unit,
quantity: item.quantity,
unit_price: item.unit_price,
amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100,
wbs: item.wbs,
sort_order: itemSort++,
}));
const env = await callCoreFn(database, "core.fn_budget_replace", {
project_id: projectId,
chapters,
items,
});
if (!env.ok) throw new RpcCallError(env);
}
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
const groups: ParsedGroup[] = [];
const seen = new Set<string>();
for (const item of parsed) {
const wbs = item.parentWbs || "1";
if (seen.has(wbs)) continue;
seen.add(wbs);
groups.push({
kind: "capitulo",
wbs,
parentWbs: parentOfWbs(wbs),
code: item.chapterCode || "",
name: item.chapter || "General",
});
}
await replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 });
}
export function lineAmount(quantity: number, unitPrice: number) {
return Math.round(quantity * unitPrice * 100) / 100;
}
const FORMAT_LABEL: Record<ParsedBudget["format"], string> = {
wbs: "Plantilla del panel",
opus: "Opus / Neodata",
flat: "Excel plano",
none: "No reconocido",
};
function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } {
const wb = XLSX.read(bytes, { type: "array" });
const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0];
if (!sheet) return { error: "El archivo no tiene hojas" };
return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] };
}
function previewTreeOf(parsed: ParsedBudget): BudgetPreviewNode[] {
const byParent = new Map<string, ParsedGroup[]>();
for (const group of parsed.groups) {
const key = group.parentWbs || "";
const list = byParent.get(key) || [];
list.push(group);
byParent.set(key, list);
}
function stats(wbs: string): { amount: number; item_count: number } {
const direct = parsed.items.filter((item) => item.parentWbs === wbs);
const children = byParent.get(wbs) || [];
const childStats = children.map((child) => stats(child.wbs));
return {
amount: Math.round(
(direct.reduce((sum, item) => sum + Number(item.amount || 0), 0) +
childStats.reduce((sum, child) => sum + child.amount, 0)) * 100,
) / 100,
item_count: direct.length + childStats.reduce((sum, child) => sum + child.item_count, 0),
};
}
function node(group: ParsedGroup): BudgetPreviewNode {
const roll = stats(group.wbs);
return {
wbs: group.wbs,
code: group.code,
name: group.name,
kind: group.kind,
amount: roll.amount,
item_count: roll.item_count,
children: (byParent.get(group.wbs) || []).map(node),
};
}
const roots = byParent.get("") || parsed.groups.filter((group) => !group.parentWbs);
return roots.map(node);
}
export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview {
const read = readBudgetSheet(bytes);
if ("error" in read) {
return {
format: "none",
format_label: FORMAT_LABEL.none,
sheet: "",
tree: [],
items: [],
totals: { subtotal: 0, iva: 0, total: 0, item_count: 0 },
skipped: 0,
errors: [{ row: 0, messages: [read.error] }],
};
}
const parsed = parseBudgetRows(read.rows);
const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100;
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
const errors = parsed.items.length
? []
: [{ row: 0, messages: ["No se encontraron partidas. Revise que el Excel tenga Código/Concepto o la plantilla WBS."] }];
return {
format: parsed.format,
format_label: FORMAT_LABEL[parsed.format],
sheet: read.sheet,
tree: previewTreeOf(parsed),
items: parsed.items.map((item) => ({
code: item.code,
description: item.description,
unit: item.unit,
quantity: item.quantity,
unit_price: item.unit_price,
amount: item.amount,
chapter: item.chapter,
parentWbs: item.parentWbs,
})),
totals: {
subtotal,
iva,
total: Math.round((subtotal + iva) * 100) / 100,
item_count: parsed.items.length,
},
skipped: parsed.skipped,
errors,
};
}
export async function importBudgetExcel(database: Db, projectId: number, bytes: Uint8Array): Promise<BudgetImportReport> {
const read = readBudgetSheet(bytes);
if ("error" in read) {
return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] };
}
const parsed = parseBudgetRows(read.rows);
if (!parsed.items.length) {
return {
replaced: false,
chapters: 0,
inserted: 0,
skipped: parsed.skipped,
errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }],
};
}
await replaceBudgetFromParsed(database, projectId, parsed);
const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100;
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
const totals = {
subtotal,
iva,
total: Math.round((subtotal + iva) * 100) / 100,
item_count: parsed.items.length,
};
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
const projectEnv = await callCoreFn(database, "core.fn_project_update", {
id: projectId,
contract_amount: subtotal,
});
if (!projectEnv.ok) throw new RpcCallError(projectEnv);
return {
replaced: true,
chapters: parsed.groups.length,
inserted: parsed.items.length,
skipped: parsed.skipped,
errors: [],
totals,
contract_amount: subtotal,
};
}