Zum Inhalt springen

Stadtfinanzen

Der Haushalt ist der sperrigste Stoff, den Ratslotse zeigt: Doppik-Vokabular, Millionenbeträge ohne Bezugsgröße, Zuständigkeiten quer über drei staatliche Ebenen. Der Bereich unter /haushalt übersetzt ihn — und macht dabei an jeder Stelle sichtbar, welche Zahl amtlich ist, welche wir gerechnet haben und welche schlicht fehlt.

Der Einstieg trägt einen Wegweiser (components/haushalt/wegweiser.tsx), und der ist keine Linkliste, sondern die Leserichtung des ganzen Bereichs: neunzehn Schritte in vier Stufen (6 · 4 · 6 · 3). Die Tabelle steht deshalb in genau dieser Reihenfolge.

Route Inhalt
/haushalt Einstieg: Anzeigetafel mit der Kernzahl, Gegenbalken (umschaltbar auf die 100-Euro-Ansicht), Rücklagen-Reichweite, Bereichstabelle, Geldfluss (für Planjahre nur die Herkunftsseite, s. u.), Zeitreihe, Datenstand
Die Zahlen
/haushalt/einnahmen Schritt 1 — alle Einnahmequellen, nach Entscheidungsmacht gruppiert statt nach Betrag sortiert
/haushalt/pflicht Schritt 2 — muss oder kann: Ausgaben nach Gestaltungsspielraum, gegen die Selbstauskunft der Stadt gehalten
/haushalt/produkte[?nr=<produkt_nr>] Schritt 3 — „Was kostet eigentlich …?“, zwei Abschnitte: #bereiche die zehn Teilhaushalte im Klartext (ihre Namen stammen aus der Verwaltungs­gliederung und sagen, wer zuständig ist, nicht worum es geht), #produkte die einzelnen Produkte mit Kosten, durchsuchbar. Die dritte Ebene — der Steckbrief eines Teilhaushalts — bleibt /haushalt/bereich
/haushalt/personal Schritt 4 — „Wer macht die Arbeit?“: der Stellenplan je Amtsbezeichnung, mit besetzten und unbesetzten Stellen zum Stichtag — dazu, was das Personal kostet: Posten 13 der Ergebnisrechnung (Ist, Plan gegen Ist je Jahr) und der Ansatz des jüngsten Haushalts
/haushalt/investitionen[?jahr=<jahr>&thh=<nr>] Schritt 5 — „Was gebaut wird — und was daraus wurde“, zwei Abschnitte: #plan der Finanzhaushalt je Teilhaushalt mit dem Investitionsprogramm (Vorhaben einzeln, durchsuchbar), #gebaut was am Jahresende tatsächlich abgeflossen ist — seit 2003, nach Auszahlungsart
Die Gegenprobe
/haushalt/plan-ist[?jahr=<jahr>] Schritt 6 — vorn der Haushaltsvollzug (#vollzug): was die Verwaltung im laufenden Jahr zum 30.06., 30.09. und 31.12. erwartet, je Stichtag und Teilhaushalt; dann geplant gegen tatsächlich, je Teilhaushalt, mit den Abweichungsgründen der Verwaltung im Wortlaut
/haushalt/pruefung[?jahr=<jahr>] Schritt 7 — „Geprüft und zusammengefasst“, zwei Abschnitte: #feststellungen die Feststellungen des Rechnungsprüfungsamts im Wortlaut, je Jahrgang und als Wiederholungskette, #kennzahlen die dreizehn Kennzahlen, auf die die Stadt ihren Abschluss selbst eindampft, mit den gedruckten Rechenwegen
Der Rahmen
/haushalt/konzern[?g=<gesellschaft>] Schritt 8 — „Und ist das die ganze Stadt?“, vier Abschnitte: #summe Kernverwaltung gegen Gesamtabschluss mit der Konsolidierung, #gesellschaften jede städtische Gesellschaft mit Auftrag, Eigentümern, Aufsicht und Kennzahlen-Zeitreihe (g öffnet den Steckbrief IM Abschnitt), #betriebe die Wirtschaftspläne der Eigenbetriebe je Betrieb und Jahrgang, #gebuehren die Gebührenbedarfsberechnung für Abfall und Straßenreinigung
/haushalt/vergleich Schritt 9 — Steuerkraft, Hebesätze und Steuereinnahmekraft der acht kreisfreien Städte aus der amtlichen Statistik — und die Erklärung, warum Ausgaben und Personal nicht verglichen werden
/haushalt/schulden Schritt 10 — dreißig Jahre Schuldenstand aus Tabelle 1108 des Statistischen Jahrbuchs, mit der Angabe, was mitgezählt ist
Mitreden
/haushalt/mitreden[?jahr=<jahr>] Schritt 11 — „Mitreden“, zwei Abschnitte auf einer Seite: #termine wann der Haushalt entschieden wird (jede Station im Rat, aus acht Jahrgängen, mit Link auf die Sitzung), #streit je Jahrgang die Änderungslisten mit Abstimmungsergebnis, ihr Inhalt Position für Position („Was in den Listen stand“, aus council_budget_amendments), die Wortbeiträge im Protokollwortlaut und die Schlussabstimmung
/haushalt/labor Schritt 12 — das Haushalts-Labor, drei Werkbänke mit je eigener Zielgröße: Einnahmen (Gewerbesteuer-Hebesatz mit mitlaufender Städte-Leiter und eigener Treppe seit 1980, Grundsteuer B mit belegter LSN-Aufteilung, Hundesteuer, Gebühren als absichtlich gesperrte Schraube), Ausgaben (freiwillige Teilhaushalte), Investitionen & Finanzierung (Vorhaben-Schalter aus Anlage 004, Kredit-Schalter mit gezahlter Zins-Spanne). Ergebnis-Spalte mit Lücken-Balken, Rücklagen-Pfad über die Finanzplanungsjahre samt Kipp-Jahr und Finanzausgleichs-Spanne aus den echten Ausgleichsjahren
Steckbriefe (ohne Schritt)
/haushalt/bereich?name=<slug> Dossier je Teilhaushalt: Wasserfall Brutto → eigene Erträge → Zuschussbedarf, Entwicklung seit 2020, Produkte des Bereichs
/haushalt/steuer?art=<slug> Steckbrief je Einnahmeart: „Wer entscheidet was“, Ist-Kurve, Hebesatz, Ein-Punkt-Überschlag

Die beiden Steckbriefe tragen keinen Schritt, weil sie einen Bereich bzw. eine Einnahmeart brauchen, über die man sie aufruft — als eigener Schritt stünde dort ein beliebiger Einzelfall. Man erreicht sie aus Schritt 1 und 2 sowie aus der Bereichstabelle des Einstiegs.

Query-Parameter statt dynamischer Segmente, weil der Capacitor-Export die Slugs zur Bauzeit nicht kennt — dieselbe Konvention wie /council/decision?id=.

Das Fundament liefert ein Endpunkt: GET /api/council/budget gibt Planjahre, Ergebnisrechnung, Ist-Steuern, Steuerkraft und die Einwohnerzahl in einem Aufruf — er trägt den Einstieg und die meisten Vertiefungsseiten. Einen eigenen Endpunkt bekommt nur, was groß genug ist, um Seiten zu belasten, die es nicht zeigen:

Endpunkt Wofür Warum getrennt
…/haushalt/pruefberichte /haushalt/pruefung, Prüfungs-Karte auf /haushalt/plan-ist eine Viertel-Megabyte Prosa
…/haushalt/produkte /haushalt/produkte, /haushalt/pflicht, /haushalt/bereich, Labor mehrere hundert Zeichen Steckbrief je Zeile; gesucht und gefiltert wird serverseitig
…/haushalt/konzern /haushalt/konzern eigene Tabellen, eigene Jahrgangsreihe
…/haushalt/stellenplan /haushalt/personal rund 190 Zeilen je Jahrgang; die Einzelposten kommen nur für den angefragten Jahrgang mit
…/haushalt/vergleich /haushalt/vergleich eigene Tabelle (LSN), acht Städte × Jahrgänge
…/haushalt/investitionen /haushalt/investitionen eigene Tabelle, anderer Haushalt (Finanz- statt Ergebnishaushalt) — nicht mit den übrigen Zahlen verrechenbar
…/haushalt/gebaut /haushalt/investitionen#gebaut eigene Tabellen; Ist statt Plan und nach Auszahlungsart statt nach Teilhaushalt — bewusst nicht mit …/haushalt/investitionen zusammengelegt, damit die beiden Summen nicht als „geplant gegen gebaut“ gelesen und voneinander abgezogen werden. Seit 21.08.2026 stehen sie als zwei ABSCHNITTE einer Seite; die Datenwege bleiben getrennt, und der Einwand („Warum hier keine Umsetzungsquote steht“) steht seither VOR den Ist-Zahlen statt dahinter
…/budget/execution Haushaltsvollzug (Frontend noch offen) eigene Tabelle; die Summenzeilen aller Jahrgänge kommen immer mit, die dreizehn Teilhaushalte nur für den angefragten Jahrgang — über acht Jahrgänge wären das rund 1.800 Zeilen
…/budget/loans Kredite und Zinsen (Block „Zu welchem Zins“ auf der Schulden-Seite, KI-Facette loans) notices je Unterrichtung, items je Posten, rates = Posten mit gedrucktem Zinssatz (jüngste zuerst), refinancing_by_year mit Zinsersparnis, coverage mit den Lücken 2019–2021
…/haushalt/schulden /haushalt/schulden eigene Tabelle, eigene Jahrgangsreihe (bis 1995 zurück)
…/haushalt/weg /haushalt/mitreden#termine Ratsdaten statt Finanzdokumenten (Beratungsfolge, Sitzungen)
…/haushalt/datenstand Block „Bis wann die Zahlen reichen“ rechnet über den Bestand, nicht über Inhalte
…/haushalt/dokumente Quellenverzeichnis jeder Haushalts-Seite je Quelle und Jahrgang das Dokument — die Angabe, die die statische Quellenliste nicht haben kann
Tabelle Inhalt Quelle Ingest
council_budget Ergebnishaushalt je Teilhaushalt, 2020–2026 (Plan) Beschlossene Haushaltsplan-PDFs; 2024 aus dem Open-Data-CSV scripts/ingest_haushalt.py
council_taxes Steuereinnahmen je Art seit 1998 (Ist) Open-Data-Portal, Datensatz 1104 scripts/ingest_finanzen_opendata.py
council_tax_capacity Steuerkraftmesszahl + Schlüsselzuweisungen je Ausgleichsjahr seit 1993 (Jahreszahl beim Einlesen korrigiert, s. u.) Open-Data-Portal, Datensatz 1106 dito
council_einwohner Einwohnerzahl je Jahr seit 2010 Open-Data-Portal, Datensatz 1102 dito
council_investments Investitionen des Finanzhaushalts je Teilhaushalt, 2020–2025 (Plan) — Ein- und Auszahlungen, dazu die Summenzeile und der Gesamtbetrag des Finanzhaushalts als Bezugsgröße Open-Data-Portal, Datensatz 1101, Tabellenblatt „Finanzhaushalt“ dito
council_investment_measures Einzelne Vorhaben je Teilhaushalt, 2019–2026 (Plan) — IPSP-Element, Bezeichnung und Gesamtinvestitionssumme; ebene (massnahme / teilhaushalt / gesamt). Ohne Jahresraten, s. u. Investitionsprogramm (Anlage 004 des Haushaltsplans) — Anlagen im RIS scripts/ingest_finanzberichte.py
council_budget_execution Haushaltsvollzug: Ansatz, Prognose zum Jahresende und Abweichung je Teilhaushalt, 2018–2026, viermal jährlich — für Ergebnis- und Finanzhaushalt. plan_basis sagt, ob die Ansatz-Spalte die Ermächtigungsübertragungen enthält (bis 2020) oder nicht (ab 2021) Finanz- und Leistungsberichte — Anlagen im RIS scripts/ingest_haushaltsvollzug.py
council_loan_notices, council_loan_items Kredite und Zinsen: je Unterrichtung des Rates nach der Kreditrichtlinie der Berichtszeitraum und die Zinsersparnis der Umschuldung, je Posten Art (Kreditaufnahme, Umschuldung, Prolongation, Ausleihung), Schuldner, Betrag, Zinssatz, Zinsbindung und Datum der Kreditentscheidung, 2018–2026 Vorlagen „Unterrichtung des Rates über Kreditaufnahmen, Derivatabschlüsse und Umschuldungen“ — Volltexte im RIS scripts/ingest_kredite.py
council_liquidity Liquiditätsstand: je Monatsende der Kontostand der Stadt in Euro seit 2015, jüngster Beleg je Monat, Wertzahl- und Überlappungsprobe Grafiken „Liquiditätsstand zum Monatsende“ — Anlagen der Monatsvorlagen im RIS (der Ingest lädt sie selbst) scripts/ingest_liquiditaet.py
council_enterprise_accounts Jahresabschlüsse der Eigenbetriebe: je Betrieb, Jahr und Kennzahl eine Zahl (Umsatzerlöse, Jahresergebnis, Bilanzsumme, Eigenkapital, Abschreibungen, Investitionen, Cashflow …) aus dem jüngsten Bericht, bezeugt von den Vorjahresspalten der folgenden; EGH und Hafen aus der Mehrjahresübersicht des RPA-Berichts, AWB aus dem Wirtschaftsprüfer-Bericht, Bäderbetrieb aus GuV und Bilanz Vorlagen „Jahresabschluss und Lagebericht … für den Eigenbetrieb …“ — Anlagen im RIS (Prüfberichte, GuV, Bilanz) scripts/ingest_eigenbetriebe_abschluss.py
council_income_statement Ansatz, Plan und Ergebnis je Posten — gesamt und je Teilhaushalt, 2017–2024 Jahresabschlüsse — Anlagen im RIS scripts/ingest_finanzberichte.py
council_income_budget Dieselben Posten für Jahre ohne Abschluss, 2019–2026 — je Zeile art (ansatz / finanzplanung) und plan_jahrgang Gesamtergebnishaushalt (Anlage 005 des Haushaltsplans) — Anlagen im RIS dito
council_variance_reasons Warum ein Posten vom Plan abwich (Abschnitt 6.3.1), 45 Einträge dito dito
council_audit_report_sources Fundstelle des RPA-Schlussberichts je Jahrgang (eine Zeile je Jahr) dito dito
council_products Produktebene: was einzelne Aufgaben kosten — plus Steckbrief (Kurzbeschreibung, Auftragsgrundlage, Beeinflussbarkeit, Wirkungskreis, Zielgruppe) Teilhaushalts-Pläne — Anlagen im RIS dito
council_staff_plan Stellen je Amtsbezeichnung, 2023–2026 — teil A (Beamt*innen) / B (Tarifbeschäftigte), art (posten / gruppe / gesamt), dazu Besetzung und unbesetzte Stellen zum Stichtag Stellenplan (Anlage 21/22 des Haushaltsplans) — Anlagen im RIS dito
council_audit_reports Prüfungsfeststellungen 2017–2023, eine Zeile je Randmarke Schlussberichte des Rechnungsprüfungsamts — Anlagen im RIS scripts/ingest_pruefberichte.py
council_group_items Gesamtergebnisrechnung des Konzerns je Posten, 2014–2024 Konsolidierte Gesamtabschlüsse — Anlagen im RIS scripts/ingest_konzernabschluss.py
council_group_entities Dieselben Summen je Aufgabenträger (Kernverwaltung, Klinikum, Eigenbetriebe …), 2017–2024, in TEUR dito dito
council_city_comparison Steuerkraft, Hebesätze und Steuereinnahmekraft der acht kreisfreien Städte je Jahrgang — Reihen steuerkraft, realsteuern und finanzausgleich (die drei Komponenten der Landeszuweisung, in TEUR) Landesamt für Statistik Niedersachsen (Kommunaler Finanzausgleich, Realsteuervergleich) scripts/ingest_staedtevergleich.py
council_investments_actual Tatsächliche Investitions-Auszahlungen je Jahr seit 2003 (Ist) — Summe und regelwerk (kameral bis 2009, doppik ab 2010) Statistisches Jahrbuch der Stadt, Tabellen 1107 und 1107-1 (PDF von oldenburg.de) scripts/ingest_investitionen_ist.py
council_investments_actual_kinds Dieselben Jahrgänge nach Auszahlungsart, mit der Überschrift der Quelle — vier Arten je kameralem, sechs je doppischem Jahrgang dito dito
council_investments_actual_rejected Die Jahrgänge, die die Zeilensumme nicht bestanden haben: Grund und gemessene differenz in Euro. Damit die Seite ihre Lücke beziffern kann, statt sie nur zu behaupten dito dito
council_debt Schuldenstand je Jahr seit 1995 — vier Schuldenarten, Summe und Betrag je Einwohner*in Statistisches Jahrbuch der Stadt, Tabelle 1108 (PDF von oldenburg.de) scripts/ingest_schulden.py
council_expense_series Ausgaben je Jahr seit 1972 (Ist) — regelwerk (kameral bis 2009, doppik ab 2010), die bestandenen proben je Zeile und, wo die beiden Quellen sich widersprechen, der konflikt_betrag der unterlegenen. Ohne Einwohnerzahl, s. u. Datensatz 1102 — Statistisches Jahrbuch, Tabelle 1102 (PDF) und Open-Data-Portal (zwei CSV) scripts/ingest_ausgabenreihe.py
council_tax_plan Je Steuerart und Jahr der Ansatz des Haushaltsplans neben dem Rechnungsergebnis; vorlaeufig ist die Angabe der Quelle über sich selbst. art trägt dieselbe Schreibweise wie council_taxes — daran hängt die Prüfung der Jahresbeschriftung Statistisches Jahrbuch, Tabelle 1103 (PDF von oldenburg.de), alle im Archiv gesicherten Ausgaben scripts/ingest_steuertabellen.py
council_business_plans Eckwerte der Wirtschaftspläne je Eigenbetrieb und Haushaltsjahr — Erfolgsplan (Erträge, Aufwendungen, steuerliche Aufwendungen, Ergebnis), Vermögensplan und Verpflichtungsermächtigungen, dazu der Stand des Verwaltungsentwurfs. Bisher nur der Eigenbetrieb Gebäudewirtschaft und Hochbau, 2019–2026 — als einziger nennt er sie im Beschlusstext Die Ratsvorlage selbst (Beschlussvorschlag), nicht eine Anlage scripts/ingest_wirtschaftsplaene.py
council_tax_rates Die Realsteuer-Hebesätze je Änderungsjahr seit 1980 — Grundsteuer A, Grundsteuer B, Gewerbesteuer, dazu der vorheriger Satz. Neun Änderungsjahre (27 Zeilen) decken 45 Jahre — zuletzt 2025, als die Grundsteuerreform A auf 500 und B auf 539 Punkte hob Statistisches Jahrbuch, Tabelle 1105 (auf demselben Blatt wie 1104) dito

Alle Ingests sind idempotent. Die neun Schichten, die der Cron aus dem Ratsinformations- system von allein nachzieht, tut er seit 08/2026 (siehe unten); die Ingest-Skripte bleiben der Weg von Hand, wenn ein verbesserter Parser über den Bestand laufen soll. Die Plan- und Open-Data-Schichten (council_budget, council_taxes, council_tax_capacity, council_einwohner) kommen per Download von oldenburg.de und bleiben Handarbeit, ebenso der Städtevergleich (LSN, einmal jährlich — siehe unten) und die Schuldenzeitreihe, die Ist-Investitionen und die lange Ausgabenreihe (alle drei Statistisches Jahrbuch, einmal jährlich).

Die lange Ausgabenreihe (Datensatz 1102, seit 1972)

Abschnitt betitelt „Die lange Ausgabenreihe (Datensatz 1102, seit 1972)“

Die längste Reihe des Bereichs — 54 Jahrgänge — und die einzige, die aus zwei Veröffentlichungen zugleich kommt: dem Statistischen Jahrbuch (Tabelle 1102 als PDF, ab 2002) und dem Open-Data-Portal (zwei CSV-Dateien, ab 1972). Genau diese Doppelung macht sie prüfbar.

Drei gestaffelte Proben, und welche ein Jahrgang bestanden hat, steht an seiner Zeile (proben):

  1. Pro-Kopf-Rechnung der Quelle — beide Dateien führen neben dem Betrag eine Einwohnerzahl und einen Betrag je Einwohner*in; Betrag ÷ Einwohnerzahl muss den ausgewiesenen Wert ergeben. Trägt jede der 54 Zeilen, auch die dreißig, für die es keine zweite Quelle gibt. Sie entscheidet nebenbei eine Einheitenfrage: Die ältere CSV beschriftet ihre Spalte „in Euro“ und meint Tausend Euro — die Probe geht nur in Tausend Euro auf, und zwar in allen 38 Zeilen.
  2. Jahrbuch gegen Open-Data-Portal — in den 24 gemeinsamen Jahren müssen beide denselben Betrag nennen. Sie tun es 23-mal.
  3. Abgleich mit dem Jahresabschluss — für die Jahre mit Abschluss gegen Posten 20 der Ergebnisrechnung.

Warum der Abgleich eine Toleranz hat und keine Gleichheit. Die Statistik liegt in jedem geprüften Jahrgang zwischen 0,03 und 0,05 % über council_income_statement — 2017 um 166.253 €, 2024 um 328.936 €. Das ist kein Rundungsrest, sondern eine Abgrenzung: Der Jahresabschluss führt die Tabelle zweimal, als Ergebnisrechnung der Kernverwaltung (Abschnitt 3.1, das ist, was wir parsen) und als Gesamtergebnisrechnung (3.2 — im Rechenschaftsbericht ausgeschrieben als „Kernhaushalt und nicht rechtsfähige Stiftungen“). Die Statistik nimmt die zweite. Der Bericht rechnet den Unterschied selbst vor: „ordentliche Aufwendungen gemäß Haushaltsplan 728.170.348,30 abzüglich Aufwendungen der nicht rechtsfähigen Stiftungen −286.683,03“. Gegen die Gesamtergebnisrechnung stimmt die Statistik auf den Tausender genau (2024: 764.745.383,29 € → 764.745 T€).

Die Naht 2009/2010 ist dieselbe wie bei den Ist-Investitionen und stammt aus derselben Fußnote: Umstellung auf das Neue Kommunale Rechnungswesen zum

  1. Januar 2010. Links steht das Anordnungssoll des Verwaltungshaushalts, rechts sind es die ordentlichen Aufwendungen der Gesamtergebnisrechnung. Über den Schnitt zieht kein Lesepfad eine Linie, und das Modul stellt bewusst keine Summen-, Wachstums- oder Mittelwertfunktion bereit.

2021: zwei amtliche Quellen, zwei Beträge. Das CSV nennt 613.572 T€, das PDF 608.910 T€ — 4,662 Mio. € Unterschied, der einzige Widerspruch in 24 gemeinsamen Jahren. Auflösbar ist er, weil beide ihre Pro-Kopf-Spalte mitbringen: Die PDF-Zeile geht auf (608.910.000 ÷ 169.605 = 3.590,17 gegen ausgewiesene 3.590), die CSV-Zeile nicht (3.617,65 gegen 3.611) — sie widerspricht sich selbst. Der Jahresabschluss 2021 bestätigt das PDF und zeigt zugleich, was passiert ist: 613.571.622,10 € ist auf den Tausender genau der Ansatz des Jahres, der in der Tabelle eine Spalte links vom Ergebnis steht. Übernommen wird der PDF-Wert; der abweichende steht als konflikt_betrag daneben im Bestand, und die Seite nennt beide Zahlen. Ohne das PDF fällt der Jahrgang ganz heraus, statt still die falsche Zahl zu übernehmen.

Ohne Einwohnerzahl, und das mit Absicht. Beide Quellen führen den Divisor, und die Probe braucht ihn — gespeichert wird er trotzdem nicht. Läge er in der Tabelle, wäre die naheliegendste Grafik „Ausgaben pro Kopf seit 1972“, und die wäre falsch: Die Einwohnerreihe hat zwei Zensus-Brüche (2011 und 2022), an denen ein Pro-Kopf-Wert springt, ohne dass sich etwas verändert hätte. Was die API nicht liefern kann, kann das Frontend nicht versehentlich zeichnen.

Der Nebengewinn: Die Reihe führt das gerade abgelaufene Jahr, Monate bevor sein Jahresabschluss vorliegt (2025: 850,17 Mio. €). Diese Jahrgänge tragen die dritte Probe nicht — und behaupten sie auch nicht.

Die Steuertabellen 1103 und 1105 — und der erste Parser, der aus dem Archiv liest

Abschnitt betitelt „Die Steuertabellen 1103 und 1105 — und der erste Parser, der aus dem Archiv liest“

Zwei Tabellen desselben Jahrbuch-Kapitels, beide für den Steuer-Steckbrief (council/steuertabellen.py):

  • 1103 stellt je Steuerart den Haushaltsplan neben das Rechnungsergebnis. Das ist die einzige Stelle, an der wir die Plan-Seite je Steuerart bekommen: Weder council_income_budget noch council_income_statement schlüsseln Steuern auf, beide führen nur „Steuern und ähnliche Abgaben“ als eine Summe. Der Befund: Die Gewerbesteuer wurde drei Jahre in Folge um über 40 % unterschätzt (2023 +42,3 %, 2024 +52,1 %, 2025 +42,8 %).
  • 1105 führt die Realsteuer-Hebesätze seit 1980, aber nur die Änderungsjahre — neun Zeilen für 45 Jahre. Ein Satz gilt bis zur nächsten Änderung: eine Treppe, keine Kurve, und dazwischen wird nichts interpoliert (<Zeitreihe treppe>, GB-01).

Tabelle 1103 führt nur drei Jahrgänge. Erscheint die Ausgabe 2026, fällt 2023 heraus — und die Stadt hält keine alten Ausgaben online (1103-2024-AZ.pdf: 404, nachgemessen am 17.08.2026; das Internet Archive hat vom Statistik-Verzeichnis null Schnappschüsse). Wer nur die Live-Datei liest, hat für immer drei Jahre.

Seit #603 sichert scripts/archive_statistik.py täglich jede Ausgabe. Weil der Dateiname den Jahrgang trägt, ist jede Ausgabe ein eigener Ordner — die alten bleiben stehen. council/archiv.neueste_je_datei() holt je Ausgabe ihre zuletzt gesicherte Fassung, älteste zuerst; bei gleichem Jahrgang gewinnt die jüngere (sie trägt das abgerechnete Ergebnis, wo die ältere ein vorläufiges auswies). Die Reihe wächst damit um einen Jahrgang pro Jahr.

Mit council/archiv.py steht der Aufbau des Archivs an einer Stelle; scripts/archive_statistik.py importiert ihn von dort, statt ihn selbst zu definieren. Es schreibt weiterhin allein — es tut es nur nicht mehr nach eigenen Regeln.

Die Jahresbeschriftung: die Lehre aus Datensatz 1106

Abschnitt betitelt „Die Jahresbeschriftung: die Lehre aus Datensatz 1106“

Datensatz 1106 war um ein Jahr zu früh beschriftet, und es fiel jahrelang niemandem auf, weil jede einzelne Zahl für sich plausibel aussah. Beide Tabellen hier werden deshalb gegen eine zweite Quelle gehalten, bevor etwas gespeichert wird:

  • 1103 — jedes Rechnungsergebnis steht ein zweites Mal in Tabelle 1104, die ihre Jahre einzeln beschriftet (council_taxes, 1998–2025). Für 2023, 2024 und 2025 nennen beide in allen sechs Steuerarten denselben Betrag. Das ist zugleich das Aufnahmekriterium: Ein Jahrgang ohne diese Zweitquelle kommt nicht herein (istabgleich).

  • 1105 hat keine Tabelle mit denselben Zahlen. Geprüft wird deshalb der Zeitpunkt der Wirkung (sprungjahrprobe): Wo der Grundsteuer-Hebesatz stieg, muss das Aufkommen im genannten Jahr stärker steigen als im Jahr danach.

    Änderungsjahr Hebesatz B Aufkommen im Jahr im Jahr danach
    2002 360 → 410 +14,55 % +1,71 %
    2011 410 → 430 +9,31 % −0,64 %
    2015 430 → 445 +8,48 % +0,19 %

    Und die Gegenprobe: Unterstellt man die Änderung ein Jahr später, reißt die Rechnung in allen drei Fällen (+1,71 gegen +1,73 · −0,64 gegen +0,56 · +0,19 gegen +0,81). Beide Richtungen des Versatzes sind damit ausgeschlossen, nicht bloß unplausibel.

    Drei der acht Änderungen sind so prüfbar. Die fünf von 1984 bis 1997 liegen vor dem Beginn der Aufkommensreihe (1998), und 2025 ist es nicht — dort hat die Grundsteuerreform die Bemessungsgrundlage mitverändert (BEMESSUNG_NEU). Das steht als Ausnahme im Code, damit der Lauf nicht in dem Moment bricht, in dem 2026 in der Ist-Reihe steht.

2025 stieg der Grundsteuer-B-Hebesatz von 445 auf 539 (+21 %). „Grundsteuer +21 %“ allein wäre falsch verstanden: Das Aufkommen sank im selben Jahr von 34,17 auf 32,59 Mio. € (−4,6 %), weil die Reform gleichzeitig alle Messbeträge umstellte. Ein höherer Satz auf eine kleinere Grundlage ist nicht mehr Geld.

Deshalb liefert der Endpunkt bemessung_neu mit, und die Seite stellt zu jeder Änderung das Aufkommen desselben Jahres daneben — nicht als Fußnote, sondern in derselben Zeile. Wo es keines gibt (vor 1998), steht das da.

Jede Zeile der Tabellen oben trägt eine herkunft_id. Sie zeigt auf council_provenance — einen Datensatz je Dokument-und-Abschnitt mit:

Feld Was drinsteht Beispiel
art ris · opendata · stadt · lsn ris
dokument_id council_attachments.document_id — der stabile Anker 280863
label / url wie das Dokument heißt und wo es liegt „Jahresabschluss 2024 …“
fundstelle wo im Dokument gelesen wurde „Abschnitt 6.3.1 — Erläuterungen …“
seite Seitenzahl, wo das Dokument eine trägt 161
probe die bestandene(n) Rechenprobe(n) strukturprobe,vorjahreskette
probe_ergebnis ihr Messwert „0.00 % Abweichung zur Gesamtrechnung“
stand Stichtag des Inhalts, nicht des Abrufs „Jahresabschluss 2024“
fetched_at zuletzt bestätigt (wandert nur vorwärts)

Warum eine eigene Tabelle statt Spalten je Zieltabelle — die Begründung steht ausführlich im Modulkopf von council/herkunft.py, kurz:

  1. Eine Zieltabelle trägt Zeilen aus mehreren Dokumenten. Bei den Beteiligungen ist das der Normalfall: dieselbe Kennzahl im Konzernabschluss, im Einzelabschluss der Gesellschaft und im Beteiligungsbericht, mit verschiedenen Stichtagen und Konsolidierungsstufen. Verschiedene Dokumente heißen automatisch verschiedene Herkunfts-Datensätze — ohne dass eine Tabelle dafür etwas wissen muss.
  2. Ein neues Herkunftsfeld darf nicht zwölf ALTER TABLE kosten. So viele Tabellen stehen inzwischen in HERKUNFT_TABELLEN, und es werden mehr.
  3. Wiederholung. Ein Jahresabschluss-Jahrgang schreibt rund 200 Zeilen aus demselben Abschnitt hinter derselben Probe.

Die alten Spalten (quelle_label, quelle_url, source_url) bleiben und werden weiter aus derselben Angabe gefüllt. Sie zu entfernen hieße, neun der zwölf Tabellen neu zu schreiben, darunter vier, deren Inhalt nur über einen Download von oldenburg.de wiederzubeschaffen wäre — kosmetischer Gewinn, echtes Risiko. Die drei jüngsten (beide Konzern-Tabellen und der Städtevergleich) sind erst mit der Herkunft entstanden und tragen gar keine Altspalten; im Nachrüst-Weg stehen sie trotzdem, weil ein Eintrag „nichts nachzutragen“ billiger ist als eine Ausnahme (_HERKUNFT_ALTFELDER).

GET /api/council/budget liefert die Datensätze als provenance, nach ID nachschlagbar, samt eines Erklärsatzes je Probe für die Oberfläche.

Das Quellenverzeichnis am Fuß jeder Haushalts-Seite beschreibt eine Quelle über alle Jahrgänge hinweg („Die Jahresabschlüsse 2017–2024“). Das ist Absicht — es ist eine redaktionelle Zusammenfassung, keine Angabe, die aus einer Zeile fällt. Genau daran scheiterte aber sein Link: Eine Adresse, die für acht Jahrgänge zugleich stimmt, ist bei sechs Quellen die Startseite des Ratsinformationssystems gewesen. Wer dort landete, durfte selbst suchen.

GET /api/council/budget/documents liefert die fehlende Ebene: je Quellenschlüssel eine Liste {jahr, url, label, fundstelle, seite}. Die Zuordnung Quelle → Tabelle steht in CouncilStore._DOKUMENT_QUELLEN; sie ist die einzige Stelle, an der Backend-Code die Schlüssel des Frontend- Verzeichnisses kennt, und das mit Grund: Welche Zeile aus welchem Dokument stammt, weiß nur die Datenbank.

Drei Regeln, die die Oberfläche daraus ableitet (web/frontend/lib/haushalt-dokumente.ts):

  1. Der Link folgt dem gezeigten Jahr. Wechselt der Jahr-Umschalter, führt derselbe Beleg auf ein anderes PDF.
  2. Kein Jahrgang wird verschwiegen. Hat die Seite kein Jahr, oder liegt für ihres kein Dokument vor, nimmt sie das jüngste und schreibt den Jahrgang an („Jahrgang 2024“).
  3. Kein Link verspricht mehr, als er hält. Fehlt ein Dokument, bleibt die statische Adresse — aber der Linktext wird aus ihr abgeleitet und heißt dann „Im Ratsinformationssystem suchen“ statt „Dokument öffnen“.

Ein Jahrgang darf mehrere Dokumente tragen: Die Produktebene verteilt sich auf zwölf bis dreizehn Teilhaushalts-Anlagen. Die API nennt alle; das Verzeichnis listet sie, der Beleg-Chip verweist auf die Langfassung.

Neben den Rechtsgrundlagen der Steuer-Steckbriefe steht seit 08/2026 ein zweiter Chip: eine Waage statt einer Ziffer. Ein Klick erklärt die Stelle in zwei Sätzen und führt zum amtlichen Volltext — Bundesrecht beim Bundesamt für Justiz, Landesrecht im niedersächsischen Vorschrifteninformationssystem (VORIS). Register: web/frontend/lib/gesetze.ts, Chip: components/haushalt/gesetz.tsx.

Warum er nicht mitgezählt wird. Der Beleg-Apparat beantwortet genau eine Frage: „Woher kommt diese Zahl?“ Jede Ziffer im Verzeichnis zeigt auf ein Papier, aus dem wir gelesen haben. Aus einem Gesetz haben wir keine Zahl gelesen — es sagt, warum es die Zahl überhaupt gibt. Beides in eine Nummernfolge zu werfen hieße, das Verzeichnis um eine zweite Bedeutung zu erweitern, die niemand ansagt. Deshalb: dasselbe Fähnchen (useFaehnchenLage ist aus quelle.tsx exportiert, nicht nachgebaut), dieselbe Bedienung, anderes Zeichen, außerhalb der Nummerierung.

Im Fähnchen steht außerdem, ob Bund oder Land die Vorschrift gemacht hat. Das ist keine Deko, sondern die Anschlussfrage: Wer könnte das ändern? Beim Hebesatz ist die Antwort „der Rat“, beim Steuergeheimnis „der Bundestag“ — dazwischen liegt der Unterschied zwischen einer politischen und einer rechtlichen Grenze.

Stufen ohne einzelne Vorschrift tragen keinen Chip. „Der Rat beschließt die Satzung und die Sätze“ beruht auf keiner Norm, die man aufschlagen könnte; ein Link auf irgendein nahes Gesetz wäre schlechter als keiner.

Drei Schritte, mehr nicht:

  1. Eine Herkunft bauen. art und probe sind Pflicht — eine Herkunft ohne Probe lässt sich nicht konstruieren (ValueError). Trägt die Quelle wirklich keine Rechenprobe, ist das ausdrücklich zu sagen: probe=herkunft.UNGEPRUEFT. Dazu mindestens ein Verweis (dokument_id oder url), und so viel von fundstelle, seite, probe_ergebnis, stand, wie das Dokument hergibt. Leer ist erlaubt, geraten nicht.

  2. Sie an die save_*-Methode geben — sie steht dort, wo früher label, url bzw. source_url standen. Der Store trägt sie ein (merke_herkunft, idempotent über einen Fingerabdruck der Inhaltsfelder) und verknüpft die Zeilen.

  3. Die Zieltabelle in herkunft.HERKUNFT_TABELLEN eintragen. Damit bekommt sie ihre herkunft_id-Spalte beim nächsten Öffnen und wird beim Nachrüsten aus den Altfeldern mitversorgt.

    Geprüft und aufgeräumt wird aber nicht nach dieser Liste, sondern nach dem Schema (store._herkunft_verweistabellen() sucht jede Tabelle mit einer herkunft_id-Spalte). Der Grund ist genau dieser Schritt 3: Er ist der, den man vergisst — und wer seine Tabelle mit herkunft_id schon im CREATE TABLE anlegt (so die neueren), merkt davon beim Anlegen nichts. Ginge das Aufräumen nach der Liste, hätte eine vergessene Tabelle aus dessen Sicht keine Verweise: Ihre Herkünfte gälten als verwaist und fielen weg, während ihre Zeilen weiter auf deren Nummern zeigen. Weil die Nummern neu vergeben werden, zeigte so eine Zeile am Ende nicht ins Leere, sondern auf ein fremdes Dokument — und herkunft_luecken() schwiege dazu, weil auch sie nur die Liste durchginge.

    So gemeldet wird jede Zeile ohne Herkunft, auch aus einer Tabelle, die die Liste nicht kennt. Die Ingest-Skripte geben das nach jedem Lauf aus; leer ist der Sollzustand.

Eine neue Rechenprobe braucht einen Eintrag in herkunft.PROBEN — Name plus einen Satz für Leserinnen, denn der Satz landet über die API im Beleg und beantwortet dort „warum soll ich das glauben?“. Ein unbekannter Probenname fliegt beim Bauen der Herkunft auf, nicht erst in der Datenbank.

Zweiunddreißig Datenschichten, jede einmal von Hand eingelesen — ohne Cron veraltet der ganze Bereich still, sobald niemand mehr daran denkt. check_finanzdaten.py (sonntags) nimmt das ab: Neun liest er selbst nach (sie liegen als Anlage im Ratsinformationssystem), die dreiundzwanzig übrigen werden nur beobachtet — er meldet, dass ein Jahrgang fällig wäre, und nennt Quelle und Skript. Elf davon kommen von außerhalb, zwölf liegen zwar im Ratsinformationssystem, haben aber eigene Einlese-Skripte. „Lädt nichts herunter“ ist die Regel, an der dieser Job hängt. Maßgeblich ist finanzquellen.REIHENFOLGE; diese Doku zählt nach, sie legt nichts fest.

Bestandsgesteuert, nicht kalendergesteuert. Der Job fragt nicht „ist es September?“, sondern „welche Einheit fehlt mir, und liegt inzwischen ein Dokument dafür vor?”. Ein Job, der im September nach dem Jahresabschluss sucht, bricht in dem Jahr, in dem die Stadt später dran ist. So ist der Takt egal: Verspätungen, Nachtragshaushalte und nachgereichte Prüfberichte sind automatisch abgedeckt, und der Job darf beliebig oft laufen.

Aus acht Jahrgängen Sitzungsdaten (council_sessions.session_date über council_agenda_items) ergibt sich der Rhythmus der Stadt:

Was Wann im Rat Versatz zum Jahrgang Ausnahmen
Jahresabschluss + RPA-Schlussbericht + Rechenschaftsbericht Anfang September + 1 Jahr 1× August
Haushaltsplan mit Gesamtergebnishaushalt, Teilhaushalten und Stellenplan Anfang Oktober − 1 Jahr 1× November
Konsolidierter Gesamtabschluss (Prüfbericht des RPA) Februar + 2 Jahre Juni bis Februar

Der dritte Takt kam mit dem Konzern-Bereich dazu und ist der langsamste: Ein Gesamtabschluss entsteht erst, wenn alle einbezogenen Betriebe geprüft sind, und liegt damit rund zwei Jahre hinter seinem Haushaltsjahr. Deshalb steht auf /haushalt der Plan für das kommende Jahr und auf /haushalt/konzern eine Rechnung von vorgestern — beides richtig, beides erklärt der Datenstand-Block.

Der Monat steuert nicht die Suche, sondern nur die Meldung: Bleibt ein Jahrgang länger als vier Wochen über seinen üblichen Monat hinaus aus, geht ein Hinweis an ALERT_EMAIL — kein Fehler, sondern die Frage, ob die Stadt spät dran ist oder ein Erkennungsmuster nicht mehr greift. Die Mail unterscheidet beides: Liegt ein passendes Dokument vor und wird trotzdem nichts übernommen, steht das ausdrücklich drin. Gemeldet wird nur, wenn sich die Liste gegenüber dem letzten Lauf geändert hat (Vergleich über job_runs) — alle vierzehn Tage dieselbe Mail wäre eine, die niemand mehr liest.

Dieselbe Mail trägt seit dem 20.08.2026 einen dritten Block: Zeilen, die dastehen, ohne zu sagen, woher sie kommen (store.herkunft_luecken()). Der Befund wurde vorher nur ins Cron-Log geschrieben und als Kennzahl ins Admin-Panel gereicht — er stand nicht in ausbleibend und löste deshalb nie eine Mail aus. Das war die stillste der drei Lagen: Ein fehlender Jahrgang fällt in jeder Jahresliste auf, eine Zahl ohne Beleg nicht — der Jahrgang steht da, die Zahl steht da, und erst wer auf den Herkunfts-Chip tippt, merkt etwas.

Wichtig für die Einschätzung, wann dieser Block überhaupt erscheint: Bei den neun Schichten, die der Job selbst einliest, heilt er eine solche Lücke beim nächsten Lauf — die Einheit gilt als offen und wird samt frischer Herkunft neu geschrieben. Liegen bleibt sie nur dort, wo niemand automatisch nachzieht, also in den sechs Schichten von außerhalb. Der Wiederholungs-Schlüssel führt die Zahl mit (herkunft:<tabelle>:<n>): Wächst die Lücke von einer Zeile auf dreihundert, ist das eine neue Nachricht und keine Wiederholung.

  1. Er lädt nichts herunter. Die Anlagen kommen über check_protocols.py ins System; der Job liest nur aus, was schon in council_attachments liegt. Zwei Wege zu denselben Daten wären einer zu viel. Deshalb deckt er council_budget und die Open-Data-Schichten nicht ab — ihr Ausbleiben meldet er trotzdem, damit ingest_haushalt.py nicht vergessen wird.
  2. Er senkt keine Prüfschwelle. Summenprobe, Strukturprobe, Vorjahres-Kette und die Rechenprobe der Erläuterungen gelten unverändert. Was sie reißt, kommt nicht in die Datenbank, wird gezählt und gemeldet. Ein unbeaufsichtigter Lauf ist der Grund, warum es diese Proben gibt — nicht der Anlass, sie zu lockern.
  3. Er ergänzt nur, was fehlt — Einheit für Einheit. Eine vorhandene Einheit wird nicht angefasst. Zweimal hintereinander laufen ändert beim zweiten Mal keine einzige Zeile.

Und was ein Jahrgang bekommt, bekommt er in einer Transaktion (store.transaktion(), verschachtelbar). Ohne die Klammer braucht ein Jahresabschluss 1 + n + 1 Transaktionen — Gesamtrechnung, je Teilhaushalt eine, Erläuterungen. Ein Abbruch dazwischen ließe den Jahrgang halb in der Datenbank, und halb sieht für den nächsten Lauf aus wie fertig.

Woran ein Dokument erkannt wird — Label-Muster, Mindestseitenzahl, Ausschlüsse, Parser, Zieltabelle, erwarteter Monat — steht in council/finanzquellen.py, je Datenart einmal. Die Ingest-Skripte und der Cron benutzen dieselbe Definition; auf die Frage „ist das ein Jahresabschluss?“ gibt es sonst zwei Antworten, und eine davon veraltet still.

Datenart Erkennung Zieltabelle Erwartet
Jahresabschluss Label %Jahresabschluss%, > 100 Seiten, ohne %Rechenschaft% / %Schlussbericht% council_income_statement (+ council_variance_reasons) September, Jahrgang + 1
Schlussbericht des RPA (Fundstelle) Label %chlussbericht% oder Text beginnt mit Schlussbericht; entschieden am Textanfang council_audit_report_sources September, Jahrgang + 1
Prüfungsfeststellungen Text %Rechnungsprüfungsamtes%, > 30 Seiten; entschieden am Textanfang council_audit_reports September, Jahrgang + 1
Teilhaushalts-Pläne Label %THH%, > 25 Seiten; Jahrgang aus der dritten Spalte des Tabellenkopfs council_products Oktober, Jahrgang − 1
Gesamtergebnishaushalt Label %Gesamtergebnishaushalt%, > 10 Seiten; Jahrgang aus dem Tabellenkopf (vier der acht Dokumente tragen keine Jahreszahl im Label) council_income_budget Oktober, Jahrgang − 1
Stellenplan Label %Stellenplan%, > 10 Seiten, ohne %eändert%; Jahrgang aus dem Tabellenkopf (drei Schreibweisen im Label, eine mit zwei Jahreszahlen) council_staff_plan Oktober, Jahrgang − 1
Konsolidierter Gesamtabschluss nur Text (konzernabschluss.TEXT_MUSTER), > 40 Seiten — die Labels dieser Reihe sind wertlos council_group_items (+ council_group_entities) Februar, Jahrgang + 2
Haushaltsvollzug Label %Leistungsbericht% oder %FLB%; entschieden wird am Dokument — wer die stadtweite Übersichtstabelle nicht führt, fällt durch (Eigenbetriebe, Fachausschüsse) council_budget_execution April, Jahrgang + 1 (der Bericht zum 31.12. ist der späteste eines Jahrgangs)
Haushaltsplan (kein Anlagen-Muster — Download) council_budget Oktober, Jahrgang − 1
Steuerkraft im Städtevergleich (kein Anlagen-Muster — Download beim LSN) council_city_comparison, Reihe steuerkraft April, Jahrgang + 0
Realsteuervergleich (Hebesätze, Steuereinnahmekraft) (kein Anlagen-Muster — Download beim LSN) council_city_comparison, Reihe realsteuern November, Jahrgang + 1

Der Städtevergleich (council_city_comparison) steht nicht in dieser Tabelle: Seine Quellen sind Tabellenmappen des Landesamts, keine Anlagen im Ratsinformationssystem, und sie erscheinen einmal jährlich. Er hat deshalb weder Erkennung noch Cron — und taucht folgerichtig auch im Datenstand-Block nicht auf.

GET /api/council/budget/data-status liefert diese Matrix live aus dem Bestand; der Block „Bis wann die Zahlen reichen“ am Fuß von /haushalt (components/haushalt/datenstand.tsx) zeigt sie.

Das ist kein Entwickler-Feature. Auf /haushalt steht der Plan für 2026, auf /haushalt/plan-ist die Abrechnung für 2024, auf /haushalt/pruefung Feststellungen bis 2023, auf /haushalt/konzern eine Rechnung bis 2024 — die Frage „warum steht hier 2024 und nicht 2025?“ müsste sonst auf jeder der zwölf Unterseiten einzeln beantwortet werden. Die Ursache ist immer dieselbe und liegt bei der Stadt. Wo ein Jahrgang erwartet wird, aber noch fehlt, steht das ausdrücklich da: „Der Jahrgang 2025 wird üblicherweise im September 2026 vorgelegt.“ Das Wort „fehlt” kommt nicht vor — was die Stadt noch nicht veröffentlicht hat, fehlt uns nicht.

Ein halber Jahrgang gibt sich zu erkennen. Sonst stünde er in derselben Jahresspanne wie ein vollständiger und sähe aus wie einer: „Für 2019 haben wir 12 von 13 Teilhaushalten.“ / „Für 2024 fehlt noch die Aufteilung auf die einzelnen Bereiche.” Der Maßstab ist der bestbelegte Jahrgang desselben Bestands — mehr wissen wir nicht, und weniger zu behaupten wäre falsche Bescheidenheit.

Auch die Fußzeile verspricht nur, was sie halten kann. „Wir tragen neue Jahrgänge automatisch nach“ galt pauschal für die ganze Liste — deren erste und prominenteste Zeile aber der Haushaltsplan ist, den der Cron gar nicht anfasst. Jetzt nennt sie die Schichten, die nicht automatisch nachkommen, beim Namen, und zieht die Liste aus den Daten: automatisch === false.

Auch die Stelle dahinter kommt aus den Daten. Der Satz endete bis 08/2026 auf „— die Zahlen dafür holen wir vom Portal der Stadt“, was stimmte, solange der Haushaltsplan die einzige Schicht von Hand war. Mit dem Städtevergleich kamen zwei Reihen einer Landesbehörde dazu; der feste Satz hätte das Landesamt für Statistik zur Stadtverwaltung erklärt. Die Fußzeile gruppiert die Schichten deshalb nach ihrer Quelle (quelle, aus finanzquellen.STELLEN) und nennt sie in Klammern. Dieselbe Falle steckte in der Cron-Meldung, die pauschal zu scripts/ingest_haushalt.py schickte — welches Skript zuständig ist, steht jetzt bei der Schicht (Finanzquelle.nachschub).

Und sonst steht dort nichts mehr. Zwei Sätze sind am 16.08. aus der Fußzeile gefallen, beide über unseren Betriebsablauf statt über den Datenstand: der Takt, in dem der Cron nachsieht („geprüft wird alle zwei Wochen“ — er steht in finanzquellen.REIHENFOLGE und weiter oben in diesem Kapitel), und die Rechenprobe als Türsteher („Zahlen, die eine Rechenprobe des Dokuments nicht bestehen, bleiben draußen“ — Kapitel „Vier Prüfungen“). Beides läuft unverändert weiter. Für die Frage, die dieser Block beantwortet — bis wann reichen die Zahlen? — war es keine Antwort: Wo ein Jahrgang wirklich fehlt, sagt das die Zeile darüber aus luecken, am richtigen Ort und ohne Prüfzeugnis (DESIGNSPRACHE.md § 7).

Der Städtevergleich ist überhaupt der Fall, für den der Ausblick gebaut ist: Es gibt kein Dokument im Ratsinformationssystem, an dem ein Cron merken könnte, dass ein Jahrgang vorliegt, und geholt wird nur einmal im Jahr von Hand. An eine Handreichung, die zwölf Monate zurückliegt, erinnert sich niemand von selbst — die Meldung „Der Jahrgang 2026 wäre seit November 2027 zu erwarten“ ist der einzige Wecker, den diese Schicht hat.

Fünf Dinge liefert keine Datenquelle. Sie stehen als gepflegte Konstanten im Frontend, damit sie überprüfbar bleiben:

  • lib/haushalt-bereiche.ts — das Bereichs-Wörterbuch: je Teilhaushalt ein kanonischer Schlüssel, die Alias-Liste jeder im Bestand vorkommenden Schreibweise (gegen council_budget, council_income_statement und council_products geprüft), ein Kurzname fürs Balkensegment und die eine Zeile Klartext, die /haushalt/bereiche trägt. Der Grund ist eine Wartungsfalle: Die Stadt benennt Teilhaushalte um, ohne den Zuschnitt zu ändern — Teilhaushalt 9 hat vier Schreibweisen in sieben Jahrgängen. Jede Map auf den exakten Namen verliert beim nächsten Jahrgang stillschweigend Zeilen. bereichKanon() gibt deshalb immer etwas zurück; ein unbekannter Name fällt auf sich selbst zurück (bekannt: false), statt zu verschwinden.
  • lib/haushalt-steuern.ts — je Einnahmeart: die Stufen „Wer entscheidet was“, Spielraum-Einstufung, Rechenbeispiel, Lotti-Erklärung.
  • lib/haushalt-pflicht.ts — Einordnung der Teilhaushalte in Pflicht / Pflicht mit Spielraum / überwiegend freiwillig. Eine Einschätzung auf Ebene ganzer Teilhaushalte; die Seite sagt das auch — und hält sie seit 08/2026 gegen die Selbstauskunft der Stadt (s. u.).
  • lib/haushalt-vergleich.ts — was auf /haushalt/vergleich nicht aus der Landesstatistik kommt: die Ausgliederungs-Übersicht der sieben Städte aus Vorlage 18/0911 und das wörtliche Zitat der Verwaltung dazu.
  • lib/haushalt-quellen.ts — Fundstellen, Datenstände und Lizenzen.

Nicht redaktionell, aber leicht damit zu verwechseln: lib/haushalt-jahr.ts, lib/haushalt-konzern.ts und lib/haushalt-pruefung.ts halten Rechenwege zu ihren Endpunkten, keine gepflegten Inhalte. Sie liegen im Frontend, weil es Aussagen über die Jahrgänge sind („der Entwurf kam siebenmal im Oktober“) und sich mitverändern müssen, wenn ein Jahrgang dazukommt — genau das, was die Regel „keine jahresabhängige Rechenaussage als fester Text“ verlangt.

Jede Zahl trägt einen Beleg-Chip, am Seitenende steht das Verzeichnis mit Dokument, Fundstelle, Stand, Lizenz und Direktlink. Die Nummerierung läuft seitenweise über <Quellenkontext> — global gezählt trüge eine Seite mit zwei Quellen die Nummern 2 und 4.

Werte, die wir selbst bilden (Anteile, Differenzen, Rücklagen-Reichweite, Pro-Kopf-Angaben, Ein-Punkt-Überschlag), sind an Ort und Stelle als „unsere Rechnung, keine amtliche Kennzahl“ gekennzeichnet.

lib/haushalt-quellen.ts fasst die Quelle einer ganzen Seite in einem Absatz zusammen. Je einzelner Datenzeile weiß es die Datenbank genauer: council_provenance (siehe oben) führt Fundstelle, Probe und Anker je Dokument-und-Abschnitt.

Jahresabschlüsse und Produktebene aus dem eigenen Bestand

Abschnitt betitelt „Jahresabschlüsse und Produktebene aus dem eigenen Bestand“

Beide Dokumenttypen mussten nirgends beschafft werden: Sie hängen als Anlagen an Ratsvorlagen und liegen mit Volltext in council_attachments.

Jahresabschluss (300+ Seiten je Jahrgang) → die Ergebnisrechnung der Kernverwaltung führt Ansatz und Ergebnis nebeneinander. Damit gibt es „geplant gegen tatsächlich“ und die Aufschlüsselung der Erträge nach Arten (Steuern, Zuwendungen, Entgelte, Kostenerstattungen). Eingelesen sind alle acht Jahrgänge 2017–2024, je mit zwölf Teilhaushalten.

Dass 2017, 2018 und 2020 lange fehlten, lag nie am Text, sondern an drei verschiedenen Spaltenlayouts: 2017 steht das Ergebnis vor dem Ansatz, 2018 hat elf Spalten mit sechs möglichen Leerfeldern, 2019–2024 tragen eine meist leere Nachtragsspalte. _spalten_zuordnen() liest die Anordnung deshalb aus dem Tabellenkopf statt aus einer festen Reihenfolge. Zwei Kleinigkeiten kamen dazu: 2017 schreibt die Summenzeilen als „12.= Summe“ ohne Leerzeichen, und im Abschluss 2022 fällt bei THH09 ein Zeilenumbruch mitten in die Überschrift („A. Teil\n-Ergebnisrechnung THH09“) — ohne ihn im Muster fand der Parser dort nur die Fortsetzungsseite und verwarf über die Summenprobe die ganze Teilhaushalts-Ebene.

Abschnitt 6.3.1 des Jahresabschlusses begründet jede Abweichung ab 20 % gegenüber dem Plan, je Posten und in den Worten der Verwaltung — etwa, dass die Mehrerträge 2024 „nahezu auf den Bereich der Gewerbesteuer“ entfallen und „unter anderem aus einem Einmaleffekt“ stammen. Der Abschnitt existiert in allen acht Jahrgängen (45 Posten, ~37.000 Zeichen).

Die Eintrittskarte ist hart: Die Überschrift nennt die Abweichung doppelt, als Betrag und als Prozentsatz („+75,1 Millionen Euro, +24,82 %“). Beides muss zu der Zeile passen, die der Tabellen-Parser für denselben Posten gelesen hat (pruefe_abweichungsgruende) — damit prüft sich das Dokument an einer zweiten Stelle selbst. 45 von 45 bestehen; was nicht passt, wird verworfen statt angezeigt. Angezeigt wird der Wortlaut, nicht eine Zusammenfassung: nur Silbentrennung am Zeilenende und eingestreute Seitenfüße („JA 161”) werden entfernt.

Schlussberichte des Rechnungsprüfungsamts werden nur verlinkt, nicht ausgewertet — sie sagen selbst, dass die Plan-Ist-Abweichungen „im Anhang und im Rechenschaftsbericht erläutert“ werden. Erkannt werden sie an der Eingangsformel, nicht am Label („Schlussbericht JA 2017“ ist der Bericht zum Eigenbetrieb Gebäudewirtschaft, weitere Treffer betreffen Stiftungen). Achtung: Der Volltext ist umbrochen, ein LIKE auf die Formel findet nichts — pruefbericht_aus_anlage() normalisiert vorher. Der Jahrgang 2024 ist ein kaputter Textextrakt (Glyphen-Indizes statt Zeichen, Buchstabenanteil 0,00 gegen 0,71–0,76 bei den übrigen); er wird als lesbar = 0 gespeichert und auf der Seite so benannt, statt überspielt zu werden.

Teilhaushalts-Pläne (THH01–13) → die Produktebene: was einzelne Aufgaben kosten, mit Produktnummer und zuständigem Amt (2023 etwa „Kindertagesbetreuung“ mit 71,1 Mio. € Aufwand und 58,6 Mio. € Zuschussbedarf). Die Abdeckung ist unvollständig — für 2023 erklären die gefundenen Produkte rund 82 % der geplanten Aufwendungen. Der Endpunkt liefert diese Quote als coverage_percent mit, damit die Oberfläche die Liste nicht als Vollbild ausgeben kann.

Die Kassensicht: Abschnitt 4.1 desselben Dokuments

Abschnitt betitelt „Die Kassensicht: Abschnitt 4.1 desselben Dokuments“

Die Ergebnisrechnung bucht. Für 2024 weist sie ein Jahresergebnis von +6,1 Mio. € aus. Dreißig Seiten weiter, in Abschnitt 4.1 desselben Berichts, steht die Finanzrechnung der Kernverwaltung — und die zahlt: Am Jahresende lagen 118,0 Mio. € in der Kasse, am Jahresanfang 143,1 Mio. €. Beides stimmt. Abschreibungen mindern das Ergebnis, ohne dass jemand etwas überweist; ein Neubau kostet sofort Geld, im Ergebnis aber erst über die Jahre. Wer nur die erste Zahl sieht, bekommt einen falschen Eindruck — und für einen Bereich, dessen Anspruch Ehrlichkeit ist, war das die unangenehmste Lücke. Eingelesen sind alle acht Jahrgänge 2017–2024 (council_cash_flow_statement, Parser parse_finanzrechnung).

Die Tabelle hat dieselbe Grammatik wie die Ergebnisrechnung, also liest sie derselbe Spaltenapparat (_tabellenkopf, _fenster, _spalten_zuordnen). Vier Dinge sind anders, und jedes davon ist beim Bauen aufgelaufen:

1. Die Postennummern verschieben sich. 2017–2020 hat die Tabelle 42 Zeilen, 2021–2024 nur 41: Die Einzahlungsart „Veräußerung geringwertiger Vermögensgegenstände“ fällt weg, und alles ab Posten 08 rutscht um eins. Der Finanzmittelsaldo ist 2019 die Zeile 33 und 2024 die Zeile 32. Ein fester Nummern-Katalog wie ERGEBNIS_POSTEN ginge für die Hälfte der Jahrgänge daneben. Deshalb kommt die Bezeichnung aus dem Dokument und die Bedeutung aus finanzberichte.ROLLEN — das Dokument benennt seine Zeilen selbst („= Summe der Auszahlungen aus Investitionstätigkeit“). Frontend und Proben hängen an der Rolle, nie an der Nummer.

2. Die Zeilen verweisen aufeinander. „18. Saldo aus laufender Verwaltungstätigkeit (Zeile 10 abzüglich Zeile 17)“ — ein Zeilensplit an zweistelligen Zahlen schneidet mitten in diesen Verweis und lässt die Zahlenkolonne dahinter liegen. Ohne _VERWEIS kamen die Posten 18, 33, 36, 37 und 40 in keinem Jahrgang an.

3. Der Seitenfuß „JA 29“ sieht aus wie ein Posten. Er steht am Ende der ersten Tabellenseite, also vor dem echten Posten 29 auf der zweiten. Wer ihn stehen lässt, verliert „29. Sonstige Investitionstätigkeit“ (2024: 27,6 Mio. €) und reißt damit die Summenprobe.

4. Es gibt eine Spalte mehr: die Ermächtigungen aus Haushaltsvorjahren. 2024 stehen dort 58,8 Mio. € neben 96,4 Mio. € tatsächlichen Investitions-Auszahlungen — bewilligtes Geld für Vorhaben, die noch nicht fertig sind. Das ist die Antwort auf „warum wird das Geplante nicht gebaut?“. Gelesen wird sie nur, wenn der Tabellenkopf sie hinter dem Ergebnis führt: 2018 steht sie als sechste von elf Spalten davor, und hinter der Abweichung steht dort „Zu Spalte 5: Davon bisher nicht bewilligte …” mit 0,00 € in jeder Zeile — ungeprüft hätte der Jahrgang 2018 lauter Ermächtigungen von 0,00 € getragen.

Das Dokument rechnet sich selbst vor, und jede Stufe hängt an der vorigen (finanzprobe). Was welche Probe kostet, ist bewusst abgestuft:

Probe Was sie prüft Reißt sie, …
Summenzeilen (4×) Einzahlungs- und Auszahlungsarten ergeben ihre Summe — je Block, im Ist und im Ansatz … fällt der ganze Jahrgang
Salden (3×) Einzahlungen − Auszahlungen = Saldo, und beide Salden = Finanzmittelsaldo … fällt der ganze Jahrgang
Ermächtigungen dieselbe Blocksumme für die übertragenen Beträge … fällt die Spalte
Tilgungskette Finanzmittelsaldo + Saldo Finanzierung = Finanzmittelveränderung … fallen diese zwei Zeilen
Bestandskette Anfangsbestand + Veränderung + haushaltsunwirksam = Endbestand … fallen die drei Bestandszeilen
Kassen-Kette Endbestand steht im Folgejahrgang als Anfangsbestand … fällt die Finanzrechnung beider Jahrgänge

Eine fehlende Einzelzeile zählt in den Summen als Null. Das ist kein Loch im Beweis, sondern der Beweis: Fehlt sie, weil das Dokument sie leer lässt („12. Versorgungsauszahlungen“ trägt 2024 nur den Vorjahreswert), geht die Summe auf — fehlt sie, weil wir sie falsch gelesen haben, geht sie nicht auf.

Die Abstufung ist der Grund, warum trotz zweier echter Quellendefekte alle acht Jahrgänge im Bestand sind: 2022 verliert nur seine Ermächtigungsspalte, weil der PDF-Extrakt dort Leerzeichen mitten in die Beträge setzt („3.912. 463,20“), und die optionalen Bestandszeilen sind laut Fußnote des Dokuments ohnehin freiwillig („Die Zeilen 37 bis 41 können optional ergänzt werden“).

Was der Parser nicht tut: aus Jahresergebnis und Kassenveränderung eine Differenz bilden. Diese Zahl steht in keiner Quelle und hieße nichts — dieselbe Regel, an der der „Kostendeckungsgrad“ gescheitert ist.

Die Vermögensseite: Abschnitt 2.1 desselben Dokuments

Abschnitt betitelt „Die Vermögensseite: Abschnitt 2.1 desselben Dokuments“

Ergebnis- und Finanzrechnung zählen ein Jahr. Die Bilanz zählt einen Stichtag: was die Stadt am 31. Dezember hat, und wem es zusteht. Sie ist die Antwort auf die naheliegendste Anschlussfrage der Schuldenseite — „Oldenburg hat kaum Kredite, also keine Schulden?“. Zum 31.12.2024 stehen 43,69 Mio. € Kredite bei Banken neben 311,79 Mio. € Zusagen für Pensionen und Beihilfe. Eingelesen sind neun Stichtage 2016–2024 (council_balance_sheet, Parser parse_bilanz in council/bilanz.py); der älteste hat kein eigenes Dokument, er stammt aus der Vorjahresspalte des Abschlusses 2017 und wird nur übernommen, wenn diese Spalte für sich ausgeglichen ist.

Zwei Zahlen, die beide „die Pensionsrückstellungen“ heißen

Abschnitt betitelt „Zwei Zahlen, die beide „die Pensionsrückstellungen“ heißen“

Der Bilanzauszug 2024 schreibt untereinander:

3. Rückstellungen 329.095.270,90 337.210.902,05
3.1 Pensionsrückstellungen und
ähnliche Verpflichtungen 1) 290.925.292,00 311.789.660,00
3.1.1 Pensionsrückstellungen 249.721.281,00 266.259.316,00
3.1.2 Beihilferückstellungen 41.204.011,00 45.530.344,00

Beide Zahlen stimmen, sie messen nur Verschiedenes. 311,79 Mio. € ist die Oberposition 3.1 einschließlich der Beihilfe, 266,26 Mio. € die Pension allein (3.1.1); die Differenz ist Position 3.1.2 und geht in jedem Jahrgang auf den Cent auf. Der Rechenschaftsbericht bestätigt es in Worten („für die Beihilferückstellungen wurden 17,10 % der Pensionsrückstellungen angesetzt“ — 45.530.344 / 266.259.316 = 17,10 %). Wer eine der beiden Zahlen zeigt, muss sagen welche: Deshalb heißen die Rollen pensionen_gesamt und pensionsrueckstellungen und nicht beide „Pension“, und deshalb trägt jede Zeile ihren Wortlaut aus dem Dokument mit.

Das Layout wechselt zweimal, und nicht an derselben Stelle

Abschnitt betitelt „Das Layout wechselt zweimal, und nicht an derselben Stelle“

Naheliegend wäre „bis 2020 so, ab 2021 anders“. Am Bestand nachgesehen sind es zwei Änderungen in zwei verschiedenen Jahren:

Jahrgang Nummerierung Anordnung
2017–2019 römisch (I.–V.) erst der ganze Aktiva-Block, dann Passiva
2020 römisch (I.–V.) zweispaltig ineinander verschränkt
2021–2024 arabisch (1.–5.) zweispaltig ineinander verschränkt

2020 ist damit der Jahrgang, den eine Fallunterscheidung „römisch = Blocksatz“ falsch liest — und zwar lautlos, weil die Hälfte der Zeilen trotzdem ankommt. Verschränkt heißt: Beide Seiten teilen sich die Textzeile, und die rechte Spalte fängt mitten in der Zeile an, direkt hinter den Beträgen der linken:

1.2.3 Rücklagen aus Investitions-
zuwendungen für nicht abnutzbare
Vermögensgegenstände
4.372.861,06 4.439.504,15 2. Sachvermögen 1) 608.118.677,60 605.573.107,06
└─ hier beginnt die Aktivseite wieder

Ein Parser, der Zeilen an ^ trennt, verliert damit jeden zweiten Hauptposten. Die Positionserkennung verankert deshalb nicht am Zeilenanfang, sondern an „steht hinter Leerraum und vor einem Buchstaben“ — Beträge fangen nie mit einem Buchstaben an, Gliederungsnummern immer.

Und die Nummer ist als Schlüssel wertlos: „1.“ gibt es ab 2021 auf beiden Seiten (Aktiva: Immaterielles Vermögen, Passiva: Nettoposition), und bis 2020 war sie römisch. Erkannt werden die Zeilen deshalb wie in der Finanzrechnung am Namen, den das Dokument ihnen selbst gibt (bilanz.ROLLEN). Alle drei Layouts lesen sich damit ohne eine einzige Fallunterscheidung.

Fünf Proben, und die letzte ist die stärkste im Bereich

Abschnitt betitelt „Fünf Proben, und die letzte ist die stärkste im Bereich“
Probe Was sie prüft Reißt sie, …
bilanz_ausgleich Aktiva = Passiva, auf den Cent … fällt der ganze Stichtag
bilanzsumme_gedruckt die unter die Tabelle gedruckte Summe (nur 2017–2020) … fällt nur diese Probe
rueckstellungs_gliederung 3.1.1 + 3.1.2 = 3.1 … fällt nur diese Probe
bilanz_vorjahreskette jeder Hauptposten steht im Folgejahrgang noch einmal als Vorjahr … fällt die Bilanz beider Stichtage
bilanz_kassenprobe „Liquide Mittel“ = „Endbestand an Zahlungsmitteln“ der Finanzrechnung … fällt dieser Stichtag

Die Kreuzprobe ist die stärkste, die der Bereich hat: Beide Tabellen stehen im selben Heft, aber dreißig Seiten auseinander, in verschiedenen Layouts, und werden von zwei getrennt geschriebenen Parsern gelesen (council/finanzberichte.py und council/bilanz.py). Wenn beide dieselbe Zahl herausbekommen, hat sich keiner von beiden verlesen. Am Bestand: acht von acht Jahrgängen, jedes Mal auf den Cent (2024: 118.001.891,26 €). Die Vorjahres-Kette trägt sieben Übergänge à neun Pflichtposten ohne einen Riss.

Eine Fundstellen-Falle steckt schon in der Auswahl: Weiter hinten im selben Heft stehen die Bilanzen der neun nicht rechtsfähigen Stiftungen. Sie haben dieselbe Gliederung, dieselben Zeilennamen und gehen genauso auf — nur um Bilanzsummen von rund 300.000 € statt 1,48 Mrd. €. Ein Parser, der die erwischt, merkt es an keiner Rechenprobe. Genommen wird deshalb die Fundstelle mit den meisten Beträgen dahinter; das Inhaltsverzeichnis und die Stiftungen fallen beide über dieselbe Regel weg.

Der Anhang erläutert die Bilanz Position für Position: 6.2.1 Immaterielles Vermögen bis 6.2.9 Passive Rechnungsabgrenzung — genau die neun Hauptposten, in genau deren Reihenfolge. Ein Text lässt sich nicht nachrechnen, seine Zuordnung schon, und die ist hier auch das einzige Risiko: Eine Erläuterung unter der falschen Bilanzposition wäre eine Falschaussage, die keine Rechenprobe je bemerkte. erlaeuterungsprobe prüft deshalb, dass die Überschrift von 6.2.N auf das ROLLEN-Muster des N-ten Hauptpostens passt — mit demselben Muster, mit dem der Parser oben seine Bilanzzeile erkennt. Besteht sie nicht, wird kein Text gespeichert.

Was hier nicht gelesen wird: die über hundert Unterpositionen jenseits von ROLLEN (eine Zeile ohne Rollen-Marke ließe sich im Layout ab 2021 keiner Seite sicher zuordnen, und eine Bilanzzeile auf der falschen Seite wäre schlimmer als eine fehlende) und die Anlagen zum Anhang — Anlagen-, Forderungs-, Schulden- und Rückstellungsübersicht. Sie liegen jenseits der Textmenge, die scripts/backfill_anlagen_texte.py aus den PDFs übernimmt, und stehen in keinem Jahrgang im Volltext.

Zu jedem Produkt führen die Pläne einen Steckbrief: Kurzbeschreibung (was die Aufgabe umfasst), Auftragsgrundlage (die Gesetze, Satzungen und Verträge dahinter), Grad der Beeinflussbarkeit, Wirkungskreis und Zielgruppe. Das beantwortet die häufigste Bürgerfrage zum Haushalt — „was kostet eigentlich das Stadtarchiv?“ — und gibt der Pflicht/Kür-Einordnung auf /haushalt/pflicht einen Boden.

Der Bestand (584 Produkte, 2019–2026, gemessen am 02.09.2026): Wirkungskreis trägt 96,6 %, Auftragsgrundlage 96,4 %, Kurzbeschreibung, Beeinflussbarkeit und Zielgruppe je 95,2 %. Die Lücken sind echt und benennbar: 20 der 21 Stiftungs-Produkte (THH13) führen gar keinen Steckbrief — der Plan stellt sie nur mit ihrer Rechnung dar —, „Personalzuweisung an das Jobcenter“ (THH10) steht in allen acht Jahrgängen ohne Beschreibungstext, und einem Produkt aus THH03 fehlt die Auftragsgrundlage. Der Lauf von ingest_finanzberichte.py weist die Quote je Feld aus, die Seite nennt sie ebenfalls.

Und ein Feld fehlt dort absichtlich: Die nicht rechtsfähigen Stiftungen sind keinem Amt zugeordnet, im Plan steht unter ihrer Produktnummer direkt die Tabelle. office bleibt für sie leer, statt die Kopfzeile der Tabelle als zuständige Stelle auszugeben.

beeinflussbarkeit ist normalisiert (niedrig / mittel / hoch; die Pläne schreiben mal „niedrig“, mal „gering“, mal groß). Der Wortlaut bleibt in beeinflussbarkeit_roh erhalten — Mischformen wie „niedrig/mittel bei Gesundheitsförderung und Prävention“ bekommen keine Stufe zugewiesen, weil jede Wahl eine Behauptung wäre; die Seite zeigt dann den Rohwert.

Gesucht und gefiltert wird serverseitig (q, amt, spielraum am Endpunkt): Mit dem Steckbrief trägt jede Zeile mehrere hundert Zeichen Fließtext. nr holt zusätzlich ein einzelnes Produkt, damit der Steckbrief auch dann lädt, wenn ein Filter es aus der Liste nähme. facets liefert Ämter und Spielraum-Stufen mit Anzahl sowie die Abdeckung je Feld.

Verwaltungsdeutsch wird nicht ungefiltert durchgereicht: „übertragender Wirkungskreis“, „Grad der Beeinflussbarkeit“ und „Produkt“ stehen im Glossar (lib/glossary.ts), die drei Stufen werden in SPIELRAUM_TEXT zu Sätzen übersetzt. Eingefärbt wird nichts — ein teures Produkt ist keine schlechte Note, und „kaum Spielraum“ kein Missstand.

Planjahre: „Ansatz“ heißt fünfmal etwas anderes

Abschnitt betitelt „Planjahre: „Ansatz“ heißt fünfmal etwas anderes“

council_income_statement kann die Einnahmearten nur für abgeschlossene Jahre zeigen — 2025 und 2026 haben keinen Jahresabschluss. Die Zahlen stehen aber längst im Haushaltsplan selbst: Anlage 005, der Gesamtergebnishaushalt, führt dieselben Posten 01–24 auf 16 bis 18 Seiten. Acht Dokumente decken die Planjahre 2019–2026 ab (council/ergebnishaushalt.py).

Der Tabellenkopf sieht harmlos aus und ist die eigentliche Gefahr:

Ergebnis 2024 | Ansatz 2025 | Ansatz 2026 | Ansatz 2027 | Ansatz 2028 | Ansatz 2029

Fünfmal „Ansatz“ — beschlossen ist genau eins davon (hier 2026). 2027–2029 sind mittelfristige Finanzplanung nach § 8 NKomVG, 2025 ist der fortgeschriebene Vorjahresansatz. Gespeichert wird deshalb nur, was sich belegen lässt, und jede Zeile sagt, was sie ist:

Spalte Was sie ist Wird gespeichert?
1 — Ergebnis JJJJ Ist des Vorvorjahres, Gesamtebene (mit den nicht rechtsfähigen Stiftungen) nein — Gegenprobe
2 — Ansatz JJJJ+1 fortgeschriebener Vorjahresansatz nein (s. u.)
3 — Ansatz JJJJ+2 beschlossener Haushaltsansatz ja, art='ansatz'
4–6 mittelfristige Finanzplanung ja, art='finanzplanung'

Warum Spalte 2 draußen bleibt: Sie widerspricht dem, was der Plan des Vorjahres für dasselbe Jahr beschlossen hat — über sieben Jahrgangspaare stimmen nur 7 bis 11 von 23 Posten überein (Nachträge, Umschichtungen). Zwei Zeilen für dasselbe Jahr mit verschiedenen Beträgen und keine Regel, welche gilt: das wäre eine Lücke, die aussieht wie ein Bestand.

Warum plan_jahrgang im Schlüssel steht: Dasselbe Jahr kommt in mehreren Plänen vor — 2027 ist im Haushalt 2026 die erste Finanzplanungsstufe und im Haushalt 2027 der Ansatz. Und die Finanzplanung wird jedes Jahr neu geschrieben: Zwischen zwei aufeinanderfolgenden Plänen stimmen für dasselbe Finanzplanungsjahr 0 bis 2 von 23 Posten überein. Ohne plan_jahrgang überschriebe der jüngste Plan stumm den älteren.

Zwischen dem Plan für das kommende Jahr und dem Jahresabschluss, der zwei Jahre zurückliegt, klaffte bis 09/2026 das Jahr, um das es gerade geht. Die Stadt berichtet darüber vierteljährlich: § 31 der Niedersächsischen Kommunalhaushalts- und -kassenverordnung verpflichtet die Verwaltung, dem Ausschuss für Finanzen und Beteiligungen zu den Stichtagen 31.03., 30.06., 30.09. und 31.12. zu sagen, was sie bis zum Jahresende erwartet. Das Papier heißt seit 2018 Finanz- und Leistungsbericht (davor „Quartalsbericht“).

Gelesen werden daraus die beiden Übersichtstabellen unter „Auswertung der Berichte zum …“: 1. Ergebnishaushalt und 2. Finanzhaushalt, je dreizehn Teilhaushalte und eine gedruckte Summenzeile, mit Ansatz, Prognose zum 31. Dezember und der Abweichung dazwischen. Tabelle council_budget_execution, Ingest scripts/ingest_haushaltsvollzug.py, Endpunkt GET /api/council/budget/execution.

Warum dieser Parser das PDF holt statt den gespeicherten Text zu lesen

Abschnitt betitelt „Warum dieser Parser das PDF holt statt den gespeicherten Text zu lesen“

Er ist nach den Änderungslisten der zweite, der mit Wortkoordinaten arbeitet (pymupdf), und aus einem anderen Grund als jene. Dort war es die Spaltenzuordnung; hier ist es die leere Zelle. Wo ein Teilhaushalt keine Einzahlungen plant, druckt der Bericht nichts — im Fließtext fehlt die Zahl dann einfach, aus elf Spalten werden neun, und ab da steht jeder Betrag eine Spalte zu weit links. Gemessen am Bestand: 128 leere Zellen in 18 der 43 Tabellen, und eine einzige davon genügt, um den Rest ihrer Zeile zu verschieben. Mit den echten Koordinaten ist die leere Zelle genau das: eine Spalte ohne Wort.

Der Preis dafür steht im Register: Diese Schicht ist nicht cron-fähig. check_finanzdaten lädt nichts herunter — das ist die Regel, an der der Job hängt (siehe oben) —, also beobachtet er den Haushaltsvollzug nur und meldet, wenn ein Stichtag ausbleibt.

Seit 09/2026 ruft der Job außerdem die Ingest-Skripte der vier Schichten aus dem Ratsinformationssystem, die keinen eigenen Leser haben (Haushaltsvollzug, Haushaltssatzung, Gebührenbedarf, Wirtschaftspläne) — sobald ein Dokument im Bestand liegt, das jünger ist als die Marke seines letzten Laufs (Finanzquelle.lauf, Finanzquelle.dokumentmarke, Tabelle council_ingest_marks). Der Job selbst lädt weiter nichts herunter; was ein Skript für sich holt, steht in dessen Kopf. Ein gescheiterter Lauf bleibt offen, wird beim nächsten Mal wiederholt und steht in der Hinweis-Mail.

Vier Proben, und die erste sichert keine Zahl, sondern eine Bedeutung

Abschnitt betitelt „Vier Proben, und die erste sichert keine Zahl, sondern eine Bedeutung“
  1. Spaltenprobe (execution_columns). Die Tabelle hat in allen Jahrgängen elf Zahlenspalten — aber nicht dieselben. Bis 2020 steht die Ermächtigungsübertragung an dritter Stelle und ist in die Ansatz-Spalte eingerechnet („Verfügbare Mittel im Saldo“); ab 2021 steht sie als „nachrichtlich“ hinten und bleibt draußen. Die Kopfzeile sagt das zwar, ist aber über die Seite verstreut („Ermächti- gungsübertra- gungen“) und in einem Bericht von einem senkrecht gesetzten „T e n d e n z“ durchsetzt. Also entscheidet die Rechnung: Beide Belegungen werden durchprobiert, und genommen wird die, unter der die Gleichungen aufgehen. Unter der anderen geht keine einzige auf — die Beträge liegen Millionen auseinander.
  2. Zeilenprobe (execution_row). Fünf Gleichungen je Teilhaushalt: Erträge − Aufwendungen (− Übertragung) = Ansatz-Ergebnis, dasselbe für die Prognose, und die drei gedruckten Abweichungen sind die Differenz der beiden. Stünde ein Betrag eine Spalte daneben, ginge das nicht auf.
  3. Summenprobe (execution_totals). Die dreizehn Teilhaushalte ergeben in jeder Spalte die Summenzeile, die der Bericht selbst darunter druckt.
  4. Zeitraumprobe (execution_period). Das Haushaltsjahr steht zweimal auf derselben Seite: über dem Stichtag und in der Spaltengruppe „Plan/Ist-Abweichung“. Ein Bericht zum 31. Dezember erscheint erst im Folgejahr und ließe sich ohne diese Probe dort einordnen.

Die Toleranz liegt bei 15 €, und der Bericht begründet sie selbst: „Es kann zu geringen Rundungsdifferenzen im Vergleich zu den einzelnen Übersichten der Teilhaushalte kommen.“ Gemessen über 43 Tabellen sind es höchstens 3 € auf der Summenzeile — fünf Größenordnungen unter jedem Spaltenfehler, den diese Proben fangen sollen.

Ein Haushaltsjahr besteht aus bis zu vier Stichtagen mit je zwei Haushalten. Buchgeführt wird deshalb über (Jahrgang, Stichtag, Haushalt). Wer je Jahrgang buchführte, hielte 2025 nach dem Bericht zum 31. März für erledigt und zöge die drei folgenden Quartale nie nach.

Das ist auch kein theoretischer Fall: Im Bericht zum 30.06.2024 weist die Stadt beim Teilhaushalt 13 eine Aufwands-Abweichung von −40.377 € aus, obwohl Ansatz und Prognose dort gleich sind — die Zahl ist die Ermächtigungsübertragung, einmal zu weit links. Die Zeilenprobe reißt, der Ergebnishaushalt dieses Stichtags bleibt draußen. Sein Finanzhaushalt, an dem nichts falsch ist, kommt herein.

Kein Ist zum Stichtag. Alle gelesenen Berichte führen Ansatz, Prognose zum Jahresende und Abweichung — keiner nennt, was bis zum Stichtag tatsächlich gebucht war. „Zum 30. Juni“ ist der Tag, an dem die Ämter ihre Erwartung für den 31. Dezember abgegeben haben, nicht ein Halbjahres-Ist. Eine Spalte dafür gibt es deshalb nicht; sie wäre in jeder Zeile leer und in jeder Anzeige eine Einladung zum Missverständnis. Was am Jahresende wirklich herauskam, steht im Jahresabschluss — und der kommt zwei Jahre später.

Kein erstes Quartal. Die Berichte zum 31. März hängen als Tabelle im Vorlagentext und nicht als Anlage, und sie führen je Teilhaushalt nur das Jahresergebnis, nicht Erträge und Aufwendungen. Sie sind damit eine andere Tabelle mit anderen Proben. Dazu kommt ein Befund, der gegen ein schnelles Nachrüsten spricht: In der Vorlage 25/0308 (Q1 2025) stehen in der Finanzhaushalts-Tabelle bei drei Teilhaushalten die Prognosewerte des Ergebnishaushalts, und die gedruckte Summe passt zu keiner der beiden Lesarten. Ein Parser, der das übernähme, machte aus einem Fehler der Stadt eine Zahl mit Beleg.

Nicht 2017. Der Bericht zum 31.12.2017 (Anlage 184154) führt eine Tabelle mit zwölf Zahlenspalten und ohne die Überschrift „Auswertung der Berichte zum …“. Er wird gemeldet und nicht gelesen — ein drittes Layout für einen einzigen Stichtag zu bauen, ohne zweiten Beleg dafür, dass es je wiederkommt, wäre Code, den niemand prüfen kann.

Nicht die Teilhaushalts-Berichte. Unter demselben Namen liegen im Bestand die „Finanz- und Leistungsberichte des Teilhaushaltes 07“ für den Planungsausschuss, dazu die Fassungen der Eigenbetriebe (Gebäudewirtschaft, Hafen). Sie tragen dieselben Label und fallen still durch: Wer die stadtweite Übersichtstabelle mit dreizehn Teilhaushalten und Summenzeile nicht führt, ist kein Kandidat. Von 80 Anlagen mit einem Bericht-Label liefern 24 die Übersichtstabelle, zwei weitere sind der stadtweite Bericht und kommen trotzdem nicht durch (s. oben), und die restlichen 54 gehören anderen Häusern. „56 ohne Übersichtstabelle“ im Protokoll des Laufs ist deshalb der Sollzustand und kein Befund. Als Ausbau wären die Teilhaushalts-Berichte die nächste Stufe: dieselbe Frage eine Ebene feiner, aber mit eigener Tabellenform und eigenen Proben.

Aus PDF-Text extrahierte Tabellen verschmelzen Zahlen („355.188334.704“) und kleben Seitenzahlen an Werte. Ein Jahrgang kommt deshalb nur in die Datenbank, wenn er alle folgenden Proben besteht:

Probe Was sie prüft Wo
Zeilenprobe Abweichung = Ergebnis − Plan, wobei als Plan nur zugelassen ist, was der Tabellenkopf als Spalte nennt _spalten_zuordnen
Summenprobe Die zwölf Teilhaushalte ergeben die Gesamtrechnung — in Plan und Ist, Zeilen 12 und 20 summenprobe
Strukturprobe 12 − 20 = 21 innerhalb der Tabelle, in Plan und Ist strukturprobe
Vorjahres-Kette Das Ist eines Jahres taucht im Folgejahrgang als Vorjahresspalte wieder auf vorjahreskette

Stand heute: Summenprobe 0,0000 % in allen acht Jahrgängen, Strukturprobe 8/8, Vorjahres-Kette 14/14 Glieder.

Diese Tabelle ist der Ort dafür. Die drei letzten Proben standen bis 16.08. auch als Fließtext unter /haushalt/plan-ist („nur Jahre, deren Zahlen unsere Prüfung bestehen: Die Summe der Teilhaushalte muss die Gesamtrechnung ergeben …“). Sie sind dort raus — was bleibt, ist die Grenze, die eine Leserin angeht: Es erscheinen nur Jahre, für die überhaupt ein Jahresabschluss vorliegt. Dass ein Jahrgang an einer Probe scheitert, ist kein Seiteninhalt, sondern ein Betriebsvorgang; er steht hier und im Lauf-Protokoll (DESIGNSPRACHE.md § 7).

Für die Planjahre (council/ergebnishaushalt.py) gelten zwei eigene, und beide sind Pflicht:

Probe Was sie prüft Wo
Summenzeilen 01–11 = 12, 13–19 = 20 und 12 − 20 = 21 — in allen sechs Jahresspalten, also achtzehnmal je Dokument summenprobe
Planspalte Die hervorgehobene Planjahr-Spalte steht in jeder Zeile ein zweites Mal am Zeilenende und zeigt auf dieselbe Spalte wie der Kopf planspaltenprobe

Die zweite trägt die Trennlinie zwischen Ansatz und Finanzplanung: Ohne sie wäre „dritte Spalte = beschlossener Ansatz“ eine Reihenfolgeannahme. Stand heute in 8/8 Dokumenten aufgegangen, 23 von 23 Zeilen je Dokument.

Der Stellenplan: vier Proben, und eine davon absichtlich kein Gate

Abschnitt betitelt „Der Stellenplan: vier Proben, und eine davon absichtlich kein Gate“

Der Stellenplan (council/stellenplan.py) ist die einzige Schicht des Bereichs, die nicht in Euro rechnet. Er hat zwei Teile — A für Beamtinnen und Beamte (neun Spalten), B für Tarifbeschäftigte (acht) — und jeder Teil kommt einzeln durch seine Proben. Deshalb ist die Einheit der Teil und nicht der Jahrgang: Im Stellenplan 2026 gibt der Textextrakt für Teil B Glyphen-Nummern statt Buchstaben aus (Seiten 5–9, s. u.), Teil A steht sauber da.

Probe Was sie prüft Wo
Spaltenprobe Die Tabelle nummeriert ihre Spalten selbst (1 2 3 … 9), auf jeder Seite neu und überall gleich _spaltenzeile
Gruppensummen Die Einzelzeilen zwischen zwei Summenzeilen ergeben die Summenzeile — in jeder Wertespalte gruppenprobe
Besetzungsprobe besetzt + nicht besetzt = Stellen im Vorjahr, geprüft auf den Summenzeilen besetzungsprobe
Gesamtsumme Die Gruppensummen ergeben die Gesamtzeile; Teil A führt sie zweimal, beide müssen stimmen gesamtprobe

Teil B trägt die vierte nicht: Er hat eine Gruppe, deren Summe zugleich die Gesamtsumme ist — sie wiederholte dort nur die zweite unter neuem Namen.

Warum die Besetzungsprobe auf Summenzeilen läuft und nicht auf jeder Einzelzeile. Der Stellenplan 2023 widerspricht sich in Teil B in genau zwei Zeilen: Bei „Dipl.-Ingenieur/-in E 11“ ist die Besetzung um eine Stelle zu hoch, bei „Verw.-Angest. E 11“ um eine zu niedrig — die Stadt hat eine Stelle in der falschen Zeile verbucht. In der Gruppensumme heben sich beide auf, und alle vier Spalten stimmen auf 0,00. Als Gate über jede Einzelzeile fiele dafür ein Teil mit 140 Zeilen und 1.643 Stellen. Die beiden Zeilen werden deshalb gekennzeichnet statt verworfen (Spalte stimmig), gezählt und im Lauf-Protokoll namentlich gemeldet. Was ein Zeilen-Gate abfangen sollte — eine verrutschte Spalte — fangen die Spaltenvergleiche ohnehin ab.

Die Toleranz der Besetzungsprobe wächst mit der Zeilenzahl (0,01 × Zeilen, mindestens 0,05). Das ist keine Nachgiebigkeit, sondern eine Ableitung: Der Plan rundet jede Zeile auf zwei Nachkommastellen (eine halbe Stelle im Schichtdienst steht als 0,88 und 0,13), je Zeile bleiben höchstens 0,01 übrig, und diese Reste addieren sich in die Summenzeile. Ein fester Wert wäre genau falsch herum streng — er ginge bei vier Zeilen durch und schlüge bei 143 zu.

Stand heute: Sieben von acht möglichen Teilen im Bestand (2023–2026 Teil A, 2023–2025 Teil B), 611 Zeilen, alle Summenproben auf 0,00 aufgegangen, zwei gekennzeichnete Zeilen. Die Jahrgänge 2019–2022 gibt es nicht — dort endet das Anlagenverzeichnis des Haushaltsplans bei „021 Wirtschaftsplan EGH“. Für 2020 liegen zwar zwei Anlagen einer späteren Vorlage vor (19/0945), aber beide zeigen nur die Tarifhälfte: ein „Geänderter Stellenplan Teil B“ und eine „Geänderte Übersicht zum Stellenplan Teil A“, die im Inneren ebenfalls „II. Arbeitnehmerinnen und Arbeitnehmer“ führt. Ein Jahrgang aus beiden läse sich wie ein Jahr ohne Beamtenstellen — er bleibt draußen.

Gegenprobe, keine Probe: Die Ist-Spalte des Vorvorjahres lässt sich gegen council_income_statement halten — aber sie ist die Gesamtebene (mit den nicht rechtsfähigen Stiftungen), der gespeicherte Abschluss die Kernverwaltung. Deckungsgleich sind 6 bis 8 von 23 Posten, der größte Abstand liegt bei 0,075 % der Ertragssumme. Gegen die Gesamtergebnisrechnung desselben Abschlusses trifft sie dagegen auf den Cent: 184 von 184 Posten in allen acht Jahrgängen. Der Lauf misst den Abstand und meldet ihn ab 0,5 % — verwerfen darf er deswegen nichts, sonst flöge irgendwann ein richtiger Jahrgang wegen einer anderen Konsolidierungsstufe raus.

Welche Probe eine gespeicherte Zeile bestanden hat, steht seit 08/2026 an der Zeile — über ihre herkunft_id in council_provenance.probe, mitsamt dem Messwert (probe_ergebnis, etwa „0.00 % Abweichung zur Gesamtrechnung“). Bis dahin lief die Probe zwar, aber nur das Lauf-Protokoll wusste davon, und das ist nach dem Lauf weg. Jede Probe hier braucht deshalb einen Eintrag in herkunft.PROBEN — Name plus einen Satz für Leserinnen.

Zwei Details, die teuer erkauft sind:

  • Die Summenprobe prüfte früher nur den Ansatz. Das genügt nicht — ein Fehlgriff, der zufällig denselben Ansatz trägt, käme durch. Sie prüft deshalb Plan und Ist. (Im Abschluss 2022 wurde THH09 einmal mit 0,1 statt 26,8 Mio. € gelesen; erst die Summe machte es sichtbar.)
  • Gesucht wird das letzte passende Fenster in der Zahlenfolge einer Zeile, denn der Kopf ordnet von rechts: Abweichung, davor Ergebnis, davor die Bezugsgröße. Damit dabei nicht die Zeile unter Posten 24 mitgelesen wird — „Jahresergebnis“ trägt keine Nummer und rutscht in dieselbe Zeile —, wird die Zeile vorher an diesem Wort abgeschnitten. Ohne den Schnitt ergäbe sich für Posten 24 ein zweites, in sich stimmiges Tripel (2024: 34,6 + −28,5 = 6,1).

Ein Nebenertrag: Die Ansätze aus den Jahresabschlüssen bestätigen die Werte, die wir aus den Plan-PDFs lesen. council_budget minus der Zeile „nicht rechtsfähige Stiftungen“ trifft den ursprünglichen Ansatz auf den Cent — 2020 also 588.539.108,34 € (vor Nachtrag), nicht die 591,3 Mio. € des fortgeschriebenen Plans. Genau dafür gibt es die beiden Felder.

Die Prüfung: was das Rechnungsprüfungsamt beanstandet

Abschnitt betitelt „Die Prüfung: was das Rechnungsprüfungsamt beanstandet“

Der Jahresabschluss ist die Abrechnung — der Schlussbericht des Rechnungsprüfungsamts ist die einzige regelmäßige, förmliche Kontrolle davon durch eine Stelle, die dem Rat berichtet und nicht der Verwaltungsspitze. Auch er hängt als PDF an einer Ratsvorlage und wird dort nie wieder gelesen.

Greifbar ist er, weil er seine Befunde mit Randmarken auszeichnet und deren Bedeutung selbst in den Vorbemerkungen erklärt: B Beanstandung, WB Wiederholte Beanstandung, H Hinweis, K Korrektur/Klärung. Für 2017–2023 stehen so 257 Feststellungen im Bestand — 166 Hinweise, 42 Beanstandungen, 37 wiederholte, 12 Korrekturen.

Jede trägt Jahr, Textziffer, Seite und einen Deeplink auf das Quelldokument. Die Marken werden auf der Seite erklärt, nicht bewertet, und zwar mit dem Wortlaut aus der Legende des jeweiligen Jahrgangs — die ändert sich (der Bericht 2023 führt kein K mehr). Bewertungsfarben gibt es keine: Rot für „Beanstandung“ machte aus einer Aufbereitung eine Anklage, mit einem Mittel, das dem Bericht selbst fremd ist.

Zwei Fallen, die dabei teuer waren und als Test festgehalten sind:

  • Die Legende schreibt die Marken genau wie der Fließtext ( B Beanstandung). Wer vor dem Berichtsanfang zu zählen beginnt, zählt für 2019–2023 jede Marke einmal zu oft.
  • Am Berichtsende steht der Name der Amtsleitung in gesperrter Schrift („K R U P K E“). Mit nur einem Leerzeichen hinter der Marke ginge er als K-Marke durch. Deshalb verlangt das Muster zwei Leerzeichen — den Abstand der Randspalte.
  • Die OCR schreibt die Randspalte als einen Tabulator („B\tDas Rechnungsprüfungsamt …“). Der Schlussbericht 2024 kommt nur so in den Bestand, und mit dem Zwei-Leerzeichen-Muster fand der Parser darin die Legende, aber keine einzige Marke. Seit dem 02.09.2026 gilt ein einzelner Tabulator als Trenner — er ist so eindeutig wie zwei Leerzeichen, denn die gesperrte Unterschrift steht mit Leerzeichen da. Am echten Text gemessen: 15 Feststellungen 2024 (4 Beanstandungen, 1 wiederholte, 10 Hinweise), nichts verworfen.

Zuordnung und Dedup. Welcher Bericht zu welchem Jahresabschluss gehört, entscheidet der Textanfang, nicht das Label: „Schlussbericht JA 2017“ (document_id 192039) ist der Bericht zum Eigenbetrieb Gebäudewirtschaft, und jedes Jahr kommen die formgleichen Berichte zu Klävemann-Stiftung, VOSS, AWB und EGH dazu. Der Titel steht im Extrakt über vier Zeilen — ein LIKE 'Schlussbericht des …' in SQL findet deshalb keinen einzigen Bericht; erst nach Whitespace-Normalisierung greift der Vergleich.

Die Ketten. Eine „wiederholte Beanstandung“ sagt von selbst, dass ein Mangel schon einmal dastand — sie sagt nur nicht, seit wann. Über den Abschnittstitel (ohne Klammerzusätze, weil „Internes Kontrollsystem (IKS)“ ab 2020 nur noch „Internes Kontrollsystem“ heißt) finden dieselben Sachen über die Jahrgänge zusammen. Die Textziffer taugt dafür nicht, sie verschiebt sich. Längste Kette: Plan-Ist-Vergleich, in allen sieben geprüften Jahren als WB ausgewiesen — zuletzt mit dem Satz „Dies widerspricht dem Grundsatz der Haushaltswahrheit“. Deshalb steht der Hinweis auf die Prüfung auch auf /haushalt/plan-ist, im Wortlaut und mit Link.

Was daneben steht. Der Absatz, der im Bericht direkt auf eine Marke folgt, wird als folgeabsatz getrennt geführt und getrennt angezeigt. Dort steht oft die Gegenseite („Die Verwaltung hat hierzu erklärt, dass eine entsprechende Umsetzung bis 31.12.2024 erfolgen soll.“). Getrennt, weil sonst als Beanstandung gälte, was der Bericht gar nicht so gemeint hat — 97 der 257 Feststellungen haben einen, 23 davon mit Bezug auf die Verwaltung.

Eine regelmäßige Rückmeldung der Verwaltung gibt es nicht: Im ganzen Bestand liegen dazu drei Dokumente — die „Nacharbeiten-Übersicht“ zum Prüfbericht 2020 (council_attachments 243109, Vorlage 21/0944) und je eine Stellungnahme des Oberbürgermeisters zu den Berichten 2019 und 2021. Die Nacharbeiten-Übersicht war beim Bau die beste Gegenprobe: Sie nennt zu jeder Feststellung Ziffer und Seite, und beide stimmen mit dem Geparsten überein (3.1.1 → Seite 18, 3.1.3 → 20, 3.2 → 22).

Ein Regler, der eine Zahl verändert, sagt nichts darüber, ob das viel ist. Deshalb hängt an jeder Bewegung ein Bezug, und alle drei kommen aus Daten:

  • Anteil an der Lücke — der Balken im Ergebnis füllt sich, Einnahmen und Kürzungen getrennt eingefärbt.
  • Euro je Einwohner — Bezugsgröße ist council_einwohner (jüngstes Jahr).
  • Der Beispielbetrieb am Hebesatz — 100.000 € Gewerbeertrag × Messzahl 3,5 % (§ 11 GewStG) × Hebesatz. Dieselbe Rechnung wie im Steuer-Steckbrief, damit beide Seiten dieselbe Zahl nennen.
  • Vergleichsgrößen aus der Produktebene — „ungefähr so viel, wie Kulturgutvermittlung im ganzen Jahr kostet“. Verglichen wird nur innerhalb desselben Teilhaushalts: Eine Kultur-Kürzung neben eine Sozialleistung zu stellen legt nahe, man könne die stattdessen streichen. Wo für einen Bereich keine Produkte vorliegen, entfällt der Vergleich.
  • „Wie verlässlich ist der Plan?“ — der Ansatz gegen das Ergebnis aus den Jahresabschlüssen. In allen fünf eingelesenen Jahren fiel das Ergebnis besser aus als geplant (zwischen +2,9 und +38,1 Mio. €). Der Block sagt ausdrücklich dazu, dass das Minus damit nicht unecht wird — ein Plan preist Vorsicht ein.

Seit dem 30.08.2026 steht unter den Werkbänken die Größe, gegen die man jede Labor-Zahl halten sollte: wie weit der Haushalt im wirklichen Verfahren gewandert ist. Die „Zusammenstellung der Veränderungen“ jeder Änderungsliste weist Verwaltungsentwurf und Endsumme nebeneinander aus — dazwischen liegt alles, was Verwaltung und Fraktionen geändert haben, und die Zeilen dazwischen sagen, wer davon wie viel bewegte.

Für den Haushalt 2026: Der Entwurf stand bei −89,3 Mio. €, beschlossen wurden −68,7 Mio. €. Das Verfahren bewegte also 20,6 Mio. € — davon 20,3 aus den drei Listen der Verwaltung und 218.299 € aus der Liste der Koalition, also 1,1 %.

Genommen wird je Jahrgang das vollständigste Dokument, weil die Listen kumulativ sind: die Beschluss-Datei des Finanzausschusses, wo es sie gibt, sonst die höchste Verwaltungsliste. Gegenprobe an 2026: Verw. III endet bei −68.957.646 €, die Beschluss-Datei bei −68.739.348 € — die Differenz ist auf den Euro die politische Zeile. Wo keine Beschluss-Datei vorliegt (2019, 2022–2025), endet der Weg beim letzten Stand der Verwaltung, und die Karte nennt ihn auch so; „beschlossen“ steht dort nicht.

Der Konzern Stadt: was der Kernhaushalt nicht zeigt

Abschnitt betitelt „Der Konzern Stadt: was der Kernhaushalt nicht zeigt“

Alles bisher Beschriebene ist die Kernverwaltung. Das ist nicht die ganze Stadt: Klinikum, Verkehr und Wasser, Abfallwirtschaftsbetrieb, Bäderbetrieb, Weser-Ems Halle und der Eigenbetrieb Gebäudewirtschaft führen eigene Bücher und tauchen im Haushalt bestenfalls als Zuschusszeile auf. 2024 stehen 799,1 Mio. € Kernverwaltung gegen 1.241,5 Mio. € Konzern — der Haushalts- Bereich zeigte bis dahin rund 64 % dessen, was die Stadt bewegt.

Die Quelle ist der konsolidierte Gesamtabschluss nach § 128 NKomVG, genauer der Prüfbericht, den das Rechnungsprüfungsamt dazu vorlegt (council_attachments, zwölf Jahrgänge 2013–2024). Parser: council/konzernabschluss.py.

Die Labels dieser Reihe sind wertlos. Der Gesamtabschluss 2016 heißt im Bürgerinfo schlicht „Anlage“, 2013 ebenso; „Prüfbericht GA 2021“ trifft nur drei von zwölf. Erkannt wird deshalb am Textanfang — und zwar erst nach dem Glätten des Zeilenumbruchs, denn im Rohtext steht der Titel über fünf Zeilen verteilt. Ein raw_text LIKE 'Bericht über die Prüfung des konsolidierten Gesamtabschlusses zum 31.12.2016%' fände nichts.

Abschnitt Tabelle Proben
3.2 Gesamtergebnisrechnung council_group_items Erträge − Aufwendungen = ordentliches Ergebnis; dasselbe für die außerordentlichen Posten; beide zusammen = Gesamtjahresergebnis
4.1.1 Trägeraufstellung council_group_entities je Zeile Jahr − Vorjahr = Veränderung; Träger + Konsolidierung = ausgewiesene Summe; diese Summe = Summenposten aus 3.2

Dazu die Vorjahres-Kette über Dokumentgrenzen: 39 von 39 Gliedern schließen. Sie ist bewusst kein Ausschlussgrund, sondern eine Meldung — sie prüft zwei Jahrgänge gegeneinander, und wer bei Streit beide wegwirft, verliert einen guten wegen eines schlechten.

Stand heute: 11 von 12 Jahrgängen gespeichert (2014–2024), 289 Posten, 135 Trägerzeilen.

  • Die Postennummern wechseln. Bis 2018 ist Posten 15 die Summe der ordentlichen Erträge, ab 2019 ist es Posten 13. Wer weiter 15 liest, bekommt ab 2019 „Versorgungsaufwendungen“ — 8,4 Mio. statt 1,14 Mrd., und keine Zeile im Log. Gespeichert wird deshalb eine Rolle (ertraege_summe, zinsaufwand, …), erkannt an der Beschriftung; die Nummer steht nur noch als Fundstelle daneben.
  • Die Vorjahresspalte ist nicht immer in Euro. 2014–2016 führt der Tabellenkopf EUR EUR TEUR. Wer das übersieht, liest einen Konzern, der über Nacht auf ein Tausendstel schrumpft. Die Einheit kommt aus dem Kopf.
  • 2019 hat keine Zeilenumbrüche. Der Extrakt setzt die ganze Tabelle in eine Zeile, und die nächste Postennummer klebt am Vorjahreswert (269.835.099,832. Zuwendungen). Auflösbar, weil ein deutscher Betrag immer genau zwei Nachkommastellen hat — mehr macht entzerren() nicht, und die drei Proben entscheiden anschließend wie bei jedem anderen Jahrgang.

Dazu kommt eine Eigenheit, die keine Falle, sondern eine Grenze ist: In den Jahrgängen bis 2017 stehen Leerzeichen mitten in Beträgen (105.667.339, 23, 160.026.568 ,55). Solche Zeilen fallen weg, statt eine geratene Zahl zu liefern — der erste saubere Betrag einer Zeile ist sonst nicht mehr der des Haushaltsjahres, sondern der des Vorjahres. Die Beschriftung darf deshalb keine Ziffernfolge enthalten; tut sie es, ist die Zeile zerschossen.

Die Gegenprobe — die stärkste Bestätigung im ganzen Bereich

Abschnitt betitelt „Die Gegenprobe — die stärkste Bestätigung im ganzen Bereich“

Der Gesamtabschluss führt die Kernverwaltung als eigene Trägerzeile. Diese Zeile muss den Ist-Wert wiedergeben, den council_income_statement aus einem anderen Dokument eines anderen Jahres trägt. Sie tut es in 10 von 10 vergleichbaren Fällen (5 Jahrgänge × Erträge und Aufwendungen), jeweils auf die Rundung eines Tausend genau — 2024 etwa 799.057 TEUR gegen 799.057.202,86 €. Zwei getrennt eingelesene Quellen, dieselbe Zahl.

Die API rechnet den Abgleich weiter (cross_check in web/backend/app/routers/council.py), festgehalten ist er in tests/test_konzernabschluss.py::test_gegenprobe_gegen_die_kernverwaltung und tests/test_backend_api.py::test_haushalt_konzern_liefert_luecke_und_gegenprobe. Die Seite zeigt ihn seit 16.08. nicht mehr: Acht Zeilen, in denen dieselbe Zahl zweimal steht und daneben „unter 1 Tsd. € Unterschied“, waren Selbstvergewisserung, keine Information für Leserinnen. Der Beleg dafür, dass wir den Zahlen trauen können, gehört hierher und in die Tests — nicht auf die Seite.

  • Die Liste der einbezogenen Gesellschaften. In den jüngeren Jahrgängen ist der Konsolidierungskreis eine Grafik ohne Textebene („Aus der nachfolgenden Grafik ist ersichtlich, welche Aufgabenträger …“) — dieselbe Sackgasse wie bei der Schuldenübersicht. Wer dazugehört, sagt stattdessen die Trägeraufstellung, und die trägt Zahlen. Beteiligungsquoten stehen nirgends.
  • Der Schuldenstand. Die Gesamtschuldenübersicht ist Pflichtanlage, aber ihre Seiten tragen im PDF keinen Text. Ohne OCR ist dort nichts zu holen, und ein aus Diagrammbeschriftungen zusammengerechneter Schuldenstand wäre still falsch.
  • Der Jahrgang 2013 (document_id 188333): 74 Seiten, aber nur 38.000 Zeichen Volltext — die Tabellenseiten kommen ohne Textebene. Keine der drei Proben ist überhaupt rechenbar, der Jahrgang fällt durch.
  • Die Aufwendungsseite der Trägeraufstellung 2018. Ihre Konsolidierungs- zeile (−80.462 TEUR) passt nicht zur eigenen Summenzeile; der Bericht des Folgejahres führt für dasselbe Jahr −80.398. Die Spaltenprobe schlägt mit 64 TEUR an, die Aufstellung wird verworfen — die Ertragsseite desselben Jahrgangs und seine Postentabelle bleiben. Das ist der Gate im Betrieb, nicht die Theorie dazu.

Der Gesamtabschluss sagt, wie viel die städtischen Betriebe bewegen. Was sie damit tun, steht dort nicht — nur ihre Beiträge zur Ergebnisrechnung. Das sagt der Beteiligungsbericht nach § 151 NKomVG: einmal im Jahr, rund 200 Seiten, je Gesellschaft acht Abschnitte (Gegenstand, Beteiligungs- verhältnisse, Aufsichtsorgane, eigene Beteiligungen, Geschäftsverlauf, Bilanz und Kennzahlen, öffentlicher Zweck, Auswirkungen auf den Haushalt).

Parser: council/beteiligungsbericht.py. Seite: /haushalt/beteiligungen (Schritt 9), Steckbrief über ?g=<gesellschaft>.

Ein zweiter Cron-Typ: der erste, der herunterlädt

Abschnitt betitelt „Ein zweiter Cron-Typ: der erste, der herunterlädt“

check_finanzdaten lädt nichts herunter. Das ist seine erste Regel, und sie ist richtig: Seine Dokumente hängen als Anlagen an Ratsvorlagen und werden vom Protokoll-Scraper ohnehin geholt; ein zweiter Weg dorthin wäre Doppelarbeit mit doppelter Fehlerquelle.

Der Beteiligungsbericht hängt an keiner Vorlage. Er liegt auf oldenburg.de. Ihn im bestehenden Job mitzuholen hieße, dessen klarste Regel aufzuweichen — deshalb scripts/check_beteiligungsbericht.py als eigener Job mit eigenem Takt (alle vier Wochen; die Quelle erscheint einmal im Jahr) und council/stadtdownload.py als eigener, geprüfter Netzweg. Der hält vier Dinge ein, die ein Abruf auf fremden Servern einhalten muss:

  • Er sagt, wer er ist — ein User-Agent mit Namen, Adresse und Zweck.
  • Er fragt erst, ob es sich lohnt — If-Modified-Since / If-None-Match; bei 304 wird nichts übertragen. Sieben Berichte sind zusammen 25 MB.
  • Er wartet zwischen den Abrufen und nimmt nur, was er erwartet: PDF laut Content-Type, %PDF am Anfang, höchstens 40 MB. Eine Fehlerseite mit 200 OK ist bei CMS-Systemen der Normalfall.
  • Er hält die Regeln der Seite ein. robots.txt erlaubt User-agent: * mit Allow: /; gesperrt sind TYPO3-Innereien und cHash-Adressen. Die Berichts-PDFs unter /fileadmin/oldenburg/… sind frei (nachgesehen 16.08.2026).

Die Adressen der PDFs werden aus der Übersichtsseite gelesen, nicht geraten: Der Dateiname wechselt von Jahrgang zu Jahrgang (Beteiligungsbericht_2021.pdf gegen Beteiligungsbericht_2024_kombiniert_final.pdf).

Im Datenstand steht die Schicht mit automatisch = false — check_finanzdaten beobachtet sie mit und meldet, wenn ein Jahrgang ausbleibt, lädt aber selbst nichts.

Auf oldenburg.de stehen sieben Berichte (2018–2024). Gelesen werden 2022, 2023 und 2024. Der Grund ist ein Formatbruch, kein Aufwand:

2018–2021 ab 2022
Gliederung je Gesellschaft frei betextet acht nummerierte Abschnitte 1)–8)
Bilanz Aktiva und Passiva nebeneinander einspaltig, BILANZSUMME auf beiden Seiten
Kennzahlen keine Tabelle „Kennzahlen im Zeitverlauf“, 4–5 Jahre

Gemessen am Bestand: „Kennzahlen im Zeitverlauf“ kommt in den Jahrgängen 2018–2021 null-mal vor, ab 2022 je 14–16-mal. BILANZSUMME ebenso: null gegen 28–32. Die zweispaltige Bilanz der alten Jahrgänge verschränkt pypdf zu Zeilen, in denen Aktiv- und Passivbeträge abwechselnd stehen.

Der Verzicht kostet keine Zahlen. Jeder Bericht führt vier bis fünf Jahre mit; der Bestand deckt deshalb 2017–2024. Was fehlt, ist der Fließtext der Jahre 2018–2021 — und „was macht die GSG eigentlich?“ beantwortet ohnehin der jüngste Bericht.

Der eigentliche Fallstrick bei 200 Seiten. Der Bericht beantwortet ihn selbst zweimal: Das Inhaltsverzeichnis nennt für jede Gesellschaft ihre Gliederungsnummer und ihre Anfangsseite, und auf genau dieser Seite steht eine Trennseite mit derselben Nummer. Stimmen beide nicht überein, ist die Zuordnung nicht gesichert und der Abschnitt fällt weg. In den drei gelesenen Jahrgängen gehen 45 von 45 Zuordnungen auf; das ist die Probe beteiligung_seitenprobe.

Drei Proben für die Kennzahlen — und keine für den Text

Abschnitt betitelt „Drei Proben für die Kennzahlen — und keine für den Text“
Probe Was sie zeigt
beteiligung_bilanzprobe Die Bilanz weist ihre Summe zweimal aus (Aktiva, Passiva), die Kennzahlen-Tabelle ein drittes Mal
beteiligung_ergebnisprobe Die Gewinn- und Verlustrechnung schließt mit dem Jahresergebnis der Kennzahlen-Tabelle
beteiligung_ueberlappung Dasselbe Jahr steht in bis zu drei Berichten — verschiedene Veröffentlichungen, dieselbe Zahl

Gemessen über die drei Jahrgänge: 246 von 246 Dokumentproben bestanden, 202 Werte durch die Überlappung bestätigt, kein einziger Widerspruch. Von 286 gelesenen Werten tragen 230 eine Probe; die übrigen 56 werden verworfen statt geschätzt — es trifft die älteste Spalte des ältesten Berichts (keine Bilanz daneben, kein zweiter Bericht) und die Eigenkapitalquote des jüngsten Jahres, die das Dokument nirgends vorrechnet.

Die beschreibenden Abschnitte tragen herkunft.UNGEPRUEFT, und das ist die ehrliche Angabe: Gegen Fließtext lässt sich nichts rechnen. Er steht trotzdem mit Dokument, Abschnitt und Seite da — „keine Probe“ ist etwas anderes als „keine Quelle“.

Fünf Eigenheiten, an denen ein naiver Parser scheitert

Abschnitt betitelt „Fünf Eigenheiten, an denen ein naiver Parser scheitert“
  • Die Jahresspalten wechseln die Richtung. Der Eigenbetrieb Gebäudewirtschaft führt 2024 → 2020, der Abfallwirtschaftsbetrieb zwei Seiten weiter 2020 → 2024. Gelesen wird die Kopfzeile, nie die Reihenfolge.
  • Der Berichtsjahrgang steht nicht immer in der Tabelle. Die Großleitstelle führt noch im Bericht für 2024 die Jahre 2017–2021.
  • Beträge tragen Leerzeichen mitten drin — 650 .289,04, 23.439 .654,83, 2.103.265, 69. Dieselbe Sorte Schaden wie im Gesamtabschluss (105.667.339, 23).
  • Die Beschriftung schwankt: Eigenkapitalquote, Eigenkapitaquote (Tippfehler im Bericht 2022), Eigenkapital-\nquote, Eigenkapital -\nQuote in Prozent.
  • Manche Zahl ist im Dokument falsch gesetzt. Der Bericht 2022 führt für die GSG ein Jahresergebnis 5.698.082.44 — Punkt statt Komma. Die Zeile wird verworfen, nicht zurechtgebogen; im Bericht 2024 beginnt dieselbe Reihe erst 2020 und geht durch.

Zwei der fünf Abschnitte sind gar kein Fließtext

Abschnitt betitelt „Zwei der fünf Abschnitte sind gar kein Fließtext“

„Beteiligungsverhältnisse“ und „Besetzung der Aufsichtsorgane“ sehen im Extrakt aus wie Prosa und sind Tabellen. Sie stehen deshalb zusätzlich zerlegt im Bestand — council_company_owners und council_company_people —, während der Rohtext daneben stehen bleibt.

Die Aufsichtsorgane sind zweispaltig, und pypdf liest spaltenweise: erst alle fünfzehn Namen untereinander, dann alle fünfzehn Ämter. Was auf der gedruckten Seite nebeneinander steht, liegt im Extrakt fünfzehn Zeilen auseinander. Zusammenführen lässt sich das nur über die Position — der n-te Name zum n-ten Amt —, und das ist genau dann richtig, wenn beide Listen gleich lang sind.

Gemessen über die 45 Abschnitte: 44 von 45 halten die Probe (beteiligung_spaltenprobe, 502 Namen). Der eine Ausreißer ist der Abfallwirtschaftsbetrieb 2023 — dort nennt der Bericht selbst acht Personen und sieben Ämter. Drei weitere Abschnitte (TGO Besitz GmbH & Co. KG, 2022–2024) führen gar keine Funktionsspalte, sondern Entsendungsrechte („Vertreter/in der Landessparkasse“); dort gibt es nichts zuzuordnen, und das ist kein Befund.

Bei den Eigentümern ist die Stammkapital-Zeile die Probe, kein Gesellschafter. Sie sieht im Extrakt aus wie einer (Name, Betrag, Prozent), ist aber die Summenzeile: beteiligung_anteilsprobe verlangt, dass die Anteile genau dieses Stammkapital ergeben und zusammen 100 % (Toleranz 0,5 Prozentpunkte — sechs Anteile zu je 16,67 % sind 100,02 %). Auch hier: 44 von 45. Der Ausreißer ist die Stadion Oldenburg GmbH & Co. KG 2024, deren Anteile 5.000 € ergeben, während die Summenzeile 25.000 € nennt. Welche Zahl stimmt, verrät das Dokument nicht — also kommt keine halbe Eigentümerliste heraus, sondern keine, und der Rohtext bleibt stehen.

Die Personen-Verlinkung entsteht beim Lesen, nicht beim Einlesen

Abschnitt betitelt „Die Personen-Verlinkung entsteht beim Lesen, nicht beim Einlesen“

Ob „Ruth Regina Drügemöller“ im Aufsichtsrat dieselbe ist wie die Ratsfrau auf /council/person, entscheidet die API (_lexikon_zuordnung) gegen store.personen_lexikon() — nicht die Datenbank. Das Lexikon wächst mit jedem Protokoll, der Beteiligungsbericht wird alle vier Wochen eingelesen; als Fremdschlüssel eingefroren zeigte ein Steckbrief bald auf eine Person, die inzwischen anders geführt wird.

Zugeordnet wird über Vor- und Nachnamen, und nur bei genau einem Treffer. Der Bäderbetrieb führt 2024 „Dr. Sebastian Rohe“ und „Dr. Georg Rohe“ nebeneinander; wer auf den Nachnamen zuordnet, hängt einem der beiden die Personen-Seite des anderen an. Gemessen: 129 von 179 Namen des Berichts 2024 bekommen einen Link (72 %). Die übrigen sind zum großen Teil gar keine Ratspersonen — Aufsichtsräte entsenden auch Banken, Hochschulen und Mitgesellschafter.

Der Abgleich mit dem Gesamtabschluss — nachgerechnet, aber keine Probe

Abschnitt betitelt „Der Abgleich mit dem Gesamtabschluss — nachgerechnet, aber keine Probe“

Dieselben Gesellschaften stehen auch in council_group_entities, dort mit ihren ordentlichen Erträgen und Aufwendungen. Deren Differenz ist die einzige Größe, die sich mit dem Jahresergebnis vergleichen lässt. Nachgerechnet für 2024 (in TEUR):

Gesellschaft Konzern E−A Jahresergebnis Differenz
Klinikum Oldenburg AöR −27.132 −27.132 0
Weser-Ems Halle −4.378 −4.378 0
Bäderbetriebsgesellschaft −5.699 −5.709 10
Eigenbetrieb Gebäudewirtschaft −2.701 −2.726 25
Abfallwirtschaftsbetrieb 231 294 −63
Bäderbetrieb 233 0 233
Verkehr und Wasser GmbH −77 0 −77

Zwei stimmen auf die Tausenderstelle, drei liegen dicht daneben, zwei weichen deutlich ab — und alle drei Befunde sind richtig: Der Gesamtabschluss zählt nur die ordentlichen Posten (daher die kleinen Abweichungen), und Bäderbetrieb wie Verkehr und Wasser weisen 0,00 € aus, weil ihr Ergebnis abgeführt beziehungsweise ausgeglichen wird.

Städtevergleich: was sich vergleichen lässt — und was nicht

Abschnitt betitelt „Städtevergleich: was sich vergleichen lässt — und was nicht“

/haushalt/vergleich ist aus einer Absage entstanden. Der Entwurf sah einen breiten Städtevergleich vor (Ausgaben, Personal, Schulden je Einwohner). Der wurde nicht gebaut, und zwar nicht wegen fehlender Daten: Ein Vergleich von Kernhaushalten misst zuerst, wie weit eine Stadt ausgelagert hat.

Oldenburgs Kernhaushalt zeigt rund 64 % dessen, was der Konzern bewegt; Osnabrücks knapp 48 %, weil dort die Stadtwerke dazugehören. Dieselbe Kennzahl, dieselbe Stadt, dasselbe Jahr — Kern gegen Konzern — unterscheidet sich bei der Pro-Kopf-Verschuldung um Faktor 2,53 (2024) und 6,04 (2023). Wer eine Kernhaushaltszahl aus Stadt A gegen eine Konzernzahl aus Stadt B stellt, kennt nicht einmal die Größenordnung des Fehlers.

Robust gegen die Auslagerungsfrage ist, was die Stadt als Hoheitsträger festlegt oder was das Land für alle gleich berechnet — es wandert nicht mit, wenn ein Aufgabenblock in einen Eigenbetrieb zieht:

Reihe Kennzahlen Quelle
steuerkraft Steuerkraftmesszahl (TEUR), Einwohnerzahl LSN, Kommunaler Finanzausgleich, Blatt ST_KR_MESS_VGL
realsteuern Hebesätze Grundsteuer A/B und Gewerbesteuer, Ist-Aufkommen je Einwohner, Steuereinnahmekraft je Einwohner LSN, Realsteuervergleich, Blätter 2_1 und 5_1

Die Steuerkraftmesszahl rechnet § 11 NFAG mit Nivellierungshebesätzen, also für alle Gemeinden mit denselben fiktiven Sätzen; sie misst die Steuerbasis, nicht die Hebesatzpolitik. Und Steuern erhebt nie ein Eigenbetrieb — der Auslagerungseffekt existiert hier schlicht nicht.

Die Steuerkraft je Einwohnerin wird nicht gespeichert. Das Landesamt weist sie nicht aus; die Division ist unsere und steht auf der Seite als solche gekennzeichnet. Gespeichert werden Messzahl und Einwohnerzahl getrennt, sonst ließe sich später nicht mehr unterscheiden, was amtlich ist und was gerechnet.

Drei Proben, und eine davon prüft zwei Dokumente gegeneinander

Abschnitt betitelt „Drei Proben, und eine davon prüft zwei Dokumente gegeneinander“
  • lsn_zweijahresueberlappung — Jede KFA-Ausgabe nennt zwei Ausgleichsjahre nebeneinander. Das ältere muss die Hauptspalte der Vorjahresausgabe wiederholen, für jede der 403 Gemeinden. Das ist die stärkste Probe des Bereichs: Sie prüft nicht eine Rechnung innerhalb eines Dokuments, sondern zwei Veröffentlichungen gegeneinander. Gemessen: 403 von 403 identisch.
  • lsn_hebesatzprobe — Grundbetrag × Hebesatz = Ist-Aufkommen, die Definition einer Realsteuer. Bei der Gewerbesteuer zusätzlich brutto − Umlage = netto.
  • lsn_dreijahresmittel — Der ausgewiesene Dreijahresdurchschnitt ist das Mittel der drei Jahreswerte, und geteilt durch die mitgelieferte durchschnittliche Einwohnerzahl ergibt er den Pro-Kopf-Wert der Zeile.
  1. Der Städtename ist nicht stabil. KFA 2025 schreibt Oldenburg (Oldb), Stadt, KFA 2026 Oldenburg (Oldenburg), Stadt; Bad Zwischenahn bekommt zwischendurch ein *. Verbunden wird über die Schlüsselnummer.
  2. Die Schlüsselnummer hat zwei Schreibweisen — sechsstellig im Finanzausgleich (403000), dreistellig im Realsteuervergleich (403). schluessel_normalisieren() gleicht das an; ohne sie fänden die beiden Reihen einander nie, und der Fehler wäre still.
  3. Die Spaltenposition ist keine Zusage. Gelesen wird über den ausgeschriebenen Tabellenkopf, den das LSN als Vorlesehilfe für Screenreader mitliefert — und wo er steht, sagt die Datei in ihren ersten Zeilen selbst („Der Tabellenkopf für Vorlesehilfen befindet sich in Zeile 14“). Fehlt der Hinweis, bricht der Parser ab, statt zu raten.

XLSX ist ein ZIP mit XML darin; council/staedtevergleich.py liest beides mit der Standardbibliothek. Ein Extra-Paket nur für einen jährlichen Ingest käme in requirements.txt und damit auf den Server — dieselbe Überlegung, aus der fastembed draußen steht. Eine Dokumenttyp-Deklaration lehnt der Leser ab (eine echte Tabellenmappe hat keine), womit die Entity-Expansion-Lücke der Standardbibliothek zu ist.

Beide Quellen erscheinen einmal jährlich, deshalb kein Cron. Die Download-Adressen des LSN tragen undurchsichtige Nummern (/download/227086), die sich nicht hochzählen lassen; sie stehen auf der Übersichtsseite und werden beim jährlichen Lauf mitgegeben:

Terminal-Fenster
python scripts/ingest_staedtevergleich.py \
--kfa <KFA N, Datei oder Adresse> \
--kfa-vorjahr <KFA N−1> \
--realsteuer <Realsteuervergleich> \
--jahrbuch-1103 2025:79787 # optionale Gegenprobe, s. u.

Der Lauf schreibt nichts, wenn die Zwei-Jahres-Überlappung scheitert; eine einzelne Stadt, deren Hebesatzprobe nicht aufgeht, fällt mit Begründung heraus, ohne den Jahrgang mitzunehmen.

Dieselbe Datei, zweites Blatt (9a), gelesen von council/steuerkraft.py. Sie schließt eine Lücke, die vorher niemandem auffiel, weil sie eine Zahl nicht falsch, sondern zu klein machte.

Der Open-Data-Datensatz 1106 der Stadt führt eine Spalte „Schluesselzuweisungen, Anordnungssoll“. Nachgemessen enthält sie exakt zwei von drei Komponenten des kommunalen Finanzausgleichs:

Ausgleichsjahr Gemeindeaufgaben Kreisaufgaben = Datensatz 1106 + übertragener Wirkungskreis = Nettobetrag
2025 51.653 17.557 69.210 ✓ 10.575 79.785
2026 62.654 19.624 82.278 ✓ 11.160 93.438

(TEUR. Die dritte Komponente ist Geld dafür, dass die Stadt staatliche Aufgaben miterledigt — Standesamt, Melde- und Ausländerwesen, Bauaufsicht — und an diese Aufgaben gebunden.)

Zwei Proben:

  1. kfa_komponentenprobe — im Dokument: Die drei Komponenten minus Finanzausgleichsumlage ergeben den ausgewiesenen Nettobetrag. Geprüft für alle acht kreisfreien Städte und beide Jahre, die eine Ausgabe führt; 8/8 in den Ausgaben 2023, 2025 und 2026.
  2. kfa_jahrbuchabgleich — gegen die Bücher der Stadt: Tabelle 1103 des Statistischen Jahrbuchs nennt unter „Finanzzuweisungen“ für 2023 110.049 und für 2024 109.498 TEUR — beides auf das Tausend genau der Nettobetrag des Landes. Für 2025 stehen 79.785 (Land) gegen 79.787 (Jahrbuch); dort ist das Rechnungsergebnis der Stadt noch vorläufig. Toleranz JAHRBUCH_TOLERANZ = 0,5 % — eng genug, dass eine vergessene Komponente (13 % der Summe) sie zwangsläufig reißt.

Die Falle: Der Kopftext der Netto-Spalte lautet „… abzüglich der Finanzausgleichsumlage im Jahr 2026)“ und enthält damit den Namen einer Komponente. Wer die Komponenten zuerst matcht, hält die Netto-Spalte für die Umlage und meldet danach eine Datei ohne Nettobetrag. Zwischen den Ausgaben wechseln außerdem die Schreibweise der Schlüsselnummer (403000 gegen 403) und die Reihenfolge in „Euro je Einwohner/Einwohnerin“; gelesen wird deshalb ausschließlich über den ausgeschriebenen Tabellenkopf.

Wo es steht: /haushalt/einnahmen, direkt unter der Finanzausgleichs-Kurve (components/haushalt/zuweisung-dreiteilig.tsx). Die bisherige Zahl bleibt — sie ist nicht falsch, sondern enger, und „Schlüsselzuweisungen“ heißen genau die ersten beiden Teile. Der Block stellt die vollständige daneben und sagt, dass sie größer ist. Die Pro-Kopf-Spalte des Blattes bleibt draußen: Für das Ausgleichsjahr 2025 nennt die Ausgabe 2025 „452,46 € je Ew.“, die Ausgabe 2026 „452,27 €” — derselbe Nettobetrag, revidierte Einwohnerzahl.

Der Steuer-Steckbrief erklärt im Block „Wer zahlt das eigentlich“, warum Namen nicht genannt werden dürfen (§ 30 AO, Steuergeheimnis). Die Anschlussfrage — wie viele Betriebe sind es denn? — blieb bis 08/2026 offen. Diese Zahl ist, anders als der einzelne Betrag, amtlich veröffentlicht: in der Gewerbesteuerstatistik (EVAS 735 11) des Landesamts für Statistik Niedersachsen, Statistischer Bericht L IV 13.

Gelesen wird sie von council/gewerbesteuerstatistik.py in die Tabelle council_trade_tax_statistics; eingelesen wird von Hand (scripts/ingest_gewerbesteuerstatistik.py), weil die Statistik einmal jährlich erscheint.

Was je Gemeinde drinsteht (Blätter 6.1 und 6.2 des Berichts):

Merkmal Oldenburg, Erhebungsjahr 2021
Betriebe und Betriebsstätten 8.421
davon mit positivem Steuermessbetrag 3.642 (43,2 %)
Summe der Steuermessbeträge 30.015.356 €
davon aus reiner Festsetzung 14.103.549 € (2.763 Betriebe)
davon aus Zerlegung 15.911.807 € (879 Betriebsstätten)
Hebesatz (nachrichtlich) 439 %

Im Bestand stehen die Erhebungsjahre 2017–2021 und alle acht kreisfreien Städte — dieselbe Menge wie beim Städtevergleich, weil es derselbe Schleifendurchlauf und dieselbe Probe ist.

  1. Summenprobe — reine Festsetzungen + Zerlegungen = insgesamt, und zwar dreimal je Stadt (Fälle, zahlende Fälle, Messbetrag). Das ist nicht eine Kontrollrechnung neben der Tabelle, sondern ihr Aufbau: Jeder Fall ist entweder das eine oder das andere.
  2. Blattprobe — Blatt 6.2 (alle Gemeinden) nennt für jede kreisfreie Stadt dieselben drei Zahlen wie Blatt 6.1 (kreisfreie Städte und Landkreise). Zwei verschieden gebaute Tabellen desselben Berichts, getrennt gelesen.
  3. Hebesatzprobe — der Hebesatz, den das Landesamt seiner Gemeindetabelle nachrichtlich beilegt, steht auch in Tabelle 1105 des Statistischen Jahrbuchs der Stadt (council_tax_rates). Zwei Häuser, dieselbe Zahl. Sie liest die Treppe, nicht das Jahr: Für 2021 gibt es keine Zeile in 1105, es gilt der Satz der letzten Änderung davor (2015 → 439 %).

Reißt Probe 1 für eine Stadt, kommt diese Stadt nicht herein; reißen Probe 2 oder 3, kommt der ganze Jahrgang nicht herein.

Wo ein einzelner Zahler eine Gemeinde dominiert, sperrt das Landesamt den Betrag und druckt g — 2021 bei Salzgitter und Wolfsburg, 2020 dort sogar in allen neun Spalten. Der Parser unterscheidet deshalb drei Zustände, und die Datenbank auch:

  • eine Zahl → der Wert;
  • g → messbetrag_eur IS NULL plus gesperrt = 1. Die Anzahlen daneben stehen weiter und sind die eigentliche Auskunft. Ein Parser, der g zu 0 machte, behauptete, dort werde keine Gewerbesteuer gezahlt;
  • alle neun Spalten gesperrt → die Stadt kommt gar nicht herein, und im Protokoll steht „Geheimhaltung“, nicht „Probe gerissen“. Der Unterschied ist wichtig: Nichts widerspricht sich, es steht nur nichts da.

Oldenburg ist in keinem der fünf eingelesenen Jahrgänge gesperrt.

  • Die Spaltenköpfe sind umformuliert worden. 2017–2019 heißt eine Spalte „Betrag der Festsetzungen und Zerlegungen … mit positivem Steuermessbetrag in €“, 2020/2021 „Festsetzungen und Zerlegungen …; darunter mit positivem Steuermessbetrag in Euro”; die Schlüsselspalte wechselte von „Regionale Gliederung nach AGS“ zu „Amtlicher Gemeindeschlüssel“. Eingeordnet wird deshalb über Block und Rolle (spaltenzuordnung), nicht über den Kopftext. Und die Einheit taugt nicht als Merkmal: Blatt 6.2 nennt sie im Jahrgang 2017 gar nicht.
  • Der Städtename wandert („Oldenburg (Oldenburg), Stadt“ → „Oldenburg (Oldb), Stadt“), und die Schlüsselnummer steht in 6.1 dreistellig, in 6.2 sechsstellig. Verbunden wird über schluessel_normalisieren aus council/staedtevergleich.py; gespeichert wird unser Name, sonst sähe die Reihe nach zwei Städten aus.

Der ursprüngliche Wunsch war eine Konzentrationsaussage: „x % der Betriebe tragen y % des Messbetrags“. Die gibt es je Gemeinde nicht. Größenklassen des Gewerbeertrags veröffentlicht die Statistik nur für das Land und den Bund; die einzige Städte-Tabelle des Bundes führt die 50 Städte mit den höchsten Steuermessbeträgen, und Oldenburg liegt mit 30,0 Mio. € knapp unter Platz 50 (35,7 Mio. €). An Oldenburger Größenklassen käme man nur über die Einzeldaten des Forschungsdatenzentrums — Gastwissenschaftsarbeitsplatz oder kontrollierte Datenfernverarbeitung, für wissenschaftliche Vorhaben, mit Antrag.

Belegen lässt sich die Konzentration trotzdem, nur anders: über die Aufteilung, die je Gemeinde doch veröffentlicht wird. 2021 trugen 879 zerlegte Betriebsstätten — 10,4 % aller erfassten Fälle — 53,0 % des Steuermessbetrags, je zahlendem Fall das 3,5-Fache einer rein örtlichen Firma. Genau der Weg über die Arbeitslöhne (§ 29 GewStG), den der Block bis dahin nur beschreiben konnte.

Eine Veranlagung ist erst nach den Betriebsprüfungen endgültig; der Bericht erscheint rund fünf Jahre nach dem Erhebungsjahr (2019 → August 2024, 2020 → September 2025, 2021 → März 2026). Neben einer Aufkommenskurve bis 2025 steht also ein Nenner von 2021. Der Block schreibt das Erhebungsjahr deshalb sichtbar an, und ABGRENZUNG reist als Text mit den Daten aus der API — nicht als Satz im Frontend, der gegen die Zahlen driften könnte.

Jahrgänge vor 2017 gibt es nur als PDF (für 2013 nachgesehen: die Gemeindetabelle ist da, aber im PDF-Satz). Sie brauchten einen zweiten Parser und stehen deshalb nicht im Bestand.

Bis 08/2026 zeigte der Bereich ausschließlich den Ergebnishaushalt — laufende Erträge und Aufwendungen. Darin steht keine einzige Investition. Ein Schulneubau taucht dort nur als Abschreibung auf, verteilt über Jahrzehnte, lange nachdem gebaut wurde. Die häufigste Bürgerfrage überhaupt („was wird eigentlich gebaut?“) war damit unbeantwortbar, und zwar nicht aus Nachlässigkeit: Die Zahl stand in keiner Tabelle.

Sie steht im Finanzhaushalt, der zweiten Hälfte jedes Haushaltsplans. Ein- und Auszahlungen statt Erträgen und Aufwendungen — das ist der Unterschied zwischen „was verbraucht die Stadt in diesem Jahr?“ und „was legt sie in diesem Jahr an?“.

Datensatz 1101 des Open-Data-Portals, dasselbe Paket wie beim Plan-Ergebnishaushalt, nur das zweite Tabellenblatt. Je Jahrgang eine Datei mit 15 Zeilen:

Teilhaushalt;Bezeichnung;Einzahlungen [Euro];Auszahlungen [Euro]
THH01;Verwaltungsfuehrung;0;44500
…
THH13;Nicht rechtsfaehige Stiftungen;27900;0
Finanzhaushalt Gesamtinvestitionen;;39672063;80781520
Gesamtbetrag des Finanzhaushaltes;;743796496;850520503

Das macht sie zur einzigen Portal-CSV des Bereichs mit einer Rechenprobe im Dokument selbst: Die 13 Teilhaushalts-Zeilen müssen die Zeile Finanzhaushalt Gesamtinvestitionen ergeben, in beiden Spalten. Die drei anderen Portal-CSVs (Steuern, Steuerkraft, Einwohner) tragen ausdrücklich keine und stehen mit herkunft.UNGEPRUEFT in der Datenbank. Über die sechs verfügbaren Jahrgänge (2020–2025) geht die Probe auf den Euro genau auf — zwölf Proben, Restbetrag jeweils 0 €. 2020 und 2021 stehen in einer anderen Datei: EINE Latin-1-CSV je Jahr, erst der Ergebnishaushalt, dann der Finanzhaushalt; gelesen wird ab der Kopfzeile mit Ein- und Auszahlungen.

Eine Zahl in der Datei ist nicht gedeckt — und wird als solche geführt

Abschnitt betitelt „Eine Zahl in der Datei ist nicht gedeckt — und wird als solche geführt“

Die zweite Summenzeile, Gesamtbetrag des Finanzhaushaltes, zählt auch die laufende Verwaltungstätigkeit mit (Personal, Zuschüsse, Steuern) und ist rund zehnmal so groß wie die Investitionssumme. Nichts in der Datei summiert sich auf sie. Sie wird trotzdem übernommen, weil sie die Bezugsgröße ist, die aus „80,8 Mio. €“ erst eine Aussage macht (2025: 9,5 % aller Auszahlungen) — aber mit einer eigenen herkunft_id und herkunft.UNGEPRUEFT. Käme sie unter derselben Herkunft wie die geprüften Zeilen, behauptete die Seite eine Probe, die es für diese Zahl nicht gibt. save_investitionen nimmt die zweite Herkunft deshalb als eigenes Argument.

  1. Das Jahr steht nicht in der Datei. Keine Spalte, keine Kopfzeile. Es steht im Dateinamen (…_2025_Finanzhaushalt.csv) und im Titel des Datensatzes. Der Jahrgang kommt deshalb aus der URL (investitionen.jahrgang_aus_url), und die Herkunft nennt den Dateinamen ausdrücklich als Fundstelle des Jahrgangs. Das ist die schwächste Stelle dieser Schicht; sie wird ausgewiesen statt weggelassen.
  2. Die Schreibweisen schwanken zwischen den Jahrgängen. 2022 steht dort „Verkehr und Straßenbau“ und „Gruen u Friedhoefe“, 2025 „Strassenbau“ und „Gruen und Friedhoefe“; 2022 hat außerdem „Sicherheit und Ordnung“ mit doppeltem Leerzeichen und als einziger die Kopfzeile „Einzahlungen in Euro“ statt „[Euro]“. investitionen.NAMEN führt die bekannten Formen zusammen — sonst stünden in jeder Zeitreihe zwei Bereiche, wo einer ist. Unbekannte Namen laufen unverändert durch: Ein neuer Teilhaushalts-Zuschnitt soll den Import nicht stoppen. Der Schlüssel ist ohnehin thh_nr, nicht der Name.
  3. Der Jahrgang erscheint erst im Folgejahr. Gemessen an den vier Lieferungen: 2023 → 19.06.2024, 2024 → 16.06.2025, 2025 → 14.07.2026 (2022 kam am 24.04.2024 als Nachzügler). Finanzquelle.erwarteter_monat steht deshalb auf Juli, dem spätesten gemessenen Monat — mit Juni meldete der Cron den Jahrgang 2025 drei Wochen lang als überfällig, obwohl das Portal nur seinem üblichen Takt folgte.

/haushalt/investitionen trägt einen eigenen Block „Was diese Zahlen nicht sagen“, und er steht nicht am Ende, sondern als Abschnitt. Drei Sätze müssen hängen bleiben:

  • Schulgebäude stehen nicht darin — siehe den Abschnitt zum Investitionsprogramm unten.
  • Plan, nicht Ist. Was am Jahresende wirklich verbaut wurde, steht nicht darin. Bei Investitionen ist der Abstand notorisch groß.
  • Die Zahlen enden 2025, weil das Portal erst im Folgejahr liefert.
  • Die beiden Summen der Seite zählen Verschiedenes — auch das unten.

Der Anteil am Finanzhaushalt ist unsere Division und steht auf der Seite als solche gekennzeichnet; die beiden Beträge darin stehen so in der Quelle.

Die Ebene unter council_investments, auf derselben Seite: nicht „Schule und Bildung: 8,3 Mio. €“, sondern „BBS Haarentor: Ausstattung”. Quelle ist Anlage 004 des Haushaltsplans, seit acht Jahrgängen (2019–2026) im Anlagenbestand — kein Download nötig, der Cron liest sie wie den Gesamtergebnishaushalt und den Stellenplan aus council_attachments (council/investitionsprogramm.py).

Gemessen über alle acht Jahrgänge: 4.459 Vorhaben, 102 Teilhaushalts-Summen, kein verworfener Jahrgang.

Das Dokument rechnet sich selbst vor, auf drei Ebenen. Alle drei gehen in allen acht Jahrgängen auf den Euro auf:

  1. investitionsprogramm_abschnitt — die Vorhaben eines Teilhaushalts ergeben die Gesamtsumme seines Abschnitts.
  2. investitionsprogramm_wiederholung — diese Summe steht ein zweites Mal im Dokument, rund siebzig Seiten früher, in der Übersicht „Investitionssummen je Teilhaushalt“. Zwei unabhängig gesetzte Stellen, die übereinstimmen müssen. Verglichen wird über den Betrag, nicht über den Namen: Die Übersicht schreibt „Klima/Umwelt/Mobilität/Bau/Grün/Fri edh.“, der Abschnittskopf „…/Friedh.” — über den Namen verglichen scheiterte die Probe an einem Zeilenumbruch statt an einer Zahl.
  3. investitionsprogramm_kopftabelle — in dieser Übersicht ergeben die Teilhaushalte die ausgewiesene Gesamtsumme (2026: 170.140.918 €).

Reißt eine, fällt der ganze Jahrgang; halbe Maßnahmen kommen nicht herein.

Nur eine Spalte — und warum das die richtige Entscheidung ist

Abschnitt betitelt „Nur eine Spalte — und warum das die richtige Entscheidung ist“

Die Tabelle führt neun Spalten: Gesamtinvestitionssumme, bisher bereitgestellt, und je Planjahr Ansatz und Verpflichtungsermächtigung. Übernommen wird nur die erste.

Grund ist der Textextrakt: Leere Zellen fallen darin ersatzlos weg. Eine Zeile mit sechs Zahlen kann die Spalten 1, 2, 3, 4, 6, 8 meinen oder 1, 2, 5, 7, 8, 9 — welche, steht nirgends. Zu retten wären sie nur über die x-Koordinaten des PDFs, und die trägt council_attachments.raw_text nicht. Die erste Zahl einer Zeile ist dagegen immer die Gesamtinvestitionssumme: Sie ist die linke Spalte, und links kann nichts wegfallen. Eine Spalte, die trägt, ist mehr wert als fünf geratene — die Seite sagt, dass die Jahresaufteilung fehlt.

  1. Jede Maßnahme steht zweimal da — als IPSP-Element (I10.090126) und als Sachkonto-Detailzeile (I10.090126.525) mit denselben Beträgen; 31 Elternelemente haben mehrere Kinder. Gezählt wird nur das Elternelement.
  2. Namen brechen um, und der Rest landet mal auf einer eigenen Zeile („Inklusion, Baukosten,“ / „2026“ / „0 0“), mal vor den Beträgen derselben Zeile („Erwerb Sportgeräte,“ / „2027 110.000 110.000“). Die zweite Form kostete THH06, weil das führende Jahr als Betrag gelesen wurde.
  3. Ziffern im Namen sind keine Beträge. Fachdienst-Nummern („…, FD 102, 2026 135.000 135.000“), Bebauungsplan-Nummern („BPL 823“) und die Seitenzahl am Blattfuß sehen wie Zahlen aus. Die erste Fassung suchte vom Zeilenanfang nach der ersten Zahl und las 102 statt 135.000 — dem Teilhaushalt 02 fehlten 134.898 €, und Probe 1 hat es gefunden.

Gelesen wird deshalb vom Zeilenende her, solange die Token Tausenderpunkte tragen oder Null sind; besteht eine Zeile ganz aus Zahlen (mindestens zwei, mindestens eine mit Punkt), gilt der erste Token mit Punkt. Das deckt auch Beträge unter 1.000 ab, die es gibt („6.100 4.000 700 700 700“).

  • Keine Schulgebäude. Der Teilhaushalt „Schule und Bildung“ führt Ausstattung und die berufsbildenden Schulen namentlich (BBS Haarentor, BBS Maastrichter Str., BBS Wechloy, BZTG); die allgemeinbildenden Schulen stehen nur als Sammelposten. Sanierung und Neubau der Gebäude liegen beim Eigenbetrieb Gebäudewirtschaft und Hochbau mit eigenem Wirtschaftsplan, den dieses Dokument nur referenziert. Die Frage „wird MEINE Schule saniert?“ ist damit auch hier nicht beantwortet, und die Seite sagt das.
  • Plan, und zwar der Entwurf. Die Anlage hängt an der Einbringungs-Vorlage; was der Rat in den Beratungen ändert, steht nicht darin (das steht in der Herkunft als stand).
  • Negative Beträge sind normal, keine Fehler: Tilgungen, Zuschüsse von Land und Bund, Grundstücksverkäufe. „Finanzmanagement und Recht“ ist 2026 in der Summe −121,4 Mio. €. Sie bekommen kein Rot (keine Bewertungsfarben) und dürfen nicht weggelassen werden — ohne sie ginge keine Probe auf.

Kein Abgleich mit council_investments — und warum das keine Lücke ist

Abschnitt betitelt „Kein Abgleich mit council_investments — und warum das keine Lücke ist“

Naheliegend wäre, die Teilhaushalts-Summen des Programms gegen die Investitionen aus Datensatz 1101 zu halten. Das wäre keine Probe, sondern ein Fehlschluss: Das Dokument sagt in einer Fußnote der Übersicht selbst an, dass seine Gesamtsumme von der Zeile 31 „Saldo aus Investitionstätigkeit“ des Gesamtfinanzhaushaltes abweicht — zu aktivierende Eigenleistungen gehören ins Investitionsprogramm, sind aber nicht zahlungswirksam und stehen deshalb nicht im Finanzhaushalt. Dazu kommt: Das Programm führt die Gesamtkosten über alle Jahre, der Finanzhaushalt die Zahlungen eines Jahres.

Beide Zahlen stimmen und zählen Verschiedenes. Auf der Seite steht das als Erklärung, die Verbindung zwischen den beiden Blöcken ist Navigation („welche Vorhaben stecken in diesem Bereich?“) und nirgends eine Differenz.

/haushalt/gebaut beantwortet die Frage, die der Abschnitt darüber offen lässt: Was ist von dem Geplanten wirklich abgeflossen? Die Quelle ist das Statistische Jahrbuch der Stadt, Kapitel 11 — dieselbe Veröffentlichung, aus der auch die Schuldenzeitreihe kommt.

Die Stadt liefert beide in einer PDF-Datei, aber als zwei Tabellen, und sie begründet den Schnitt selbst in einer Fußnote:

Einführung Neues Komunales Rechnungswesens (NKR) zum 01. Januar 2010.

  • 1107 (2003–2009) heißt „Ausgaben der Stadt Oldenburg für eigene Investitionen“ und führt vier Arten, darunter „Gewährung von Darlehen“.
  • 1107-1 (2010–2025) heißt „Auszahlungen der Stadt Oldenburg für Investitionstätigkeiten“ und führt sechs Arten, nach den Positionen, die § 3 GemHKVO für die Finanzrechnung vorgibt. Der Untertitel nennt zusätzlich die Abgrenzung: „Rechnungsergebnisse laut Finanzrechnung der Kernverwaltung“.

Deshalb trägt jeder Jahrgang seine Spalte regelwerk, deshalb hat jedes Regelwerk eigene Feldnamen (auch wo sich zwei Überschriften ähneln), und deshalb zeichnet die Seite zwei getrennte Diagramme statt einer Achse. Wer die beiden Reihen verbindet, behauptet eine Vergleichbarkeit, die das Dokument mit seiner Fußnote gerade bestreitet.

investitionen_ist.zeilensumme ist die Probe des Dokuments: Die Auszahlungsarten einer Zeile ergeben die Summe, die dieselbe Zeile daneben ausweist. Sie greift in 22 von 23 Jahrgängen.

2019 reißt sie, im Dokument selbst: Die sechs Arten ergeben 66.595 T€, ausgewiesen sind 67.899 T€ — 1,304 Mio. € Unterschied. Welche der sieben Zahlen danebenliegt, sagt die Tabelle nicht.

Bei den Schulden rettete an dieser Stelle eine zweite, unabhängige Probe den Jahrgang (Fall 2022, siehe unten): Die Summe hing an der Pro-Kopf-Gegenprobe, also kam sie herein und nur die Aufteilung fiel. Für 1107-1 wurde dieselbe zweite Probe gesucht und nicht gefunden:

Gesucht Befund
Pro-Kopf-Spalte in der Tabelle Es gibt keine. Eine eigene Division wäre unsere Rechnung und könnte einen Übertragungsfehler nicht aufdecken.
Zweite Ausgabe des Jahrbuchs (Überlappungsprobe) Die Übersichtsseite führt Tabellen aus zwei Jahrgängen, für Kapitel 11 aber nur den von 2025. Die Vorjahresdatei ist nicht mehr abrufbar.
Spiegel im Open-Data-Portal Das Portal führt 91 Datensätze, darunter die kameralen Ausgaben bis 2009 und die ordentlichen Aufwendungen seit 2010 — die Investitions-Ist-Zahlen sind nicht dabei.
Der Plan als Gegenprobe Andere Abgrenzung, siehe unten — keine Probe, sondern eine andere Größe.

Also gilt die Grundregel ohne Rettungsanker: 2019 wird ganz verworfen, mit allen sieben Zahlen — anders als 2022 bei den Schulden ist hier auch die Summe durch nichts gedeckt. Die Seite zeichnet für das Jahr einen schraffierten Platzhalter mit gestricheltem Rahmen und benennt die Lücke im Text; der Endpunkt liefert sie als fehlend.

Mit ihrer Weite. Der Ingest-Lauf schreibt die verworfenen Jahrgänge nach council_investments_actual_rejected — Grund als Satz, differenz als Zahl (Arten minus ausgewiesene Summe, in Euro). fehlend trägt sie je Lücke mit, und die Seite macht daraus „verworfen: 1,3 Mio. € Differenz im Dokument“. Dieselbe Rolle wie aufteilung_verworfen bei den Schulden, nur eine Tabelle weiter: Dort trägt die gerettete Zeile ihre Lücke selbst, hier gibt es keine Zeile, die sie tragen könnte. Wo der Bestand keine Messung führt, kommt differenz: null — dann nennt die Seite die Lücke ohne Betrag. Der Betrag steht an keiner Stelle im Frontend; er wird mit dem nächsten Jahrbuch neu gemessen oder verschwindet.

Die naheliegende Zahl wäre Ist ÷ Plan. Gerechnet ergäbe sie für 2022–2025 Werte zwischen 41 % und 75 %. Sie steht auf keiner Seite, und das ist kein Übersehen:

  • Der Plan (council_investments, Datensatz 1101) ist nach Teilhaushalten gegliedert — nach Organisation.
  • Das Ist (council_investments_actual) stammt aus der Finanzrechnung der Kernverwaltung und ist nach Auszahlungsarten gegliedert.

Keine der beiden Quellen nennt die andere, keine weist eine Differenz aus, und keine sagt, dass ihre Gesamtsumme dieselbe Menge zählt. Die Quote wäre die meistgelesene Zahl der Seite und die einzige, die in keinem Dokument steht. Beide Seiten stehen deshalb nebeneinander und verlinken einander; die Begründung steht auf /haushalt/gebaut als eigener Block, nicht als Fußnote.

Dieselbe Regel gilt im Prompt der KI-Frage: qa._gebaut_block trägt sie im Klartext, und die Facetten investitionen und gebaut stehen in GELD_FACETTEN nebeneinander, damit nicht eine von beiden am Zeichenbudget herausfällt und die Warnung an einer Zahl hinge, die gar nicht im Kontext steht.

Wie bei Tabelle 1108 klebt eine Fußnotenziffer an einer Zahl — hier aber im Titel und nicht an den Beträgen: Aus „in Tausend Euro 2010 bis 2025“ mit Fußnote 1 wird im Extrakt 2010 bis 20251. Die Erkennung nimmt die Ziffer ausdrücklich als Marke an (Jahreszahlen haben vier Stellen). In den Datenzeilen selbst trägt heute kein Betrag eine Marke — geprüft an allen 23 Zeilen mit einem Positions-Dump des PDFs; die Zellen-Regel bringt sie trotzdem mit, weil sie nichts kostet.

  • Nicht die ganze Bautätigkeit. Gezählt wird die Kernverwaltung. Was der Eigenbetrieb Gebäudewirtschaft und Hochbau baut — seit 2010 ein großer Teil des städtischen Hochbaus —, steht nicht darin.
  • Kein einzelnes Vorhaben. „Baumaßnahmen: 16,2 Mio. €“ sagt nicht, welche Straße. Die Vorhaben-Ebene gibt es nur auf der Plan-Seite (council_investment_measures, Abschnitt darüber) — und auch dort ohne die Schulgebäude, die beim Eigenbetrieb liegen. Ein Ist je Vorhaben führt keine der beiden Quellen.
  • „Sonstige Investitionstätigkeit“ bleibt unaufgeschlüsselt und ist in den jüngeren Jahrgängen einer der größten Posten (2018 sprang er von 123.000 € auf 19 Mio. €). Das Jahrbuch sagt nicht, was darin steckt, und die Seite vermutet es nicht.
  • Ein Abfluss, kein Baufortschritt. Eine Abschlagszahlung im Dezember zählt für das alte Jahr, auch wenn der Bagger im März kommt.

/haushalt/schulden beantwortet die häufigste offene Frage an den Bereich — und die Antwort hängt vollständig daran, was gezählt wird. Bei Kommunalschulden gibt es zwei Werte, die beide „die Schulden der Stadt“ heißen und sich um ein Vielfaches unterscheiden.

Quelle ist Tabelle 1108 des Statistischen Jahrbuchs (council/schulden.py, scripts/ingest_schulden.py): eine Seite, dreißig Jahre, je Jahr vier Schuldenarten, ihre Summe und der Betrag je Einwohner*in.

Gezählt wird die Stadt als Rechtsträger: Kernhaushalt und Eigenbetriebe, ohne die rechtlich selbstständigen Beteiligungen. Das steht nicht als Satz in der Tabelle, sondern in ihren Spalten und Fußnoten:

  1. Die vierte Spalte heißt „Schulden der Eigenbetriebe einschließlich Kliniken und innere Darlehen“ — Eigenbetriebe haben keine eigene Rechtspersönlichkeit, ihre Schulden sind rechtlich die der Stadt.
  2. Fußnote 1: „Ab 1999 ohne Kliniken, die jetzt als Klinikum Oldenburg AöR geführt werden.“ Sobald eine Einheit eine eigene Rechtsform bekommt, fällt sie aus der Reihe. Das Kriterium ist damit benannt: Rechtsträgerschaft, nicht Eigentum.
  3. Fußnote 3 sagt es ausdrücklich (Weser-Ems-Halle): „Die Schulden des Eigenbetriebs verbleiben rechtlich bei der Stadt. Wirtschaftlich werden die Darlehen der WEH GmbH & Co. KG zugerechnet.“ Die Tabelle folgt der rechtlichen Zurechnung.

Der Wortlaut steht als schulden.ABGRENZUNG im Backend und kommt über das Feld abgrenzung des Endpunkts auf die Seite — zwei Formulierungen für dieselbe Grenze wären zwei Grenzen.

  • schulden_summenzeile (intern): Die vier Schuldenarten müssen die Summe ergeben, die die Tabelle daneben ausweist. Ohne Toleranz — die Quelle rundet auf volle Tausend und geht in 30 von 31 Jahrgängen auf den Euro auf.
  • schulden_prokopf (unabhängig, und damit die stärkere): Die ausgewiesene Gesamtschuld, geteilt durch die Einwohnerzahl aus council_einwohner (Datensatz 1102), muss den Pro-Kopf-Betrag derselben Zeile ergeben. Beide Seiten stammen aus verschiedenen Veröffentlichungen der Stadt, und die Stichtage decken sich exakt (1108 im Kopf: „Bevölkerungsstand: 31. Dezember des Vorjahres“; 1102 in der Spaltenüberschrift: „Einwohner am 31.12. des Vorjahres“). Ergebnis: 16 von 16 prüfbaren Jahrgängen gehen auf.

2022 ist der Fall, für den beide gebaut sind. Dort ergeben die Schuldenarten 282.535 T€, ausgewiesen sind 281.457 T€ — 1,078 Mio. € Unterschied, im Dokument selbst. Die Summenprobe reißt. Die Pro-Kopf-Probe entscheidet den Jahrgang: 281.457.000 € / 170.389 = 1.651,85 €, und genau 1.652 € nennt die Tabelle. Also kommt die Summe herein und die Aufteilung nicht (die vier Artenspalten stehen auf NULL, aufteilung_verworfen hält die Lücke fest). Welche Spalte danebenliegt, sagt das Dokument nicht, und geraten wird nicht. Die Seite benennt die Lücke, statt einen leeren Balken zu zeichnen.

Weil die Probenlage nicht für alle Jahrgänge gleich ist, schreibt der Ingest drei Herkünfte statt einer: 1995–2009 (nur Summenprobe, davor gibt es keine Einwohnerzahlen), 2010–2025 ohne 2022 (beide), 2022 (nur Pro-Kopf). Der Beleg auf der Seite soll für die jeweilige Zahl gelten.

Fußnotenziffern kleben an den Beträgen: 26.5981 ist 26.598 mit Fußnote 1, nicht 265.981. Ein Parser, der bloß die Tausenderpunkte entfernt, liest dort das Zehnfache und meldet nichts. Auflösbar ist das, weil deutsche Tausendergruppen genau drei Ziffern haben — was hinter der letzten vollständigen Gruppe steht, gehört nicht mehr zur Zahl. Dasselbe gilt für das r revidierter Werte (251.160r), das als Angabe erhalten bleibt. Vier Jahrgänge tragen solche Marken (1999, 2001, 2008, 2010), und die Summenprobe schließt in allen vieren — sie prüft damit nicht nur die Quelle, sondern auch den Entzerrer.

Die beiden größten Sprünge der Reihe sind keine Politik, und die Seite sagt das im Text statt es der Farbe zu überlassen (Bewertungsfarben sind im ganzen Bereich ausgeschlossen, s. components/grafik/hantel.tsx):

  • 2001, −139,1 Mio. €: Die Stadtentwässerung ging an den Oldenburgisch-Ostfriesischen Wasserverband, der dabei Darlehen über 139,5 Mio. € übernahm (Fußnote 2). Kein Abbau, sondern ein Übergang mit der Aufgabe.
  • 2010, 108,9 Mio. € Spaltenwechsel bei nahezu gleicher Summe: Gründung des Eigenbetriebs Gebäudewirtschaft und Hochbau (Fußnote 4). Die Kreditmarkt-Spalte fällt von 130,8 auf 30,5 Mio., die Eigenbetriebs-Spalte steigt von 18,6 auf 123,5 Mio., die Summe bewegt sich von 149,5 auf 154,0 Mio. Wer nur die erste Spalte zeigte, verkündete einen Schuldenabbau um drei Viertel, den es nie gab — der Grund, warum die Spalten einzeln gespeichert werden.

Aus demselben Grund trägt die Seite zwei Ansichten: Über dreißig Jahre sind die Schulden absolut um 35,5 Mio. € gestiegen und je Einwohner*in um 106 € gesunken, weil die Stadt in derselben Zeit gewachsen ist. Nur die absolute Reihe zu zeigen läse Bevölkerungswachstum als Schuldenaufbau.

Nachbewilligungen: was am Plan vorbei beschlossen wurde

Abschnitt betitelt „Nachbewilligungen: was am Plan vorbei beschlossen wurde“

council/nachbewilligungen.py · Tabellen council_supplementary_approvals, council_supplementary_years, council_supplementary_channels · Ingest scripts/ingest_nachbewilligungen.py · Seite /haushalt/plan-ist

Nach § 117 NKomVG braucht jede Ausgabe, die im beschlossenen Haushalt nicht oder nicht in dieser Höhe steht, eine eigene Bewilligung. Im Ratsinformationssystem sind das seit 2018 161 Vorlagen.

Quelle Was sie beantwortet Zeitraum
Vorlagen im RIS Was hat der Rat beschlossen? Mit Betrag und Beschluss-Seite seit 2018
Rechenschaftsbericht, Kapitel 3 Wie viel wurde insgesamt nachbewilligt, auf welchem der vier Wege? 2022–2024

Der zweite Bestand ist der Grund, warum es den ersten nicht allein tun darf: Die Gesamtsumme stieg von 26,68 (2022) über 40,24 (2023) auf 57,49 Mio. € (2024), der Anteil mit Ratsbeschluss fiel von 89 auf 73 %. Die vier Wege sind Rat, Oberbürgermeister, Fachdienst 200 per Haushaltsvermerk und Eilentscheidung.

Stufe 1 ist der Titel, Stufe 2 der Beschlussvorschlag der Vorlage. Gemessen am Bestand vom 18.08.2026: 145 von 152 Einzelvorlagen (95,4 %) tragen ihren Betrag im Titel, die restlichen sieben schließt der Beschlussvorschlag vollständig — zweistufig also 152 von 152.

Die Ausreißer, an denen ein naiver Regex scheitert, stehen alle als Fixture in tests/test_nachbewilligungen.py: „1 Million EUR“ ausgeschrieben, „341.000 EU“ als Tippfehler der Stadt, „450.000,00 €“ mit Cent und Eurozeichen, „insgesamt 500.000 Euro“, und die umgedrehte Wortstellung („Stadion Marschweg … – Außerplanmäßige Verpflichtungsermächtigung …“).

Zwei Dinge, die im Bestand anders liegen, als man erwartet:

  • council_decisions.kvonr ist durchgehend NULL (8.369 von 8.369). Der Join auf council_templates läuft ausschließlich über den Text vorlage_nr; ein Join über kvonr liefert schweigend null Treffer.
  • council_templates.beschlussvorschlag ist fast leer (7 von 5.019 Zeilen). Die zweite Stufe erntet den Vorschlag deshalb aus raw_text — mit derselben Funktion, die auch die Spalte füllt (council/ernte.py).
  1. nachbewilligung_volltext (intern) — der Titelbetrag steht im Volltext derselben Vorlage noch einmal: 145 von 145.
  2. nachbewilligung_ratsabgleich (extern, die härteste im Bereich) — der Rechenschaftsbericht nennt dieselben Fälle mit Vorlagen-Nummern.
  3. nachbewilligung_tabellenprobe (im Dokument) — die vier Wege ergeben die Summenzeile, beide Spalten zusammen die Gesamtsumme des Fließtextes.
Jahr unsere Summe Bericht (Rat) Abweichung Fallliste
2022 23.956.742,00 23.825.742,00 +0,55 % 11 von 12
2023 33.871.800,00 33.871.700,00 +100 € 26 von 26
2024 43.096.100,00 42.171.646,29 +2,19 % 21 von 21

Die 2024er Abweichung ist auf den Cent aufgelöst und keine Unschärfe, sondern eine Definitionsdifferenz: Drei Vorlagen wurden niedriger gebucht als beantragt — 24/0411 (190.000 → 51.500), 24/0678 (430.000 → 230.000) und 24/0648 (11.232.400 → 10.646.446,29, „Reduzierung aufgrund fehlender Erträge“). Zusammen exakt 924.453,71 €. Wir zählen, was die Vorlage beantragt; der Bericht zählt, was gebucht wurde — deshalb endet seine Zahl auf ,29 und unsere sind glatt. Die 2022er Differenz ist eine einzige Vorlage (22/0914, 131.000 €), die der Bericht in seinem Kapitel nicht führt; sie ist zugleich der einzige Rest beim Nummern-Abgleich, Fallliste und Summe zeigen also auf denselben Fall.

Zwei Dokument-Widersprüche, beide angezeigt statt geglättet

Abschnitt betitelt „Zwei Dokument-Widersprüche, beide angezeigt statt geglättet“
  • 2022: Der Fließtext nennt 26.969.523,30 €, seine eigene Tabelle ergibt 26.681.523,30 € — 288.000 € Unterschied. Der Fließtext widerspricht dabei sich selbst: Seine beiden Teilbeträge ergeben die Tabellensumme, nicht seine Gesamtzahl.
  • 2023: In der Zeile „Fachdienst 200“ steht investiv Anzahl 0 und trotzdem ein Betrag von 1.051.184,65 € — auf den Cent derselbe Wert wie im Vorjahr an derselben Stelle. Die Summenzeile rechnet ihn nicht mit (8.470.300,00 + 365.007,05 = 8.835.307,05 genau), der Fließtext auch nicht. Drei unabhängige Signale sprechen für einen Übernahmerest aus der Vorjahrestabelle; repariert wird trotzdem nichts.

2024 geht beides auf den Cent auf — der Jahrgang ist die Gegenprobe dafür, dass die Probe nicht grundsätzlich meckert.

  1. Verpflichtungsermächtigungen gehören nicht in die Summe. Sie binden künftige Jahre, fließen aber nicht in diesem; der Bericht zählt sie ausdrücklich getrennt. 19 Vorlagen im Bestand.
  2. Sitzungsdatum ≠ Haushaltsjahr. Maßgeblich ist der Jahrgang der Vorlagen-Nummer: Das Kapitel für 2022 führt die Vorlage 23/0010, das für 2023 die 24/0029, das für 2024 die 25/0002. Naiv nach Sitzungsjahr summiert liegt man 20 bis 27 % daneben.
  3. Die Sammelberichte tragen Schwellenwerte, keine Beträge. „… bis zu 50.000 Euro“ ist die Grenze, unter der der Rat gar nicht entscheidet. Neun solche Vorlagen; sie bekommen art='schwelle' und keinen Betrag.

„Im Rat beschlossen“ heißt mehr, als es klingt

Abschnitt betitelt „„Im Rat beschlossen“ heißt mehr, als es klingt“

Der Rechenschaftsbericht bucht auch das unter „Beschluss des Rates“, was der Ausschuss für Finanzen und Beteiligungen abschließend entscheidet. 2024 haben 8 von 21 Fällen keine Plenarsitzung mehr gesehen — der Rat tagte am 16.12.2024 als Haushaltssitzung mit 21 Punkten, keiner davon eine Nachbewilligung. Wer den Rats-Anteil aus dem Gremiennamen rechnet, veröffentlicht für 2024 30.896.100 statt 43.096.100 €, also 28 % zu wenig — ausgerechnet für die Kennzahl „der Rats-Anteil sinkt“. Deshalb trägt Bewilligung zwei Eigenschaften: im_rat (das Plenum hat abgestimmt, eine Auskunft für Leser*innen) und ratsentscheidung (die Definition des Berichts, die einzige, die in eine Summe darf).

287 Beschlusszeilen stehen über 156 Vorlagen — 131 Dubletten, weil Finanzausschuss und Rat über dieselbe Sache abstimmen. Je Zeile gezählt wäre fast jeder Betrag doppelt in der Summe. Fünf Vorlagen tragen gar keine Beschlusszeile; beantragt ist nicht bewilligt, sie zählen nicht mit.

Der Ingest lädt nichts nach, aber er braucht Vorlauf: Die drei Rechenschaftsberichte (Dokumente 265441, 280862, 295295) liegen mit status='listed' und leerem raw_text im Bestand. Ohne backfill_anlagen_texte.py --nur-finanz bleibt Kapitel 3 leer — und übrig bliebe genau die halbe Wahrheit, gegen die dieser Block gebaut ist. Der Ops-Workflow hält die Reihenfolge ein.

Die fünf älteren Berichte (2017–2021: 192336, 205649, 219465, 238770, 250437) liegen ebenfalls als Anlage vor. Seit dem 18.08.2026 tragen sie auch Volltext — das Label-Muster der Finanzquellen kannte „Rechenschaftsbericht“ bis dahin nicht. Sie kommen trotzdem nicht in BERICHTE dazu: Gegen kapitel3 gehalten findet der Parser dort nur einen bis drei der vier Entscheidungswege, und die Spaltenprobe reißt in jedem der fünf Jahrgänge (investiv 0,55 bis 0,92 Mio. €). Das Kapitel gibt es, sein Tabellenlayout ist ein anderes. Die Reihe bleibt bei 2022–2024, statt Zahlen zu zeigen, die ihre eigene Probe nicht bestehen.

In acht Jahren wurde keine einzige Nachbewilligung abgelehnt. Das steht auf der Seite als Befund, nicht als Vorwurf: Die Vorlagen sind vorher im Fachausschuss beraten, und was dort keine Mehrheit findet, erreicht den Rat meist gar nicht erst.

Die dreizehn Kennzahlen: die Zusammenfassung der Stadt selbst

Abschnitt betitelt „Die dreizehn Kennzahlen: die Zusammenfassung der Stadt selbst“

Am Ende jedes Rechenschaftsberichts steht die Anlage „Kennzahlenübersicht und Berechnungsmethoden“: dreizehn Zahlen, auf die die Stadt ihren ganzen Jahresabschluss eindampft — Eigenkapitalquote, Anlagenintensität, Steuerquote, Verschuldung je Kopf. Parser: council/kennzahlen.py, Tabellen council_indicators und council_indicator_formulas, Seite /haushalt/kennzahlen.

Der Grund, diese Schicht überhaupt zu bauen, steht unter der Tabelle: die gedruckten Rechenwege. „Ermittlung: Sachvermögen * 100 / Bilanzsumme“ ist ein Zitat, keine Definition von uns — und damit die einzige Stelle im Bereich, an der eine Kennzahl gezeigt werden darf, ohne dass wir sie erfunden hätten.

Ein Bericht sind fünf Jahre, und die Berichte widersprechen sich

Abschnitt betitelt „Ein Bericht sind fünf Jahre, und die Berichte widersprechen sich“

Jeder Bericht druckt fünf Jahrgänge. Sechs Berichte (2019–2024) decken so 2015–2024 ab, und die mittleren Jahre stehen mehrfach da. Von 240 doppelt gedruckten Zellen stimmen 221 exakt überein. Die übrigen sind der Ertrag dieser Schicht:

Kennzahl Jahr alt neu
Steuerquote 2021 45,90 % (Bericht 2021) 49,05 % (2022), dann 45,92 % (2023)
Steuerquote 2022 46,16 % (Bericht 2022) 44,00 % (2023)
Verschuldung je Einwohner*in (mit Rückstellungen) 2021 2.340,30 € 2.224,11 €
Netto-Neuinvestitionen je Einwohner*in 2021 120,45 € 151,81 €

Die Steuerquote 2021 geht hoch und wieder zurück: Der Bericht 2022 hat diese Zeile verrechnet, der Bericht 2023 hat sie ohne Anmerkung geradegezogen. Deshalb steht bericht_jahr im Primärschlüssel — ohne ihn überschriebe der jüngere Stand den älteren still, und die Korrektur wäre weg.

Drei Kennzahlen haben zwischen 2019 und 2024 ihre gedruckte Formel geändert. Ob das etwas bedeutet, wird gemessen, nicht angenommen — ein geänderter Rechenweg schaltet den Wertvergleich nicht ab:

  • Personalintensität — „Aufwand für Personal (inklusive Versorgung)“ wurde „Aufwendungen für aktives Personal“. Die Werte springen (2020: 26,03 % → 25,09 %). Ein echter Definitionswechsel; über die Stelle läuft auf der Seite keine Linie (JahrWert.bruchDavor, GB-00).
  • Verschuldung je Einwohner*in — „Gesamtschulden“ wurde „Schulden“.
  • Vermögen je Einwohner*in — „Gesamtvermögen (inklusive liquide Mittel)“ wurde „Aktiva (ohne aktive Rechnungsabgrenzung)“.

Bei den letzten beiden bleiben die Werte über den Wechsel hinweg auf den Cent gleich: umformuliert, nicht umgerechnet. Ohne den Vergleich hätte man sie zusammen mit der Personalintensität als Brüche behandelt — drei Reihen zerschnitten, wo eine es verdient.

  1. kennzahlen_gegen_bilanz — Anlagenintensität, Infrastrukturquote und Eigenkapitalquote II lassen sich aus council_balance_sheet nachrechnen. 87 Nachrechnungen über 2016–2024, keine Abweichung über der gedruckten Genauigkeit.
  2. kennzahlen_ueberlappung — die 240 doppelten Zellen (s. o.).
  3. kennzahlen_vermoegensprobe — „Vermögen je Einwohner*in“ mal „Anzahl der Einwohnenden“, zwei Zeilen derselben Tabelle, ergibt die Bilanzsumme ohne aktive Rechnungsabgrenzung. Vier unabhängige Größen, 20 Jahrgang- Bericht-Paare, Abweichung unter einem Tausendstel Prozent. Die Toleranz ist gerechnet: Ein je-Kopf-Wert mit zwei Nachkommastellen darf um einen halben Cent danebenliegen, mal 176.068 Einwohnenden also um rund 880 €.

stellen hält fest, wie viele Nachkommastellen gedruckt waren: 2019 stand „48%“, ab 2021 „53,15%”. Ein stumpfer Vergleich meldete deshalb reihenweise Abweichungen, die nur Rundung sind; die Toleranz der Überlappungsprobe kommt aus der gröberen der beiden Angaben. Auf der Seite hängt daran auch die Vorjahresdifferenz: Von 54,62 % auf 50,11 % sind 4,51 Prozentpunkte, nicht 4,51 % — die Grafik hat dafür ein eigenes Format (differenzFormat).

2017 und 2018 zeigen dieselben Kennzahlen nur als Diagramm: Die Werte stehen als Balkenbeschriftung zwischen den Achsenwerten, und der „Ermittlung:“-Satz steht dort vor seinem Diagramm statt darunter. Das wäre geraten — und es wäre umsonst, denn die Jahrgänge 2015–2018 stehen als Tabelle im Bericht 2019.

Die Berichte 2017–2021 lagen bis zum 18.08.2026 ohne Volltext im Bestand, und zwar aus einem Ein-Buchstaben-Grund: Das Label-Muster der Finanzquellen kannte %chlussbericht% (für die Schlussberichte des Rechnungsprüfungsamts), und „Rechenschaftsbericht“ endet auf -chaftsbericht. Die Berichte ab 2022 rutschten nur durch, weil ihr Titel zusätzlich „Jahresabschluss“ enthält. Die Quelle kennzahlen bringt das Muster jetzt selbst mit; ausgeschlossen werden die Stiftungs-Berichte namentlich und nicht über „Stiftung“, denn der städtische Bericht heißt selbst „… der Kernverwaltung und ihrer nicht rechtsfähigen Stiftungen“.

Der Haushalt neben dem Haushalt: die Wirtschaftspläne

Abschnitt betitelt „Der Haushalt neben dem Haushalt: die Wirtschaftspläne“

Der Kernhaushalt ist nicht alles, was der Rat beschließt. Daneben stehen die Wirtschaftspläne der Eigenbetriebe und Gesellschaften — eigene Erfolgs- und Vermögenspläne, in derselben Sitzung entschieden. Der größte ist der Eigenbetrieb Gebäudewirtschaft und Hochbau (EGH): 2026 rund 82,8 Mio. € Erträge und Aufwendungen, dazu ein Vermögensplan über 51,1 Mio. €. Er baut und saniert die städtischen Gebäude — also auch die Schulen, die im Investitionsprogramm des Kernhaushalts ausdrücklich fehlen.

Was /haushalt/konzern und /haushalt/beteiligungen von diesen Betrieben zeigen, ist ihr Ist, aus Gesamtabschluss und Beteiligungsbericht, und beides hinkt rund zwei Jahre hinterher. Was sie für das laufende Jahr vorhaben, stand bis 08/2026 nirgends.

Die einzige Schicht, deren Quelle eine Vorlage ist

Abschnitt betitelt „Die einzige Schicht, deren Quelle eine Vorlage ist“

Nicht eine Anlage, sondern der Beschlussvorschlag der Ratsvorlage:

im Erfolgsplan
mit Erträgen von 82.815.150 Euro
mit Aufwendungen von 82.824.771 Euro
mit steuerlichen Aufwendungen von 6.000 Euro
und einem Jahresergebnis von - 15.621 Euro
und im Vermögensplan
mit Einzahlungen und Auszahlungen von je 51.134.100 Euro
und Verpflichtungsermächtigungen von 104.980.000 Euro

Das ist der Text, über den abgestimmt wird — keine Zusammenfassung und keine Anlage, die später ausgetauscht werden könnte. council_templates führt ihn längst als Volltext; es brauchte nur niemand.

  1. wirtschaftsplan_erfolgsplan — Erträge − Aufwendungen − steuerliche Aufwendungen = Jahresergebnis. Vier Zahlen, von denen die vierte aus den ersten dreien folgt. Über alle acht Jahrgänge (2019–2026) geht sie auf den Cent auf. Die Toleranz steht deshalb auf 0,005 € und nicht auf 1 €: Die Quelle führt volle Euro, eine Ein-Euro-Toleranz ließe genau den einen Fehler durch, den die Probe sehen könnte (dieselbe Überlegung wie bei investitionen.TOLERANZ_EUR).
  2. wirtschaftsplan_jahr — Das Haushaltsjahr steht im Fließtext und im Titel der Vorlage, beide müssen dasselbe sagen. Das ist die wichtigere Probe für den wahrscheinlicheren Fehler: Eine Vorlage vom Oktober 2025 beschließt das Jahr 2026. Wer das Jahr aus dem Vorlagen-Datum nähme, verschöbe die ganze Reihe um eins.

Von 46 Wirtschaftsplan-Vorlagen im Bestand (2018–2026) tragen acht diesen Block, alle acht vom EGH. Die übrigen 38 nennen im Beschlusstext keine Zahl:

Betrieb Vorlagen im Beschlusstext
Gebäudewirtschaft und Hochbau 8 vollständige Eckwerte
Bäderbetrieb (BBO) 12 „wird in der als Anlage beigefügten Fassung zugestimmt“
Bäderbetriebsgesellschaft (BBGO) 10 eine Zahl (Jahresfehlbetrag), ohne Gegenrechnung
Abfallwirtschaftsbetrieb 8 Zustimmung zur Anlage
Stadion 5 eine Zahl (maximaler Fehlbetrag)
Eigenbetrieb Hafen 2 Zustimmung zur Anlage

Gelesen wird deshalb nur, was sich prüfen lässt. Bei der BBGO stünde eine Zahl im Fließtext („Jahresfehlbetrag von −10.128.335 Euro“) — sie zu übernehmen hieße, eine Angabe ohne Gegenprobe in dieselbe Tabelle zu schreiben, in der daneben lauter cent-genau geprüfte stehen. Die Herkunft sagte „ungeprüft“, und auf der Seite sähe die Zahl aus wie der Rest. Was fehlt, fehlt gezählt (wirtschaftsplan.ohne_eckwerte, der Ingest-Lauf listet es nach Betrieb).

Und es ist der Verwaltungsentwurf, nicht der Beschluss — der Text sagt das selbst („in der Fassung des Verwaltungsentwurfes vom 01.10.2025“). Das Datum wird mitgelesen und gehört an jede Anzeige, dieselbe Vorsicht wie beim Gesamtergebnishaushalt. Der Jahrgang 2024 schreibt „des I. Verwaltungsentwurfes“; die Ordnungszahl zählt die Fassung und wird zugelassen, aber nicht ausgewertet.

Welche Dokumente geprüft wurden — und was daraus wurde

Abschnitt betitelt „Welche Dokumente geprüft wurden — und was daraus wurde“

Damit im nächsten Oktober niemand bei null anfängt: Das hier ist der Befund je Betrieb, mit Stand 20.08.2026. Die Spalte „Anlage“ nennt die council_attachments.document_id — der Anker, der Label- und URL-Wechsel überlebt.

Betrieb Vorlagen Eckwerte im Beschlusstext Anlage lesbar?
Gebäudewirtschaft und Hochbau (EGH) 8 (2019–2026) ja, alle acht — eingelesen nicht nötig
Abfallwirtschaftsbetrieb (AWB) 8 nein teils — s. u.
Bäderbetrieb (BBO) 12 nein ja (Text vorhanden)
Bäderbetriebsgesellschaft (BBGO) 10 nein ja
Stadion / Stadionplanungsges. 5 nein ja
Eigenbetrieb Hafen 2 (2019–2020) nein — aber die Kernzahl steht drin ja, und sie belegt sie

Der AWB im Detail (geprüft an den heruntergeladenen PDFs):

Jahrgang Anlage Befund
2019 193959 Scan — keine Textebene
2020 208461 Scan
2021 224533 Scan
2023 252313 Text, Layout A (Posten je Bereich)
2024 269051 Text, Layout A
2025 283481 Text, Layout A
2025 (Anpassung) 292139 Text, Layout A
2026 299038 Text, Layout B (Positionsnummern, „Summe Erträge“)

Zwei Layouts, drei Scans — der AWB ist also nicht der einfache Fall, als der er zuerst aussah. Layout A gliedert jeden Posten nach den vier Betriebszweigen (Straßenreinigung · Abfallsammlung · Abfallbehandlungsanlagen · Werkstatt) und setzt darunter eine unbeschriftete Summenzeile; die vier ergeben sie exakt (5.153.245 + 15.492.551 + 680.984 = 21.326.780). Layout B nummeriert die Posten nach § 275 HGB und schreibt „Summe Erträge“ aus, rundet dabei aber je Position: 23.824.312 + 17.812 + 378.352 = 24.220.476 gegen ausgewiesene 24.220.475. Eine Toleranz von 2 € ist hier Pflicht, cent-genau wie beim EGH geht nicht.

OCR: gelesen ist gelesen — maskiert wird an der Index-Grenze

Abschnitt betitelt „OCR: gelesen ist gelesen — maskiert wird an der Index-Grenze“

Der OCR-Lauf schreibt status = 'ok' und vermerkt in ocr_modell, welches Sehmodell den Text gelesen hat. Ein gescannter Wirtschaftsplan ist damit so durchsuchbar wie ein getippter.

Zwei Stufen (council/kontaktdaten.py), und der Unterschied ist wichtig:

was wo
entfernen() IBAN, BIC, vollständige Anschrift schon beim Speichern — sie kommen gar nicht erst in den Bestand
maskieren() zusätzlich Telefon, Fax, E-Mail an der Index-Grenze — im Bestand bleiben sie

entfernen() ist ohne erneutes Laden des PDF unumkehrbar. Deshalb steht dort nur, was nachweislich kein Parser braucht: Eine Kontonummer ist nie eine Haushaltszahl, und eine vollständige Postanschrift auch nicht — der Straßenname allein bleibt ja stehen, und der ist das Einzige, was das Investitionsprogramm daraus braucht.

Telefon und E-Mail bleiben bewusst im Bestand: Eine Rufnummer der Verwaltung kann der Anker sein, an dem jemand eine Fundstelle im PDF wiederfindet. Am Index fallen sie trotzdem — store.anlagen_missing_embeddings() und store.rebuild_fts() schicken ihren Text durch maskieren().

scripts/bereinige_kontaktdaten.py holt nach, was vor dem 20.08.2026 hereinkam: 81 IBAN, 42 BIC und 1.453 Anschriften in 607 Dokumenten. Der Lauf ist idempotent und hängt im Ops-Workflow.

Das ist kein OCR-Thema. Gemessen am Prod-Stand vom 16.08.2026 tragen 606 Anlagen Kontaktdaten — 1.382 Anschriften, 1.024 Telefon- und Faxnummern, 533 E-Mail-Adressen, 81 IBAN, 42 BIC — und sie standen längst im Suchindex, ganz ohne Texterkennung. Der Textverlust durch die Maskierung liegt bei 0,22 %.

Was bewusst NICHT maskiert wird:

  • Namen. Der Bestand nennt 1.271 Ratsmitglieder namentlich, Protokolle führen jede Wortmeldung mit Namen, Vorlagen nennen Amtsleitungen. Namen herauszunehmen hieße, das halbe System unbrauchbar zu machen.
  • Straßennamen ohne Postleitzahl dahinter. Das ist die gefährlichste Falle dieses Moduls: „Ausbau Bümmersteder Tredde“, „Sanierung Butjadinger Straße 61“ — der halbe Investitionsbereich besteht aus Straßennamen. Erkannt wird eine Anschrift deshalb nur am Stück (Straße, Hausnummer, Postleitzahl, Ort) oder als eigene Zeile (26122 Oldenburg).

Ein erstes Muster nahm jede fünfstellige Zahl vor einem großgeschriebenen Wort für eine Postleitzahl — und traf damit „Produkt 11101 Verwaltungssteuerung“. Der eigene Test hat das gefunden, nicht der Betrieb.

Drei Entscheidungen, die vorher gemessen wurden (an zwei echten AWB-Seiten, 300 dpi gerade und 200 dpi quer, bewertet gegen die Rechenprobe dieses Bereichs):

  1. Wir rastern selbst, statt das PDF hochzuladen. OpenRouters file-parser-Plugin läuft über OpenRouters eigenen Mistral-Schlüssel — unser provider-Block aus kern.llm steuert nur die Modell-Endpunkte. Der OCR-Schritt liefe damit still an NWZ_OPENROUTER_ROUTING vorbei, also an der Anbieter-Sperre und der Zero-Data-Retention-Pflicht.
  2. Wir brauchen dafür keinen Renderer. Jede Seite dieser Scans ist genau ein eingebettetes JPEG, das pypdf unverändert herausgibt — keine neue Abhängigkeit im Deployment. Nur Vektorseiten (der Schlussbericht des Rechnungsprüfungsamts) bräuchten einen; der ist optional, und fehlt er, bleibt die Seite ungelesen statt falsch gelesen.
  3. Die Lage-Metadaten lügen. Von drei AWB-Jahrgängen tragen zwei /Rotate passend zum Inhalt, einer nicht. Wir folgen ihm gar nicht — auf der um 90° liegenden Seite gingen alle 24 Rechenproben auf. Die Modelle kommen mit der Drehung zurecht, unsere Metadaten nicht.

Die Lücke, die dabei aufging — und seit dem 20.08. geschlossen ist. Die Spaltenprobe ist skaleninvariant: Steht in TEUR über der Tabelle und liest jemand die Angabe nicht mit, liegt die ganze Spalte um den Faktor 1.000 daneben — und Erträge − Aufwendungen = Ergebnis geht trotzdem auf, weil sich der Faktor auf beiden Seiten wegkürzt. TOLERANZ_EUR kann das prinzipiell nicht sehen.

spaltenproben() bricht deshalb ab, wenn einheit_an_der_kopfzeile() eine Angabe findet, die nicht volle Euro meint. Umgerechnet wird bewusst nicht: Eine Tabelle in TEUR gibt es im Bestand bisher nicht, und einen Umrechner zu bauen, den niemand an einem echten Dokument geprüft hat, hieße raten. Wer den ersten solchen Jahrgang findet, trägt die Umrechnung ein und prüft sie an ihm.

Das Fenster ist eng — vier Zeilen über der Kopfzeile —, und das ist der Punkt: Jeder Vorbericht redet irgendwo von „Mio. €“ (beim AWB rund 140 Zeilen früher von „ca. 20,3 Mio. €“). Wer im ganzen Dokument sucht, kann keine einzige Tabelle mehr lesen.

Ebenso gilt: Vollständigkeit ist keine Richtigkeit — fehlt eine Zeile, prüft die Probe weniger und geht auf. Deshalb zählt ocr.Lesung mit, wie viele Seiten wirklich gelesen wurden.

Was der erste Lauf ergab (Dokument 193959, AWB-Wirtschaftsplan 2019, sechs Seiten): 62 von 66 Spaltenproben gehen cent-genau auf, vier sind um exakt 1 € daneben. Ein Zweitleser (Claude Sonnet 4.6) las dieselbe Seite unabhängig und lieferte zeichengleiche Zahlen — der Riss steht also im Dokument der Stadt, nicht im OCR. Genau der Fall, für den TOLERANZ_EUR = 2.0 kalibriert wurde.

Die drei fehlenden Jahrgänge lesen sich ohne jede Parser-Erweiterung. Kurz sah es anders aus: Ein erster Lauf über sechs Seiten fand keine Summenzeile, und der Schluss lag nahe, das Layout 2019–2021 sei ein anderes. Er war falsch. Gesamtertrag / Gesamtaufwendungen / Gesamtergebnis stehen dort sehr wohl — nur auf Seite fünf der Tabelle, hinter den elf nach Sparten gegliederten Positionsblöcken. Der Befund war ein Artefakt des abgeschnittenen Textes, kein Befund über das Dokument. Vollständig gelesen (36 Seiten je Jahrgang) gehen alle drei durch, je 6 von 6 Spalten:

Jahrgang Vorlage Erträge Aufwendungen Ergebnis
2019 18/0691 20.280.001 € 19.989.470 € 290.531 €
2020 19/0778 20.747.250 € 20.317.670 € 429.580 €
2021 20/0636 21.254.400 € 20.807.750 € 446.650 €

Eine Falle trägt das alte Layout allerdings, dieselbe wie Layout B mit anderem Wort: Unter dem Gesamtergebnis (2019: 290.531 €) steht weiter unten noch ein Jahresergebnis (93.031 €). Die Differenz ist die Eigenkapitalverzinsung, die der Betrieb an den städtischen Haushalt abführt. Wer die Zeile nimmt, die am besten klingt, speichert die falsche.

Die Gebührenbedarfsberechnung: was im Portemonnaie ankommt

Abschnitt betitelt „Die Gebührenbedarfsberechnung: was im Portemonnaie ankommt“

Von allen Zahlen des Haushalts landet keine so direkt bei den Leuten wie die Abfall- und Straßenreinigungsgebühr. Wie sie zustande kommt, legt der Abfallwirtschaftsbetrieb jedes Jahr als Anlage zur Ratsvorlage vor — und diese Anlage ist das am besten prüfbare Dokument des ganzen Bestands (council/gebuehren.py, scripts/ingest_gebuehren.py, council_fees, council_fee_rates).

Drei Bereiche je Jahrgang, jeder mit eigener Bezugsgröße:

Anlage Bereich Gebühr bemessen nach
1 Abfallbehandlungsanlagen Tonne (Mg) angelieferter Abfall
2 Abfallsammlung Behältervolumen in Litern
3 Straßenreinigung Meter Quadratwurzel gebührenpflichtiger Fläche

Zwei Proben je Block, beide aus dem Dokument selbst:

Kostenkalkulation 2025 11.661.361 €
− von Dritten erstattet −3.668.314 €
− Erlöse nach § 2 −5.000 €
− aus der Nachsorge-Rückstellung −240.000 €
− Über-/Unterdeckung aus Vorjahren −361.777 €
= durch Gebühren zu decken 7.386.270 € ← Kaskade
÷ 52.845 Mg
= Gebühr je Mg 139,772 € ← Division

Die Kaskade rechnet nach, was die Zwischenzeilen des Dokuments behaupten. Die Division ist davon unabhängig: Menge und Gebühr stehen im Gebührenermittlungs-Block, nicht in der Kaskade. Über alle zwölf geprüften Blöcke gehen beide auf — neunmal cent-genau, zweimal um genau 1 € (dieselbe Rundungs-Signatur wie beim Erfolgsplan, TOLERANZ_EUR = 2.0).

Was der Bestand zeigt:

Jahrgang Abfallbehandlung Straßenreinigung
2023 123,284 €/Mg 3,660 €/m
2024 134,709 €/Mg 3,665 €/m
2025 139,772 €/Mg 3,744 €/m
2026 151,214 €/Mg 4,039 €/m

Vier Eigenheiten, die der Parser kennen muss:

  1. Zahlen zerreißen an Leerzeichen. -295. 000 € (2026) — dort ist entscheidbar, dass die Zahl weitergeht: davor eine Ziffer und ein Punkt, dahinter genau drei Ziffern. Bei 7 71.000 (Straßenreinigung 2026) ist es das nicht. Die Bezugsmenge wird deshalb nicht geraten, sondern an der Division erkannt: Von allen Kandidaten gilt die, die zusammen mit den zu deckenden Kosten die gedruckte Gebühr ergibt.
  2. Der Jahrgang 2024 legt erst alle Beträge ab und danach erst ihre Beschriftungen. Ein Muster, das die Zahl neben ihrem Namen sucht, findet dort nichts. Die Zuordnung kommt aus der Reihenfolge — und gilt nur, weil sie die Kaskade erfüllt.
  3. Die errechnete Gebühr hat drei Nachkommastellen, der Vorschlag an den Rat zwei (134,709 gegen 134,70). Wer den Vorschlag nimmt, speichert eine Zahl, die die Division nicht erfüllt — geprüft wird deshalb gegen die dreistellige, und beide werden gespeichert.
  4. Die Abfallsammlung hat gar keine einzelne Gebühr. Sie erhebt eine Grundgebühr und eine Gebühr je Liter Behältervolumen; eine Division „Kosten ÷ Menge“ gibt es dort nicht. Der Jahrgang wird trotzdem gespeichert — seine Kaskade ist geprüft —, aber gebuehr und bezugsmenge bleiben leer, und proben sagt, dass nur eine der beiden Proben lief.

Anlage 4: die konkreten Tarife. Neben den drei Kalkulationsblöcken stehen jetzt zwölf einzeln benannte Vorschläge in council_fee_rates: Grundgebühr, allgemeine Litergebühr, Biogrundmenge 60 L, Sperr- und Grüngutkarten, fünf Anlieferungsmengen sowie die Gebühren je Mg und je Meter Quadratwurzel. Eine Matrix aus Behältergröße und Abfuhrrhythmus enthält die Quelle nicht; eine solche Kombination wird deshalb nicht abgeleitet.

Die Layouts unterscheiden sich: 2023–2025 stehen alle zwölf Werte in einer Zeile, 2026 steht jeder Tarif in einer eigenen Zeile. In beiden Fällen müssen alle zwölf Tarifarten vorhanden sein. Die Gebühren je Mg und je Meter Quadratwurzel werden zusätzlich gegen die getrennten Vorschläge aus Anlagen 1 und 3 geprüft. Im neuen Layout wird außerdem jede gedruckte Veränderung gegen das Vorjahr nachgerechnet (139,70 → 151,21 = +8,24 %). Gespeichert wird ausdrücklich ein Verwaltungsvorschlag, nicht automatisch der endgültige Satz aus einer später beschlossenen Gebührensatzung. Anlage 4 des gescannten Jahrgangs 2020 ist im OCR-Text noch leer; die direkt belegten Einzeltarife beginnen deshalb derzeit 2023.

Der Haushaltsplan sagt, wofür das Geld ausgegeben werden soll. Die Haushaltssatzung sagt, in welchem Rahmen — auf drei Seiten, je Jahrgang, und bis zum 20.08.2026 las sie niemand. Sie füllt council_budget_bylaw (council/haushaltssatzung.py, scripts/ingest_haushaltssatzung.py).

§ Was dort steht Warum es fehlte
§ 1.1 Ergebnishaushalt: ordentliche und außerordentliche Erträge/Aufwendungen teilweise über council_income_budget da
§ 1.2 Finanzhaushalt: sechs Beträge plus zwei Summen der Bereich las daraus nur die Investitionen
§ 2 Kredite für Investitionen stand nirgends
§ 3 Verpflichtungsermächtigungen stand nirgends
§ 4 Höchstbetrag für Liquiditätskredite — der Dispo der Stadt stand nirgends
§ 5 Hebesätze über Tabelle 1105 da, hier als Gegenprobe

Die Satzung prüft sich selbst. Unter § 1 stehen die drei Einzahlungs- und die drei Auszahlungszeilen des Finanzhaushalts einzeln — und darunter noch einmal ihre Summe („Nachrichtlich: Gesamtbetrag der Einzahlungen des Finanzhaushaltes …“). Über alle sieben eingelesenen Jahrgänge geht diese Probe cent-genau auf. Sie ist der Grund, dass diese Schicht ohne Zweitquelle auskommt, und TOLERANZ_EUR steht deshalb auf 0,005 € und nicht höher: Die Satzung führt volle Euro, eine Toleranz wäre hier kein Schutz, sondern ein blinder Fleck.

Was der Bestand zeigt. Die Kreditermächtigung lautet in jedem gelesenen Jahrgang „werden nicht veranschlagt“ — die Stadt nimmt keine Investitionskredite auf. Der Dispo dagegen wächst: 60 Mio. € (2019/2020) → 95 Mio. (2021) → 60 Mio. (2023) → 100 Mio. (ab 2024).

Drei Fallen:

  1. EUR statt Euro. Die Jahrgänge 2019 und 2020 schreiben die Einheit durchgängig aus. Dieselbe Falle wie beim Eigenbetrieb Hafen, und genauso lautlos: Ein Muster, das nur „Euro“ kennt, findet dort nichts und meldet keinen Fehler, sondern eine fehlende Zeile.
  2. Der Nachtrag trägt dasselbe Wort im Label. Die Nachtragshaushaltssatzung 2020 führt eine ganz andere Tabelle (bisher / erhöht um / vermindert um / Gesamtbetrag) — und in ihrem Textextrakt steht eine Zahl mit einem Leerzeichen mitten drin (609.717 .785). Sie wird bewusst nicht gelesen; der Parser weist sie am Wort ab, die erkennung schließt sie zusätzlich aus.
  3. Ab 2025 fehlt die Grundsteuer in § 5. Die Satzung nennt nur noch die Gewerbesteuer und verweist für die Grundsteuer auf eine eigene Satzung. Die beiden Felder sind dann leer — das ist die Auskunft, keine Lücke im Einlesen.

Was fehlt: der Jahrgang 2022 (keine Satzung im Bestand) und die Nachträge. 2021 liegt doppelt (Anlagen 229865 und 230043, gleicher Inhalt); der Primärschlüssel (jahr, nachtrag) fängt das.

Seit 08/2026 liest der Bereich auch die Betriebe, die im Beschlusstext keine Zahl nennen — aus dem Erfolgsplan ihrer Anlage (council/wirtschaftsplan_tabelle.py). Den Anfang macht der Abfallwirtschaftsbetrieb, und zwar aus einem Grund: Aus seinem Erfolgsplan werden die Abfallgebühren kalkuliert. Von allen Betrieben ist er der, dessen Zahlen jeder Haushalt direkt bezahlt.

Zwei Layouts, dieselbe Aussage. Der AWB hat zwischen 2025 und 2026 gewechselt:

Layout A (2023–2025) Layout B (ab 2026)
Erträge Gesamtertrag Summe Erträge
Aufwendungen Gesamtaufwendungen Summe Aufwendungen
Ergebnis Gesamtergebnis 11. Ergebnis nach Steuern

Ein Layout-Schalter ist deshalb nicht nötig: Das Vokabular führt beide Schreibweisen, und welche gegriffen hat, hält die Herkunft fest. Beide Male gilt Erträge − Aufwendungen = Ergebnis, und beide Male steht das Ergebnis unmittelbar unter seinen Summanden.

Drei Proben, alle aus dem Dokument:

  1. wirtschaftsplan_spalten — die Rechnung gilt in jeder Spalte, nicht nur in der gespeicherten. Ein Dokument führt sechs (ein Ist- und fünf Planjahre); über die fünf lesbaren Jahrgänge sind das 30 Proben, von denen 29 auf den Cent aufgehen und eine um 1 € daneben liegt. Die Quelle rundet je Position, deshalb steht die Toleranz auf 2 € statt auf 0,005 € wie beim Beschlusstext.
  2. wirtschaftsplan_prosa — Unter der Tabelle steht ein Satz, der die beiden Summen des Planjahres wiederholt („Der Erfolgsplan 2025 umfasst … Erträge in Höhe von insgesamt 25.197.796 € und … Aufwendungen … 24.570.285 €“). Zwei unabhängig gesetzte Stellen desselben Dokuments. Weich: Fehlt der Satz, fällt der Jahrgang nicht — widerspricht er, schon.
  3. wirtschaftsplan_bereiche — Die Ertragszeile kommt fünfmal vor: einmal für alle Bereiche und je einmal in den vier Anlagen (Abfallbehandlung, Abfallsammlung, Straßenreinigung, Werkstatt). Die vier ergeben die erste. Damit ist „die erste Zeile ist die Gesamtrechnung“ gemessen und nicht angenommen — ein versehentlich gegriffener Betriebszweig wäre fünf- bis zwölfmal kleiner und fiele sofort durch.

Welche Spalte der Plan ist, entscheidet die Kopfzeile: gesucht wird das Haushaltsjahr der Vorlage, nicht eine Position. Die Spaltenzahl schwankt, und 2026 schreibt „Ergebnis 2024“ statt „Ist 2024“. Findet sich das Jahr nicht, wird nichts gespeichert. Die Finanzplanungsjahre werden geprüft, aber nicht gespeichert — dieselbe Begründung wie beim Gesamtergebnishaushalt.

Die drei AWB-Scans sind seit dem 20.08.2026 gelesen. 2019 bis 2021 lagen nur als Bild vor; scripts/backfill_anlagen_ocr.py hat sie lesbar gemacht (je 36 Seiten, vollständig), und sie gehen ohne jede Parser-Erweiterung durch — je 6 von 6 Spalten, größte Abweichung 0,00 €. Ihre Herkunft sagt es dazu: „Erfolgsplan der Anlage (per OCR gelesen)“, mit dem Namen des Modells.

Für Bäderbetrieb, Bäderbetriebsgesellschaft und Stadion ist weiterhin kein Vokabular hinterlegt: Ein geratenes fände irgendeine Zeile, und ihre Zahlen stünden dann unter einem Namen, den nie jemand nachgeschlagen hat. Sie kommen über den dritten Weg.

Der dritte Weg: die Kernzahl, belegt durch zwei Dokumente

Abschnitt betitelt „Der dritte Weg: die Kernzahl, belegt durch zwei Dokumente“

Für Bäderbetriebsgesellschaft, Stadion, Bäderbetrieb und den Hafen trägt keiner der beiden Wege — und der Grund ist der lehrreichste Befund dieser Schicht.

Der Eigenbetrieb Hafen sagt „Verlust“. Er ist der einzige Betrieb, der weder „Fehlbetrag“ noch „Überschuss“ schreibt: „Für das Wirtschaftsjahr 2019 ist … ein Verlust von 273.950 EUR ermittelt worden“ — und die Einheit heißt dort EUR, nicht € oder Euro. Beides kannte das Muster bis zum 20.08.2026 nicht, und das Ausbleiben war lautlos: Die Vorlage wurde schlicht nie erkannt, es gab nie einen Fehler zu sehen. Seine beiden Jahrgänge (2019 und 2020) sind jetzt drin, beide zweifach belegt — dieselbe Zahl steht im Beschlusstext und in der Anlage.

Zwei Jahrgänge sind dabei keine Lücke, sondern der ganze Bestand: Der Eigenbetrieb wurde aufgelöst (Vorlage 20/0322 Rechtsformwechsel, 20/0809 Auflösungssatzung). Es gibt keinen dritten Wirtschaftsplan, den man vermissen könnte.

Gleiches Vokabular, andere Bedeutung. Ihre Erfolgspläne führen dieselben Zeilennamen wie der Abfallwirtschaftsbetrieb: Gesamtleistung, Gesamtkosten, Gesamtergebnis. Die Probe Gesamtleistung − Gesamtkosten = Gesamtergebnis geht aber auf:

Betrieb Spalten, in denen die Rechnung aufgeht
Stadion 5 von 5
Bäderbetrieb 0 von 11 · 0 von 6
Bäderbetriebsgesellschaft 1 von 24 · 2 von 15

Bei den beiden Bäder-Gesellschaften stehen zwischen Gesamtkosten und Gesamtergebnis noch Abschreibungen, Zinsen und neutrale Posten — Gesamtkosten ist dort nicht der Gesamtaufwand. Ein Parser, der dem Zeilennamen glaubt, hätte plausible und falsche Zahlen geliefert. Genau dafür gibt es Rechenproben.

Was stattdessen trägt, ist der Beschlusstext der Vorlage:

…wird in der anliegenden Fassung mit einem für die Gesellschaft ausgewiesenen Jahresfehlbetrag von −10.128.335 Euro beschlossen.

Und dieselbe Zahl steht in der Anlage. Zwei getrennte Dokumente, unabhängig gesetzt — die einzige Probe des Bereichs, die nicht davon abhängt, eine Tabellenzeile richtig zu deuten (wirtschaftsplan_kernzahl).

Drei Beleglagen, auseinandergehalten, weil die eine sich später auflöst und die andere nie:

Lage Bedeutung Zahl
belegt dieselbe Zahl steht in der Anlage 11
ausgeglichen Ergebnis 0 — die Ziffer 0 steht in jeder Anlage hundertfach, daran ändert auch OCR nichts 8
ohne_anlage die Anlage trägt (noch) keinen lesbaren Text 3

Nur das Ergebnis. Diese Route liefert kein Erträge/Aufwendungen-Paar; die einzige zweifach belegte Zahl dieser Dokumente ist das Jahresergebnis. Darum sind ertraege und aufwendungen in council_business_plans seit dem 20.08.2026 nullbar — ein NULL sagt „diese Quelle nennt es nicht“, eine 0 wäre eine Behauptung. Der Umbau erkennt Altbestände am Schema selbst (PRAGMA table_info), kopiert spaltenweise um und bricht ab, wenn die Zeilenzahl nicht stimmt.

Im Register, damit der nächste Jahrgang sich meldet

Abschnitt betitelt „Im Register, damit der nächste Jahrgang sich meldet“

Die Schicht steht als manuelle Quelle in finanzquellen.REIHENFOLGE: erwarteter_monat=11, versatz=-1. Beides ist gemessen und nicht geschätzt — die acht Entwurfsdaten im Bestand reichen vom 04.09. bis zum 22.11., und die Schwelle steht auf dem spätesten (zu früh gemeldet wäre der teurere Fehler). Der Plan für 2027 ist damit ab dem 01.11.2026 fällig; bleibt er aus, nennt die Cron-Mail die Schicht samt Skript.

check_finanzdaten ist auf council_attachments gebaut: Finanzquelle.erkennung sucht ein Anlagen-Label. Diese Schicht wäre die erste, deren Einheit eine Vorlage ist — ein eigener Umbau. Bis dahin ist scripts/ingest_wirtschaftsplaene.py der Weg, und weil die Quelle im Haus liegt (kein Download), ist er auch der richtige, wenn ein verbesserter Parser über den Bestand laufen soll.

Von Hand über SSH muss er dafür nicht mehr laufen: Der Ops-Workflow „Finanzdaten einlesen (dev)“ ruft ihn mit auf, und sein Bestandsbericht zählt council_business_plans mit. Sein Exit-Code wird dort bis ans Ende aufgehoben — eine gerissene Rechenprobe färbt den Lauf rot, reißt aber nicht den Bericht und die Archiv-Sicherung mit sich, die nach ihm kommen.

Der Bereich zeigt lieber eine Lücke als eine Schätzung:

  • Die Fraktions-Änderungslisten selbst — es gibt sie nicht. Geplant war, aus den Änderungslisten zum Haushaltsentwurf zu zeigen, was die Fraktionen ändern wollten. Der Ladetest am 18.08.2026 hat die Prämisse widerlegt: Die Listen im Bestand heißen „Verwaltung I“, „II“ oder „III“ — Nachträge der Verwaltung zu ihrem eigenen Entwurf. Die Listen der Fraktionen wurden als Tischvorlagen verteilt und liegen in keinem Dokument des Ratsinformationssystems.

    Seit dem 26.08.2026 liest council/aenderungslisten.py diese Dokumente trotzdem — Verwaltungslisten und die „beschlossenen Änderungen“ des Finanzausschusses, Position für Position, jede Liste beim Einlesen gegen ihre eigene „Zusammenstellung der Veränderungen“ bewiesen (Tabellen council_budget_amendments/…_summen, Ingest scripts/ingest_aenderungslisten.py, Anzeige auf /haushalt/mitreden#streit als „Was in den Listen stand“).

    Der Ingest läuft seit 08/2026 im Ops-Workflow „Finanzdaten einlesen (dev)“ mit; pymupdf zieht der Workflow bei Bedarf nach, wie den PDF-Renderer der OCR. Davor musste er nach jedem Parser-Merge von Hand über SSH laufen — und wer ihn vergaß, sah dev neuen Code auf altem Bestand zeigen. Das ist der unangenehmere Fall als eine leere Tabelle: Die Seite bleibt nicht leer, sie wird falsch.

    Auch die Erläuterungs-Spalte wird gelesen: der Text der Verwaltung, was jede Änderung ist. Text hat keine Schlusssumme, gegen die man ihn beweisen könnte — an die Stelle der Rechenprobe tritt Geometrie: Alle Dokumente zeichnen ihre Tabellen als Linienraster, und die waagerechten Linien ordnen die mehrzeilig umbrochenen Texte ihrer Zeile eindeutig zu (Silbentrennung wird nur am gemessenen Umbruch zusammengezogen). Ohne eindeutiges Band bleibt das Feld leer; über 99 % der Positionen tragen ihren Text, die restlichen Zellen sind im Original leer. Von den Fraktionslisten existiert dort in aller Regel genau eine digitale Spur: ihre Summenzeile in der Beschluss-Datei, mit dem Urheber daneben („SPD/ CDU/ FDP …“). Genau so — Summe mit Urheber, nicht mehr — zeigt die Seite sie an.

    Ein Jahrgang macht die Ausnahme, und nur einer. Die Beschluss-Datei zum Haushalt 2021 (Dokument 230011, dazu ihre inhaltsgleiche Zweitablage 230030) führt auf 20 ihrer 21 Seiten eine neunte Spalte: „Vorschlag von“, je Position. Für diesen Jahrgang steht deshalb seit dem 30.08.2026 nicht nur die Summe der Koalitionsliste da, sondern jede einzelne ihrer Positionen — 114 von 187, gegen 71 der Verwaltungsliste I und 2 der Liste II. Eine frühere Notiz nannte auch die 2020er-Datei (212801) als Trägerin dieser Spalte; gemessen an allen 18 EHH-Dokumenten stimmt das nicht: Dort steht „Vorschlag von“ allein über der Zusammenstellung, also über den Summen, die ohnehin schon gelesen werden. Genauso wenig taugt das bloße Wort als Erkennungsmerkmal — in 271304 steht „Der eingebrachte Vorschlag zur Erhöhung der Bewohnerparkgebühren der Politik …“ mitten in einer Erläuterung. Erkannt wird deshalb der zweizeilig gesetzte KOPF („Vorschlag“ mit „von“ direkt darunter) in der letzten Spalte des gezeichneten Rasters, rechts der Beträge.

    Bewiesen wird die Zuschreibung, nicht gelesen: Die Summe der Positionen jedes Urhebers muss seine eigene Zeile in der Zusammenstellung treffen (Probe aenderungsliste_urheber, 9 von 9 Gruppen über vier Planjahre auf den Cent). Diese Probe ist hart wie die anderen — geht auch nur eine Gruppe nicht auf, gilt das Dokument als ungelesen und es wird gar nichts gespeichert. Der Grund ist der Gegenstand: Wem die Stadt eine Streichung zuschreibt, ist die folgenreichste Angabe dieses Moduls und darf nicht an einer geschätzten Spaltenkante hängen. Eine stille Lücke wäre hier auch deshalb schlecht, weil sie aussähe wie ein Dokument ohne die Spalte — und genau diese Unterscheidung ist die Auskunft.

    Zwei Fehler fielen beim Bau dieser Spalte auf, beide waren live: Die Erläuterungs-Spalte hatte keine rechte Grenze und zog das Urheber-Label mit hinein — weil die Labels auf eigenen Grundlinien wickeln, landete es mitten im Satz („… gegen Gewalt an SPD/ Frauen u. häusl. Gewalt …“), und zwar in allen 187 Positionen des Jahrgangs. Und die Wickel-Nachlese der Bezeichnungen schätzte ihre Spalte aus dem zentrierten Kopfwort, statt die gezeichneten Linien zu nehmen; wie weit der Kopf neben seiner Spalte steht, schwankt je Jahrgang. Gemessen fielen aus der geschätzten Zone 12–72 % der Wörter zwischen den gedruckten Linien, im fertigen Feld 175 von 1.799 Positionen (9,7 %) mit angeschnittenem Namen — „für SGB II” statt „Grundsicherung für Arbeitssuchende SGB II“, „von und Frauen“ statt „Chancengleichstellung von Männern und Frauen“. Beide Spalten kommen jetzt aus dem Raster.

  • Der Jahrgang 2019 des Finanzhaushalts. Die Änderungslisten zum Finanzhaushalt liest seit dem 30.08.2026 ein eigener Parser (council/aenderungslisten_fhh.py, Ingest scripts/ingest_aenderungslisten_fhh.py, Tabellen council_budget_amendments_cash/…_summen). Er ist das Gegenstück zum Ergebnishaushalt: Dort steht, was die Stadt erwirtschaftet und verbraucht, hier, was tatsächlich fließt — und vor allem, was investiert wird.

    Warum ein eigener Parser. Wo der EHH zwei Betragsspalten führt, führt der FHH fünf: Soll laut Entwurf | Einzahlungen ± | Auszahlungen ± | VE ± | neues Soll. Den an 1.799 Positionen bewiesenen EHH-Leser darauf zu verallgemeinern hätte ihn angefasst, um einen zweiten zu sparen. Geteilt wird die Geometrie, nicht die Logik.

    Der FHH hat eine Probe, die der EHH nicht hat, und sie ist die schärfste des Ressorts: Soll laut Entwurf + Einzahlung + Auszahlung = neues Soll, auf jeder Positionszeile. Landete ein Betrag eine Spalte daneben, ginge sie nicht auf. Dazu kommen die drei Proben des EHH (Zeile, Kette, Position).

    Gelesen werden 15 der 18 Listen des Kernhaushalts, 250 Positionen, 132 davon mit ihrem Investitionscode (I10.089904.500) — dem Anschluss an council_investment_measures und damit an die 4.459 Vorhaben auf /haushalt/investitionen. Sieben der acht Jahrgänge sind vollständig; es fehlt 2019, dessen einzige Liste ihre Positionsprobe um 200.000 € verfehlt. Zwei weitere Dokumente fallen durch (212802 in seinen späteren Planjahren, 230016) — bei 230016 ohne Verlust: Die Beschluss-Datei 2021 liegt doppelt im Bestand, und die Zweitablage 230178 läuft durch. Was seine Proben nicht besteht, wird nicht gespeichert; der Ingest nennt es beim Namen.

    Acht Eigenheiten, jede an einem Riss gemessen: Die Spalten kommen aus dem letzten Lauf von sechs gleich breiten Linienkanten, nicht aus dem Kopfwort — nur die erste Seite eines Blocks trägt einen Tabellenkopf. Das Planjahr gilt über Seitengrenzen (in 256703 trug nur jede vierte Seite eines). Eine Position besitzt ihre Grundlinie und alles bis zur nächsten; ein Teil des Bestands setzt die Beträge 44 bis 67 pt unter die Zeile, also näher an die folgende Position. Der Gedankenstrich ist ein Betrag (Null) — auch das „+ / −“ im Spaltenkopf, weshalb der Kopf draußen bleiben muss. Die Blocksumme steht im Rahmen, trägt aber kein „neues Soll“. Beträge stehen auch in Klammern („(275.900)“), der Teilhaushalt ein- oder zweistellig („8“ wie „08“), und dieselbe Position wird über zwei Zeilen gedruckt. Entwurf und Endsumme sind optional: 212802 überschreibt seine Seite mit „Übersicht aller Änderungen“ und nennt weder das eine noch das andere.

    Die politischen Zeilen gibt es auch hier: 2020 −195.000 € und 2026 −45.000 € für SPD/CDU/FDP, 2021 für SPD/Bündnis 90/Die Grünen.

    Angezeigt wird das seit dem 30.08.2026 auf /haushalt/mitreden#streit als eigene Karte „Was am Bauen geändert wurde“, unter der Karte zum Ergebnishaushalt. Eigene Karte und kein Umschalter: Die beiden Haushalte beantworten verschiedene Fragen, und ein Umschalter legte nahe, es seien zwei Ansichten derselben Sache. Drei Regeln stecken in ihr:

    • Ein- und Auszahlungen werden nicht gegeneinander verrechnet. Im Finanzhaushalt sind das zwei Richtungen, keine zwei Vorzeichen — eine Einzahlung ist ein Zuschuss oder ein Verkauf, eine Auszahlung die Investition. Den Saldo bildet das Dokument am Fuß der Liste.
    • Die Verpflichtungsermächtigung steht als Satz, nicht als Betrag der Zeile. Sie ist die Erlaubnis, künftige Jahre zu binden, kein Geld dieses Jahres — und zählt auch im Dokument nicht in den Saldo.
    • Positionen ohne jeden Betrag bleiben draußen. Das sind reine Haushaltsvermerke; in einer Liste „was geändert wurde“ behaupteten sie eine Änderung, die es nicht gibt.

    Der Investitionscode ist seit dem 30.08.2026 ein Link auf „Was wird gebaut?“ — genauer: auf eine SUCHE dort, mit Nummer und Jahrgang in der Adresse (/haushalt/investitionen?vorhaben=…&jahr=…#vorhaben). Dafür waren drei Dinge nötig, und zwei davon sind eigene Befunde:

    1. Die Suche findet jetzt auch die Nummer, nicht nur den Namen (lib/haushalt-investitionsprogramm.ts: suche). Vorher wäre der Link auf einer leeren Trefferliste angekommen; nebenbei findet nun auch, wer eine Nummer von Hand eintippt.
    2. Der Explorer nimmt Suchwort und Jahrgang aus der Adresse — als Startwert, nicht als gebundenen Zustand: Wer nach dem Ankommen etwas anderes sucht, soll nicht gegen die Adresse antippen.
    3. Die Nummer muss gekürzt werden. Die Änderungslisten führen sie je BUCHUNGSZEILE: „I10.180800.500“ ist das Vorhaben I10.180800 („SG Käthe-Kollwitz-Straße“) in der Sachkonto-Gruppe 500 (Hoch- und Tiefbau); .550 wäre die Zuweisung des Landes zu demselben Vorhaben, .525 ein Zuschuss. Das Investitionsprogramm führt dagegen das Vorhaben als Ganzes. Gemessen: Mit dem Sachkonto trifft die Nummer 7 von 56 Positionen, ohne es 32 von 56.

    Die übrigen 24 haben im Programm kein Gegenstück — nicht jede Zeile des Finanzhaushalts gehört zu einem dort benannten Vorhaben. Deshalb heißt der Link „suchen“ und nicht „zum Vorhaben“: Eine Suche ohne Treffer ist ein normales Ergebnis, und die Seite sagt es auch so („Kein Vorhaben 2020 enthält ‚I10.089905’“). Ein Link, der ein Vorhaben verspricht und in 43 % der Fälle leer ankommt, wäre eine Zusage, die die Daten nicht decken.

    Eine Falle beim Anschluss ans Frontend, die zweimal Zeit gekostet hat: Der Endpunkt /council/budget/amendment-lists hat seit 08/2026 eine typisierte Antwortform (web/backend/app/antworten.py), und die ist zugleich das Response-Model. Was dort nicht steht, schneidet FastAPI lautlos weg — die neuen Schlüssel fhh_zeilen/fhh_summen fehlten deshalb in einer sonst völlig korrekten Antwort, ohne jeden Fehler im Log.

  • Der Schlussbericht 2024 — sein PDF bringt keine Zeichenzuordnung mit, der Volltext besteht aus Glyphen-Nummern (/12 /8 /6 □ /13 …), sein Buchstabenanteil ist 0,000. Eine zweite Kopie gibt es nicht.

    Seit dem 20.08.2026 führt ein Weg dorthin: backfill_anlagen_texte.py setzt ihn wegen MIN_BUCHSTABEN auf status='empty', und backfill_anlagen_ocr.py liest von dort. Sein Label trifft zwei Finanz-Muster (%Jahresabschluss%, %chlussbericht%), er steht also in der --nur-finanz-Liste. Und er ist der leichteste Fall dieser Klasse, nicht der schwerste: Er ist born-digital, randscharf, ohne Rauschen und ohne Schräglage — beim Probelauf las das Modell Seite 33 cent-genau (Summe Eigenbetriebe 273.295.915,34 €, geprüft gegen die gedruckte Summe).

    Zwei Dinge standen ihm noch im Weg, beide seit dem 20.08.2026 behoben:

    Erstens hielt ocr.seite_als_bild() sein Briefkopf-Logo für die Seite — die Regel lautete „genau ein eingebettetes Bild = der Scan“, und eine Vektorseite mit Logo erfüllt das auch. Zurück kamen 62 Zeichen, die aussahen wie ein Ergebnis. Jetzt entscheidet die Auflösung: Das Logo liegt bei 64 dpi auf der A4-Fläche, ein echter Scan bei 300 (MIN_DPI = 100).

    Zweitens verlangte pruefberichte.erkenne_jahrgang() den Titel an Position 0. Das ging gut, solange der PDF-Textextrakt mit ihm begann; per OCR steht davor, was auf dem Papier auch davorsteht. Der ^-Anker hat die eigentliche Arbeit ohnehin nie gemacht — die leistet „der Stadt Oldenburg“ am Titelende, an dem die Stiftungs- und Eigenbetriebsberichte („…zum 31. Dezember“) durchfallen. Genau das bleibt streng.

    Was ihm danach noch im Weg stand (bis 02.09.2026): Er war am 18.08. VOR der Buchstaben-Schwelle geladen worden und stand deshalb mit seinen 460.084 Glyphen auf status='ok' — und ok fasst kein Lauf mehr an: der Text-Backfill holt listed/failed, die OCR liest empty. Der Renderer (pypdfium2) war längst installiert; das Dokument lag nur in der falschen Menge. Seitdem zieht backfill_anlagen_texte.py --glyphen die Schwelle nachträglich über den Bestand (Buchstabenanteil unter MIN_BUCHSTABEN → empty), und der OCR-Schritt desselben Ops-Laufs liest, was dort landet.

  • Vollständige Produktebene — seit 09/2026 weitgehend erledigt: Im Bestand stehen alle 13 Teilhaushalte (2019–2023: 12, dort hängt THH13 nicht als eigene Anlage an der Vorlage), 584 Produkte über die Jahrgänge 2019–2026. Was bleibt, ist die Ebene darunter: Ein Produkt kommt nur in die Tabelle, wenn seine drei Summenzeilen so viele Zahlen tragen, wie der Kopf Spalten nennt, und Erträge − Aufwendungen = Ergebnis aufgeht. Zwei Fälle fallen deshalb heraus — Produkte ohne ordentliche Erträge (die Zeile 12 bleibt leer) und umstrukturierte Teilhaushalte: THH07 nennt für 2026 sechs Produkte, von denen fünf nur eine halbe Zeile tragen. Die alten führen nur noch die Vergangenheitsspalten (der Plan hat sie zusammengelegt), die neuen erst ab der Planjahr-Spalte. Vier Zahlen unter sechs Spalten sagen aber nicht, zu welcher Spalte sie gehören — und eine geratene Zuordnung wäre eine Zahl, für die niemand einstehen kann. Wie viel der geplanten Aufwendungen die Produkte erklären, weist der Endpunkt je Jahrgang als coverage_percent aus; jedes Produkt trägt zusätzlich ein Abdeckungs-Badge — eine Reihe, die nur die vorhandenen Jahre zeigt, sähe sonst durchgehend aus.

  • Der Open-Data-Datensatz 1102 enthält abweichende Aufwendungen (2024: 764,7 statt 728,2 Mio. €), ist aber weder als Ist noch als Nachtrag gekennzeichnet; genutzt wird daraus nur die Einwohnerspalte.

  • Grundsteuer-Aufkommen für A und B getrennt — die Hebesätze liegen seit 08/2026 getrennt vor (council_tax_rates führt „Grundsteuer A“ und „Grundsteuer B“ als eigene Zeilen, 1980–2025). Das Aufkommen nicht: Der Open-Data-Datensatz führt es als eine Spalte, und council_taxes wie council_tax_plan tragen die Art deshalb als Grundsteuer A+B. Daran hängt, dass es im Labor keinen Grundsteuer-Regler gibt: Ein Regler braucht beides, sonst rechnet er einen Satz gegen ein Aufkommen, das zur Hälfte einer anderen Steuer gehört.

  • Gebühren und Beiträge nach Art — als Summe stehen sie längst da: Posten 05 „öffentlich-rechtliche Entgelte“ (2026 im Ansatz 26,6 Mio. €) trägt sie in council_income_budget wie in council_income_statement, und im Flussbild heißt das Band „Gebühren und Beiträge“. Was fehlt, ist die Aufschlüsselung je Gebührenart — welcher Betrag aus Kita-Beiträgen kommt und welcher aus Abfallgebühren, sagt keiner der Datensätze. Darum führt /haushalt/einnahmen sie nicht als eigene Karte, sondern nennt sie im Text als das, was dort nicht steht.

  • Verschuldung pro Einwohner im Städtevergleich — Anlage 2 der Gesamtabschlüsse führt sie über acht Jahrgänge samt Osnabrück, Braunschweig und Hannover, und die Vorjahres-Kette schließt dort 4/4. Nicht gebaut, weil die Vergleichsstädte nicht aktuell sind (Braunschweig 2016 gegen Oldenburg 2024, vom Dokument selbst markiert) — eine Grafik ohne Jahr an jedem Balken wäre still falsch, und das gehört sorgfältig gemacht statt nebenbei.

  • Der Schuldenstand aus dem Vorbericht des Haushaltsplans — er stünde als zweite, aktuellere Quelle neben Tabelle 1108 zur Verfügung, steht dort aber in einem Diagramm. Im Textextrakt sind die Achsenbeschriftungen nicht von den Datenwerten zu unterscheiden: keine Summenzeile, keine zweite Spalte, nichts, woran sich prüfen ließe, ob eine gelesene Zahl ein Datenpunkt oder eine Gitterlinie ist. Keine Probe möglich, also nicht eingelesen — auch nicht „mit Vorsicht“. Tabelle 1108 deckt dieselbe Frage ab und bringt ihre Proben mit.

  • Ist je Vorhaben — keine der drei Investitions-Schichten führt es. Das Investitionsprogramm (council_investment_measures, Anlage 004, Abschnitt „Investitionsprogramm: die einzelne Maßnahme“ weiter oben) führt seit 08/2026 4.459 einzelne geplante Vorhaben namentlich, durchsuchbar auf /haushalt/investitionen — „welche Straße, welche Schule“ ist damit zur Hälfte beantwortet. Was am Jahresende wirklich abfloss, kennt seither der Anlagenspiegel (council_investments_actual, Abschnitt „Gebaut: das Ist zum Investitionsplan“ weiter oben) — aber nur nach Auszahlungsart summiert, nicht nach Vorhaben. Keine der beiden Quellen führt die Gliederung der anderen mit; ein Ist je Vorhaben bräuchte eine dritte Quelle, die bisher nicht bekannt ist.

  • Schulgebäude fehlen in allen drei Investitions-Schichten — im Finanzhaushalt (council_investments) so wenig wie im Investitionsprogramm oder im Anlagenspiegel. Sanierung und Neubau liegen beim Eigenbetrieb Gebäudewirtschaft und Hochbau mit eigenem Wirtschaftsplan, den keine der drei Quellen enthält.

  • Erträge je Teilhaushalt nach Herkunft — für Planjahre. Die Ertragsarten der Planjahre sind seit #530 eingelesen (council_income_budget, 2019–2026); was fehlt, ist ihre Aufteilung je Teilhaushalt, und die ist auch nicht nachrüstbar:

    council_budget kennt je Bereich nur eine Ertragssumme. Wer sie in Bund, Land und Gebühren aufteilen will, braucht council_income_statement — die löst die Posten 01–11 je Teilhaushalt auf, endet aber mit dem letzten Jahresabschluss (2024). Der Gesamtergebnishaushalt reicht bis 2026, führt aber keine Teilhaushalte: In allen acht Dokumenten kommt „THH“ kein einziges Mal vor.

    Beide zu mischen scheitert an den Ständen. Für 2026 weist council_budget 812,9 Mio. € Erträge aus, council_income_budget 788,6 Mio. € — 24,3 Mio. € Abstand, weil das eine der beschlossene Plan ist und das andere Anlage 005 der Einbringungs-Vorlage, also der Entwurf. Das Flussbild rechnet mit einer Toleranz von 0,05 Mio. €; eine Grafik, die ihre linke Seite aus dem Entwurf und ihre rechte aus dem Beschluss nimmt, wäre um das Fünfhundertfache daneben, ohne dass man es ihr ansieht.

    Darum heißt die Leiste auf /haushalt „Wo das Geld eingeht“ und nicht „Woher das Geld kommt“: Im Plan 2026 stehen 529,3 der 812,9 Mio. € Erträge bei „Finanzmanagement und Recht“, weil dort die Kämmerei bucht — das ist ein Buchungsort, keine Geldquelle. Aus demselben Grund heißt die zweite Spalte der Bereichstabelle „eigene Erträge des Bereichs“ und nicht „von Bund, Land oder über Gebühren“. Was es bräuchte, wäre ein Dokument, das beide Seiten in einem Stand führt — im Bestand ist keines.

    Was daraus wurde (20.08.2026): Für Planjahre zeigt /haushalt an der Stelle des Flussbilds seitdem die eine Seite, die es gibt — die Ertragsarten aus dem Gesamtergebnishaushalt, als Rangliste, ohne Gegenstück. Das halbe Bild ist mehr wert als gar keines, solange es sich als halbes zu erkennen gibt: Der Block heißt „Woher das Geld kommen soll“, nennt den Plan-Jahrgang und sagt, dass es der Entwurf der Einbringung ist.

    Und er beziffert den Abstand zur Anzeigetafel derselben Seite, weil die eine andere Ertragssumme nennt: 2026 sind es 24,3 Mio. €, 2025 sogar 26,2 Mio. € — einmal liegt der Entwurf darunter, einmal darüber. Gerechnet, nicht geschrieben (einnahmearten().tafel), damit die Zahl beim nächsten Jahrgang nicht still falsch wird. Zwei Zahlen auf einer Seite, die dasselbe zu meinen scheinen, sind schlimmer als eine Lücke.

Beim Abgleich der Entwürfe mit den echten Zahlen fielen Annahmen durch, die plausibel klangen:

  • Die Finanzkrise 2009 ist in Oldenburg nicht sichtbar. Die Gewerbesteuer stieg 2009 von 58,9 auf 61,9 Mio. €. Die realen Einbrüche liegen 2000 (−8,5) und 2003 (−7,8); Corona 2020 kostete nur 3,8 Mio. Die Ist-Kurve berechnet ihre Marker deshalb aus den Daten, statt Geschichte zu deuten.
  • Der Finanzausgleich dämpft, aber nicht mit festem Faktor. Ausgleichs- jahr 2024 → 2025 stieg die Steuerkraft um 45,9 Mio. €, während die Zuweisungen um 30,4 Mio. fielen; 2025 → 2026 stiegen beide. Der Effekt ist systematisch real, seine Höhe hängt am Landestopf — das Labor beziffert ihn deshalb nicht, sondern benennt ihn mit den echten Jahreszahlen daneben.
  • Stiftungsvermögen ist keine freiwillige Leistung. Es ist zweckgebunden und wird treuhänderisch verwaltet; als kürzbar geführt hätte das Labor eine Handlungsmöglichkeit behauptet, die es nicht gibt.
  • Bestätigt: Alle überwiegend freiwilligen Bereiche zusammen kosten 47,1 Mio. € — das geplante Defizit beträgt 71,1 Mio. Kürzen allein schließt es rechnerisch nicht.

Der einzige Fall bisher, in dem wir eine Quelle korrigieren statt sie zu übernehmen — und der einzige, der eine so lange Begründung verdient.

Der Open-Data-Datensatz 1106 („Steuerkraftmesszahlen und Schlüsselzuweisungen“) führt seine Spalte als Ausgleichsjahr, beschriftet die Zeilen aber um ein Jahr zu früh. Geprüft am 16.08.2026, an drei unabhängigen Strängen:

  1. Das Landesamt für Statistik Niedersachsen (LSN) — die Stelle, die den Begriff definiert und die Zahlen liefert. Seine KFA-Tabellen (Blatt ST_KR_MESS_VGL, Schlüssel-Nr. 403000) tragen dieselben Beträge auf den Euro genau, nur ein Jahr später: 12 von 12 Steuerkraftmesszahlen aus den Jahrgängen KFA 2016–2026 decken sich mit der CSV-Zeile Jahr−1, keine einzige mit der gleichnamigen. Für die Schlüsselzuweisungen (Blatt 9a) gilt dasselbe; die Ausreißer sind durchweg vorläufige Stände, die das LSN später selbst korrigiert hat.
  2. Die Bücher der Stadt — und das entscheidet die Frage, weil es kein Beschriftungs-, sondern ein Kassenfakt ist. Der Haushaltsplan 2026 weist als Ist 2024 99.569.132 € Schlüsselzuweisungen aus (Konten 31111000 + 31112000), der Haushaltsplan 2025 als Ist 2023 100.319.768 € — beide stehen in der CSV eine Zeile zu früh. Der Jahresabschluss 2024 nennt im Fließtext „rund 109,5 Millionen Euro“ und trifft damit den LSN-Nettobetrag des Ausgleichsjahrs 2024.
  3. Die Metadaten widersprechen sich selbst. Die Spalte heißt „Ausgleichsjahr“, die Datensatzbeschreibung spricht von „für jedes Haushaltsjahr“. Der Widerspruch ist im Portal nicht aufgelöst.

Was wir daraus machen: haushalt.parse_steuerkraft rückt jede Jahreszahl um eins nach vorn; council_tax_capacity.jahr ist damit das Ausgleichsjahr, wie es die Tabelle ohnehin immer behauptet hat. Die beiden Pro-Kopf-Spalten des Datensatzes bleiben liegen — die Stadt rechnet sie gegen die Einwohnerzahl ihrer eigenen, verschobenen Jahresangabe (16 von 16 Mal von 2010 bis 2025 nachgerechnet), nach dem Rücken stünde eine Ausgleichsjahr-Zahl über einem Nenner aus dem Vorjahr.

Was offen bleibt: Direkt belegt ist der Versatz für die CSV-Jahre 2015–2025; weiter zurück stellt das LSN nichts mehr online. Dass die Reihe durchgehend derselben Konvention folgt, zeigt die Pro-Kopf-Probe oben auch für die Jahre davor — ein Bruch mitten in der Reihe müsste sich dort zeigen und tut es nicht.

Gemeldet am 16.08.2026 an die Stadt Oldenburg (Ansprechpartner laut Katalog: die Statistikstelle). Damit liegt der Befund bei der Stelle, die ihn beheben kann — die Korrektur in haushalt.parse_steuerkraft bleibt bis dahin und darüber hinaus bestehen: Sie greift am Bestand, nicht an der Quelle, und ein stillschweigend korrigiertes Portal würde sie sonst doppelt anwenden. Wer eine neue Lieferung einliest, prüft deshalb zuerst die Pro-Kopf-Probe.