diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-28 08:13:29 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-28 08:13:29 +0200 |
| commit | 0be6e41ca5ae1c16aab308931616f1ec84ea3eba (patch) | |
| tree | d2d5342f687747b36c0ad00798df34c62ea73a95 /src | |
| parent | 3203725daa2c8db9ce25ff4541dd4ea438ac5cd7 (diff) | |
Der Korpus sagt, woran die Reste im Ganzen liegen
Der Befund über das Erzeugnis ruhte auf handverlesenen Fällen. Rund drei Dutzend
Akzeptanzprüfungen sagen, dass diese Hefte aufgehen; sie sagen nicht, woran die
Reste im Ganzen liegen — ob an der Befehlssprache eines Landes, an der
Aufbereitung des Druckwerks oder am Alter der eingesetzten Stammfassung. Die
Auszählung der Gründe beantwortet das für ein Heft; über einen Korpus geführt
beantwortet sie es für das Erzeugnis.
Der Schalter --korpus arbeitet eine Auftragsliste ab und gibt je Auftrag eine
Zeile Kennzahlen aus: erschlossene, angewandte, liegengebliebene und am Stichtag
zurückgestellte Befehle, sodann die gleichen, geprüften, fehlenden, überzähligen
und abweichenden Normen des Abgleichs, zuletzt die Gründe nach Häufigkeit. Die
Liste nennt dieselben Angaben, die auch die Befehlszeile entgegennimmt — ein
Auftrag ist nichts anderes als ein Lauf. Mit --synopsen bleiben die
Einzelsynopsen samt einer Übersichtsseite stehen; ohne sie wäre der Bericht eine
Zahlenreihe, aus der niemand den Grund eines Restes erführe.
Eingecheckt ist der Bericht die Grundlinie. --grundlinie hält den Lauf dagegen
und rügt allein, was fällt: weniger erschlossene oder angewandte Befehle, weniger
gleiche Normen, mehr liegengebliebene, fehlende, überzählige oder abweichende.
Ein Fortschritt ist kein Fehler, sondern der Zweck der Arbeit; er verlangt
allein, die Grundlinie fortzuschreiben. Der Rückschritt endet mit 3 wie ein nicht
aufgehender Abgleich. Ein gescheiterter Auftrag hält den Lauf nicht an, sondern
trägt seinen Fehlertext in seiner Zeile — der Grundsatz der Nichtverwerfung gilt
auch hier.
Der mitgelieferte Korpus umfasst einunddreißig Aufträge und gibt 1413 Befehle
her, von denen 1287 angewandt werden; 632 von 641 verglichenen Normen gehen auf.
Er reproduziert die Zahlen der bestehenden Akzeptanzfälle (UWG 19/0, GEG 119/119,
BayJG 154/154, Berlin 171/171, WDR 101, Bremen 41/37, Hamburg 21/17). Drei
Aufträge tragen mit Absicht hohe Reste: Bei den beiden Beschlussempfehlungen und
bei Rheinland-Pfalz schreibt die Vorlage eine ältere Fassung fort als die
eingesetzte Stammfassung. Sie sind gleichwohl Wächter — steigen ihre Reste, so
hat sich etwas verschlechtert.
Nebenbei: Die Auszählung der Gründe stand doppelt (Befehlszeile und Synopse) und
liegt nun in Pipeline.zaehleGruende; das Ergebnis der Pipeline führt sie mit.
Geprüft: mvnw verify (443 Testfälle) und reuse lint (214/214) gehen durch; der
Korpuslauf ist Teil der Prüfung. Die Rügeregel ist am ganzen Korpus erprobt: Eine
um eins verschärfte Grundlinie führt zur Rüge „UWG-BGBl: angewandte Befehle 19
statt 20“ und zum Rückgabewert 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I4e10b041b78d95772da1c3ff18ab92b0b45dacaa
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/AendGgner.java | 90 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/Pipeline.java | 30 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java | 476 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java | 134 | ||||
| -rw-r--r-- | src/test/resources/sampledata/korpus-grundlinie.tsv | 44 | ||||
| -rw-r--r-- | src/test/resources/sampledata/korpus.tsv | 46 |
6 files changed, 811 insertions, 9 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index d089f21..3067a13 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.anwendung.Grund; +import eu.mulk.aendggner.bericht.Korpusbericht; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -15,7 +16,6 @@ import java.nio.file.Path; import java.time.LocalDate; import java.time.format.DateTimeParseException; import java.util.ArrayList; -import java.util.EnumMap; import java.util.List; import java.util.concurrent.Callable; import java.util.logging.LogManager; @@ -36,6 +36,7 @@ public class AendGgner implements Callable<Integer> { @Parameters( index = "0", + arity = "0..1", paramLabel = "<base law>", description = "The base law: gii-norm XML from gesetze-im-internet.de, or — for state law — the" @@ -134,6 +135,28 @@ public class AendGgner implements Callable<Integer> { + "exit code 3.") private String nachfassung; + @Option( + names = "--korpus", + paramLabel = "<file>", + description = + "Run every job of the given tab-separated job list and write a report of key figures " + + "instead of a synopsis. Paths in the list are relative to the list's directory.") + private String korpus; + + @Option( + names = "--grundlinie", + paramLabel = "<file>", + description = + "With --korpus: hold the run against this earlier report and complain about every figure " + + "that has fallen. A regression yields exit code 3.") + private String grundlinie; + + @Option( + names = "--synopsen", + paramLabel = "<dir>", + description = "With --korpus: also write each job's synopsis into this directory.") + private String synopsen; + /** Die angegebenen Eingaben, jede über {@link Bezug} beschafft. */ private static List<Quelle> hole(List<String> angaben) throws IOException, InterruptedException { var quellen = new ArrayList<Quelle>(); @@ -154,6 +177,15 @@ public class AendGgner implements Callable<Integer> { log.debugf("Logging configured."); + if (korpus != null) { + return fuehreKorpusAus(); + } + + if (baseFile == null) { + System.err.println("Fehler: das Stammgesetz muss angegeben werden."); + return 1; + } + if (dumpGesetz) { var gesetz = Pipeline.ladeStammgesetz(Bezug.hole(baseFile)); System.out.printf( @@ -305,12 +337,7 @@ public class AendGgner implements Callable<Integer> { * jedem Befehl darüber. */ private static void haeufigkeitDerGruende(BefehlAnwender.AnwendungsErgebnis anwendung) { - var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class); - for (var eintrag : anwendung.protokoll()) { - if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) { - haeufigkeit.merge(eintrag.grund(), 1, Integer::sum); - } - } + var haeufigkeit = Pipeline.zaehleGruende(anwendung.protokoll()); if (haeufigkeit.isEmpty()) { return; } @@ -320,6 +347,55 @@ public class AendGgner implements Callable<Integer> { .forEach(e -> System.out.printf(" %4d %s%n", e.getValue(), e.getKey().bezeichnung())); } + /** + * Der Massenlauf: die Aufträge der Liste nacheinander, eine Zeile Kennzahlen je Auftrag. + * + * <p>Der Bericht geht dahin, wohin sonst die Synopse ginge ({@code -o}). Ist eine Grundlinie + * angegeben, so wird der Lauf gegen sie gehalten; jede gefallene Kennzahl wird gerügt und der + * Lauf endet mit 3 — dieselbe Zahl, mit der ein nicht aufgehender Abgleich endet. + */ + private Integer fuehreKorpusAus() throws IOException { + var liste = Path.of(korpus); + var wurzel = liste.toAbsolutePath().getParent(); + var auftraege = Korpusbericht.liesListe(liste); + log.infof("Korpuslauf: %d Aufträge aus %s.", auftraege.size(), liste); + + var zeilen = + Korpusbericht.fuehreAus(auftraege, wurzel, synopsen == null ? null : Path.of(synopsen)); + var tsv = Korpusbericht.alsTsv(zeilen); + if (output == null || output.equals("-") || output.equals("synopse.html")) { + // Die Voreinstellung des Schalters meint die Synopse; ein Bericht gehört dann auf die + // Standardausgabe und nicht in eine Datei namens „synopse.html“. + System.out.print(tsv); + } else { + Files.writeString(Path.of(output), tsv, StandardCharsets.UTF_8); + System.out.println("Bericht geschrieben: " + output); + } + if (synopsen != null) { + var uebersicht = Path.of(synopsen).resolveSibling("uebersicht.html"); + Files.writeString( + uebersicht, + Korpusbericht.alsHtml(zeilen, Path.of(synopsen).getFileName().toString()), + StandardCharsets.UTF_8); + System.out.println("Übersicht geschrieben: " + uebersicht); + } + System.out.println(Korpusbericht.summe(zeilen)); + + if (grundlinie == null) { + return zeilen.stream().anyMatch(z -> z.fehler() != null) ? 3 : 0; + } + var ruegen = + Korpusbericht.gegenGrundlinie( + zeilen, Files.readString(Path.of(grundlinie), StandardCharsets.UTF_8)); + if (ruegen.isEmpty()) { + System.out.println("Die Grundlinie ist gehalten."); + return 0; + } + System.out.println("Rückschritte gegenüber der Grundlinie:"); + ruegen.forEach(r -> System.out.println(" " + r)); + return 3; + } + private static String kuerze(String text) { var einzeilig = text.replaceAll("\\s+", " "); return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…"; diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index e4ce9fb..483d456 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -30,7 +30,9 @@ import java.io.IOException; import java.nio.file.Path; import java.time.LocalDate; import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -116,6 +118,11 @@ public final class Pipeline { * @param abgleich der normweise Vergleich mit der amtlichen Nachfassung; {@code null}, wenn der * Auftrag keine nannte. */ + /** + * @param gruende die Auszählung der Gründe, aus denen Befehle liegengeblieben sind. Sie steht + * schon in der Synopse; als Zahl herausgegeben trägt sie überdies den Korpusbericht (§ 6d), + * der über viele Hefte hinweg zeigt, woran die Reste liegen. + */ public record Ergebnis( String html, long anzahlAngewandt, @@ -123,7 +130,25 @@ public final class Pipeline { int anzahlGeaenderteNormen, int anzahlProtokollEintraege, String neufassung, - @Nullable Nachfassungsabgleich abgleich) {} + @Nullable Nachfassungsabgleich abgleich, + Map<Grund, Integer> gruende) { + + public Ergebnis { + gruende = Map.copyOf(gruende); + } + } + + /** Die Gründe der liegengebliebenen Befehle, nach Häufigkeit auszählbar. */ + public static Map<Grund, Integer> zaehleGruende( + List<BefehlAnwender.AngewandteAenderung> protokoll) { + var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class); + for (var eintrag : protokoll) { + if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) { + haeufigkeit.merge(eintrag.grund(), 1, Integer::sum); + } + } + return haeufigkeit; + } /** * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text. @@ -257,7 +282,8 @@ public final class Pipeline { synopse.eintraege().size(), protokoll.size(), LandesRechtTextAusgeber.ausgeben(gesetz), - abgleich); + abgleich, + zaehleGruende(protokoll)); } /** diff --git a/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java new file mode 100644 index 0000000..1daba42 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/bericht/Korpusbericht.java @@ -0,0 +1,476 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.bericht; + +import eu.mulk.aendggner.Pipeline; +import eu.mulk.aendggner.Quelle; +import eu.mulk.aendggner.anwendung.Grund; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Der Massenlauf: viele Hefte in einem Durchgang, eine Zeile Kennzahlen je Heft. + * + * <p>Wozu: Ein Belegfall sagt, dass <em>dieses</em> Heft aufgeht. Er sagt nicht, woran die Reste im + * Ganzen liegen — ob an der Befehlssprache eines Landes, an der Aufbereitung des Druckwerks oder am + * Alter der Stammfassung. Die Auszählung der Gründe (§ 1 Absatz 5 des Handbuchs) beantwortet das + * für ein Heft; über einen Korpus geführt beantwortet sie es für das Erzeugnis. + * + * <p>Der Bericht ist zugleich eine Grundlinie: Wird er eingecheckt, so schlägt jeder Lauf an, bei + * dem eine Kennzahl <em>fällt</em> — weniger erschlossene Befehle, weniger angewandte, weniger + * gleiche Normen. Steigt eine, ist die Grundlinie fortzuschreiben; das ist der Vermerk der Welle. + * + * <p>Verworfen wird auch hier nichts: Ein Auftrag, der mit einem Fehler abbricht, hinterlässt seine + * Zeile samt Fehlertext und hält den Lauf nicht an. + */ +public final class Korpusbericht { + + private Korpusbericht() {} + + /** Kein Wert — in der Liste wie im Bericht. */ + private static final String LEER = "-"; + + /** + * Ein Auftrag der Liste. + * + * @param bezeichnung der Name, unter dem der Auftrag im Bericht und in der Grundlinie steht. + * @param stamm die Stammfassung, relativ zum Verzeichnis der Liste. + * @param hefte die Änderungsdokumente, in der Reihenfolge der Liste. + * @param artikel nur diesen Artikel anwenden; {@code null} = alle betroffenen. + * @param nachfassung die amtliche Nachfassung; {@code null} = kein Abgleich, dann bleiben dessen + * Spalten leer. + * @param stichtag die Fassung dieses Tages; {@code null} = alle Befehle anwenden. + */ + public record Auftrag( + String bezeichnung, + String stamm, + List<String> hefte, + @Nullable String artikel, + @Nullable String nachfassung, + @Nullable LocalDate stichtag) { + + public Auftrag { + hefte = List.copyOf(hefte); + } + } + + /** + * Die Kennzahlen eines Laufes. + * + * @param fehler der Grund des Abbruchs; {@code null}, wenn der Auftrag durchgelaufen ist. + */ + public record Zeile( + String bezeichnung, + int befehle, + long angewandt, + long manuell, + long zurueckgestellt, + @Nullable Integer gleich, + @Nullable Integer geprueft, + @Nullable Integer fehlend, + @Nullable Integer ueberzaehlig, + @Nullable Integer abweichend, + Map<Grund, Integer> gruende, + @Nullable String fehler) { + + public Zeile { + gruende = Map.copyOf(gruende); + } + } + + /** Die Spaltenüberschriften des Berichts; sie stehen als Kommentarzeile obenan. */ + private static final List<String> SPALTEN = + List.of( + "Bezeichnung", + "Befehle", + "angewandt", + "manuell", + "zurückgestellt", + "gleich", + "geprüft", + "fehlend", + "überzählig", + "abweichend", + "Gründe"); + + // ---------------------------------------------------------------- Liste lesen + + /** + * Liest die Auftragsliste. Sie ist eine Tabulatortabelle mit den Spalten Bezeichnung, + * Stammfassung, Hefte (durch Komma getrennt), Artikel, Nachfassung, Stichtag; „-“ steht für + * „nicht angegeben“. Zeilen, die mit „#“ beginnen, und Leerzeilen werden übergangen. + */ + public static List<Auftrag> liesListe(Path liste) throws IOException { + var auftraege = new ArrayList<Auftrag>(); + int nummer = 0; + for (var zeile : Files.readAllLines(liste, StandardCharsets.UTF_8)) { + nummer++; + if (zeile.isBlank() || zeile.startsWith("#")) { + continue; + } + var felder = zeile.split("\t"); + if (felder.length < 3) { + throw new IOException( + "%s, Zeile %d: Ein Auftrag braucht wenigstens Bezeichnung, Stammfassung und Heft." + .formatted(liste, nummer)); + } + auftraege.add( + new Auftrag( + felder[0].strip(), + felder[1].strip(), + List.of(felder[2].strip().split("\\s*,\\s*")), + feld(felder, 3), + feld(felder, 4), + feld(felder, 5) == null ? null : LocalDate.parse(feld(felder, 5)))); + } + return auftraege; + } + + private static @Nullable String feld(String[] felder, int index) { + if (index >= felder.length) { + return null; + } + var wert = felder[index].strip(); + return wert.isEmpty() || wert.equals(LEER) ? null : wert; + } + + // ---------------------------------------------------------------- Lauf + + /** + * Führt alle Aufträge aus. + * + * @param wurzel das Verzeichnis, auf das sich die Pfade der Liste beziehen. + * @param synopsen wohin die Einzelsynopsen zu legen sind; {@code null} = keine ablegen. + */ + public static List<Zeile> fuehreAus( + List<Auftrag> auftraege, Path wurzel, @Nullable Path synopsen) { + var zeilen = new ArrayList<Zeile>(auftraege.size()); + for (var auftrag : auftraege) { + zeilen.add(fuehreAus(auftrag, wurzel, synopsen)); + } + return zeilen; + } + + public static Zeile fuehreAus(Auftrag auftrag, Path wurzel, @Nullable Path synopsen) { + try { + var hefte = new ArrayList<Quelle>(auftrag.hefte().size()); + for (var heft : auftrag.hefte()) { + hefte.add(Quelle.lies(wurzel.resolve(heft))); + } + var ergebnis = + Pipeline.erzeugeSynopse( + new Pipeline.Auftrag( + Quelle.lies(wurzel.resolve(auftrag.stamm())), + hefte, + auftrag.artikel(), + false, + auftrag.stichtag(), + auftrag.nachfassung() == null + ? null + : Quelle.lies(wurzel.resolve(auftrag.nachfassung())))); + if (synopsen != null) { + Files.createDirectories(synopsen); + Files.writeString( + synopsen.resolve(dateiname(auftrag.bezeichnung()) + ".html"), + ergebnis.html(), + StandardCharsets.UTF_8); + } + var abgleich = ergebnis.abgleich(); + return new Zeile( + auftrag.bezeichnung(), + ergebnis.anzahlProtokollEintraege(), + ergebnis.anzahlAngewandt(), + ergebnis.anzahlManuell(), + // Was am Stichtag noch nicht galt, ist weder angewandt noch liegengeblieben. + ergebnis.anzahlProtokollEintraege() + - ergebnis.anzahlAngewandt() + - ergebnis.anzahlManuell(), + abgleich == null ? null : abgleich.gleich(), + abgleich == null ? null : abgleich.geprueft(), + abgleich == null ? null : abgleich.fehlende().size(), + abgleich == null ? null : abgleich.ueberzaehlige().size(), + abgleich == null ? null : abgleich.abweichungen().size(), + ergebnis.gruende(), + null); + } catch (Exception e) { + // Ein gescheiterter Auftrag hält den Lauf nicht an; sein Fehler steht in seiner Zeile. + return new Zeile( + auftrag.bezeichnung(), + 0, + 0, + 0, + 0, + null, + null, + null, + null, + null, + Map.of(), + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + private static String dateiname(String bezeichnung) { + return bezeichnung.replaceAll("[^\\p{L}\\p{N}]+", "-"); + } + + // ---------------------------------------------------------------- Ausgabe + + /** Der Bericht als Tabulatortabelle — die Form, in der er auch als Grundlinie liegt. */ + public static String alsTsv(List<Zeile> zeilen) { + var sb = new StringBuilder("# ").append(String.join("\t", SPALTEN)).append('\n'); + for (var zeile : zeilen) { + sb.append(zeile.bezeichnung()) + .append('\t') + .append(zeile.befehle()) + .append('\t') + .append(zeile.angewandt()) + .append('\t') + .append(zeile.manuell()) + .append('\t') + .append(zeile.zurueckgestellt()) + .append('\t') + .append(zahl(zeile.gleich())) + .append('\t') + .append(zahl(zeile.geprueft())) + .append('\t') + .append(zahl(zeile.fehlend())) + .append('\t') + .append(zahl(zeile.ueberzaehlig())) + .append('\t') + .append(zahl(zeile.abweichend())) + .append('\t') + .append(gruendeText(zeile.gruende())); + if (zeile.fehler() != null) { + sb.append('\t').append(zeile.fehler().replace('\t', ' ').replace('\n', ' ')); + } + sb.append('\n'); + } + return sb.toString(); + } + + private static String zahl(@Nullable Integer wert) { + return wert == null ? LEER : String.valueOf(wert); + } + + /** „Zieltext nicht vorhanden:3; Befehl nicht erkannt:1“ — in der Reihenfolge der Gründe. */ + static String gruendeText(Map<Grund, Integer> gruende) { + if (gruende.isEmpty()) { + return LEER; + } + var geordnet = new EnumMap<>(gruende); + var sb = new StringBuilder(); + for (var eintrag : geordnet.entrySet()) { + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(eintrag.getKey().bezeichnung()).append(':').append(eintrag.getValue()); + } + return sb.toString(); + } + + /** + * Der Bericht als Übersichtsseite. Sie ist kein Ersatz für die Tabulatortabelle, sondern deren + * Lesefassung: Jede Zeile verweist auf die Synopse ihres Auftrags, sodass sich ein Rest an seinem + * eigenen Dokument nachschlagen lässt. + * + * @param verzeichnis das Verzeichnis der Einzelsynopsen, relativ zur Übersicht; {@code null} = + * ohne Verweise. + */ + public static String alsHtml(List<Zeile> zeilen, @Nullable String verzeichnis) { + var sb = new StringBuilder(); + sb.append("<!DOCTYPE html>\n<html lang=\"de\"><head><meta charset=\"utf-8\">\n") + .append("<title>Korpusbericht — ÄndGgner</title>\n") + .append("<style>\n") + .append("body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 70rem; }\n") + .append("table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }\n") + .append("th, td { border: 1px solid #999; padding: 0.25rem 0.5rem; text-align: right; }\n") + .append("th:first-child, td:first-child { text-align: left; }\n") + .append("td.gruende { text-align: left; font-size: 0.8rem; }\n") + .append("tr.rest td { background: #fff6f2; }\n") + .append("caption { text-align: left; padding-bottom: 0.5rem; }\n") + .append("</style></head><body>\n") + .append("<h1>Korpusbericht</h1>\n<p>") + .append(esc(summe(zeilen))) + .append("</p>\n<table><thead><tr>"); + for (var spalte : SPALTEN) { + sb.append("<th>").append(esc(spalte)).append("</th>"); + } + sb.append("</tr></thead><tbody>\n"); + for (var zeile : zeilen) { + sb.append(zeile.manuell() > 0 || zeile.fehler() != null ? "<tr class=\"rest\">" : "<tr>"); + sb.append("<td>"); + if (verzeichnis == null) { + sb.append(esc(zeile.bezeichnung())); + } else { + sb.append("<a href=\"") + .append(esc(verzeichnis)) + .append('/') + .append(esc(dateiname(zeile.bezeichnung()))) + .append(".html\">") + .append(esc(zeile.bezeichnung())) + .append("</a>"); + } + sb.append("</td>"); + for (var wert : + List.of( + String.valueOf(zeile.befehle()), + String.valueOf(zeile.angewandt()), + String.valueOf(zeile.manuell()), + String.valueOf(zeile.zurueckgestellt()), + zahl(zeile.gleich()), + zahl(zeile.geprueft()), + zahl(zeile.fehlend()), + zahl(zeile.ueberzaehlig()), + zahl(zeile.abweichend()))) { + sb.append("<td>").append(esc(wert)).append("</td>"); + } + sb.append("<td class=\"gruende\">") + .append(esc(zeile.fehler() != null ? zeile.fehler() : gruendeText(zeile.gruende()))) + .append("</td></tr>\n"); + } + return sb.append("</tbody></table>\n</body></html>\n").toString(); + } + + private static String esc(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + /** Die Summe über den Korpus — die Zahl, die eine Welle vor und nach sich nennt. */ + public static String summe(List<Zeile> zeilen) { + long befehle = zeilen.stream().mapToLong(Zeile::befehle).sum(); + long angewandt = zeilen.stream().mapToLong(Zeile::angewandt).sum(); + long manuell = zeilen.stream().mapToLong(Zeile::manuell).sum(); + long gleich = zeilen.stream().filter(z -> z.gleich() != null).mapToLong(Zeile::gleich).sum(); + long geprueft = + zeilen.stream().filter(z -> z.geprueft() != null).mapToLong(Zeile::geprueft).sum(); + long gescheitert = zeilen.stream().filter(z -> z.fehler() != null).count(); + var sb = + new StringBuilder( + "%d Aufträge, %d Befehle, %d angewandt, %d manuell; %d von %d Normen gleich" + .formatted(zeilen.size(), befehle, angewandt, manuell, gleich, geprueft)); + if (gescheitert > 0) { + sb.append("; %d Aufträge gescheitert".formatted(gescheitert)); + } + return sb.toString(); + } + + // ---------------------------------------------------------------- Grundlinie + + /** + * Hält den Lauf gegen die Grundlinie und meldet jede Kennzahl, die <em>gefallen</em> ist. + * + * <p>Nur der Rückschritt wird gerügt. Ein Fortschritt ist kein Fehler, sondern der Zweck der + * Arbeit; er verlangt allein, die Grundlinie fortzuschreiben. + * + * @return die Rügen; leer, wenn nichts gefallen ist. + */ + public static List<String> gegenGrundlinie(List<Zeile> lauf, String grundlinie) { + var alt = liesGrundlinie(grundlinie); + var ruegen = new ArrayList<String>(); + for (var zeile : lauf) { + if (zeile.fehler() != null) { + ruegen.add("%s: gescheitert — %s".formatted(zeile.bezeichnung(), zeile.fehler())); + continue; + } + var frueher = alt.get(zeile.bezeichnung()); + if (frueher == null) { + // Ein neuer Auftrag ist kein Rückschritt; die Grundlinie ist bloß noch nicht + // fortgeschrieben. + continue; + } + pruefe( + ruegen, zeile.bezeichnung(), "erschlossene Befehle", zeile.befehle(), frueher[1], true); + pruefe( + ruegen, zeile.bezeichnung(), "angewandte Befehle", zeile.angewandt(), frueher[2], true); + pruefe( + ruegen, + zeile.bezeichnung(), + "liegengebliebene Befehle", + zeile.manuell(), + frueher[3], + false); + pruefe(ruegen, zeile.bezeichnung(), "gleiche Normen", wert(zeile.gleich()), frueher[5], true); + pruefe( + ruegen, zeile.bezeichnung(), "fehlende Normen", wert(zeile.fehlend()), frueher[7], false); + pruefe( + ruegen, + zeile.bezeichnung(), + "überzählige Normen", + wert(zeile.ueberzaehlig()), + frueher[8], + false); + pruefe( + ruegen, + zeile.bezeichnung(), + "abweichende Normen", + wert(zeile.abweichend()), + frueher[9], + false); + } + for (var bezeichnung : alt.keySet()) { + if (lauf.stream().noneMatch(z -> z.bezeichnung().equals(bezeichnung))) { + ruegen.add( + "%s: steht in der Grundlinie, ist im Lauf aber nicht vorgekommen." + .formatted(bezeichnung)); + } + } + return ruegen; + } + + private static long wert(@Nullable Integer zahl) { + return zahl == null ? Long.MIN_VALUE : zahl; + } + + /** + * @param hoeherIstBesser ob ein Fallen der Zahl den Rückschritt bedeutet (angewandte Befehle, + * gleiche Normen) oder ihr Steigen (liegengebliebene, abweichende). + */ + private static void pruefe( + List<String> ruegen, + String bezeichnung, + String kennzahl, + long jetzt, + String frueherText, + boolean hoeherIstBesser) { + if (frueherText.equals(LEER) || jetzt == Long.MIN_VALUE) { + return; + } + long frueher; + try { + frueher = Long.parseLong(frueherText); + } catch (NumberFormatException e) { + return; + } + boolean rueckschritt = hoeherIstBesser ? jetzt < frueher : jetzt > frueher; + if (rueckschritt) { + ruegen.add("%s: %s %d statt %d.".formatted(bezeichnung, kennzahl, jetzt, frueher)); + } + } + + private static Map<String, String[]> liesGrundlinie(String tsv) { + var zeilen = new LinkedHashMap<String, String[]>(); + for (var zeile : tsv.split("\n")) { + if (zeile.isBlank() || zeile.startsWith("#")) { + continue; + } + var felder = zeile.split("\t"); + if (felder.length >= 10) { + zeilen.put(felder[0].strip(), felder); + } + } + return zeilen; + } +} diff --git a/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java b/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java new file mode 100644 index 0000000..e709337 --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/bericht/KorpusberichtTest.java @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.bericht; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Der Massenlauf über den Beispielkorpus, gehalten gegen die eingecheckte Grundlinie. + * + * <p>Die Einzelprüfungen sagen, dass ein bestimmtes Heft aufgeht. Diese sagt, dass keines der + * einunddreißig Hefte hinter das zurückfällt, was es einmal hergegeben hat — und zwar in einer + * Zahl, die sich fortschreiben lässt, statt in Dutzenden verstreuter Behauptungen. + */ +class KorpusberichtTest { + + private static final Path SAMPLEDATA = Path.of("src/test/resources/sampledata"); + private static final Path LISTE = SAMPLEDATA.resolve("korpus.tsv"); + private static final Path GRUNDLINIE = SAMPLEDATA.resolve("korpus-grundlinie.tsv"); + + @Test + void derKorpusFaelltNichtHinterDieGrundlinieZurueck() throws Exception { + assumeTrue(Files.exists(LISTE) && Files.exists(GRUNDLINIE), "Korpusliste fehlt"); + var auftraege = Korpusbericht.liesListe(LISTE); + assumeTrue( + auftraege.stream().allMatch(a -> Files.exists(SAMPLEDATA.resolve(a.stamm()))), + "Beispieldaten des Korpus fehlen"); + + var zeilen = Korpusbericht.fuehreAus(auftraege, SAMPLEDATA.toAbsolutePath(), null); + + // Ein gescheiterter Auftrag hält den Lauf nicht an — unbemerkt bleiben darf er aber nicht; + // sonst meldete der Bericht lauter Nullen und niemand sähe den Grund. + assertThat(zeilen).hasSameSizeAs(auftraege); + assertThat(zeilen).allSatisfy(z -> assertThat(z.fehler()).isNull()); + + var ruegen = + Korpusbericht.gegenGrundlinie(zeilen, Files.readString(GRUNDLINIE, StandardCharsets.UTF_8)); + + assertThat(ruegen) + .as( + "Rückschritte gegenüber der Grundlinie; steht dagegen ein Fortschritt darin, so ist" + + " korpus-grundlinie.tsv fortzuschreiben:\n%s\n\nLauf:\n%s", + String.join("\n", ruegen), Korpusbericht.alsTsv(zeilen)) + .isEmpty(); + } + + @Test + void dieListeWirdVollstaendigGelesen() throws Exception { + assumeTrue(Files.exists(LISTE), "Korpusliste fehlt"); + var auftraege = Korpusbericht.liesListe(LISTE); + + assertThat(auftraege).isNotEmpty(); + // Kommentar- und Leerzeilen sind übergangen, „-“ heißt „nicht angegeben“. + assertThat(auftraege).noneMatch(a -> a.bezeichnung().startsWith("#")); + assertThat(auftraege).allSatisfy(a -> assertThat(a.hefte()).isNotEmpty()); + var mitArtikel = + auftraege.stream().filter(a -> "GEG-BGBl-zeitrichtig".equals(a.bezeichnung())).findFirst(); + assertThat(mitArtikel).isPresent(); + assertThat(mitArtikel.orElseThrow().artikel()).isEqualTo("1"); + assertThat(mitArtikel.orElseThrow().nachfassung()).isNull(); + var mehrereHefte = + auftraege.stream() + .filter(a -> "BayJG-mit-Aenderungsantrag".equals(a.bezeichnung())) + .findFirst() + .orElseThrow(); + assertThat(mehrereHefte.hefte()).hasSize(2); + var mitStichtag = + auftraege.stream() + .filter(a -> "IfSG-Stichtag".equals(a.bezeichnung())) + .findFirst() + .orElseThrow(); + assertThat(mitStichtag.stichtag()).isNotNull(); + } + + @Test + void nurDerRueckschrittWirdGeruegt() { + var grundlinie = + """ + # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe + Fall\t10\t8\t2\t0\t20\t22\t1\t0\t1\t- + """; + + var besser = + List.of(new Korpusbericht.Zeile("Fall", 10, 9, 1, 0, 21, 22, 0, 0, 1, Map.of(), null)); + assertThat(Korpusbericht.gegenGrundlinie(besser, grundlinie)).isEmpty(); + + var schlechter = + List.of(new Korpusbericht.Zeile("Fall", 10, 7, 3, 0, 19, 22, 1, 0, 2, Map.of(), null)); + assertThat(Korpusbericht.gegenGrundlinie(schlechter, grundlinie)) + .anySatisfy(r -> assertThat(r).contains("angewandte Befehle 7 statt 8")) + .anySatisfy(r -> assertThat(r).contains("liegengebliebene Befehle 3 statt 2")) + .anySatisfy(r -> assertThat(r).contains("gleiche Normen 19 statt 20")) + .anySatisfy(r -> assertThat(r).contains("abweichende Normen 2 statt 1")); + } + + @Test + void einGescheiterterAuftragWirdGeruegtStattUebergangen() { + var grundlinie = + """ + # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe + Fall\t10\t8\t2\t0\t-\t-\t-\t-\t-\t- + """; + + var gescheitert = + List.of( + new Korpusbericht.Zeile( + "Fall", 0, 0, 0, 0, null, null, null, null, null, Map.of(), "IOException: weg")); + assertThat(Korpusbericht.gegenGrundlinie(gescheitert, grundlinie)) + .singleElement() + .asString() + .contains("gescheitert"); + } + + @Test + void einVerschwundenerAuftragFaelltAuf() { + var grundlinie = + """ + # Bezeichnung\tBefehle\tangewandt\tmanuell\tzurückgestellt\tgleich\tgeprüft\tfehlend\tüberzählig\tabweichend\tGründe + Fall\t10\t8\t2\t0\t-\t-\t-\t-\t-\t- + """; + + assertThat(Korpusbericht.gegenGrundlinie(List.of(), grundlinie)) + .singleElement() + .asString() + .contains("ist im Lauf aber nicht vorgekommen"); + } +} diff --git a/src/test/resources/sampledata/korpus-grundlinie.tsv b/src/test/resources/sampledata/korpus-grundlinie.tsv new file mode 100644 index 0000000..60da4f6 --- /dev/null +++ b/src/test/resources/sampledata/korpus-grundlinie.tsv @@ -0,0 +1,44 @@ +# Grundlinie des Korpuslaufs (§ 6d des Handbuchs), Stand 28. August 2026. +# +# Jede Zeile hält fest, was ein Auftrag der Liste „korpus.tsv“ zu diesem Zeitpunkt hergegeben hat. +# Der Lauf wird dagegen gehalten (KorpusberichtTest, „--grundlinie“): Gerügt wird allein, was +# FÄLLT — weniger erschlossene oder angewandte Befehle, weniger gleiche Normen, mehr +# liegengebliebene, fehlende, überzählige oder abweichende. Ein Fortschritt ist kein Fehler, +# sondern der Zweck der Arbeit; er verlangt allein, diese Datei fortzuschreiben. +# +# Die hohen Reste bei GEG-Beschlussempfehlung, IfSG-Beschlussempfehlung und RP-UKAPOGS beruhen +# darauf, dass die Vorlage eine ältere Fassung fortschreibt als die eingesetzte Stammfassung +# („Zieltext nicht vorhanden“); sie sind kein Mangel des Erzeugnisses, wohl aber ein Wächter: +# Steigen sie, hat sich etwas verschlechtert. +# Bezeichnung Befehle angewandt manuell zurückgestellt gleich geprüft fehlend überzählig abweichend Gründe +UWG-BGBl 19 19 0 0 - - - - - - +UWG-RegE 20 20 0 0 - - - - - - +AGG-RegE 24 23 1 0 - - - - - Stelle nicht auffindbar:1 +ProdHaftG-RegE 1 1 0 0 - - - - - - +GEG-BGBl-zeitrichtig 119 119 0 0 - - - - - - +IfSG-BGBl-zeitrichtig 75 75 0 0 - - - - - - +BayJG-GVBl 154 154 0 0 - - - - - - +SN-SaechsBeamtVG 5 5 0 0 - - - - - - +NI-NEFG 7 7 0 0 - - - - - - +SH-Gemeindeordnung 1 1 0 0 167 167 0 0 0 - +SH-Kreisordnung 1 1 0 0 86 86 0 0 0 - +NRW-WDR-Gesetz 101 101 0 0 - - - - - - +NRW-LMG 18 18 0 0 - - - - - - +NRW-TMZ-Gesetz 4 4 0 0 - - - - - - +NRW-17-RAEStV-AusfG 1 1 0 0 - - - - - - +BE-ASOG 6 6 0 0 171 171 0 0 0 - +BE-LAF-ErrG 2 2 0 0 5 5 0 0 0 - +BW-KomWO 37 31 6 0 91 93 0 0 2 Befehl nicht erkannt:6 +HE-StVRZustV 21 21 0 0 52 52 0 0 0 - +TH-KiGaFinVO 10 10 0 0 - - - - - - +BB-FraktG 4 4 0 0 24 24 0 0 0 - +HH-GutachterausschussVO 21 17 4 0 10 14 0 0 4 Befehl nicht erkannt:2; Stelle nicht auffindbar:1; Zieltext nicht vorhanden:1 +HB-BerufsfachschulVO 41 37 4 0 26 29 1 0 2 Befehl nicht erkannt:3; Fundstelle mehrdeutig:1 +MV-APOAmtsTA 24 20 4 0 - - - - - Befehl nicht erkannt:4 +RP-UKAPOGS 23 9 14 0 - - - - - Stelle nicht auffindbar:2; Zieltext nicht vorhanden:10; Bestand widerspricht dem Befehl:2 +GEG-GModG-RegE 97 87 10 0 - - - - - Befehl nicht erkannt:6; Stelle nicht auffindbar:2; Zieltext nicht vorhanden:2 +GEG-Beschlussempfehlung 119 70 49 0 - - - - - Befehl nicht erkannt:1; Stelle nicht auffindbar:3; Zieltext nicht vorhanden:40; Fundstelle mehrdeutig:1; Bestand widerspricht dem Befehl:4 +IfSG-Beschlussempfehlung 75 51 24 0 - - - - - Befehl nicht erkannt:1; Stelle nicht auffindbar:1; Zieltext nicht vorhanden:21; Bestand widerspricht dem Befehl:1 +BayJG-Landtagsentwurf 154 154 0 0 - - - - - - +BayJG-mit-Aenderungsantrag 154 154 0 0 - - - - - - +IfSG-Stichtag 75 65 0 10 - - - - - - diff --git a/src/test/resources/sampledata/korpus.tsv b/src/test/resources/sampledata/korpus.tsv new file mode 100644 index 0000000..2832539 --- /dev/null +++ b/src/test/resources/sampledata/korpus.tsv @@ -0,0 +1,46 @@ +# Auftragsliste des Korpuslaufs (§ 6d des Handbuchs). +# +# Spalten, durch Tabulator getrennt: +# Bezeichnung | Stammfassung | Hefte (durch Komma) | Artikel | Nachfassung | Stichtag +# „-“ heißt: nicht angegeben. Die Pfade beziehen sich auf das Verzeichnis dieser Datei. +# +# Aufgenommen ist, was die Akzeptanzprüfungen des EndToEndTest fahren. Deren Zahlen müssen hier +# unverändert herauskommen; weichen sie ab, liegt der Fehler an der Liste und nicht am Erzeugnis. +# +# Bezeichnung Stamm Hefte Artikel Nachfassung Stichtag +UWG-BGBl UWG/BJNR141400004.xml UWG/bgbl126s0043_regelungstext.pdf - - - +UWG-RegE UWG/BJNR141400004.xml UWG/BT-Drs-21-1855_Regierungsentwurf.pdf - - - +AGG-RegE AGG/BJNR189710006.xml AGG/BT-Drs-21-6178_Regierungsentwurf.pdf - - - +ProdHaftG-RegE ProdHaftG/BJNR021980989.xml ProdHaftG/RegE_Produkthaftungsrecht_2025-12-17.pdf - - - +GEG-BGBl-zeitrichtig GEG/BJNR172810020-2023.xml GEG/bgbl123s0280_regelungstext.pdf 1 - - +IfSG-BGBl-zeitrichtig IfSG/BJNR104510000-2020.xml IfSG/bgbl120s2397_78991.pdf - - - +BayJG-GVBl BayJG/BayJG-alt.txt BayJG/gvbl-2026-06.pdf - - - +SN-SaechsBeamtVG Sachsen/SaechsBeamtVG-alt.txt Sachsen/SaechsGVBl-2026-S134_AendG-SaechsBeamtVG_revosax.pdf - - - +NI-NEFG Niedersachsen/NEFG-alt.txt Niedersachsen/Nds-GVBl-2026-10_ELER-Foerdergesetz-AendG.pdf - - - +SH-Gemeindeordnung SchleswigHolstein/GO-SH-alt.txt SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf 1 SchleswigHolstein/GO-SH-neu.txt - +SH-Kreisordnung SchleswigHolstein/KrO-SH-alt.txt SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf 2 SchleswigHolstein/KrO-SH-neu.txt - +NRW-WDR-Gesetz NRW/WDR-Gesetz-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +NRW-LMG NRW/LMG-NRW-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +NRW-TMZ-Gesetz NRW/TMZ-Gesetz-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +NRW-17-RAEStV-AusfG NRW/17-RAEStV-AusfG-alt.txt NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf - - - +BE-ASOG Berlin/ASOG-Bln-alt.txt Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf 1 Berlin/ASOG-Bln-neu.txt - +BE-LAF-ErrG Berlin/LAF-ErrG-alt.txt Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf 2 Berlin/LAF-ErrG-neu.txt - +BW-KomWO BadenWuerttemberg/KomWO-BW-alt.txt BadenWuerttemberg/GBl-2026-26_KommunalwahlO-AendVO.pdf - BadenWuerttemberg/KomWO-BW-neu.txt - +HE-StVRZustV Hessen/StVRZustV-alt.txt Hessen/GVBl-2026-05_11-AendVO-verkehrsrechtl-Zustaendigkeiten.pdf - Hessen/StVRZustV-neu.txt - +TH-KiGaFinVO Thueringen/ThuerKigaFinVO-alt.txt Thueringen/GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf - - - +BB-FraktG Brandenburg/FraktG-alt.txt Brandenburg/GVBl-I-2026-12_FraktG-AendG.pdf - Brandenburg/FraktG-neu.txt - +HH-GutachterausschussVO Hamburg/GAusschV-HH-alt.txt Hamburg/HmbGVBl-2026-17_GutachterausschussVO-AendVO.pdf - Hamburg/GAusschV-HH-neu.txt - +HB-BerufsfachschulVO Bremen/AssBerFSchulV-BR-alt.txt Bremen/BremGBl-2026-87_BerufsfachschulVO-AendVO.pdf - Bremen/AssBerFSchulV-BR-neu.txt - +MV-APOAmtsTA MecklenburgVorpommern/APOAmtsTA-MV-alt.txt MecklenburgVorpommern/GVOBl-MV-2026-21_Besoldungsanpassung-ua.pdf 44 - - +# Rheinland-Pfalz führt keine Vorfassungen; angewandt wird deshalb auf die Nachfassung, und +# aussagekräftig ist allein die Zahl der erschlossenen Befehle (23). Die liegengebliebenen sind +# die Folge des falschen Standes, nicht eines Mangels — siehe § 13 Absatz 4 des Handbuchs. +RP-UKAPOGS RheinlandPfalz/UKAPOGS-E3-neu.txt RheinlandPfalz/GVBl-2026-21_UKAPOGS-E3-AendVO.pdf - - - +# Entwürfe und Zusammenstellungen: hier gibt es keine Nachfassung, wohl aber die Frage, wie viel +# eine Vorlage hergibt. +GEG-GModG-RegE GEG/BJNR172810020.xml GEG/Regierungsentwurf_GModG_2026-05-13.pdf - - - +GEG-Beschlussempfehlung GEG/BJNR172810020.xml GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf - - - +IfSG-Beschlussempfehlung IfSG/BJNR104510000.xml IfSG/1924334.pdf - - - +BayJG-Landtagsentwurf BayJG/BayJG-alt.txt BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf - - - +BayJG-mit-Aenderungsantrag BayJG/BayJG-alt.txt BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf,BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf - - - +IfSG-Stichtag IfSG/BJNR104510000-2020.xml IfSG/bgbl120s2397_78991.pdf - - 2020-11-19 |
