Support range structure replacement, struct renumbering, and multi-paragraph blocks

Adds bisStelle to StrukturErsetzung for coordinated target ranges, recognizes
deletion/renumbering of whole structural units (paragraphs and Gliederung
entries), and handles insertion/replacement of multi-paragraph blocks split on
§-headings. Updates FASSUNGEN.txt accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Change-Id: I637cc5effbe32c094e62c070b63c470149a77c85
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 5febfc1..029d9cc 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,87 @@
 
 
 ════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 15. Juli 2026,
+ zuletzt geändert durch die am 15. Juli 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Auf Grund der weiteren Ertüchtigung des Befehlserkenners und des Befehlsanwenders
+werden Bereichs- und Koordinationsziele bei Struktur-Ersetzungen, die Streichung
+und Umnummerierung ganzer Struktureinheiten (Paragraphen und Gliederungs-
+einheiten) sowie das Einfügen und Ersetzen ganzer Paragraphenblöcke einer
+Verarbeitung zugänglich gemacht.
+
+
+Artikel 1
+Erweiterung der Struktur-Ersetzung um Ziel-Bereiche
+
+Der Aenderungsbefehl (Aenderungsbefehl.StrukturErsetzung) wird um das Feld
+„bisStelle“ ergänzt, welches bei einem zusammenhängenden, koordinierten Ziel-
+Bereich („Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10
+ersetzt: „…““, „Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „…““)
+das letzte Ziel bezeichnet; bei einem Einzelziel bleibt es unbesetzt. Der
+Befehlsanwender (BefehlAnwender) löst den Bereich für Absätze, Sätze und
+Paragraphen auf und ersetzt ihn durch den zitierten Block.
+
+
+Artikel 2
+Aufnahme struktureller Streichungen und Umnummerierungen
+
+Der Befehlserkenner wird wie folgt geändert:
+
+1.  Die Streichung ganzer Struktureinheiten („§ 9 wird gestrichen.“, „Die §§ 34
+    bis 39 werden gestrichen.“, „Der bisherige Teil 3 wird gestrichen.“) wird als
+    Aufhebung erkannt.
+
+2.  Die Umnummerierung wird auf Paragraphen und Gliederungseinheiten (Teil,
+    Abschnitt, Unterabschnitt, Buch, Kapitel, Anlage) erstreckt sowie um die
+    paarweise §-Umnummerierung („Die §§ 46 und 47 werden zu den §§ 34 und 35.“)
+    ergänzt. Der Befehlsanwender benennt hierbei die betroffene Norm oder
+    Gliederungseinheit um; eine bereits weggefallene Zielnorm wird überschrieben.
+
+3.  Die Neufassung eines §-Bereichs wird auf die Form ohne „bisherige“ erstreckt
+    und liefert bei mehrgliedrigem Ziel nunmehr eine bereichsbezogene Struktur-
+    Ersetzung statt eines Einzelbefehls.
+
+4.  Die Neufassung der Überschrift einer benannten Einheit („In Anlage 7 wird die
+    Überschrift durch die folgende Überschrift ersetzt: „…““) sowie der Inhalts-
+    übersicht werden erkannt; letztere wird stets der manuellen Prüfung
+    zugewiesen.
+
+5.  Die Entwurfsform der Struktur-Ersetzung, bei der die Aufzählungsbezeichnung
+    außerhalb des Zitats steht („… ersetzt: 3. „…““), wird abgefangen und dem
+    Ersatztext wieder vorangestellt.
+
+6.  Der Chapeau-Qualifier („Im Satzteil vor Nummer 1 …“, „In der Angabe vor
+    Nummer 1 …“) wird auf weitere Einleitungen erstreckt; steht er allein, ohne
+    eigene Stelle, so bezieht sich die Operation auf die Kontextstelle.
+
+
+Artikel 3
+Einfügung und Ersetzung ganzer Paragraphenblöcke
+
+1.  Die Einfügung mehrerer Paragraphen in einem Zuge („Nach § 60a werden die
+    folgenden §§ 60b und 60c eingefügt: „…““) sowie die Ersetzung eines §-Bereichs
+    durch einen §-Block werden erkannt.
+
+2.  Der Befehlsanwender zerlegt den zitierten Block anhand der §-Überschriften
+    (unter Ausschluss bloßer Querverweise wie „§ 71 Absatz 1“) in die einzelnen
+    Paragraphen und fügt sie geschlossen ein oder setzt sie an die Stelle des
+    ersetzten Bereichs.
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle
+(einhundertvierundvierzig an der Zahl) sowie durch die vollständige Erstellung
+(mvnw verify) bestätigt worden. Nicht selbsttätig verarbeitbar bleiben
+einstweilen die mehrquellige Struktur-Ersetzung über mehrere Gesetze hinweg
+sowie vereinzelte Sonderfälle der Entwurfsfassung (Drucksachen); diese werden
+dem Abschnitt „Manuell prüfen“ zugewiesen. Die Unterstützung des bayerischen
+Landesrechts bleibt einem späteren Zeitpunkt vorbehalten.
+
+
+════════════════════════════════════════════════════════════════════════════════
  Fassung vom 13. Juli 2026,
  zuletzt geändert durch die am 13. Juli 2026 vorgenommenen Änderungen
 ════════════════════════════════════════════════════════════════════════════════
diff --git a/README.adoc b/README.adoc
index ad512f6..ac56564 100644
--- a/README.adoc
+++ b/README.adoc
@@ -86,9 +86,16 @@
 
 Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der
 Rechtsförmlichkeit (Ersetzen, Neufassung, Einfügen, Anfügen, Aufheben,
-Streichen, Umnummerierung). Alles andere — etwa Bereichsbefehle wie
-„Die Absätze 2 bis 4 werden …“ — landet mit Begründung im Abschnitt
-*Manuell prüfen* der Synopse und wird niemals stillschweigend verworfen.
+Streichen, Umnummerierung) einschließlich zahlreicher Sonderformen:
+Bereichs- und Koordinationsziele („Die Absätze 8 und 9 werden durch die
+folgenden Absätze 8 bis 10 ersetzt“), strukturelle Streichungen ganzer
+Einheiten („§ 9 wird gestrichen“), §- und Gliederungs-Umnummerierungen
+(„§ 9a wird zu § 9“, „Der bisherige Abschnitt 2 wird zu Abschnitt 3“),
+das Einfügen/Ersetzen ganzer §-Blöcke sowie Chapeau-Lokatoren („Im
+Satzteil vor Nummer 1 …“). Im amtlichen BGBl-Format werden damit
+praktisch alle Befehle typisiert; Entwürfe (Drucksachen) enthalten
+vereinzelt Sonderfälle, die mit Begründung im Abschnitt *Manuell prüfen*
+der Synopse landen und niemals stillschweigend verworfen werden.
 
 
 == Running the Tests
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index a2db382..f445811 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -47,9 +47,20 @@
   /**
    * „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ — ein Ziel wird durch
    * einen Block ersetzt, der auch mehrere neue Einheiten enthalten darf.
+   *
+   * @param stelle das (erste) zu ersetzende Ziel.
+   * @param bisStelle bei einem zusammenhängenden Bereich („Die Absätze 8 und 9 …“, „Die Sätze 4 bis
+   *     5 …“) das letzte Ziel; {@code null} bei einem Einzelziel.
    */
-  record StrukturErsetzung(Stelle stelle, Ebene ebene, String text, Provenienz provenienz)
-      implements Aenderungsbefehl {}
+  record StrukturErsetzung(
+      Stelle stelle, @Nullable Stelle bisStelle, Ebene ebene, String text, Provenienz provenienz)
+      implements Aenderungsbefehl {
+
+    /** Einzelziel-Konstruktor (kein Bereich). */
+    public StrukturErsetzung(Stelle stelle, Ebene ebene, String text, Provenienz provenienz) {
+      this(stelle, null, ebene, text, provenienz);
+    }
+  }
 
   /** „… werden nach dem Wort „A“ die Wörter „B“ eingefügt.“ */
   record WoerterEinfuegung(Stelle stelle, WortAnker anker, String woerter, Provenienz provenienz)
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index 7283d90..2cc5108 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -45,9 +45,23 @@
 
   // „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ (neues BGBl-Format);
   // auch „Die Überschrift wird durch die folgende Überschrift ersetzt: „…““ (Entwürfe).
+  // Der optionale Enumerator-Präfix („3. “, „a) “) fängt die Entwurfs-/Drucksachenform ab, bei der
+  // die Aufzählungsbezeichnung außerhalb des Zitats steht („… ersetzt: 3. „…““); er wird dem
+  // Ersatztext wieder vorangestellt, da das Label sonst verloren ginge.
   private static final Pattern STRUKTUR_ERSETZUNG =
       Pattern.compile(
           "^(?:In )?(.+?) (?:wird|werden) durch (?:den |die |das )?folgende[nrs]? (.+?) ersetzt: "
+              + "((?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s*)?"
+              + Z
+              + "\\.?$");
+
+  // „In Anlage 7 wird die Überschrift durch die folgende Überschrift ersetzt: „…““ — Neufassung der
+  // Überschrift einer benannten Einheit (Anlage/Gliederung/Paragraph); das „die Überschrift“-Objekt
+  // steht hier zwischen Stelle und „durch“, weshalb STRUKTUR_ERSETZUNG nicht greift.
+  private static final Pattern UEBERSCHRIFT_ERSETZUNG =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) die Überschrift durch "
+              + "(?:die |den |das )?folgende[nrs]? Überschrift ersetzt: "
               + Z
               + "\\.?$");
 
@@ -152,19 +166,31 @@
       Pattern.compile(
           "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
 
+  // „§ 9 wird gestrichen.“, „Absatz 3 wird gestrichen.“, „Die §§ 34 bis 39 werden gestrichen.“,
+  // „Der bisherige Teil 3 wird gestrichen.“ — Streichung ganzer Struktureinheiten (semantisch eine
+  // Aufhebung). Greift erst, wenn die Wörter-Streichung STREICHUNG (die ein Zitat verlangt) und die
+  // Überschrift-/Absatzbezeichnungs-Streichungen nicht passen.
+  private static final Pattern STRUKTUR_STREICHUNG =
+      Pattern.compile("^(.+?) (?:wird|werden) gestrichen\\.$");
+
   private static final Pattern UMNUMMERIERUNG =
       Pattern.compile(
           "^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
-              + "(Absatz|Satz|Nummer|Buchstabe) (\\d+[a-z]?)\\.$");
+              + "(§|Absatz|Satz|Nummer|Buchstabe|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage) "
+              + "(\\d+[a-z]?)\\.$");
 
   // „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ bzw. „Die bisherigen Nummern 4
   // bis 6 werden die Nummern 8 bis 10.“ — Bereichs-Umnummerierung, in Einzelbefehle aufgelöst.
   private static final Pattern UMNUMMERIERUNG_BEREICH =
       Pattern.compile(
-          "^Die bisherigen (?:Absätze|Sätze|Nummern|Buchstaben) (\\d+) bis (\\d+) "
+          "^Die (?:bisherigen )?(?:Absätze|Sätze|Nummern|Buchstaben) (\\d+) bis (\\d+) "
               + "werden (?:zu den |die )?(Absätzen|Sätzen|Nummern|Buchstaben|Absätze|Sätze) "
               + "(\\d+) bis (\\d+)\\.$");
 
+  // „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ — paarweise §-Umnummerierung (auch Bereiche).
+  private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN =
+      Pattern.compile("^Die §§ (.+?) werden (?:zu den §§ |zu §§ |die §§ )(.+?)\\.$");
+
   // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines §-Bereichs;
   // der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt.
   private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
@@ -323,32 +349,77 @@
 
     if ((m = NEUFASSUNG.matcher(text)).matches()) {
       var neuerText = zitat(zitate, m.group(2));
-      return StellenParser.parse(m.group(1))
-          .map(s -> new Neufassung(kontext.plus(s), neuerText, provenienz));
+      var stellen = StellenParser.parseMehrfach(m.group(1));
+      if (stellen.size() == 1) {
+        return Optional.of(new Neufassung(kontext.plus(stellen.get(0)), neuerText, provenienz));
+      }
+      if (stellen.size() > 1) {
+        // „Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „…““ — ein zusammenhängender
+        // Bereich wird durch einen Block ersetzt (Ebene aus dem Ziel abgeleitet).
+        return koordinierteErsetzung(stellen, null, kontext, neuerText, provenienz);
+      }
+      return Optional.empty();
     }
 
     if ((m = WORTLAUT_ZU_ABSATZ.matcher(text)).matches()) {
       return Optional.of(new WortlautZuAbsatz(kontext, m.group(1), provenienz));
     }
 
+    if ((m = UEBERSCHRIFT_ERSETZUNG.matcher(text)).matches()) {
+      var neuerText = zitat(zitate, m.group(2));
+      return StellenParser.parse(m.group(1))
+          .map(
+              s ->
+                  new Neufassung(
+                      kontext.plus(s).plus(new Stelle(List.of(new Stelle.Ueberschrift()))),
+                      neuerText,
+                      provenienz));
+    }
+
     if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) {
-      var neuerText = zitat(zitate, m.group(3));
+      var enumerator = m.group(3);
+      var rohText = zitat(zitate, m.group(4));
+      // Entwurfsform „… ersetzt: 3. „…““: das außerhalb des Zitats stehende Label wieder anfügen.
+      var neuerText = enumerator != null ? enumerator.strip() + " " + rohText.strip() : rohText;
       var ziel = m.group(2).strip();
-      var stelle = StellenParser.parse(m.group(1));
-      if (stelle.isEmpty()) {
+      var stellen = StellenParser.parseMehrfach(m.group(1));
+      if (stellen.isEmpty()) {
         return Optional.empty();
       }
       // „durch die folgende Überschrift ersetzt“ ist eine Neufassung der Überschrift,
-      // „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen.
-      if (ziel.equals("Überschrift") || ziel.matches("§\\s*\\d+[a-z]?")) {
-        return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz));
+      // „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen,
+      // „Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt“ eine Neufassung der
+      // Inhaltsübersicht (die der Applier stets zur manuellen Prüfung markiert).
+      if (stellen.size() == 1
+          && (ziel.equals("Überschrift")
+              || ziel.equals("Inhaltsübersicht")
+              || ziel.matches("§\\s*\\d+[a-z]?"))) {
+        return Optional.of(new Neufassung(kontext.plus(stellen.get(0)), neuerText, provenienz));
       }
       var ebene = strukturEbene(ziel);
       if (ebene == null) {
         return Optional.empty();
       }
-      return Optional.of(
-          new StrukturErsetzung(kontext.plus(stelle.get()), ebene, neuerText, provenienz));
+      if (ebene == Ebene.PARAGRAPH) {
+        // „§ 71 wird durch die folgenden §§ 71 bis 71p ersetzt: „…““ bzw. „Die §§ 42 bis 45 werden
+        // durch die folgenden §§ 42 bis 45 ersetzt: „…““ — ein §-Bereich wird durch einen §-Block
+        // ersetzt.
+        var first = stellen.get(0);
+        var last = stellen.get(stellen.size() - 1);
+        return Optional.of(
+            new StrukturErsetzung(
+                kontext.plus(first),
+                stellen.size() > 1 ? kontext.plus(last) : null,
+                Ebene.PARAGRAPH,
+                neuerText,
+                provenienz));
+      }
+      if (stellen.size() == 1) {
+        return Optional.of(
+            new StrukturErsetzung(kontext.plus(stellen.get(0)), ebene, neuerText, provenienz));
+      }
+      // „Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10 ersetzt: „…““
+      return koordinierteErsetzung(stellen, ebene, kontext, neuerText, provenienz);
     }
 
     if ((m = WORT_ZU_SATZZEICHEN.matcher(text)).matches()) {
@@ -471,9 +542,25 @@
     if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches()) {
       var vorher = m.group(1).equals("Vor");
       var stelle = StellenParser.parse(m.group(2));
-      var ebeneBez = ebeneUndBezeichnung(m.group(3));
       var textInhalt = zitat(zitate, m.group(4));
-      if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+      if (stelle.isEmpty()) {
+        return Optional.empty();
+      }
+      var ebeneBez = ebeneUndBezeichnung(m.group(3));
+      if (ebeneBez.isEmpty()) {
+        // „Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „…““ — Block mehrerer
+        // Paragraphen (Aufteilung an den §-Überschriften erfolgt beim Anwenden). Signal:
+        // Ebene PARAGRAPH mit bezeichnung == null.
+        if (m.group(3).strip().matches("§§\\s*\\d.*")) {
+          return Optional.of(
+              new StrukturEinfuegung(
+                  kontext.plus(stelle.get()),
+                  vorher,
+                  Ebene.PARAGRAPH,
+                  null,
+                  textInhalt,
+                  provenienz));
+        }
         return Optional.empty();
       }
       return Optional.of(
@@ -546,6 +633,14 @@
       return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz));
     }
 
+    if ((m = STRUKTUR_STREICHUNG.matcher(text)).matches()) {
+      return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz));
+    }
+
+    if ((m = UMNUMMERIERUNG_PARAGRAPHEN.matcher(text)).matches()) {
+      return paragraphenUmnummerierung(m.group(1), m.group(2), kontext, provenienz);
+    }
+
     if ((m = UMNUMMERIERUNG_BEREICH.matcher(text)).matches()) {
       return bereichsUmnummerierung(
           m.group(3), m.group(1), m.group(2), m.group(4), m.group(5), kontext, provenienz);
@@ -685,6 +780,24 @@
   }
 
   /**
+   * Löst „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ (auch Bereiche) in paarweise
+   * §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und zusammengeführt.
+   */
+  private static Optional<Aenderungsbefehl> paragraphenUmnummerierung(
+      String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) {
+    var alt = StellenParser.parseMehrfach("§ " + altPhrase);
+    var neu = StellenParser.parseMehrfach("§ " + neuPhrase);
+    if (alt.isEmpty() || alt.size() != neu.size()) {
+      return Optional.empty();
+    }
+    var teile = new ArrayList<Aenderungsbefehl>();
+    for (int i = 0; i < alt.size(); i++) {
+      teile.add(new Umnummerierung(kontext.plus(alt.get(i)), kontext.plus(neu.get(i)), provenienz));
+    }
+    return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+  }
+
+  /**
    * Löst „Die bisherigen Absätze X bis Y werden zu den Absätzen X′ bis Y′.“ in einzelne
    * Umnummerierungen auf, angewandt in absteigender Reihenfolge (Y→Y′ zuerst), damit die
    * sequenzielle Anwendung keine Labels kollidieren lässt.
@@ -730,6 +843,11 @@
       String phrase, Function<Stelle, Aenderungsbefehl> bauer) {
     var stellen = StellenParser.parseMehrfach(phrase);
     if (stellen.isEmpty()) {
+      // „Im Satzteil vor Nummer 1 …“, „In der Angabe vor Nummer 1 …“ — reiner Chapeau-Qualifier
+      // ohne eigene Stelle: die Operation bezieht sich auf die Kontextstelle.
+      if (StellenParser.istNurChapeau(phrase)) {
+        return Optional.of(bauer.apply(Stelle.LEER));
+      }
       return Optional.empty();
     }
     if (stellen.size() == 1) {
@@ -738,6 +856,46 @@
     return Optional.of(new Sammelbefehl(stellen.stream().map(bauer).toList()));
   }
 
+  /**
+   * Baut aus einem zusammenhängenden, koordinierten Ziel-Bereich („Die Absätze 8 und 9 …“, „Die
+   * bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und letztes
+   * Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn. Nur für Absatz- und
+   * Satz-Bereiche (die der Applier auflösen kann); andere Ebenen bleiben unbekannt.
+   */
+  private static Optional<Aenderungsbefehl> koordinierteErsetzung(
+      List<Stelle> stellen,
+      @Nullable Ebene ebeneHint,
+      Stelle kontext,
+      String block,
+      Provenienz provenienz) {
+    var first = stellen.get(0);
+    var last = stellen.get(stellen.size() - 1);
+    var ebene = ebeneHint != null ? ebeneHint : ebeneAusStelle(first);
+    if (ebene != Ebene.ABSATZ && ebene != Ebene.SATZ) {
+      return Optional.empty();
+    }
+    return Optional.of(
+        new StrukturErsetzung(
+            kontext.plus(first), kontext.plus(last), ebene, block, provenienz));
+  }
+
+  /** Die Ebene der feinsten Komponente einer Stelle (Buchstabe < Nummer < Satz < Absatz < §). */
+  private static @Nullable Ebene ebeneAusStelle(Stelle stelle) {
+    Ebene ebene = null;
+    for (var komponente : stelle.komponenten()) {
+      ebene =
+          switch (komponente) {
+            case Stelle.Paragraph p -> Ebene.PARAGRAPH;
+            case Stelle.AbsatzNr a -> Ebene.ABSATZ;
+            case Stelle.SatzNr s -> Ebene.SATZ;
+            case Stelle.NummerNr n -> Ebene.NUMMER;
+            case Stelle.BuchstabeNr b -> Ebene.BUCHSTABE;
+            default -> ebene;
+          };
+    }
+    return ebene;
+  }
+
   private record EbeneBezeichnung(Ebene ebene, String bezeichnung) {}
 
   private static Optional<EbeneBezeichnung> ebeneUndBezeichnung(String phrase) {
@@ -771,6 +929,7 @@
   private static @Nullable Ebene strukturEbene(String ziel) {
     var erstesWort = ziel.split("\\s+", 2)[0];
     return switch (erstesWort) {
+      case "§", "§§" -> Ebene.PARAGRAPH;
       case "Absatz", "Absätze" -> Ebene.ABSATZ;
       case "Satz", "Sätze" -> Ebene.SATZ;
       case "Nummer", "Nummern" -> Ebene.NUMMER;
@@ -781,10 +940,13 @@
 
   private static Stelle.Komponente komponenteFuer(String ebene, String nummer) {
     return switch (ebene) {
+      case "§" -> new Stelle.Paragraph(nummer);
       case "Absatz" -> new Stelle.AbsatzNr(nummer);
       case "Satz" -> new Stelle.SatzNr(nummer);
       case "Nummer" -> new Stelle.NummerNr(nummer);
       case "Buchstabe" -> new Stelle.BuchstabeNr(nummer);
+      case "Teil", "Abschnitt", "Unterabschnitt", "Buch", "Kapitel", "Anlage" ->
+          new Stelle.Gliederungseinheit(ebene, nummer);
       default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene);
     };
   }
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 4660343..ef45714 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -48,9 +48,19 @@
   // ohne eigene Stelle-Komponente; die Ersetzung sucht ohnehin im Text der umgebenden Stelle.
   private static final Pattern CHAPEAU_QUALIFIER =
       Pattern.compile(
-          "(?i)(?:in dem |im |in der )?(?:Satzteil|Angabe) vor "
+          "(?i)(?:im |in dem |in der |dem |der )?(?:Satzteil|Angabe) vor "
               + "(?:Nummer|Buchstabe|Satz|Absatz) \\S+");
 
+  /**
+   * Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil
+   * vor Nummer 1“, „in der Angabe vor Nummer 1“) und daher keine eigene Stelle-Komponente trägt.
+   * Die Operation bezieht sich dann auf die Kontextstelle (den umgebenden Änderungsrahmen).
+   */
+  public static boolean istNurChapeau(String phrase) {
+    var rest = CHAPEAU_QUALIFIER.matcher(phrase.strip()).replaceAll(" ").strip();
+    return rest.isEmpty() && !phrase.isBlank();
+  }
+
   public static Optional<Stelle> parse(String phrase) {
     phrase = CHAPEAU_QUALIFIER.matcher(phrase).replaceAll(" ").strip();
     var woerter = phrase.strip().split("\\s+");
@@ -62,7 +72,7 @@
         continue;
       }
       switch (wort) {
-        case "§" -> {
+        case "§", "§§" -> {
           var wert = naechstesWort(woerter, i);
           if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
             return Optional.empty();
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 86eccaf..ee56b2b 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -87,6 +87,7 @@
         return switch (befehl) {
           case Neufassung n -> wendeGliederungNeufassungAn(gliederungen, n);
           case Aufhebung a -> wendeGliederungStreichungAn(gliederungen, a);
+          case Umnummerierung u -> wendeGliederungUmnummerierungAn(gliederungen, u);
           default -> manuell(befehl, "Strukturänderung wird nicht automatisch angewandt.");
         };
       }
@@ -137,6 +138,23 @@
     return angewandt(befehl, alt.bezeichnung());
   }
 
+  /** „Der bisherige Abschnitt 2 wird zu Abschnitt 3.“ — Bezeichnung der Gliederungseinheit umsetzen. */
+  private static AngewandteAenderung wendeGliederungUmnummerierungAn(
+      List<Gliederung> gliederungen, Umnummerierung befehl) {
+    int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad());
+    if (idx < 0) {
+      return manuell(befehl, "Gliederungseinheit nicht gefunden: " + befehl.stelle().anzeigeText());
+    }
+    var neuPfad = befehl.neu().gliederungsPfad();
+    if (neuPfad.isEmpty()) {
+      return manuell(befehl, "Neue Gliederungsbezeichnung fehlt.");
+    }
+    var neueBezeichnung = neuPfad.get(neuPfad.size() - 1).bezeichnung();
+    var alt = gliederungen.get(idx);
+    gliederungen.set(idx, alt.mitBezeichnung(neueBezeichnung));
+    return angewandt(befehl, neueBezeichnung);
+  }
+
   /**
    * Findet die Gliederungseinheit zum Pfad („Teil 3 Abschnitt 2“): jede Ebene wird per Bezeichnung
    * innerhalb des Kennzahl-Präfixes der übergeordneten Ebene aufgelöst.
@@ -335,14 +353,34 @@
         }
         var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
         var norm = normen.get(fundstelle.normIndex());
+        int vonIndex = fundstelle.absatzIndex();
+        int bisIndex = vonIndex;
+        // Bereich („Die Absätze 8 und 9 werden … ersetzt“): das letzte Ziel bestimmt das Ende.
+        if (befehl.bisStelle() != null) {
+          var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle());
+          if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+            yield manuell(befehl, nicht.begruendung());
+          }
+          var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle();
+          if (f2.normIndex() != fundstelle.normIndex() || f2.absatzIndex() == null) {
+            yield manuell(befehl, "Ersetzungsbereich liegt nicht in einer einzigen Norm.");
+          }
+          bisIndex = f2.absatzIndex();
+        }
+        if (bisIndex < vonIndex) {
+          yield manuell(befehl, "Ersetzungsbereich ist leer oder absteigend.");
+        }
         var absaetze = new ArrayList<>(norm.absaetze());
-        absaetze.remove((int) fundstelle.absatzIndex());
-        absaetze.addAll(fundstelle.absatzIndex(), parseAbsaetze(befehl.text()));
+        for (int k = bisIndex; k >= vonIndex; k--) {
+          absaetze.remove(k);
+        }
+        absaetze.addAll(vonIndex, parseAbsaetze(befehl.text()));
         normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
         yield angewandt(befehl, norm.enbez());
       }
-      case SATZ ->
-          bearbeiteBereich(
+      case SATZ -> {
+        if (befehl.bisStelle() == null) {
+          yield bearbeiteBereich(
               normen,
               befehl,
               (text, bereich) ->
@@ -350,6 +388,9 @@
                       text.substring(0, bereich.von())
                           + befehl.text().strip().replaceAll("\\s+", " ")
                           + text.substring(bereich.bis())));
+        }
+        yield wendeSatzBereichsErsetzungAn(normen, befehl);
+      }
       case NUMMER, BUCHSTABE ->
           bearbeiteBereich(
               normen,
@@ -365,30 +406,116 @@
                 return TextErgebnis.ok(
                     text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis()));
               });
-      case PARAGRAPH ->
-          manuell(befehl, "Struktur-Ersetzung ganzer Paragraphen wird nicht unterstützt.");
+      case PARAGRAPH -> {
+        // „§ 71 wird durch die folgenden §§ 71 bis 71p ersetzt: „…““ — der adressierte §-Bereich
+        // wird entfernt und durch die Paragraphen des Blocks ersetzt.
+        var aufloesung = loeseNormAuf(normen, befehl.stelle());
+        if (aufloesung.fehler() != null) {
+          yield manuell(befehl, aufloesung.fehler());
+        }
+        int vonIndex = aufloesung.normIndex();
+        int bisIndex = vonIndex;
+        if (befehl.bisStelle() != null) {
+          var a2 = loeseNormAuf(normen, befehl.bisStelle());
+          if (a2.fehler() != null) {
+            yield manuell(befehl, a2.fehler());
+          }
+          bisIndex = a2.normIndex();
+        }
+        if (bisIndex < vonIndex) {
+          yield manuell(befehl, "Ersetzungsbereich ist leer oder absteigend.");
+        }
+        var neue = parseNormenBlock(befehl.text(), normen.get(vonIndex).gliederung());
+        if (neue.isEmpty()) {
+          yield manuell(befehl, "Im Ersetzungsblock wurde kein Paragraph erkannt.");
+        }
+        for (int k = bisIndex; k >= vonIndex; k--) {
+          normen.remove(k);
+        }
+        normen.addAll(vonIndex, neue);
+        yield angewandt(befehl, neue.stream().map(Norm::enbez).toList());
+      }
     };
   }
 
+  /**
+   * Ersetzt einen zusammenhängenden Satz-Bereich („Die Sätze 4 und 5 werden … gefasst“) durch einen
+   * Block: vom Anfang des ersten bis zum Ende des letzten adressierten Satzes (beide müssen im
+   * selben Absatz derselben Norm liegen).
+   */
+  private static AngewandteAenderung wendeSatzBereichsErsetzungAn(
+      List<Norm> normen, StrukturErsetzung befehl) {
+    var e1 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+    if (e1 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+      return manuell(befehl, nicht.begruendung());
+    }
+    var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle());
+    if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+      return manuell(befehl, nicht.begruendung());
+    }
+    var f1 = ((StellenAufloeser.Ergebnis.Gefunden) e1).fundstelle();
+    var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle();
+    if (f1.normIndex() != f2.normIndex()
+        || f1.absatzIndex() == null
+        || !f1.absatzIndex().equals(f2.absatzIndex())
+        || f1.bereich() == null
+        || f2.bereich() == null) {
+      return manuell(befehl, "Satz-Bereich liegt nicht in einem einzigen Absatz.");
+    }
+    int von = f1.bereich().von();
+    int bis = f2.bereich().bis();
+    if (bis < von) {
+      return manuell(befehl, "Satz-Bereich ist leer oder absteigend.");
+    }
+    var norm = normen.get(f1.normIndex());
+    var absaetze = new ArrayList<>(norm.absaetze());
+    var absatz = absaetze.get(f1.absatzIndex());
+    var text = absatz.text();
+    var neu =
+        text.substring(0, von)
+            + befehl.text().strip().replaceAll("\\s+", " ")
+            + text.substring(bis);
+    absaetze.set(f1.absatzIndex(), absatz.mitText(neu));
+    normen.set(f1.normIndex(), norm.mitAbsaetzen(absaetze));
+    return angewandt(befehl, norm.enbez());
+  }
+
   private static AngewandteAenderung wendeStrukturEinfuegungAn(
       List<Norm> normen, StrukturEinfuegung befehl) {
     return switch (befehl.ebene()) {
       case PARAGRAPH -> {
-        var enbezNeu = "§ " + befehl.bezeichnung();
-        if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) {
-          yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz.");
-        }
         var aufloesung = loeseNormAuf(normen, befehl.stelle());
         if (aufloesung.fehler() != null) {
           yield manuell(befehl, aufloesung.fehler());
         }
         var anker = normen.get(aufloesung.normIndex());
+        int position = aufloesung.normIndex() + (befehl.vorher() ? 0 : 1);
+
+        // „Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „…““ — Block mehrerer §§.
+        if (befehl.bezeichnung() == null) {
+          var neue = parseNormenBlock(befehl.text(), anker.gliederung());
+          if (neue.isEmpty()) {
+            yield manuell(befehl, "Im Einfügeblock wurde kein Paragraph erkannt.");
+          }
+          for (var n : neue) {
+            if (StellenAufloeser.normIndex(gesetzAus(normen), n.enbez()) >= 0) {
+              yield manuell(befehl, n.enbez() + " existiert bereits im Stammgesetz.");
+            }
+          }
+          normen.addAll(position, neue);
+          yield angewandt(befehl, neue.stream().map(Norm::enbez).toList());
+        }
+
+        var enbezNeu = "§ " + befehl.bezeichnung();
+        if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) {
+          yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz.");
+        }
         var neueNorm =
             parseNorm(
                 befehl.text(),
                 enbezNeu,
                 new Norm(enbezNeu, null, anker.gliederung(), List.of(), false));
-        normen.add(aufloesung.normIndex() + (befehl.vorher() ? 0 : 1), neueNorm);
+        normen.add(position, neueNorm);
         yield angewandt(befehl, enbezNeu);
       }
       case ABSATZ -> {
@@ -542,6 +669,31 @@
 
   private static AngewandteAenderung wendeUmnummerierungAn(
       List<Norm> normen, Umnummerierung befehl) {
+    // „§ 9a wird zu § 9.“ — Umbenennung einer ganzen Norm.
+    if (nurParagraph(befehl.stelle()) && nurParagraph(befehl.neu())) {
+      var enbezAlt = "§ " + befehl.stelle().paragraph().get().nummer();
+      var enbezNeu = "§ " + befehl.neu().paragraph().get().nummer();
+      int idx = StellenAufloeser.normIndex(gesetzAus(normen), enbezAlt);
+      if (idx < 0) {
+        return manuell(befehl, enbezAlt + " existiert nicht im Gesetz.");
+      }
+      if (!enbezNeu.equals(enbezAlt)) {
+        int zielIdx = StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu);
+        if (zielIdx >= 0 && !normen.get(zielIdx).weggefallen()) {
+          return manuell(befehl, enbezNeu + " existiert bereits im Gesetz.");
+        }
+        // Eine bereits weggefallene Zielnorm wird durch die Umnummerierung überschrieben.
+        if (zielIdx >= 0) {
+          normen.remove(zielIdx);
+          if (zielIdx < idx) {
+            idx--;
+          }
+        }
+      }
+      normen.set(idx, normen.get(idx).mitEnbez(enbezNeu));
+      return angewandt(befehl, enbezNeu);
+    }
+
     var altAbsatz = befehl.stelle().absatz();
     var neuAbsatz = befehl.neu().absatz();
     if (altAbsatz.isPresent() && neuAbsatz.isPresent()) {
@@ -794,6 +946,34 @@
     return anzahl;
   }
 
+  // Eine §-Überschrift beginnt mit „§ N“, gefolgt von einem großgeschriebenen Titelwort — im
+  // Gegensatz zu Querverweisen wie „§ 71 Absatz 1“ oder „§§ 42 bis 45“. Die Negativliste schließt
+  // die Untergliederungs- und Verbindungswörter aus, sodass an solchen Stellen nicht getrennt wird.
+  private static final Pattern PARAGRAPH_UEBERSCHRIFT =
+      Pattern.compile(
+          "(?=§\\s*\\d+[a-z]?\\s+"
+              + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben|Buchst"
+              + "|und|bis|oder|sowie|des|der|dieses|genannten)"
+              + "\\p{Lu})");
+
+  /**
+   * Zerlegt einen Zitatblock mehrerer Paragraphen an den §-Überschriften (nicht an Querverweisen)
+   * und parst jeden Abschnitt zu einer {@link Norm}. Die Gliederung wird von der Vorlage übernommen.
+   */
+  private static List<Norm> parseNormenBlock(String block, @Nullable Gliederung gliederung) {
+    var normen = new ArrayList<Norm>();
+    for (var stueck : PARAGRAPH_UEBERSCHRIFT.split(block.strip())) {
+      var s = stueck.strip();
+      var m = Pattern.compile("^§\\s*(\\d+[a-z]?)\\b").matcher(s);
+      if (s.isEmpty() || !m.find()) {
+        continue;
+      }
+      var enbez = "§ " + m.group(1);
+      normen.add(parseNorm(s, enbez, new Norm(enbez, null, gliederung, List.of(), false)));
+    }
+    return normen;
+  }
+
   /** Zerlegt einen zitierten Normtext („§ 28a Titel (1) … (2) …“) in Titel und Absätze. */
   private static Norm parseNorm(String zitat, String enbez, Norm vorlage) {
     var text = zitat.strip();
@@ -889,6 +1069,10 @@
         befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez)));
   }
 
+  private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, List<String> enbezliste) {
+    return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(enbezliste));
+  }
+
   private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) {
     return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of());
   }
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
index 53eb6b9..7a09093 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
@@ -24,4 +24,8 @@
   public Gliederung mitTitel(@Nullable String neuerTitel) {
     return new Gliederung(kennzahl, bezeichnung, neuerTitel);
   }
+
+  public Gliederung mitBezeichnung(String neueBezeichnung) {
+    return new Gliederung(kennzahl, neueBezeichnung, titel);
+  }
 }
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
index 10c3fa6..a5703ec 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
@@ -31,6 +31,10 @@
     return new Norm(enbez, neuerTitel, gliederung, absaetze, weggefallen);
   }
 
+  public Norm mitEnbez(String neuerEnbez) {
+    return new Norm(neuerEnbez, titel, gliederung, absaetze, weggefallen);
+  }
+
   public Norm alsWeggefallen() {
     return new Norm(enbez, titel, gliederung, List.of(new Absatz(null, "(weggefallen)")), true);
   }
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 44903e7..483f06a 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -110,11 +110,11 @@
     assertThat(parseErgebnis.artikel()).contains("1");
     assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(100);
 
-    // Der Großteil der Befehle wird erkannt; nur einige Spezialfälle (Mehrquell-Ersetzungen,
-    // §§-Bereiche, Chapeau-Angaben) bleiben unbekannt.
+    // Im BGBl-Format werden inzwischen alle Befehle typisiert (Bereichs-/Struktur-Ersetzungen,
+    // §-Blöcke, Chapeau-Lokatoren, strukturelle Streichungen usw.).
     var unbekannt =
         parseErgebnis.befehle().stream().filter(b -> b instanceof UnbekannterBefehl).count();
-    assertThat(unbekannt).isLessThanOrEqualTo(8);
+    assertThat(unbekannt).isZero();
 
     // Das XML ist bereits konsolidiert; entscheidend ist, dass die Anwendung sauber terminiert
     // und jeden Befehl protokolliert.
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
index 230bfca..bbee916 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -441,14 +441,47 @@
   }
 
   @Test
-  void faelltBeiBisBereichenAufUnbekanntZurueck() {
-    // „bis“-Bereiche über Struktureinheiten sind hier (Phase 1) noch nicht unterstützt.
-    assertThat(
-            erkenne(
-                "Die Absätze 2 bis 4 werden durch die folgenden Absätze 2 bis 6 ersetzt:"
-                    + " „(2) Text.“",
-                Stelle.LEER))
-        .isEmpty();
+  void bereichsErsetzungAbsaetzeWirdStrukturErsetzung() {
+    // „bis“-Bereich über Absätze: das erste und letzte Ziel spannen den zu ersetzenden Bereich auf.
+    var befehl =
+        erkenne(
+            "Die Absätze 2 bis 4 werden durch die folgenden Absätze 2 bis 6 ersetzt:"
+                + " „(2) Text.“",
+            new Stelle(List.of(new Stelle.Paragraph("5"))));
+    assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+    var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+    assertThat(e.ebene()).isEqualTo(Ebene.ABSATZ);
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 5 Absatz 2");
+    assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 5 Absatz 4");
+  }
+
+  @Test
+  void koordinierteAbsatzErsetzungWirdBereich() {
+    // IfSG: „Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10 ersetzt: „…““
+    var befehl =
+        erkenne(
+            "Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10 ersetzt:"
+                + " „(8) Erstes. (9) Zweites. (10) Drittes.“",
+            new Stelle(List.of(new Stelle.Paragraph("14"))));
+    assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+    var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 14 Absatz 8");
+    assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 14 Absatz 9");
+  }
+
+  @Test
+  void mehrSatzNeufassungWirdStrukturErsetzung() {
+    // IfSG: „Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „…““
+    var befehl =
+        erkenne(
+            "Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „Erster neuer Satz."
+                + " Zweiter neuer Satz.“",
+            new Stelle(List.of(new Stelle.Paragraph("14"), new Stelle.AbsatzNr("2"))));
+    assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+    var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+    assertThat(e.ebene()).isEqualTo(Ebene.SATZ);
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 14 Absatz 2 Satz 4");
+    assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 14 Absatz 2 Satz 5");
   }
 
   @Test
@@ -556,6 +589,162 @@
   }
 
   @Test
+  void strukturStreichungGanzerEinheit() {
+    // „§ 9 wird gestrichen.“ — Streichung einer ganzen Einheit ist semantisch eine Aufhebung.
+    var befehl = erkenne("§ 9 wird gestrichen.", Stelle.LEER);
+    assertThat(befehl).get().isInstanceOf(Aufhebung.class);
+    assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 9");
+  }
+
+  @Test
+  void strukturStreichungMitKontext() {
+    var befehl =
+        erkenne("Absatz 3 wird gestrichen.", new Stelle(List.of(new Stelle.Paragraph("102"))));
+    assertThat(befehl).get().isInstanceOf(Aufhebung.class);
+    assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 102 Absatz 3");
+  }
+
+  @Test
+  void strukturStreichungBereichWirdSammelbefehl() {
+    var teile =
+        ((Sammelbefehl) erkenne("Die §§ 34 bis 39 werden gestrichen.", Stelle.LEER).orElseThrow())
+            .teilbefehle();
+    assertThat(teile).hasSize(6).allMatch(t -> t instanceof Aufhebung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 34", "§ 35", "§ 36", "§ 37", "§ 38", "§ 39");
+  }
+
+  @Test
+  void strukturStreichungGliederung() {
+    var befehl = erkenne("Der bisherige Teil 3 wird gestrichen.", Stelle.LEER);
+    assertThat(befehl).get().isInstanceOf(Aufhebung.class);
+    assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue();
+  }
+
+  @Test
+  void chapeauLokatorBeziehtSichAufKontext() {
+    // „In der Angabe vor Nummer 1 …“ / „Im Satzteil vor Nummer 1 …“ tragen keine eigene Stelle;
+    // die Operation bezieht sich auf die Kontextstelle.
+    var kontext = new Stelle(List.of(new Stelle.Paragraph("48"), new Stelle.AbsatzNr("1")));
+    var befehl =
+        erkenne(
+            "In der Angabe vor Nummer 1 wird die Angabe „2025“ durch die Angabe „2030“ ersetzt.",
+            kontext);
+    assertThat(befehl).get().isInstanceOf(Ersetzung.class);
+    var e = (Ersetzung) befehl.orElseThrow();
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 48 Absatz 1");
+    assertThat(e.alt()).isEqualTo("2025");
+    assertThat(e.neu()).isEqualTo("2030");
+  }
+
+  @Test
+  void bereichsUmnummerierungOhneBisherigen() {
+    var teile =
+        ((Sammelbefehl)
+                erkenne(
+                        "Die Absätze 4 bis 7 werden zu den Absätzen 3 bis 6.",
+                        new Stelle(List.of(new Stelle.Paragraph("108"))))
+                    .orElseThrow())
+            .teilbefehle();
+    assertThat(teile).hasSize(4).allMatch(t -> t instanceof Umnummerierung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 108 Absatz 7", "§ 108 Absatz 6", "§ 108 Absatz 5", "§ 108 Absatz 4");
+  }
+
+  @Test
+  void strukturErsetzungMitEnumeratorPraefix() {
+    // Entwurfs-/Drucksachenform: das Aufzählungslabel steht außerhalb des Zitats.
+    var befehl =
+        erkenne(
+            "Nummer 3 wird durch die folgende Nummer 3 ersetzt: 3. „ die Maßgaben der §§ 42 bis 45"
+                + " entsprechend eingehalten werden.“",
+            new Stelle(List.of(new Stelle.Paragraph("10"), new Stelle.AbsatzNr("2"))));
+    assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+    var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+    assertThat(e.ebene()).isEqualTo(Ebene.NUMMER);
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 10 Absatz 2 Nummer 3");
+    // Das Label „3.“ wird dem Ersatztext wieder vorangestellt.
+    assertThat(e.text()).startsWith("3. die Maßgaben");
+  }
+
+  @Test
+  void paragraphUmnummerierung() {
+    var befehl = erkenne("§ 9a wird zu § 9.", Stelle.LEER);
+    assertThat(befehl).get().isInstanceOf(Umnummerierung.class);
+    var u = (Umnummerierung) befehl.orElseThrow();
+    assertThat(u.stelle().anzeigeText()).isEqualTo("§ 9a");
+    assertThat(u.neu().anzeigeText()).isEqualTo("§ 9");
+  }
+
+  @Test
+  void koordinierteParagraphUmnummerierung() {
+    var teile =
+        ((Sammelbefehl) erkenne("Die §§ 46 und 47 werden zu den §§ 34 und 35.", Stelle.LEER).orElseThrow())
+            .teilbefehle();
+    assertThat(teile).hasSize(2).allMatch(t -> t instanceof Umnummerierung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText()).containsExactly("§ 46", "§ 47");
+    assertThat(teile).extracting(t -> ((Umnummerierung) t).neu().anzeigeText())
+        .containsExactly("§ 34", "§ 35");
+  }
+
+  @Test
+  void gliederungsUmnummerierung() {
+    var befehl = erkenne("Der bisherige Abschnitt 2 wird zu Abschnitt 3.", Stelle.LEER);
+    assertThat(befehl).get().isInstanceOf(Umnummerierung.class);
+    assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue();
+  }
+
+  @Test
+  void paragraphenBlockEinfuegung() {
+    var befehl =
+        erkenne(
+            "Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „§ 60b Prüfung (1) Text."
+                + " § 60c Optimierung (1) Mehr.“",
+            Stelle.LEER);
+    assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+    var e = (StrukturEinfuegung) befehl.orElseThrow();
+    assertThat(e.ebene()).isEqualTo(Ebene.PARAGRAPH);
+    assertThat(e.bezeichnung()).isNull();
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 60a");
+  }
+
+  @Test
+  void paragraphBlockErsetzung() {
+    var befehl =
+        erkenne(
+            "Die §§ 42 bis 45 werden durch die folgenden §§ 42 bis 45 ersetzt: „§ 42 Grundsatz"
+                + " (1) Text.“",
+            Stelle.LEER);
+    assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+    var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+    assertThat(e.ebene()).isEqualTo(Ebene.PARAGRAPH);
+    assertThat(e.stelle().anzeigeText()).isEqualTo("§ 42");
+    assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 45");
+  }
+
+  @Test
+  void ueberschriftErsetzungMitStelle() {
+    var befehl =
+        erkenne(
+            "In Anlage 7 wird die Überschrift durch die folgende Überschrift ersetzt: „Anlage 7"
+                + " (zu § 36) Höchstwerte“.",
+            Stelle.LEER);
+    assertThat(befehl).get().isInstanceOf(Neufassung.class);
+    assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue();
+  }
+
+  @Test
+  void inhaltsuebersichtErsetzung() {
+    var befehl =
+        erkenne(
+            "Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt: „Inhaltsübersicht"
+                + " § 1 Zweck § 2 Begriffe“.",
+            Stelle.LEER);
+    assertThat(befehl).get().isInstanceOf(Neufassung.class);
+    assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue();
+  }
+
+  @Test
   void mehrfachErsetzungWirdSammelbefehl() {
     // Mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“.
     var befehl =
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 8a1b1e0..9b9c18d 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -20,6 +20,7 @@
 import eu.mulk.aendggner.anwendung.BefehlAnwender.Status;
 import eu.mulk.aendggner.gesetz.Absatz;
 import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Gliederung;
 import eu.mulk.aendggner.gesetz.Norm;
 import java.util.List;
 import org.junit.jupiter.api.Test;
@@ -349,6 +350,44 @@
   }
 
   @Test
+  void ersetztAbsatzBereichDurchBlock() {
+    // „Die Absätze 1 und 2 werden durch die folgenden Absätze 1 bis 3 ersetzt: „…““
+    var befehl =
+        new StrukturErsetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")),
+            Ebene.ABSATZ,
+            "(1) Neu eins. (2) Neu zwei. (3) Neu drei.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var norm = ergebnis.neu().norm("§ 1").orElseThrow();
+    assertThat(norm.absaetze()).hasSize(3);
+    assertThat(norm.absaetze()).extracting(Absatz::nummer).containsExactly("1", "2", "3");
+    assertThat(norm.absaetze().get(0).text()).isEqualTo("Neu eins.");
+  }
+
+  @Test
+  void ersetztSatzBereichDurchBlock() {
+    // „Die Sätze 1 und 2 werden wie folgt gefasst: „…““ in § 2 (3 Sätze).
+    var befehl =
+        new StrukturErsetzung(
+            stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("1")),
+            stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("2")),
+            Ebene.SATZ,
+            "Neuer Satz eins. Neuer Satz zwei.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(absatzText(ergebnis.neu(), "§ 2", 0))
+        .isEqualTo("Neuer Satz eins. Neuer Satz zwei. Sie endet mit einem Bericht.");
+  }
+
+  @Test
   void fuegtSatzNachSatzEin() {
     var befehl =
         new StrukturEinfuegung(
@@ -422,6 +461,112 @@
         .isEqualTo("Zweck dieses Gesetzes ist die Prüfung.");
   }
 
+  @Test
+  void nummeriertParagraphUm() {
+    var befehl =
+        new Umnummerierung(stelle(new Stelle.Paragraph("3")), stelle(new Stelle.Paragraph("4")), PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+    assertThat(enbezListe).containsExactly("§ 1", "§ 2", "§ 4");
+    assertThat(ergebnis.neu().norm("§ 4").orElseThrow().titel()).isEqualTo("Schlussvorschriften");
+  }
+
+  @Test
+  void paragraphUmnummerierungUeberschreibtWeggefalleneZielnorm() {
+    // § 2 aufheben, dann § 3 → § 2: die weggefallene Zielnorm wird ersetzt.
+    var befehle =
+        List.<eu.mulk.aendggner.aenderung.Aenderungsbefehl>of(
+            new Aufhebung(stelle(new Stelle.Paragraph("2")), PROV),
+            new Umnummerierung(
+                stelle(new Stelle.Paragraph("3")), stelle(new Stelle.Paragraph("2")), PROV));
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), befehle);
+
+    assertThat(ergebnis.protokoll()).allMatch(a -> a.status() == Status.ANGEWANDT);
+    var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+    assertThat(enbezListe).containsExactly("§ 1", "§ 2");
+    assertThat(ergebnis.neu().norm("§ 2").orElseThrow().titel()).isEqualTo("Schlussvorschriften");
+  }
+
+  @Test
+  void meldetKonfliktBeiParagraphUmnummerierung() {
+    var befehl =
+        new Umnummerierung(stelle(new Stelle.Paragraph("3")), stelle(new Stelle.Paragraph("1")), PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+    assertThat(ergebnis.protokoll().get(0).begruendung()).contains("existiert bereits");
+  }
+
+  @Test
+  void fuegtParagraphenBlockEin() {
+    // „Nach § 1 werden die folgenden §§ 1a und 1b eingefügt: „…““ (bezeichnung == null).
+    var befehl =
+        new StrukturEinfuegung(
+            stelle(new Stelle.Paragraph("1")),
+            false,
+            Ebene.PARAGRAPH,
+            null,
+            "§ 1a Erstes Neu (1) Inhalt eins. § 1b Zweites Neu (1) Inhalt zwei.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+    assertThat(enbezListe).containsExactly("§ 1", "§ 1a", "§ 1b", "§ 2", "§ 3");
+    assertThat(ergebnis.neu().norm("§ 1a").orElseThrow().titel()).isEqualTo("Erstes Neu");
+    assertThat(ergebnis.neu().norm("§ 1b").orElseThrow().titel()).isEqualTo("Zweites Neu");
+  }
+
+  @Test
+  void ersetztParagraphBlock() {
+    // „§ 2 wird durch die folgenden §§ 2 und 2a ersetzt: „…““ — § 2 wird durch zwei §§ ersetzt.
+    // Der Querverweis „§ 1 Absatz 1“ im Text darf NICHT als Grenze zerteilt werden.
+    var befehl =
+        new StrukturErsetzung(
+            stelle(new Stelle.Paragraph("2")),
+            null,
+            Ebene.PARAGRAPH,
+            "§ 2 Begriffe (1) Erprobung nach § 1 Absatz 1 ist die Prüfung. § 2a Weiteres (1) Mehr.",
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+    assertThat(enbezListe).containsExactly("§ 1", "§ 2", "§ 2a", "§ 3");
+    assertThat(ergebnis.neu().norm("§ 2").orElseThrow().absaetze().get(0).text())
+        .isEqualTo("Erprobung nach § 1 Absatz 1 ist die Prüfung.");
+    assertThat(ergebnis.neu().norm("§ 2a").orElseThrow().titel()).isEqualTo("Weiteres");
+  }
+
+  @Test
+  void nummeriertGliederungUm() {
+    var gesetz =
+        new Gesetz(
+            "TestG",
+            "Gesetz",
+            "Test",
+            List.of(new Norm("§ 1", "Zweck", null, List.of(new Absatz("1", "Text.")), false)),
+            List.of(new Gliederung("010020", "Abschnitt 2", "Früherkennung")));
+    var befehl =
+        new Umnummerierung(
+            stelle(new Stelle.Gliederungseinheit("Abschnitt", "2")),
+            stelle(new Stelle.Gliederungseinheit("Abschnitt", "3")),
+            PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(ergebnis.neu().gliederungen().get(0).bezeichnung()).isEqualTo("Abschnitt 3");
+    assertThat(ergebnis.neu().gliederungen().get(0).titel()).isEqualTo("Früherkennung");
+  }
+
   private static Gesetz gesetz() {
     return new Gesetz(
         "TestG",