import * as XLSX from "xlsx"; import { parseMoney } from "./budget.ts"; export type WorkProgramBarSlot = { period_index: number; start_pct: number; end_pct: number; }; export type ParsedConceptRow = { code: string; description: string; unit: string; bar: string; slots: WorkProgramBarSlot[]; row: number; }; export type ParsedPartidaRow = { code: string; name: string; amounts: number[]; total: number; bar?: string; row: number; }; export type WorkProgramExcelPreview = { format: "concepto" | "partida" | "none"; format_label: string; sheet: string; period_count: number; period_labels: string[]; concepts: ParsedConceptRow[]; partidas: ParsedPartidaRow[]; unmatched_codes: string[]; warnings: string[]; errors: { row: number; messages: string[] }[]; }; export type BudgetMatchContext = { items: { id: number; code: string; description: string; unit: string; amount: number; chapter_id: number | null }[]; chapters: { id: number; code: string; name: string; amount: number }[]; }; export type WorkProgramImportPayload = { concept_slots: { budget_item_id: number; period_index: number; start_pct: number; end_pct: number }[]; partida_amounts: { budget_chapter_id: number; period_index: number; amount: number }[]; chapters_to_create: { code: string; name: string; sort_order: number }[]; }; function fold(value: string) { return value.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase().replace(/\s+/g, " ").trim(); } function isSkipRow(a: unknown, b: unknown, c: unknown) { const texts = [a, b, c].map((v) => fold(String(v ?? ""))); if (texts.some((t) => /^(DIRECTOR GENERAL|TOTAL DEL PRESUPUESTO|ACUMULADO|PORCENTAJE PERIODO|PORCENTAJE ACUMULADO|MONTO ESTA HOJA)/.test(t))) { return true; } return false; } export function parseBarString(bar: string): WorkProgramBarSlot[] { const raw = String(bar ?? "").trim(); if (!raw.startsWith("Barra=")) return []; const segs = raw.slice(6).split(","); return segs.map((seg, i) => { const [a, b] = seg.split("-").map((x) => Number(x.trim()) || 0); if (a === 0 && b === 0) return null; return { period_index: i + 1, start_pct: a, end_pct: b }; }).filter((x): x is WorkProgramBarSlot => x !== null); } function findSemHeader(row: unknown[]) { const labels: string[] = []; let firstSem = -1; for (let i = 0; i < row.length; i++) { const t = fold(String(row[i] ?? "")); const m = t.match(/^SEM\s*(\d+)$/); if (m) { if (firstSem < 0) firstSem = i; labels.push(`Sem ${m[1]}`); } } return { firstSem, labels, totalCol: firstSem >= 0 ? firstSem + labels.length : -1 }; } function detectFormat(rows: unknown[][]): "concepto" | "partida" | "none" { for (let i = 0; i < Math.min(rows.length, 30); i++) { const row = rows[i] || []; const joined = row.map((c) => fold(String(c ?? ""))).join("|"); if (joined.includes("POR CONCEPTO") || (joined.includes("CODIGO") && joined.includes("DESCRIPCION") && joined.includes("UNIDAD"))) { return "concepto"; } if (joined.includes("PARTIDA") && joined.includes("SEM")) return "partida"; } for (let i = 0; i < Math.min(rows.length, 30); i++) { const row = rows[i] || []; const a = fold(String(row[0] ?? "")); const b = fold(String(row[1] ?? "")); if (a === "CODIGO" || a === "CLAVE") return "concepto"; if (a === "PARTIDA" && b === "PARTIDA") return "partida"; } return "none"; } function isPartidaCode(code: string) { return /^A\d{3}$/i.test(code.trim()); } function isConceptCode(code: string) { const t = code.trim(); if (!t || isPartidaCode(t)) return false; if (/^A$/i.test(t)) return false; return t.includes("-") || /^[A-Z]{2,}\d/.test(t) || /^SELE|^PUER|^COL|^INS|^VYD|^IE|^ABA/i.test(t); } export function parseWorkProgramRows(rows: unknown[][]): Omit { const format = detectFormat(rows); const warnings: string[] = []; const errors: { row: number; messages: string[] }[] = []; if (format === "none") { return { format: "none", format_label: "No reconocido", period_count: 0, period_labels: [], concepts: [], partidas: [], unmatched_codes: [], warnings: ["No se detectó formato de programa Neodata/Opus (por concepto o por partida)."], errors, }; } let headerAt = -1; let periodLabels: string[] = []; let firstSem = -1; let totalCol = -1; for (let i = 0; i < rows.length; i++) { const info = findSemHeader(rows[i] || []); if (info.firstSem >= 0 && info.labels.length >= 1) { headerAt = i; periodLabels = info.labels; firstSem = info.firstSem; totalCol = info.totalCol; break; } } if (headerAt < 0) { return { format, format_label: format === "concepto" ? "Programa por concepto" : "Programa por partida", period_count: 0, period_labels: [], concepts: [], partidas: [], unmatched_codes: [], warnings: ["No se encontró fila de encabezado con columnas Sem 1..N."], errors, }; } const concepts: ParsedConceptRow[] = []; const partidas: ParsedPartidaRow[] = []; if (format === "concepto") { let currentDesc: string[] = []; for (let r = headerAt + 1; r < rows.length; r++) { const row = rows[r] || []; const code = String(row[0] ?? "").trim(); const desc = String(row[1] ?? "").trim(); const unit = String(row[2] ?? "").trim(); if (isSkipRow(row[0], row[1], row[2])) continue; if (fold(String(row[2] ?? "")) === "MONTO ESTA HOJA:") continue; if (isPartidaCode(code) && !unit) continue; if (/^A$/i.test(code)) continue; if (isConceptCode(code) && unit) { let bar = ""; for (let c = firstSem; c < totalCol; c++) { const v = String(row[c] ?? "").trim(); if (v.startsWith("Barra=")) { bar = v; break; } } if (!bar) { const v = String(row[firstSem] ?? "").trim(); if (v.startsWith("Barra=")) bar = v; } const description = [desc, ...currentDesc].filter(Boolean).join(" "); currentDesc = []; concepts.push({ code, description, unit, bar, slots: parseBarString(bar), row: r + 1, }); continue; } if (!code && desc && !unit) { currentDesc.push(desc); continue; } if (code.startsWith("Barra=")) continue; } } else { for (let r = headerAt + 1; r < rows.length; r++) { const row = rows[r] || []; const code = String(row[0] ?? "").trim(); const name = String(row[1] ?? "").trim(); if (isSkipRow(row[0], row[1], row[2])) continue; if (isPartidaCode(code) && name) { const amounts: number[] = []; for (let c = firstSem; c < totalCol; c++) { amounts.push(parseMoney(row[c])); } const total = totalCol >= 0 ? parseMoney(row[totalCol]) : amounts.reduce((s, n) => s + n, 0); partidas.push({ code: code.toUpperCase(), name, amounts, total, row: r + 1 }); continue; } const barCell = String(row[firstSem] ?? "").trim(); if (!code && barCell.startsWith("Barra=") && partidas.length) { partidas[partidas.length - 1].bar = barCell; } } } return { format, format_label: format === "concepto" ? "Programa por concepto (Neodata/Opus)" : "Programa por partida (erogaciones)", period_count: periodLabels.length, period_labels: periodLabels, concepts, partidas, unmatched_codes: [], warnings, errors, }; } export function parseWorkProgramWorkbook(bytes: Uint8Array, sheetName?: string): WorkProgramExcelPreview { const wb = XLSX.read(bytes, { type: "array", cellDates: true }); const sheet = sheetName && wb.SheetNames.includes(sheetName) ? sheetName : wb.SheetNames[0]; const ws = wb.Sheets[sheet]; const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" }) as unknown[][]; const parsed = parseWorkProgramRows(rows); return { ...parsed, sheet }; } export function matchWorkProgramImport( preview: WorkProgramExcelPreview, ctx: BudgetMatchContext, ): { payload: WorkProgramImportPayload; preview: WorkProgramExcelPreview } { const itemByCode = new Map(ctx.items.map((i) => [fold(i.code), i])); const chapterByCode = new Map(ctx.chapters.map((c) => [fold(c.code), c])); const unmatched: string[] = []; const warnings = [...preview.warnings]; const concept_slots: WorkProgramImportPayload["concept_slots"] = []; for (const row of preview.concepts) { const item = itemByCode.get(fold(row.code)); if (!item) { unmatched.push(row.code); continue; } for (const slot of row.slots) { concept_slots.push({ budget_item_id: item.id, period_index: slot.period_index, start_pct: slot.start_pct, end_pct: slot.end_pct, }); } } const partida_amounts: WorkProgramImportPayload["partida_amounts"] = []; const chapters_to_create: WorkProgramImportPayload["chapters_to_create"] = []; for (const row of preview.partidas) { let chapter = chapterByCode.get(fold(row.code)); if (!chapter) { const byName = ctx.chapters.find((c) => fold(c.name) === fold(row.name)); if (byName) chapter = byName; } if (!chapter) { chapters_to_create.push({ code: row.code, name: row.name, sort_order: partida_amounts.length }); unmatched.push(row.code); warnings.push(`Partida ${row.code} no encontrada en presupuesto; se creará capítulo al importar si confirma.`); continue; } row.amounts.forEach((amount, i) => { if (amount !== 0) { partida_amounts.push({ budget_chapter_id: chapter!.id, period_index: i + 1, amount, }); } }); const sum = row.amounts.reduce((s, n) => s + n, 0); if (chapter.amount > 0 && Math.abs(sum - chapter.amount) / chapter.amount > 0.01) { warnings.push( `Partida ${row.code}: suma semanal $${sum.toFixed(2)} difiere del presupuesto $${chapter.amount.toFixed(2)}.`, ); } } return { payload: { concept_slots, partida_amounts, chapters_to_create }, preview: { ...preview, unmatched_codes: unmatched, warnings }, }; } export function buildConceptExportRows( items: { code: string; description: string; unit: string; periods: { period_index: number; start_pct: number; end_pct: number }[]; }[], periodCount: number, ): unknown[][] { const header = ["Código", "Descripción", "Unidad", ...Array.from({ length: periodCount }, (_, i) => `Sem ${i + 1}`), "Total"]; const rows: unknown[][] = [header]; for (const item of items) { const slots = Array.from({ length: periodCount }, (_, i) => { const p = item.periods.find((x) => x.period_index === i + 1); if (!p || (p.start_pct === 0 && p.end_pct === 0)) return 0; return `Barra=${Array.from({ length: periodCount }, (_, j) => { const s = item.periods.find((x) => x.period_index === j + 1); if (!s || (s.start_pct === 0 && s.end_pct === 0)) return "0-0"; return `${s.start_pct}-${s.end_pct}`; }).join(",")}`; }); const barStr = `Barra=${Array.from({ length: periodCount }, (_, j) => { const s = item.periods.find((x) => x.period_index === j + 1); if (!s || (s.start_pct === 0 && s.end_pct === 0)) return "0-0"; return `${s.start_pct}-${s.end_pct}`; }).join(",")}`; rows.push([item.code, item.description, item.unit, barStr, ...Array(periodCount - 1).fill(""), ""]); } return rows; } export function buildPartidaExportRows( chapters: { code: string; name: string; periods: { period_index: number; amount: number }[] }[], periodCount: number, ): unknown[][] { const header = ["PARTIDA", "PARTIDA", ...Array.from({ length: periodCount }, (_, i) => `Sem ${i + 1}`), "Total"]; const rows: unknown[][] = [header]; for (const ch of chapters) { const amounts = Array.from({ length: periodCount }, (_, i) => { const p = ch.periods.find((x) => x.period_index === i + 1); return p?.amount ?? 0; }); const total = amounts.reduce((s, n) => s + n, 0); rows.push([ch.code, ch.name, ...amounts, total]); const bar = `Barra=${amounts.map((a) => (a > 0 ? "0-100" : "0-0")).join(",")}`; rows.push(["", "", bar, ...Array(periodCount + 1).fill("")]); } return rows; } export function exportWorkProgramWorkbook( kind: "concepto" | "partida", projectName: string, conceptItems: Parameters[0], partidaChapters: Parameters[0], periodCount: number, ): Uint8Array { const rows = kind === "concepto" ? buildConceptExportRows(conceptItems, periodCount) : buildPartidaExportRows(partidaChapters, periodCount); const ws = XLSX.utils.aoa_to_sheet([ ["PROGRAMA DE OBRA", projectName], [], ...rows, ]); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, kind === "concepto" ? "Por concepto" : "Por partida"); return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array; }