Support multi-target "jeweils" commands and range renumbering

Add a Sammelbefehl command that applies one operation to several
"und"/"sowie"/comma-coordinated Stellen sharing a common prefix, and
resolve "Die bisherigen Absätze X bis Y werden zu den Absätzen X' bis Y'"
into descending single renumberings. Parsing gains
StellenParser.parseMehrfach; the applier folds sub-commands into one log
entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change-Id: Iaa2c7161066d40d0605c00d3b6ca9a08696070e7
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index 60f5971..6b7c093 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -88,6 +88,28 @@
   record Umnummerierung(Stelle stelle, Stelle neu, Provenienz provenienz)
       implements Aenderungsbefehl {}
 
+  /**
+   * „In A und B wird jeweils …“ — ein Befehl, der dieselbe Operation auf mehrere, per „und“
+   * koordinierte Stellen anwendet. Die Teilbefehle teilen sich Provenienz und Befehlszeile; der
+   * Applier fasst sie zu einem Protokolleintrag zusammen.
+   */
+  record Sammelbefehl(java.util.List<Aenderungsbefehl> teilbefehle) implements Aenderungsbefehl {
+
+    public Sammelbefehl {
+      teilbefehle = java.util.List.copyOf(teilbefehle);
+    }
+
+    @Override
+    public Stelle stelle() {
+      return teilbefehle.get(0).stelle();
+    }
+
+    @Override
+    public Provenienz provenienz() {
+      return teilbefehle.get(0).provenienz();
+    }
+  }
+
   /** Fallback für alles, was der Parser nicht versteht — muss manuell geprüft werden. */
   record UnbekannterBefehl(Stelle stelle, String originalText, Provenienz provenienz)
       implements Aenderungsbefehl {}
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 22c2976..ca59b12 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -6,6 +6,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
@@ -14,7 +15,10 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
 import eu.mulk.aendggner.aenderung.Provenienz;
 import eu.mulk.aendggner.aenderung.Stelle;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Optional;
+import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.jspecify.annotations.Nullable;
@@ -84,7 +88,8 @@
 
   private static final Pattern WOERTER_EINFUEGUNG =
       Pattern.compile(
-          "^(?:In )?(.+?) (?:wird|werden) (nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+          "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
+              + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
               + Z
               + " "
               + WOERTER
@@ -123,10 +128,18 @@
   private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$");
 
   private static final Pattern STREICHUNG =
-      Pattern.compile("^(?:In )?(.+?) (?:wird|werden) " + WOERTER + " " + Z + " gestrichen\\.$");
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
 
   private static final Pattern UMNUMMERIERUNG =
-      Pattern.compile("^(?:Der bisherige )?(.+?) wird (Absatz|Satz) (\\d+[a-z]?)\\.$");
+      Pattern.compile("^(?:Der bisherige )?(.+?) wird (?:zu )?(Absatz|Satz) (\\d+[a-z]?)\\.$");
+
+  // „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ (Entwürfe) — Bereichs-
+  // Umnummerierung, die in einzelne Umnummerierungen aufgelöst wird.
+  private static final Pattern UMNUMMERIERUNG_BEREICH =
+      Pattern.compile(
+          "^Die bisherigen (?:Absätze|Sätze) (\\d+) bis (\\d+) "
+              + "werden zu den (Absätzen|Sätzen) (\\d+) bis (\\d+)\\.$");
 
   private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG =
       Pattern.compile(
@@ -203,10 +216,8 @@
       var jeweils = m.group(2) != null || text.contains(" jeweils durch ");
       var alt = wortZitat(zitate, m.group(3));
       var neu = wortZitat(zitate, m.group(4));
-      var stelle = StellenParser.parse(m.group(1));
-      var effektivesJeweils = jeweils;
-      return stelle.map(
-          s -> new Ersetzung(kontext.plus(s), alt, neu, effektivesJeweils, false, provenienz));
+      return ausStellen(
+          m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, false, provenienz));
     }
 
     if ((m = ERSETZUNG_OHNE_STELLE.matcher(text)).matches()) {
@@ -244,8 +255,8 @@
               ? new WortAnker.NachWoertern(ankerWoerter)
               : new WortAnker.VorWoertern(ankerWoerter);
       var woerter = wortZitat(zitate, m.group(4));
-      return StellenParser.parse(m.group(1))
-          .map(s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+      return ausStellen(
+          m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
     }
 
     if ((m = WOERTER_EINFUEGUNG_VOR_KOMMA.matcher(text)).matches()) {
@@ -335,8 +346,12 @@
 
     if ((m = STREICHUNG.matcher(text)).matches()) {
       var woerter = wortZitat(zitate, m.group(2));
-      return StellenParser.parse(m.group(1))
-          .map(s -> new Streichung(kontext.plus(s), woerter, provenienz));
+      return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, 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);
     }
 
     if ((m = UMNUMMERIERUNG.matcher(text)).matches()) {
@@ -345,14 +360,63 @@
           .map(
               alt ->
                   new Umnummerierung(
-                      kontext.plus(alt),
-                      kontext.plus(new Stelle(java.util.List.of(neu))),
-                      provenienz));
+                      kontext.plus(alt), kontext.plus(new Stelle(List.of(neu))), provenienz));
     }
 
     return Optional.empty();
   }
 
+  /**
+   * 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.
+   */
+  private static Optional<Aenderungsbefehl> bereichsUmnummerierung(
+      String ebeneWort,
+      String altVon,
+      String altBis,
+      String neuVon,
+      String neuBis,
+      Stelle kontext,
+      Provenienz provenienz) {
+    var ebene = ebeneWort.equals("Absätzen") ? "Absatz" : "Satz";
+    int av = Integer.parseInt(altVon);
+    int ab = Integer.parseInt(altBis);
+    int nv = Integer.parseInt(neuVon);
+    int nb = Integer.parseInt(neuBis);
+    if (ab - av != nb - nv || ab < av) {
+      return Optional.empty();
+    }
+    var teile = new ArrayList<Aenderungsbefehl>();
+    for (int k = ab - av; k >= 0; k--) {
+      var alt = komponenteFuer(ebene, String.valueOf(av + k));
+      var neu = komponenteFuer(ebene, String.valueOf(nv + k));
+      teile.add(
+          new Umnummerierung(
+              kontext.plus(new Stelle(List.of(alt))),
+              kontext.plus(new Stelle(List.of(neu))),
+              provenienz));
+    }
+    return Optional.of(new Sammelbefehl(teile));
+  }
+
+  /**
+   * Wendet einen Stellen-basierten Befehlsbauer auf eine (ggf. koordinierte) Stellenangabe an: bei
+   * einer einzelnen Stelle das gewohnte Verhalten, bei mehreren per „und“ verbundenen Stellen ein
+   * {@link Sammelbefehl}, der die Operation auf jede Stelle anwendet.
+   */
+  private static Optional<Aenderungsbefehl> ausStellen(
+      String phrase, Function<Stelle, Aenderungsbefehl> bauer) {
+    var stellen = StellenParser.parseMehrfach(phrase);
+    if (stellen.isEmpty()) {
+      return Optional.empty();
+    }
+    if (stellen.size() == 1) {
+      return Optional.of(bauer.apply(stellen.get(0)));
+    }
+    return Optional.of(new Sammelbefehl(stellen.stream().map(bauer).toList()));
+  }
+
   private record EbeneBezeichnung(Ebene ebene, String bezeichnung) {}
 
   private static Optional<EbeneBezeichnung> ebeneUndBezeichnung(String phrase) {
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 ad794b5..07702b2 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -2,6 +2,7 @@
 
 import eu.mulk.aendggner.aenderung.Stelle;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
@@ -103,6 +104,74 @@
     return Optional.of(new Stelle(komponenten));
   }
 
+  private static final Pattern KOORDINATION = Pattern.compile(",\\s*|\\s+und\\s+|\\s+sowie\\s+");
+
+  /**
+   * Parst eine ggf. per „und“/„sowie“/Komma koordinierte Stellenangabe in eine Liste von Stellen.
+   * Nachfolgende Segmente teilen sich den gemeinsamen Präfix des vorigen Segments: „§ 3 Absatz 1
+   * Satz 2 und Absatz 4“ → [§ 3 Absatz 1 Satz 2, § 3 Absatz 4].
+   *
+   * <p>Für eine einfache (nicht koordinierte) Angabe liefert die Methode genau ein Element (bzw.
+   * eine leere Liste, wenn {@link #parse} sie ablehnt) und ist damit ein Drop-in für {@code
+   * parse(...).map(List::of)}. Kann ein Segment nicht geparst werden oder findet der Präfix-Merge
+   * keine Anknüpfung, wird eine leere Liste geliefert — der Aufrufer stuft den Befehl dann als
+   * unbekannt ein (konservativ: lieber manuell prüfen als falsch anwenden).
+   */
+  public static List<Stelle> parseMehrfach(String phrase) {
+    var segmente = KOORDINATION.split(phrase.strip());
+    if (segmente.length <= 1) {
+      return parse(phrase).map(List::of).orElseGet(List::of);
+    }
+
+    var ergebnis = new ArrayList<Stelle>();
+    Stelle vorige = null;
+    for (var segment : segmente) {
+      if (segment.isBlank()) {
+        return List.of();
+      }
+      var teil = parse(segment);
+      if (teil.isEmpty()) {
+        return List.of();
+      }
+      Stelle voll;
+      if (vorige == null) {
+        voll = teil.get();
+      } else {
+        var gemergt = mitGemeinsamemPraefix(vorige, teil.get());
+        if (gemergt.isEmpty()) {
+          return List.of();
+        }
+        voll = gemergt.get();
+      }
+      ergebnis.add(voll);
+      vorige = voll;
+    }
+    return ergebnis;
+  }
+
+  /**
+   * Ergänzt {@code segment} um die Präfix-Komponenten von {@code vorige}, die feiner-granular als
+   * die führende Komponente des Segments sind. Die führende Komponentenklasse des Segments wird in
+   * {@code vorige} gesucht; alle davor stehenden Komponenten bilden den gemeinsamen Präfix.
+   */
+  private static Optional<Stelle> mitGemeinsamemPraefix(Stelle vorige, Stelle segment) {
+    var fuehrende = segment.komponenten().get(0).getClass();
+    var vorKomp = vorige.komponenten();
+    int ankerIndex = -1;
+    for (int i = 0; i < vorKomp.size(); i++) {
+      if (vorKomp.get(i).getClass().equals(fuehrende)) {
+        ankerIndex = i;
+        break;
+      }
+    }
+    if (ankerIndex < 0) {
+      return Optional.empty();
+    }
+    var komponenten = new ArrayList<>(vorKomp.subList(0, ankerIndex));
+    komponenten.addAll(segment.komponenten());
+    return Optional.of(new Stelle(komponenten));
+  }
+
   private static String naechstesWort(String[] woerter, int i) {
     return i + 1 < woerter.length ? entfernePunktuation(woerter[i + 1]) : null;
   }
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 02be593..2c9a33e 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -5,6 +5,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
@@ -86,6 +87,7 @@
         case Anfuegung a -> wendeAnfuegungAn(normen, a);
         case Aufhebung a -> wendeAufhebungAn(normen, a);
         case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
+        case Sammelbefehl s -> wendeSammelAn(normen, s);
         case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
       };
     } catch (RuntimeException e) {
@@ -467,6 +469,31 @@
     return angewandt(befehl, "(keine Textänderung nötig)");
   }
 
+  /**
+   * Ein Mehrfachziel-Befehl („In A und B wird jeweils …“): wendet jeden Teilbefehl nacheinander an
+   * (jeder mutiert den fortlaufenden Zwischenstand) und fasst sie zu einem Protokolleintrag
+   * zusammen. Nur wenn alle Teile gelingen, gilt der Befehl als angewandt; sonst wird er zur
+   * manuellen Prüfung markiert (bereits angewandte Teile bleiben wirksam).
+   */
+  private static AngewandteAenderung wendeSammelAn(List<Norm> normen, Sammelbefehl befehl) {
+    var betroffene = new LinkedHashSet<String>();
+    var fehler = new ArrayList<String>();
+    int i = 1;
+    for (var teil : befehl.teilbefehle()) {
+      var ergebnis = wendeAn(normen, teil);
+      betroffene.addAll(ergebnis.betroffeneEnbez());
+      if (ergebnis.status() != Status.ANGEWANDT) {
+        fehler.add("Teil " + i + " (" + teil.stelle().anzeigeText() + "): " + ergebnis.begruendung());
+      }
+      i++;
+    }
+    if (fehler.isEmpty()) {
+      return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", betroffene);
+    }
+    return new AngewandteAenderung(
+        befehl, Status.MANUELL_PRUEFEN, String.join(" ", fehler), betroffene);
+  }
+
   // --- Gemeinsame Helfer ---------------------------------------------------------------------
 
   private record TextErgebnis(@Nullable String text, @Nullable String fehler) {
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 5254ff2..bd17d79 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -140,13 +140,27 @@
     assertThat(parseErgebnis.befehle().size()).isBetween(15, 40);
 
     var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
-    assertThat(anwendung.anzahlAngewandt()).isGreaterThan(10);
+    // Inkl. der Mehrfachziel-„jeweils“-Befehle (§ 3, § 20, § 30) werden hier ~22 Befehle
+    // angewandt; nur die zwei Sonderfälle (positionaler Lokator, Verbundbefehl) bleiben manuell.
+    assertThat(anwendung.anzahlAngewandt()).isGreaterThanOrEqualTo(20);
+    assertThat(anwendung.anzahlManuell()).isLessThanOrEqualTo(3);
 
     var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
     var html = HtmlRenderer.rendere(synopse, "E2E-Test");
     assertThat(html).contains("<del>").contains("<ins>");
     // Stichprobe aus Artikel 1 Nummer 1: „Alters“ → „Lebensalters“ in § 1.
     assertThat(html).contains("<ins>Lebensalters</ins>");
+    // Mehrfachziel-Einfügung (§ 30 Absatz 2 Satz 1 und Absatz 3): „Bildung,“ vor „Familie“.
+    assertThat(html).contains("<ins>Bildung,");
+    // Mehrfachziel-Streichung (§ 3 Absatz 1 Satz 2 und Absatz 4): gezielt entfernt — der
+    // untargetierte Absatz 5 behält die Angabe.
+    var norm3 = anwendung.neu().norm("§ 3").orElseThrow();
+    var absatz1 =
+        norm3.absaetze().stream().filter(a -> "1".equals(a.nummer())).findFirst().orElseThrow();
+    assertThat(absatz1.text()).doesNotContain("in Bezug auf § 2 Abs. 1 Nr. 1 bis 4");
+    var absatz5 =
+        norm3.absaetze().stream().filter(a -> "5".equals(a.nummer())).findFirst().orElseThrow();
+    assertThat(absatz5.text()).contains("in Bezug auf § 2 Abs. 1 Nr. 1 bis 4");
   }
 
   /**
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 f0282c5..05055e0 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -8,6 +8,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
@@ -347,6 +348,82 @@
   }
 
   @Test
+  void erkenntMehrfachzielStreichung() {
+    var befehl =
+        erkenne(
+            "In § 3 Absatz 1 Satz 2 und Absatz 4 wird jeweils die Angabe „in Bezug auf § 2 Abs."
+                + " 1 Nr. 1 bis 4“ gestrichen.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+    var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+    assertThat(teile).hasSize(2).allMatch(t -> t instanceof Streichung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 3 Absatz 1 Satz 2", "§ 3 Absatz 4");
+    assertThat(((Streichung) teile.get(0)).woerter()).isEqualTo("in Bezug auf § 2 Abs. 1 Nr. 1 bis 4");
+  }
+
+  @Test
+  void erkenntMehrfachzielErsetzung() {
+    var befehl =
+        erkenne(
+            "In § 20 Absatz 1 Satz 1 und Absatz 2 Satz 2 wird jeweils die Angabe „Alters“ durch"
+                + " die Angabe „Lebensalters“ ersetzt.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+    var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+    assertThat(teile).hasSize(2).allMatch(t -> t instanceof Ersetzung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 20 Absatz 1 Satz 1", "§ 20 Absatz 2 Satz 2");
+    assertThat(((Ersetzung) teile.get(0)).neu()).isEqualTo("Lebensalters");
+  }
+
+  @Test
+  void erkenntMehrfachzielEinfuegung() {
+    var befehl =
+        erkenne(
+            "In § 30 Absatz 2 Satz 1 und Absatz 3 wird jeweils vor der Angabe „Familie“ die"
+                + " Angabe „Bildung,“ eingefügt.",
+            Stelle.LEER);
+
+    assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+    var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+    assertThat(teile).hasSize(2).allMatch(t -> t instanceof WoerterEinfuegung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 30 Absatz 2 Satz 1", "§ 30 Absatz 3");
+  }
+
+  @Test
+  void erkenntBereichsUmnummerierungAbsteigend() {
+    var befehl =
+        erkenne(
+            "Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.",
+            new Stelle(List.of(new Stelle.Paragraph("5"))));
+
+    assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+    var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+    assertThat(teile).hasSize(3).allMatch(t -> t instanceof Umnummerierung);
+    // Absteigend, damit die Anwendung keine Labels kollidieren lässt: 4→5, 3→4, 2→3.
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 5 Absatz 4", "§ 5 Absatz 3", "§ 5 Absatz 2");
+    assertThat(teile).extracting(t -> ((Umnummerierung) t).neu().anzeigeText())
+        .containsExactly("§ 5 Absatz 5", "§ 5 Absatz 4", "§ 5 Absatz 3");
+  }
+
+  @Test
+  void verbundZweierBefehleBleibtUnbekannt() {
+    // „… wird zu Absatz 2 und nach Satz 2 werden … eingefügt“ verbindet zwei verschiedene
+    // Befehle per „und“ — bewusst nicht unterstützt.
+    assertThat(
+            erkenne(
+                "Der bisherige Absatz 1 wird zu Absatz 2 und nach Satz 2 werden die folgenden"
+                    + " Sätze eingefügt: „Ein Satz.“",
+                Stelle.LEER))
+        .isEmpty();
+  }
+
+  @Test
   void faelltBeiBereichsbefehlenAufUnbekanntZurueck() {
     // Bereichs- und Mehrfachbefehle sind in v1 bewusst nicht unterstützt.
     assertThat(
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
new file mode 100644
index 0000000..4c2c7ef
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
@@ -0,0 +1,55 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.Stelle;
+import org.junit.jupiter.api.Test;
+
+class StellenParserTest {
+
+  @Test
+  void parstEinfacheStelle() {
+    assertThat(StellenParser.parse("§ 5a Absatz 2 Satz 1").orElseThrow().anzeigeText())
+        .isEqualTo("§ 5a Absatz 2 Satz 1");
+  }
+
+  @Test
+  void lehntKoordinationInParseAb() {
+    // parse (einfach) bleibt bewusst streng: Koordination fällt durch.
+    assertThat(StellenParser.parse("§ 3 Absatz 1 und Absatz 4")).isEmpty();
+  }
+
+  @Test
+  void parstMehrfachMitGemeinsamemPraefix() {
+    // Zweites Segment erbt „§ 3“, ersetzt aber ab „Absatz“.
+    var stellen = StellenParser.parseMehrfach("§ 3 Absatz 1 Satz 2 und Absatz 4");
+    assertThat(stellen).extracting(Stelle::anzeigeText)
+        .containsExactly("§ 3 Absatz 1 Satz 2", "§ 3 Absatz 4");
+  }
+
+  @Test
+  void parstMehrfachMitSatzTiefe() {
+    var stellen = StellenParser.parseMehrfach("§ 20 Absatz 1 Satz 1 und Absatz 2 Satz 2");
+    assertThat(stellen).extracting(Stelle::anzeigeText)
+        .containsExactly("§ 20 Absatz 1 Satz 1", "§ 20 Absatz 2 Satz 2");
+  }
+
+  @Test
+  void parstMehrfachMitKomma() {
+    var stellen = StellenParser.parseMehrfach("§ 1, § 2 und § 3");
+    assertThat(stellen).extracting(Stelle::anzeigeText)
+        .containsExactly("§ 1", "§ 2", "§ 3");
+  }
+
+  @Test
+  void einfacheStelleBleibtEinElementig() {
+    assertThat(StellenParser.parseMehrfach("§ 5 Absatz 2"))
+        .extracting(Stelle::anzeigeText)
+        .containsExactly("§ 5 Absatz 2");
+  }
+
+  @Test
+  void unparsbaresSegmentLiefertLeereListe() {
+    assertThat(StellenParser.parseMehrfach("§ 5 Absatz 2 und Kauderwelsch")).isEmpty();
+  }
+}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index beae519..a854632 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -7,6 +7,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
@@ -369,6 +370,58 @@
 
   // --- Helfer --------------------------------------------------------------------------------
 
+  @Test
+  void wendetSammelbefehlAlsEinenEintragAn() {
+    var teil1 =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            "Erprobung",
+            "Prüfung",
+            false,
+            false,
+            PROV);
+    var teil2 =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("2")), "Erprobung", "Prüfung", false, false, PROV);
+    var sammel = new Sammelbefehl(List.of(teil1, teil2));
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(sammel));
+
+    // Ein Befehl → genau ein Protokolleintrag, aber beide Teiledits wirken.
+    assertThat(ergebnis.protokoll()).hasSize(1);
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    assertThat(ergebnis.protokoll().get(0).betroffeneEnbez()).containsExactlyInAnyOrder("§ 1", "§ 2");
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+        .isEqualTo("Zweck dieses Gesetzes ist die Prüfung.");
+    assertThat(absatzText(ergebnis.neu(), "§ 2", 0)).startsWith("Prüfung ist die Prüfung");
+  }
+
+  @Test
+  void sammelbefehlMitFehlschlagendemTeilWirdManuell() {
+    var teil1 =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+            "Erprobung",
+            "Prüfung",
+            false,
+            false,
+            PROV);
+    var teil2 =
+        new Ersetzung(
+            stelle(new Stelle.Paragraph("2")), "gibt es nicht", "egal", false, false, PROV);
+    var sammel = new Sammelbefehl(List.of(teil1, teil2));
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(sammel));
+
+    assertThat(ergebnis.protokoll()).hasSize(1);
+    var eintrag = ergebnis.protokoll().get(0);
+    assertThat(eintrag.status()).isEqualTo(Status.MANUELL_PRUEFEN);
+    assertThat(eintrag.begruendung()).contains("Teil 2");
+    // Der gelungene Teil bleibt trotzdem wirksam.
+    assertThat(absatzText(ergebnis.neu(), "§ 1", 0))
+        .isEqualTo("Zweck dieses Gesetzes ist die Prüfung.");
+  }
+
   private static Gesetz gesetz() {
     return new Gesetz(
         "TestG",