// CSV parsing for the four source files. Handles: // • UTF-8 BOM // • `;` separator // • German number format (`.` thousands, `,` decimal) // • Sign convention: source revenues are negative, source expenses are // positive. We flip revenues to positive so downstream code can treat both // flows symmetrically. // • Header drift: `Leistunngsentgelte` (double-n) and `algemeine` (single-l) // map to the same canonical CategoryKey. // • The 2025 plan's extra `Globaler Minderaufwand` column. // • Empty rows (the 2023 plan and Jahresabschluss have a stray blank line). import { dsvFormat } from "d3-dsv"; import type { BudgetRow, CategoryKey, Categories, RowKind, SourceFile, } from "./types.js"; import { ERTRAG_KEYS } from "./types.js"; const semicolon = dsvFormat(";"); // Maps a verbatim source-CSV header (after BOM strip and trim) to a canonical // CategoryKey. Includes the known typos and spelling variants observed across // files. If a header does not appear here it is silently ignored — the first // three columns (Produktbereich, Produktgruppe, Geschäftsjahr) are handled // separately. const HEADER_TO_KEY: ReadonlyMap = new Map([ ["Steuern und ähnliche Abgaben", "steuern"], ["Zuwendungen und allgemeine Umlagen", "zuwendungenAllgemeineUmlagen"], ["Zuwendungen und algemeine Umlagen", "zuwendungenAllgemeineUmlagen"], ["Sonstige Transfererträge", "sonstigeTransfererträge"], ["Öffentlich-rechtliche Leistungsentgelte", "öffentlichRechtlicheLeistungsentgelte"], ["Öffentlich-rechtliche Leistunngsentgelte", "öffentlichRechtlicheLeistungsentgelte"], ["Privatrechtliche Leistungsentgelte", "privatrechtlicheLeistungsentgelte"], ["Kostenerstattungen und Kostenumlagen", "kostenerstattungenKostenumlagen"], ["Sonstige ordentliche Erträge", "sonstigeOrdentlicheErträge"], ["Aktivierte Eigenleistungen", "aktivierteEigenleistungen"], ["Bestandsveränderungen", "bestandsveränderungen"], ["Personalaufwendungen", "personalaufwendungen"], ["Versorgungsaufwendungen", "versorgungsaufwendungen"], ["Aufwendungen für Sach- und Dienstleistungen", "sachUndDienstleistungen"], ["Bilanzielle Abschreibungen", "bilanzielleAbschreibungen"], ["Transferaufwendungen", "transferaufwendungen"], ["Sonstige ordentliche Aufwendungen", "sonstigeOrdentlicheAufwendungen"], ["Finanzerträge", "finanzerträge"], ["Zinsen und sonstige Finanzaufwendungen", "zinsenFinanzaufwendungen"], ["Außerordentliche Erträge", "außerordentlicheErträge"], ["Außerordentliche Aufwendungen", "außerordentlicheAufwendungen"], ["Globaler Minderaufwand", "globalerMinderaufwand"], ]); const ERTRAG_SET: ReadonlySet = new Set(ERTRAG_KEYS); /** Parse a German-formatted decimal. Returns undefined for empty/missing. */ function parseGermanNumber(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const trimmed = raw.trim(); if (trimmed === "") return undefined; // `-733.670.000,00` → `-733670000.00` const normalized = trimmed.replace(/\./g, "").replace(",", "."); const n = Number(normalized); return Number.isFinite(n) ? n : undefined; } function classifyRow(produktbereich: string, produktgruppe: string): RowKind { if (produktbereich === "Gesamt" && produktgruppe === "Gesamt") return "grandTotal"; if (produktgruppe === "Gesamt") return "produktbereichTotal"; return "leaf"; } /** * Parse one CSV file's contents into BudgetRows. * * `text` is the file's raw UTF-8 content (BOM tolerated). `source` describes * provenance for downstream conflict resolution. */ export function parseCsv(text: string, source: SourceFile): BudgetRow[] { // Strip BOM if present; d3-dsv doesn't. const stripped = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; const rows = semicolon.parse(stripped); const out: BudgetRow[] = []; for (const row of rows) { const produktbereich = (row["Produktbereich"] ?? "").trim(); const produktgruppe = (row["Produktgruppe"] ?? "").trim(); const yearRaw = (row["Geschäftsjahr"] ?? "").trim(); if (!produktbereich || !produktgruppe || !yearRaw) continue; const year = Number.parseInt(yearRaw, 10); if (!Number.isInteger(year)) continue; const categories: Categories = {}; for (const [header, value] of Object.entries(row)) { const key = HEADER_TO_KEY.get(header.trim()); if (!key) continue; const n = parseGermanNumber(value); if (n === undefined) continue; // Source convention: revenues negative, expenses positive. Flip revenues. categories[key] = ERTRAG_SET.has(key) ? -n : n; } out.push({ produktbereich, produktgruppe, year, kind: classifyRow(produktbereich, produktgruppe), categories, source, }); } return out; }