import * as XLSX from "xlsx"; import type { Database } from "@db/sqlite"; function lastId(database: Database): number { return Number((database.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id); } 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 = { 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; 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): ParsedBudget { const groups: ParsedGroup[] = []; const items: ParsedBudgetItem[] = []; let skipped = 0; const names = new Map(); 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): ParsedBudget { 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(); 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): ParsedBudget { 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(); 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(); 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 function listBudget(database: Database, projectId: number) { const chapterCols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); const wbsExpr = chapterCols.includes("wbs") ? "COALESCE(c.wbs, '') AS wbs" : "'' AS wbs"; const chapters = database.prepare( `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, ${wbsExpr}, c.sort_order FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, ).all(projectId) as ChapterRow[]; const rawItems = database.prepare( `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code FROM budget_items i LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id WHERE i.project_id = ? ORDER BY COALESCE(ch.sort_order, 9999), i.sort_order, i.id`, ).all(projectId) as (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[]; const byParent = new Map(); const byId = new Map(); 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(); 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(); 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 = 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, }, }; } function insertChapter( database: Database, projectId: number, parentId: number | null, code: string, name: string, wbs: string, sort: number, ) { const cols = (database.prepare("PRAGMA table_info(budget_chapters)").all() as { name: string }[]).map((c) => c.name); if (cols.includes("wbs")) { database.prepare( "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)", ).run(projectId, parentId, code, name, wbs, sort); } else { database.prepare( "INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)", ).run(projectId, parentId, code, name, sort); } return lastId(database); } export function replaceBudgetFromParsed(database: Database, projectId: number, parsed: ParsedBudget) { database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId); database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId); const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs)); const ids = new Map(); let sort = 1; for (const group of groups) { const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; const id = insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); ids.set(group.wbs, id); sort++; } const itemCols = (database.prepare("PRAGMA table_info(budget_items)").all() as { name: string }[]).map((c) => c.name); const hasWbs = itemCols.includes("wbs"); let itemSort = 1; for (const item of parsed.items) { const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; if (hasWbs) { database.prepare( `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); } else { database.prepare( `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort); } itemSort++; } } export function replaceBudgetFromItems(database: Database, projectId: number, parsed: ParsedBudgetItem[]) { const groups: ParsedGroup[] = []; const seen = new Set(); 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", }); } 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 = { 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(); 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 function importBudgetExcel(database: Database, projectId: number, bytes: Uint8Array): 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."] }], }; } 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. database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId); return { replaced: true, chapters: parsed.groups.length, inserted: parsed.items.length, skipped: parsed.skipped, errors: [], totals, contract_amount: subtotal, }; }