// Build a path-keyed lookup of per-Produktgruppe per-category // breakdowns extracted from the source PDF Erläuterungen. // // Currently only PG 1601 (Allgemeine Finanzwirtschaft) has parseable // tabular breakdowns — that's where the major taxes (Gewerbesteuer, // Grundsteuer, Einkommensteuer, etc.) get itemised. Other PGs have // either prose-only Erläuterungen or no breakdowns; for those the // lookup simply has no entries. import type { BudgetTree, CategoryKey, Flow } from "../data/types.js"; // Teilergebnisplan line numbers map to our CategoryKey + flow side // per the standard NRW-NKF schema. (Lines 27/28 are internal // Leistungsbeziehungen and don't map to our category list.) const LINE_TO_CATEGORY: Record = { 1: { key: "steuern", flow: "ertraege" }, 2: { key: "zuwendungenAllgemeineUmlagen", flow: "ertraege" }, 3: { key: "sonstigeTransfererträge", flow: "ertraege" }, 4: { key: "öffentlichRechtlicheLeistungsentgelte", flow: "ertraege" }, 5: { key: "privatrechtlicheLeistungsentgelte", flow: "ertraege" }, 6: { key: "kostenerstattungenKostenumlagen", flow: "ertraege" }, 7: { key: "sonstigeOrdentlicheErträge", flow: "ertraege" }, 8: { key: "aktivierteEigenleistungen", flow: "ertraege" }, 9: { key: "bestandsveränderungen", flow: "ertraege" }, 11: { key: "personalaufwendungen", flow: "aufwendungen" }, 12: { key: "versorgungsaufwendungen", flow: "aufwendungen" }, 13: { key: "sachUndDienstleistungen", flow: "aufwendungen" }, 14: { key: "bilanzielleAbschreibungen", flow: "aufwendungen" }, 15: { key: "transferaufwendungen", flow: "aufwendungen" }, 16: { key: "sonstigeOrdentlicheAufwendungen", flow: "aufwendungen" }, 19: { key: "finanzerträge", flow: "ertraege" }, 20: { key: "zinsenFinanzaufwendungen", flow: "aufwendungen" }, 23: { key: "außerordentlicheErträge", flow: "ertraege" }, 24: { key: "außerordentlicheAufwendungen", flow: "aufwendungen" }, }; export interface BreakdownItem { name: string; slug: string; /** € values keyed by year. Sparse — only years the source covers. */ values: Record; } /** Lookup keyed by `${flow}/${bereichSlug}/${gruppeSlug}/${categoryKey}` */ export type BreakdownsByPath = Record; interface PgSectionWithBreakdowns { pgNumber: string; name: string; beschreibung: string | null; erlaeuterungen: string | null; breakdowns?: Record }>>; } const SLUG_REPLACEMENTS: Array = [ [/ä/g, "ae"], [/ö/g, "oe"], [/ü/g, "ue"], [/ß/g, "ss"], ]; function slugify(input: string): string { let s = input.toLowerCase(); for (const [pat, rep] of SLUG_REPLACEMENTS) s = s.replace(pat, rep); s = s.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return s || "n-a"; } /** Match a PG by name against the budget tree and return its * (bereich, gruppe) pair. Tolerates the same abbreviations as the * pg-notes matcher. */ function matchPgInTree( pgName: string, tree: BudgetTree ): { bereichSlug: string; gruppeSlug: string } | null { const target = pgName.toLowerCase().replace(/[^a-z0-9]/g, ""); let best: { dist: number; bereich: string; gruppe: string } | null = null; for (const bereich of tree.produktbereiche) { for (const gruppe of bereich.produktgruppen) { const candidate = gruppe.name.toLowerCase().replace(/[^a-z0-9]/g, ""); if (candidate === target) { return { bereichSlug: bereich.slug, gruppeSlug: gruppe.slug }; } // Fuzzy fallback (cheap edit-distance via length symmetric diff). let dist = Math.abs(candidate.length - target.length); const min = Math.min(candidate.length, target.length); for (let i = 0; i < min; i++) { if (candidate[i] !== target[i]) dist++; } if (best === null || dist < best.dist) { best = { dist, bereich: bereich.slug, gruppe: gruppe.slug }; } } } if (best && best.dist / Math.max(target.length, 1) < 0.4) { return { bereichSlug: best.bereich, gruppeSlug: best.gruppe }; } return null; } export function buildBreakdownsByPath( tree: BudgetTree, pgSections: Record ): BreakdownsByPath { const out: BreakdownsByPath = {}; for (const pg of Object.values(pgSections)) { if (!pg.breakdowns || Object.keys(pg.breakdowns).length === 0) continue; const place = matchPgInTree(pg.name, tree); if (!place) continue; for (const [lineNumStr, items] of Object.entries(pg.breakdowns)) { const lineNum = parseInt(lineNumStr, 10); const map = LINE_TO_CATEGORY[lineNum]; if (!map) continue; // Convert string year keys back to numbers; assemble the lookup // key in the same shape consumers will use it. const breakdownItems: BreakdownItem[] = items.map((it) => { const values: Record = {}; for (const [yStr, v] of Object.entries(it.values)) { values[Number(yStr)] = v; } return { name: it.name, slug: slugify(it.name), values, }; }); const key = `${map.flow}/${place.bereichSlug}/${place.gruppeSlug}/${map.key}`; out[key] = breakdownItems; } } return out; }