feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions

Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung
zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines
anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt
beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und
`konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die
anderen neun bei jedem Onboarding weg.

Entscheidungen, alle im Issue begruendet:

- Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die
  Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht
  — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der
  Arbeit. Ein struktureller Test prueft, dass alle drei Schichten
  (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen.
- Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide
  Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und
  korrigierbar, ein fehlender faellt niemandem auf.
- OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein
  bedingter Abschnitt von Anfang an sichtbar.
- Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder.
- Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben
  eine Struktur neu und geben neue Kennungen.

Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein:

- Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das
  Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage
  verlor ihre Abteilungsabschnitte und galt dort fuer alle.
- Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage
  alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp,
  der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma
  abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie
  und Original Feld fuer Feld.

Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor
stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben
liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen.

1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen.

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-23 14:54:10 +02:00
co-authored by Claude Opus 5
parent 2f247b3617
commit a2330ef682
29 changed files with 3051 additions and 146 deletions
+29
View File
@@ -7,6 +7,35 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt
- **Bedingte Abschnitte und Schritte.** Ein Abschnitt oder ein einzelner Schritt erscheint nur,
wenn seine Bedingung zutrifft — abhängig von einer **Variable** des Laufs oder vom **Zustand
eines anderen Schritts**. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt beide
Konto-Abschnitte, fünf AD-Schritte und vier für Entra ID, und `Kontotyp` sagt beim Start, welcher
gilt. Bisher klickte ein Techniker die anderen neun bei jedem Onboarding weg.
**Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil** — hat niemand den Kontotyp
angegeben, stehen beide Abschnitte da, genau wie vorher. Ein ausgeblendeter Pflichtschritt hält
den Abschluss nicht auf, ausgeblendet heißt nicht gelöscht (eine Notiz bleibt stehen), und die
Nummerierung zählt nur, was man sieht. Ausgewertet wird **laufend**: ein Abschnitt „Nacharbeit“,
der erst erscheint, wenn der Restore-Test fehlschlägt, kommt bei einer offenen zweiten Sitzung
sofort dazu. Es gibt bewusst **kein** „und“ und kein „oder“ (#039)
### Behoben
- **Der Import einer Vorlage verwarf die Abteilungseinschränkung der Abschnitte.** Der Export
schreibt sie seit #031, das Schema nimmt sie an — der Import setzte sie nicht. Eine Vorlage, die
jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte: sie galten dort für
**alle**, also mehr Schritte statt weniger. Niemand hätte es gemerkt (#039)
- **„Bearbeiten“ einer Vorlage verlor ebenfalls alle Abteilungseinschränkungen.** Die tiefe Kopie,
aus der der Entwurf entsteht, nahm sie nicht mit — und `Vorlage duplizieren` genauso wenig, weil
beide die Kopie einzeln ausgeschrieben hatten. Ursache war ein von Hand getippter Zwischentyp, der
weniger Felder nannte als die Abfrage liefert; er ist jetzt aus Prisma abgeleitet, beide Wege
teilen eine Stelle, und ein Test vergleicht Kopie und Original Feld für Feld (#039)
- Die Auswahlfelder der neuen Bedingung trugen ihren Namen über ein umhüllendes `label`, wodurch
der zugängliche Name den Text der gewählten Option mitschluckte — für einen Screenreader eine
Bezeichnung, die sich beim Umschalten ändert (#039)
## [0.2.0] – 2026-10-11 ## [0.2.0] – 2026-10-11
### Geändert ### Geändert
+10
View File
@@ -105,6 +105,16 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
gedruckte Aufkleber wertlos. gedruckte Aufkleber wertlos.
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in - **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht. `Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
- **Bedingte Teile:** Eine Bedingung an `VorlagenAbschnitt` oder `VorlagenSchritt` sagt, wann der
Teil gilt (#039). **Nicht entscheidbar heißt: gilt** — ein Abschnitt zu viel ist sichtbar und
korrigierbar, ein fehlender fällt niemandem auf. Eine Bedingung, kein Ausdrucksbaum: kein `und`,
kein `oder`. Die Auswertung sitzt in `src/lib/bedingungen.ts` und wird von **drei** Schichten
gerufen (Abschlussregel, Abarbeitung, Druck); `geltendeStruktur` legt die Reihenfolge der zwei
Filter fest — erst die beim Start festgeschriebene Abteilungsauswahl aus #031, dann die laufende
Auswertung. `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine Schicht sie vergisst.
**Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Austausch, Import, Seed und
`entwurfSpeichern` schreiben eine Struktur neu und geben neue Kennungen; ein Titel-Pfad überlebt
das. `bedingungAufloesen` ist für alle vier dieselbe Stelle.
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt - **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf `Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
+67
View File
@@ -219,6 +219,73 @@ Import: ein Abteilungsname bedeutet in einer anderen Installation dasselbe.
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts **Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist. abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
## Bedingte Abschnitte und Schritte
Die Onboarding-Vorlage enthält **beide** Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit
fünf Schritten und „Konto anlegen (Entra ID)“ mit vier. In jedem einzelnen Lauf gilt nur einer, und
die Variable **Kontotyp** sagt beim Start, welcher. Bis hierher klickte ein Techniker die anderen
neun Schritte weg.
Mit einer **Bedingung** erscheint ein Abschnitt oder ein einzelner Schritt nur dann, wenn er
zutrifft. Im Editor steht an jedem Abschnitt und an jedem Schritt **Bedingung (optional)**; ohne
eine gilt er immer.
### Zwei Arten, und die Wahl entscheidet, wann die Antwort entsteht
| Die Bedingung hängt an … | Beispiel | Wann sie entschieden wird |
|---|---|---|
| einer **Variable** | „gilt, wenn Kontotyp gleich Active Directory ist“ | beim Start, wenn die Variablen erfasst werden |
| einem **Schritt** | „gilt, wenn Prüfen fehlgeschlagen ist“ | während der Arbeit, sobald jemand den Schritt setzt |
Die zweite ist die interessantere: ein Abschnitt „Nacharbeit“, der erst erscheint, wenn der
Restore-Test fehlgeschlagen ist. Er ist zu Beginn nicht da und kommt in dem Moment dazu, in dem der
Schritt auf „fehlgeschlagen“ geht — bei einer offenen zweiten Sitzung sofort.
### Eine Bedingung, nicht mehr
Es gibt **kein** „und“ und kein „oder“. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte oder
zwei Vorlagen. Das ist Absicht: ein Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre
eine kleine Programmiersprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
schlimmer als einer zu viel.
Bei einer Schritt-Bedingung lassen sich dagegen **mehrere Zustände** ankreuzen: „erledigt oder nicht
zutreffend“ ist eine Frage, nicht zwei.
### Vier Dinge, die man dazu wissen muss
- **Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil.** Hat niemand den Kontotyp
angegeben, stehen **beide** Konto-Abschnitte da — genau wie vorher. Auszublenden hieße, eine
Antwort zu erfinden, die niemand gegeben hat. Ein Abschnitt zu viel ist sichtbar und
korrigierbar; ein fehlender fällt niemandem auf.
- **Ein ausgeblendeter Pflichtschritt blockiert den Abschluss nicht** — dieselbe Regel wie bei den
Abteilungsabschnitten. Er steht nicht in der Abarbeitung, also lässt er sich nicht abhaken.
- **Ausgeblendet heißt nicht gelöscht.** Hat jemand in einem Schritt eine Notiz hinterlassen und
kippt dessen Bedingung danach, verschwindet der Schritt aus der Ansicht — die Notiz bleibt. Kippt
die Bedingung zurück, ist alles wieder da.
- **Die Nummerierung zählt, was man sieht.** Ein ausgeblendeter Abschnitt hinterlässt keine Lücke;
aus `01 · 02 · 03` wird nicht `01 · 03`.
### Die Warnung „dieser Schritt kommt später“
Zeigt eine Bedingung auf einen Schritt, der **nach** dem bedingten Teil steht, warnt der Editor.
Verboten ist es nicht, aber fast immer ein Versehen: der Abschnitt wäre beim Abarbeiten zunächst
unsichtbar und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also rückwärts.
Gemeint ist meist ein Schritt davor.
### Export und Import
Eine Bedingung wandert mit. Im Austauschdokument steht sie über **Titel** statt über Kennungen
(`"abschnitt": "Prüfung", "schritt": "Prüfen"`), denn eine Kennung bedeutet in einer anderen
Installation nichts. Beim Import wird daraus wieder der richtige Schritt.
Findet sich der Titel-Pfad in der Datei nicht — etwa nach einem Tippfehler in einer von Hand
geschriebenen Vorlage —, wird die Bedingung **weggelassen**. Der Abschnitt gilt dann immer, und das
steht sichtbar im Editor. Die ganze Vorlage deswegen abzuweisen wäre die teurere Antwort.
**Ein Baustein-Schritt hat keine Bedingung.** Ein Baustein ist wiederverwendbar, und eine Bedingung
auf einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht entscheidbar. Der
Abschnitt, in dem der Baustein eingesetzt wird, darf eine haben.
## Bausteine: ein Abschnitt für mehrere Vorlagen ## Bausteine: ein Abschnitt für mehrere Vorlagen
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf „Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
+36
View File
@@ -387,6 +387,42 @@ Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben; alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
was darauf steht, ist aus der Hand gegeben. was darauf steht, ist aus der Hand gegeben.
## 5f-2. Bedingte Teile: zwei Filter, eine Reihenfolge
Was ein Lauf zeigt, entsteht aus **zwei** Filtern uebereinander, und ihre
Reihenfolge ist der ganze Inhalt von `geltendeStruktur` in
`src/lib/bedingungen.ts`:
1. **Die Abteilungsauswahl (#031)** — beim Start festgeschrieben in
`Lauf.geltendeAbschnitte`. Festgeschrieben, weil ein archivierter Lauf genau
die Schritte zeigen muss, die damals abgearbeitet wurden, und ein spaeterer
Abteilungswechsel keinen Schritt entfernen darf, an dem eine Notiz haengt.
2. **Die Bedingungen (#039)** — **laufend** ausgewertet. Eine Bedingung auf einer
Variable liesse sich auch festschreiben; eine auf einem Schrittergebnis nicht,
denn ihre Antwort entsteht erst waehrend der Arbeit. Ein Abschnitt
„Nacharbeit“, der erscheint, wenn der Restore-Test fehlschlaegt, ist der Fall,
fuer den es gebaut ist.
Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
zweiten anwendet, umgeht die Abteilungsregel. Drei Schichten brauchen beide — die
Abschlussregel im Dienst, die Abarbeitung und die Druckansicht —, und
`tests/bedingte-laeufe.test.ts` prueft **strukturell**, dass keine davon die
zweite vergisst. Dieselbe Art Test wie „jede Seite prueft selbst“ (#038): die
Zusage steht in der Vollstaendigkeit einer Liste, und die muss jemand bewachen.
**Nicht entscheidbar heisst: gilt.** Hat niemand den Kontotyp angegeben, stehen
beide Konto-Abschnitte da — genau wie vor #039. Auszublenden hiesse, eine Antwort
zu erfinden, die niemand gegeben hat; und die Richtung, in die der Zweifel
faellt, soll die harmlose sein.
**Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen.** Vier Wege schreiben
eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
`entwurfSpeichern`. Eine Bedingung auf einer Kennung ueberlebte keinen davon —
ein Titel-Pfad ueberlebt alle vier, und `bedingungAufloesen` ist fuer alle
dieselbe Stelle. Dass eine Schritt-Bedingung auf **dieselbe Version** zeigt,
haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
entscheidbar, und der Abschnitt erschiene stillschweigend immer.
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse ## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —, Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
+77 -2
View File
@@ -40,12 +40,14 @@ als Unterpunkte mit Aufgabe und Dauer.
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min | | #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min | | #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min | | #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
| | **Summe** | **73 h 55 min** | | #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min |
| | **Summe** | **79 h 15 min** |
| Meilenstein | Tatsächlich | Status | | Meilenstein | Tatsächlich | Status |
|---|---|---| |---|---|---|
| v0.1.0 | 35 h 20 min | veröffentlicht | | v0.1.0 | 35 h 20 min | veröffentlicht |
| v0.2.0 | 38 h 35 min | in Arbeit | | v0.2.0 | 38 h 35 min | veröffentlicht |
| v0.3.0 | 5 h 20 min | in Arbeit |
--- ---
@@ -1764,3 +1766,76 @@ Entra-Mandanten** und **gegen echte SCIM-Anbieter** ist nichts gelaufen. Die For
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
`docs/betrieb/scim.md`. `docs/betrieb/scim.md`.
---
## 2026-10-23 · Sitzung 25 · 11:00–15:10
**Issue:** #039 (M23)
**Version:** 0.3.0 (unveröffentlicht)
### Erledigt
- **Bedingte Abschnitte und Schritte**, der erste Meilenstein von Phase 3. Eine Bedingung hängt an
einer Variable des Laufs oder am Zustand eines anderen Schritts; die Auswertung läuft bei jedem
Lesen und sitzt in `src/lib/bedingungen.ts`.
- **Der Seed nutzt es am echten Fall**: „Mitarbeiter-Onboarding“ hat die Bedingung auf `konto_typ`,
beide Konto-Abschnitte bleiben drin und schließen sich aus. Neun Schritte, die niemand mehr
wegklickt.
- Editor, Abarbeitung, Druck, Export, Import, Entwurf und Seed — sieben Wege, alle mit Tests.
- 1496 Vitest-Tests (vorher 1457), davon 29 neue. Klickprobe am gebauten Stand: Bildschirm, Druck
und 390 px zeigen dasselbe.
### Entscheidungen
- **Laufend auswerten, nicht beim Start festschreiben.** Für die Abteilungsabschnitte (#031) ist
Festschreiben richtig: die Abteilung steht beim Start fest. Für Bedingungen nicht — die Antwort auf
„ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit. `geltendeStruktur` legt die
Reihenfolge der zwei Filter fest, und ein struktureller Test prüft, dass alle drei Schichten sie
rufen.
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte. Das ist
genau das Verhalten von vorher; eine erfundene Antwort wäre die Verschlechterung. Ein Abschnitt zu
viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
- **`OFFEN` ist eine Antwort, keine Lücke** — der scharfe Gegensatz zur nicht gesetzten Variable.
Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an sichtbar, also das
Gegenteil des Zwecks.
- **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine Verschachtelung. Wer zwei
braucht, nimmt zwei Abschnitte. Ein Formular für beliebige Ausdrücke wäre eine kleine
Abfragesprache, und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist schlimmer als einer zu
viel.
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Vier Wege schreiben eine Struktur neu
und geben neue Kennungen: Import, Export, Seed, `entwurfSpeichern`. Eine Kennung überlebt keinen
davon.
### Probleme
- **Zwei stille Datenverluste, und der zweite war älter als dieser Meilenstein.**
- Der **Import** verwarf `nurFuerAbteilungen`: der Export schreibt es seit #031, das Schema nimmt
es an, der Import setzte es nicht. Eine exportierte Vorlage verlor ihre Abteilungsabschnitte.
- Die **tiefe Kopie** verwarf es ebenfalls — damit verlor **jedes „Bearbeiten“** einer Vorlage alle
Abteilungseinschränkungen. Beide Kopierstellen hatten den Code einzeln ausgeschrieben und
vergaßen dieselben Felder.
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
als die Abfrage liefert; die Daten waren da, der Code konnte sie nicht sehen. Jetzt aus Prisma
abgeleitet — ein neues Feld ist dort automatisch vorhanden. Und bewacht von einem Test, der Kopie
und Original **Feld für Feld** vergleicht, nicht stichprobenweise.
- **Gefunden hat beides die Klickprobe am gebauten Stand**, nicht die Suite: im Editor stand
„Bedingung gesetzt“, und nach „Bearbeiten“ war sie weg. Genau dafür steht diese Probe in
`CLAUDE.md`. Dreizehn Gegenproben liefen in dieser Sitzung, alle wurden rot — und trotzdem lag der
teuerste Befund außerhalb von allen.
- **Zwei Schemas für dieselbe Form** liefen fast auseinander: Austauschformat und Editor-Struktur
brauchen die Pfad-Form, und TypeScript meldete die Abweichung erst, als die Typen aufeinandertrafen.
Jetzt eine Stelle.
- **Ein Mangel an der neuen Komponente**, beim Klicken aufgefallen: die Auswahlfelder standen in
einem umhüllenden `label`, und ihr zugänglicher Name schluckte den Text der gewählten Option mit.
Derselbe Fehler wie bei der Adresse im Verzeichnis-Formular (#016) — dort war es ein Reviewbefund.
- **Zwei eigene Testfehler**: dreimal versucht, eine Bedingung an einer **veröffentlichten** Version
zu setzen (die ist unveränderlich, dafür sorgen Trigger), und `vorlageBearbeiten` mit
`entwurfHolenOderErzeugen` verwechselt. Beide Male hat die Datenbank es gesagt.
### Nächster Schritt
- **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den
Freigeber für das Blatt.
+95 -26
View File
@@ -1,10 +1,10 @@
# #039 M23 – Bedingte Schritte und Abschnitte # #039 M23 – Bedingte Schritte und Abschnitte
**Status:** offen **Status:** erledigt
**Im Tracker:** – **Im Tracker:** –
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** phase-3, backend, frontend **Labels:** phase-3, backend, frontend
**Geschätzt:** 12 h **Tatsächlich:** – **Geschätzt:** 12 h **Tatsächlich:** 5 h 20 min
## Ziel ## Ziel
@@ -23,7 +23,7 @@ Stelle, an der das sitzt.
## Zu entscheiden ## Zu entscheiden
- [ ] **Wann wird ausgewertet?** Das ist die Entscheidung, an der alles hängt. - [x] **Wann wird ausgewertet?** Das ist die Entscheidung, an der alles hängt.
Für Abteilungen (#031) wird beim **Start festgeschrieben** — die Abteilung des Laufs steht Für Abteilungen (#031) wird beim **Start festgeschrieben** — die Abteilung des Laufs steht
dann fest, und ein archivierter Lauf muss genau die Schritte zeigen, die damals galten. dann fest, und ein archivierter Lauf muss genau die Schritte zeigen, die damals galten.
@@ -36,60 +36,129 @@ Stelle, an der das sitzt.
Änderung an einem Schrittergebnis; was dabei herauskommt, wird in den Lauf geschrieben (wie Änderung an einem Schrittergebnis; was dabei herauskommt, wird in den Lauf geschrieben (wie
`geltendeAbschnitte`). Damit gilt für das Archiv weiter „der Lauf weiß, was galt“, und der `geltendeAbschnitte`). Damit gilt für das Archiv weiter „der Lauf weiß, was galt“, und der
Ereignisstrom aus #033 trägt die Änderung ohnehin schon zur zweiten Sitzung. Ereignisstrom aus #033 trägt die Änderung ohnehin schon zur zweiten Sitzung.
- [ ] **Woran darf eine Bedingung hängen?** Empfehlung: an einer **Variable** des Laufs und an - [x] **Woran darf eine Bedingung hängen?** Empfehlung: an einer **Variable** des Laufs und an
einem **Schrittergebnis** derselben Version. Nicht an Gerätetyp, Abteilung oder Rolle — für einem **Schrittergebnis** derselben Version. Nicht an Gerätetyp, Abteilung oder Rolle — für
Abteilungen gibt es #031, und je mehr Quellen, desto schwerer zu erklären, warum ein Schritt Abteilungen gibt es #031, und je mehr Quellen, desto schwerer zu erklären, warum ein Schritt
fehlt. fehlt.
- [ ] **Wie wird sie gespeichert?** Empfehlung: ein Json-Feld `bedingung` an `VorlagenAbschnitt` - [x] **Wie wird sie gespeichert?** Empfehlung: ein Json-Feld `bedingung` an `VorlagenAbschnitt`
und `VorlagenSchritt`, mit einem engen Schema — `{ art: "variable", schluessel, operator, und `VorlagenSchritt`, mit einem engen Schema — `{ art: "variable", schluessel, operator,
wert }` oder `{ art: "schritt", schrittId, zustand }`. Json, weil `konfig` es schon so macht wert }` oder `{ art: "schritt", schrittId, zustand }`. Json, weil `konfig` es schon so macht
und ein neuer Operator dann keine Migration braucht. und ein neuer Operator dann keine Migration braucht.
**Eine Bedingung, kein Ausdrucksbaum.** Kein `und`/`oder`, keine Verschachtelung: das wäre **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`/`oder`, keine Verschachtelung: das wäre
eine kleine Sprache, die niemand im Editor pflegen kann, und ein Abschnitt, der aus eine kleine Sprache, die niemand im Editor pflegen kann, und ein Abschnitt, der aus
unerklärlichen Gründen fehlt, ist schlimmer als einer zu viel. unerklärlichen Gründen fehlt, ist schlimmer als einer zu viel.
- [ ] **Was passiert mit einem Schrittergebnis, das schon Daten trägt**, wenn die Bedingung - [x] **Was passiert mit einem Schrittergebnis, das schon Daten trägt**, wenn die Bedingung
umschlägt? Empfehlung: die Zeile **bleibt** und wird nur ausgeblendet. Löschen hieße, eine umschlägt? Empfehlung: die Zeile **bleibt** und wird nur ausgeblendet. Löschen hieße, eine
Notiz zu verlieren, die jemand geschrieben hat; und schlägt die Bedingung zurück, ist sie Notiz zu verlieren, die jemand geschrieben hat; und schlägt die Bedingung zurück, ist sie
wieder da. wieder da.
## Aufgaben ## Aufgaben
- [ ] Migration: `bedingung Json?` an `VorlagenAbschnitt` und `VorlagenSchritt`, dazu ein CHECK, der - [x] Migration: `bedingung Json?` an `VorlagenAbschnitt` und `VorlagenSchritt`, dazu ein CHECK, der
sicherstellt, dass eine Bedingung auf einen Schritt **derselben Version** zeigt (sonst zeigt sicherstellt, dass eine Bedingung auf einen Schritt **derselben Version** zeigt (sonst zeigt
sie nach dem Veröffentlichen ins Leere) sie nach dem Veröffentlichen ins Leere)
- [ ] `src/lib/bedingungen.ts`, framework-frei: Schema, Auswertung, und die Liste der Schritte und - [x] `src/lib/bedingungen.ts`, framework-frei: Schema, Auswertung, und die Liste der Schritte und
Abschnitte, die gelten. **Dieselbe Stelle für alle drei Schichten**, wie Abschnitte, die gelten. **Dieselbe Stelle für alle drei Schichten**, wie
`src/lib/geltende-abschnitte.ts` `src/lib/geltende-abschnitte.ts`
- [ ] Auswertung bei jedem Schreiben an einem `Schrittergebnis`; das Ergebnis in den Lauf - [x] Auswertung bei jedem Schreiben an einem `Schrittergebnis`; das Ergebnis in den Lauf
- [ ] Abschlussregel: ein ausgeblendeter Pflichtschritt hält den Abschluss **nicht** auf (dieselbe - [x] Abschlussregel: ein ausgeblendeter Pflichtschritt hält den Abschluss **nicht** auf (dieselbe
Regel wie bei #031, dieselbe Funktion) Regel wie bei #031, dieselbe Funktion)
- [ ] Fortschrittsbalken und Zählwerte rechnen nur mit dem, was gilt - [x] Fortschrittsbalken und Zählwerte rechnen nur mit dem, was gilt
- [ ] Editor: Bedingung an Abschnitt und Schritt setzen, mit Auswahl der Variable bzw. des Schritts - [x] Editor: Bedingung an Abschnitt und Schritt setzen, mit Auswahl der Variable bzw. des Schritts
**aus derselben Version**; eine Vorschau, die sagt „gilt, wenn …“ **aus derselben Version**; eine Vorschau, die sagt „gilt, wenn …“
- [ ] Warnung im Editor bei einer Bedingung auf einen Schritt, der **nach** dem bedingten steht — - [x] Warnung im Editor bei einer Bedingung auf einen Schritt, der **nach** dem bedingten steht —
sie wäre beim Abarbeiten noch offen, und der Abschnitt erschiene erst rückwirkend sie wäre beim Abarbeiten noch offen, und der Abschnitt erschiene erst rückwirkend
- [ ] Abarbeitung: ausgeblendete Abschnitte verschwinden, ohne die Nummerierung zu verschieben - [x] Abarbeitung: ausgeblendete Abschnitte verschwinden, ohne die Nummerierung zu verschieben
(`01 · Vorbereitung` kommt aus der Position und wird nie gespeichert) (`01 · Vorbereitung` kommt aus der Position und wird nie gespeichert)
- [ ] Druckansicht und Archiv zeigen, was galt - [x] Druckansicht und Archiv zeigen, was galt
- [ ] Export/Import: die Bedingung wandert mit, und ein Verweis auf einen Schritt wird dabei auf die - [x] Export/Import: die Bedingung wandert mit, und ein Verweis auf einen Schritt wird dabei auf die
**neue** Kennung umgeschrieben — sonst zeigt sie in der zweiten Installation ins Leere **neue** Kennung umgeschrieben — sonst zeigt sie in der zweiten Installation ins Leere
- [ ] Seed: „Mitarbeiter-Onboarding“ bekommt die Bedingung auf `konto_typ`, die beiden - [x] Seed: „Mitarbeiter-Onboarding“ bekommt die Bedingung auf `konto_typ`, die beiden
Konto-Abschnitte bleiben drin und schließen sich gegenseitig aus. Das ist die Gegenprobe am Konto-Abschnitte bleiben drin und schließen sich gegenseitig aus. Das ist die Gegenprobe am
echten Fall. echten Fall.
- [ ] Anleitung Kapitel 04 (Vorlagen): wann eine Bedingung das richtige Mittel ist und wann eine - [x] Anleitung Kapitel 04 (Vorlagen): wann eine Bedingung das richtige Mittel ist und wann eine
zweite Vorlage zweite Vorlage
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: ein Lauf mit `konto_typ = AD` hat die 5 AD-Schritte und **keinen** der 4 Entra-Schritte - [x] Test: ein Lauf mit `konto_typ = AD` hat die 5 AD-Schritte und **keinen** der 4 Entra-Schritte
- [ ] Test: dasselbe umgekehrt, und bei nicht gesetzter Variable gelten **beide** — eine Bedingung, - [x] Test: dasselbe umgekehrt, und bei nicht gesetzter Variable gelten **beide** — eine Bedingung,
die nicht entschieden werden kann, blendet nichts aus die nicht entschieden werden kann, blendet nichts aus
- [ ] Test: eine Bedingung auf einem Schrittergebnis schlägt um, **während** der Lauf läuft, und der - [x] Test: eine Bedingung auf einem Schrittergebnis schlägt um, **während** der Lauf läuft, und der
Abschnitt erscheint Abschnitt erscheint
- [ ] Test: ein ausgeblendeter Pflichtschritt hält den Abschluss nicht auf - [x] Test: ein ausgeblendeter Pflichtschritt hält den Abschluss nicht auf
- [ ] Test: ein Schrittergebnis mit Notiz bleibt stehen, wenn seine Bedingung umschlägt, und ist - [x] Test: ein Schrittergebnis mit Notiz bleibt stehen, wenn seine Bedingung umschlägt, und ist
danach wieder da danach wieder da
- [ ] Test: Export und Import einer Vorlage mit Bedingungen ergeben dieselbe Struktur, und der - [x] Test: Export und Import einer Vorlage mit Bedingungen ergeben dieselbe Struktur, und der
Schritt-Verweis zeigt auf den richtigen Schritt der **neuen** Version Schritt-Verweis zeigt auf den richtigen Schritt der **neuen** Version
- [ ] Test: ein archivierter Lauf zeigt genau die Schritte, die bei seinem Abschluss galten - [x] Test: ein archivierter Lauf zeigt genau die Schritte, die bei seinem Abschluss galten
- [ ] Am Handy kein horizontales Scrollen - [x] Am Handy kein horizontales Scrollen
## Beim Umsetzen dazugekommen
- **Die Entscheidungen aus dem Issue sind so gefallen wie empfohlen**, und zwei davon haben sich im
Code als richtig erwiesen:
- **Laufend auswerten, nicht beim Start festschreiben.** Eine Bedingung auf einer Variable ließe
sich wie #031 beim Start festhalten; eine auf einem Schrittergebnis nicht — und genau die ist der
interessante Fall (ein Abschnitt „Nacharbeit“, der erscheint, wenn der Restore-Test
fehlschlägt).
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte —
genau wie vor diesem Meilenstein. Eine erfundene Antwort wäre die Verschlechterung.
- **`OFFEN` ist eine Antwort, keine Lücke.** Der Unterschied zur Variable musste scharf gezogen
werden: eine nicht gesetzte Variable ist nicht entscheidbar, ein offener Schritt dagegen klar
„noch nicht erfüllt“. Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an
sichtbar — das Gegenteil des Zwecks.
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen**, und das war die größte Einzelerkenntnis.
**Vier** Wege schreiben eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
`entwurfSpeichern`. Eine Bedingung auf einer Kennung überlebt keinen davon. `bedingungAufloesen`
ist für alle vier dieselbe Stelle; dass eine Bedingung auf **dieselbe Version** zeigt, hält
zusätzlich ein Trigger.
- **Ein struktureller Test bewacht die drei Schichten.** Abschlussregel, Abarbeitung und Druck
müssen alle `geltendeStruktur` rufen; vergisst eine es, sieht der Techniker andere Schritte als
der Ausdruck. Der Test liest die drei Dateien — dieselbe Art Prüfung wie „jede Seite prüft selbst“
(#038).
- **Zwei Schemas für dieselbe Form liefen fast auseinander.** Austauschformat und Editor-Struktur
brauchen die Pfad-Form; ich hatte sie zweimal geschrieben, und TypeScript meldete es erst, als die
beiden Typen aufeinandertrafen. Jetzt steht `PfadBedingungSchema` in `src/lib/bedingungen.ts`, und
`vorlagen-format.ts` importiert es.
## Zwei stille Datenverluste, gefunden bei der Klickprobe
Beides hätte keine Suite gezeigt, und das zweite war **älter als dieser Meilenstein**:
- **Der Import verwarf `nurFuerAbteilungen`.** Der Export schreibt die Abteilungseinschränkung seit
[#031](031-abteilungsabschnitte.md), das Schema nimmt sie an — und der Import setzte sie nicht.
Eine Vorlage, die jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
sie galten dort für **alle**. Also mehr Schritte statt weniger, und niemand merkt es.
- **Die tiefe Kopie verwarf sie ebenfalls — und seit heute auch die Bedingung.** „Bearbeiten“ einer
Vorlage erzeugt eine tiefe Kopie als Entwurf (#009); `vorlageDuplizieren` dasselbe. Beide hatten
die Kopie einzeln ausgeschrieben, und beide vergaßen dieselben Felder. Damit verlor **jede
Bearbeitung** alle Abteilungseinschränkungen.
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
als die Abfrage liefert; die Felder waren da, der Code konnte sie nicht sehen. Jetzt ist der Typ
aus Prisma abgeleitet — ein neues Feld am Modell ist dort automatisch vorhanden.
Gefunden hat es die **Klickprobe am gebauten Stand**: der Editor zeigte „Bedingung gesetzt“ in der
veröffentlichten Version, und nach „Bearbeiten“ war sie weg. Das ist genau der Grund, warum diese
Probe in `CLAUDE.md` steht.
Bewacht wird es jetzt von einem Test, der Kopie und Original **Feld für Feld** vergleicht — nicht
stichprobenweise. So fällt auch das nächste Feld auf, das jemand ergänzt und beim Kopieren
vergisst.
- **Ein Mangel an der neuen Komponente**, aufgefallen beim Klicken: die Auswahlfelder standen in
einem umhüllenden `<label>`, und damit schluckte ihr zugänglicher Name den Text der gewählten
Option mit („VariableKontotyp“). Für einen Screenreader ist das eine Bezeichnung, die sich beim
Umschalten ändert. Jetzt `htmlFor`/`id` — derselbe Fehler wie bei der Adresse im Verzeichnis-Formular
(#016).
## Was offen bleibt
- **Kein `und`/`oder`**, bewusst. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte.
- **Ein Baustein-Schritt hat keine Bedingung** (der Abschnitt, in dem er steht, darf eine haben): ein
Baustein ist wiederverwendbar, und eine Bedingung auf einer Variable der Zielvorlage wäre dort
nicht entscheidbar.
- **Zwei Schritte mit demselben Titel im selben Abschnitt** sind über den Titel-Pfad nicht
unterscheidbar; die Bedingung zeigt dann auf den ersten. Benannt in `src/lib/bedingungen.ts` —
zwei gleich benannte Schritte nebeneinander kann auch ein Mensch nicht unterscheiden.
+2 -2
View File
@@ -43,8 +43,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min | | #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min |
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min | | #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min | | #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
| #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | offen | v0.3.0 | 12 h | – | | #039 | [M23 – Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | erledigt | v0.3.0 | 12 h | 5 h 20 min |
| #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | offen | v0.3.0 | 8 h | – | | #040 | [M24 – Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | offen | v0.3.0 | 8 h | – |
| #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – | | #041 | [M25 – PDF-Übergabeprotokoll mit Unterschrift](041-pdf-uebergabeprotokoll.md) | offen | v0.3.0 | 8 h | – |
| #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – | | #042 | [M26 – Weitere Schritt-Typen](042-weitere-schritt-typen.md) | offen | v0.3.0 | 6 h | – |
| | **Summe** | | | **272 h** | **73 h 55 min** | | | **Summe** | | | **272 h** | **79 h 15 min** |
@@ -0,0 +1,161 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
-- Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Bedingte Abschnitte und Schritte (#039).
ALTER TABLE "VorlagenAbschnitt" ADD COLUMN "bedingung" JSONB;
ALTER TABLE "VorlagenSchritt" ADD COLUMN "bedingung" JSONB;
/*
* Die **Form** der Bedingung, in der Datenbank.
*
* Das vollstaendige Schema steht in `src/lib/bedingungen.ts` und wird beim
* Schreiben angewandt. Hier steht, was auch dann gelten muss, wenn jemand an
* der Service-Schicht vorbei schreibt (CLAUDE.md) — und das ist mehr als
* Formsache: eine Bedingung mit unbekannter `art` wuerde beim Lesen als „keine
* Bedingung“ gelten, der Abschnitt waere also sichtbar. Das ist die harmlose
* Richtung, aber es waere **stillschweigend** falsch, und niemand koennte sagen,
* warum ein Abschnitt, der eine Bedingung hat, immer erscheint.
*
* Geprueft wird deshalb: `art` ist einer der beiden Werte, und die Felder, die
* diese Art braucht, sind da. Nicht geprueft wird der Inhalt von `zustaende` —
* ein Array-Element einzeln zu pruefen ginge nur mit einer Unterabfrage, und
* die gehoert nicht in einen CHECK.
*/
ALTER TABLE "VorlagenAbschnitt"
ADD CONSTRAINT "VorlagenAbschnitt_bedingung_form"
CHECK (
"bedingung" IS NULL
OR (
"bedingung" ->> 'art' = 'variable'
AND "bedingung" ->> 'schluessel' IS NOT NULL
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
AND (
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
)
)
OR (
"bedingung" ->> 'art' = 'schritt'
AND "bedingung" ->> 'schrittId' IS NOT NULL
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
)
);
ALTER TABLE "VorlagenSchritt"
ADD CONSTRAINT "VorlagenSchritt_bedingung_form"
CHECK (
"bedingung" IS NULL
OR (
"bedingung" ->> 'art' = 'variable'
AND "bedingung" ->> 'schluessel' IS NOT NULL
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
AND (
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
)
)
OR (
"bedingung" ->> 'art' = 'schritt'
AND "bedingung" ->> 'schrittId' IS NOT NULL
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
)
);
/*
* **Eine Schritt-Bedingung zeigt auf einen Schritt derselben Version.**
*
* Das ist die eigentliche Regel, und ein CHECK kann sie nicht halten: sie
* verlangt einen Blick in zwei andere Tabellen. Ein Fremdschluessel auf
* `VorlagenSchritt` ginge auch nicht — der Wert steckt in einem Json, und
* „derselbe Version“ steht dort nicht.
*
* Ohne diese Pruefung zeigt eine Bedingung nach dem Veroeffentlichen ins Leere:
* „Bearbeiten“ erzeugt eine **tiefe Kopie** mit neuen Kennungen (CLAUDE.md), und
* ein Verweis auf die alte Kennung waere dann nicht mehr entscheidbar. Die
* Auswertung zeigt in diesem Fall an, der Abschnitt erscheint also immer — und
* niemand koennte sagen, warum die Bedingung nicht wirkt. Genau die Art Fehler,
* die eine gruene Suite nicht sieht.
*
* Geprueft wird beim Schreiben der **Bedingung**, nicht beim Loeschen des
* Ziels: einen Schritt zu loeschen, auf den eine Bedingung zeigt, ist nur im
* Entwurf moeglich, und dort faengt es der Dienst mit einer besseren Meldung.
*/
CREATE OR REPLACE FUNCTION laufzettel_bedingung_gleiche_version() RETURNS TRIGGER AS $$
DECLARE
eigene_version TEXT;
ziel_version TEXT;
ziel_id TEXT;
BEGIN
IF NEW."bedingung" IS NULL OR NEW."bedingung" ->> 'art' <> 'schritt' THEN
RETURN NEW;
END IF;
ziel_id := NEW."bedingung" ->> 'schrittId';
-- Die eigene Version: am Abschnitt steht sie direkt, am Schritt eine Ebene hoeher.
IF TG_TABLE_NAME = 'VorlagenAbschnitt' THEN
eigene_version := NEW."versionId";
ELSE
SELECT "versionId" INTO eigene_version
FROM "VorlagenAbschnitt" WHERE id = NEW."abschnittId";
END IF;
SELECT a."versionId" INTO ziel_version
FROM "VorlagenSchritt" s
JOIN "VorlagenAbschnitt" a ON a.id = s."abschnittId"
WHERE s.id = ziel_id;
IF ziel_version IS NULL THEN
RAISE EXCEPTION
'Die Bedingung zeigt auf den Schritt %, den es nicht gibt.', ziel_id
USING ERRCODE = 'integrity_constraint_violation';
END IF;
IF ziel_version <> eigene_version THEN
RAISE EXCEPTION
'Die Bedingung zeigt auf einen Schritt einer anderen Version. Nach dem Veroeffentlichen waere sie nicht mehr entscheidbar.'
USING ERRCODE = 'integrity_constraint_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "VorlagenAbschnitt_bedingung_gleiche_version"
BEFORE INSERT OR UPDATE ON "VorlagenAbschnitt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
CREATE TRIGGER "VorlagenSchritt_bedingung_gleiche_version"
BEFORE INSERT OR UPDATE ON "VorlagenSchritt"
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
/*
* Gegenprobe: die neuen Spalten sind ueberall leer, und die CHECKs lassen die
* bestehenden Zeilen zu. `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich
* aus — aber eine Migration, die ihre eigene Annahme nicht nachrechnet, ist
* genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
mit_bedingung INTEGER;
BEGIN
SELECT count(*) INTO mit_bedingung FROM "VorlagenAbschnitt" WHERE "bedingung" IS NOT NULL;
IF mit_bedingung > 0 THEN
RAISE EXCEPTION 'Unerwartet % Abschnitte mit Bedingung vor der Migration.', mit_bedingung;
END IF;
SELECT count(*) INTO mit_bedingung FROM "VorlagenSchritt" WHERE "bedingung" IS NOT NULL;
IF mit_bedingung > 0 THEN
RAISE EXCEPTION 'Unerwartet % Schritte mit Bedingung vor der Migration.', mit_bedingung;
END IF;
END $$;
+14
View File
@@ -636,6 +636,17 @@ model VorlagenAbschnitt {
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites /// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
/// Feld dafuer waere eine zweite Wahrheit. /// Feld dafuer waere eine zweite Wahrheit.
bausteinVersionId String? bausteinVersionId String?
/// Wann dieser Abschnitt gilt (#039). Leer heisst: immer.
///
/// Form und Auswertung stehen in `src/lib/bedingungen.ts`; ein CHECK haelt die
/// Form auch gegen Schreiben an der Dienstschicht vorbei fest, und ein Trigger
/// haelt fest, dass eine Schritt-Bedingung auf **dieselbe Version** zeigt —
/// nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
/// entscheidbar, und der Abschnitt erschiene stillschweigend immer.
///
/// **Nicht entscheidbar heisst: gilt.** Ein Abschnitt zu viel ist sichtbar und
/// korrigierbar; ein fehlender faellt niemandem auf.
bedingung Json?
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict) bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
@@ -719,6 +730,9 @@ model VorlagenSchritt {
/// Parameter fuer Schritte vom Typ AKTION. /// Parameter fuer Schritte vom Typ AKTION.
konfig Json @default("{}") konfig Json @default("{}")
dokuUrl String? dokuUrl String?
/// Wann dieser Schritt gilt (#039). Leer heisst: immer. Siehe
/// `VorlagenAbschnitt.bedingung` — dieselbe Form, dieselben Regeln.
bedingung Json?
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade) abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
ergebnisse Schrittergebnis[] ergebnisse Schrittergebnis[]
+51 -5
View File
@@ -1,4 +1,5 @@
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync } from "node:fs";
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format"; import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
@@ -199,13 +200,32 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
}); });
} }
/*
* Erst alles anlegen, dann die Bedingungen (#039) — wie beim Import und
* aus demselben Grund: eine Schritt-Bedingung verweist ueber einen
* Titel-Pfad, und der laesst sich erst aufloesen, wenn die Schritte
* stehen. Die Seed-Vorlagen benutzen bisher nur Variablen-Bedingungen;
* der zweite Durchgang steht trotzdem hier, damit eine kuenftige Vorlage
* mit Schritt-Bedingung nicht stillschweigend ohne sie landet.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: string[] = [];
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) { for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({ const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position }, data: {
versionId: version.id,
titel: abschnitt.titel,
position,
// Wandert mit, auch wenn bisher keine Seed-Vorlage eine hat (#031).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
}); });
if (abschnitt.schritte.length === 0) continue; abschnittsZeilen.push(zeile.id);
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittPosition) => ({ for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
const angelegt = await tx.vorlagenSchritt.create({
data: {
abschnittId: zeile.id, abschnittId: zeile.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -217,8 +237,34 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
konfig: schritt.konfig as object, konfig: schritt.konfig as object,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition, position: schrittPosition,
})), },
}); });
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
}
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const abschnittId = abschnittsZeilen[position];
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as object },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as object },
});
}
}
} }
await tx.vorlagenVersion.update({ await tx.vorlagenVersion.update({
@@ -94,6 +94,12 @@
}, },
{ {
"titel": "Konto anlegen (Active Directory)", "titel": "Konto anlegen (Active Directory)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Active Directory"
},
"schritte": [ "schritte": [
{ {
"titel": "Konto anlegen", "titel": "Konto anlegen",
@@ -141,6 +147,12 @@
}, },
{ {
"titel": "Konto anlegen (Entra ID)", "titel": "Konto anlegen (Entra ID)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Entra ID"
},
"schritte": [ "schritte": [
{ {
"titel": "Konto anlegen", "titel": "Konto anlegen",
@@ -0,0 +1,273 @@
"use client";
import { useId } from "react";
import { AlertTriangle, GitBranch } from "lucide-react";
import { FELD } from "@/lib/formular";
import {
BEDINGUNGS_ZUSTAENDE,
type BedingungsZustand,
type PfadBedingung,
VARIABLEN_OPERATOREN,
type VariablenOperator,
} from "@/lib/bedingungen";
/**
* Eine Bedingung an einem Abschnitt oder Schritt setzen (#039).
*
* **Eine Bedingung, kein Ausdrucksbaum** — das ist die Entscheidung aus dem
* Dienst, und sie zeigt sich hier: es gibt kein „und“, kein „oder“ und keine
* zweite Zeile. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte. Ein
* Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre eine kleine
* Abfragesprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
* schlimmer als einer zu viel.
*
* **Verwiesen wird über Titel, nicht über Kennungen.** `entwurfSpeichern`
* löscht den Entwurf und schreibt ihn neu; eine Kennung überlebte das nicht.
* Titel sind außerdem das, was ein Redakteur ohnehin liest.
*/
const OPERATOR_TEXT: Record<VariablenOperator, string> = {
ist: "ist gleich",
istNicht: "ist nicht",
gesetzt: "ist ausgefüllt",
leer: "ist leer",
};
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
/** Ein Schritt, auf den sich eine Bedingung beziehen kann. */
export type WaehlbarerSchritt = {
readonly abschnitt: string;
readonly schritt: string;
/** Steht dieser Schritt **nach** dem bedingten Teil? Dann wird gewarnt. */
readonly spaeter: boolean;
};
export function BedingungWaehlen({
bedingung,
variablen,
schritte,
lesend,
setzen,
}: {
bedingung: PfadBedingung | undefined;
/** Die Variablen dieser Version: Schlüssel und Beschriftung. */
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
schritte: readonly WaehlbarerSchritt[];
lesend: boolean;
setzen: (bedingung: PfadBedingung | undefined) => void;
}) {
const kennung = useId();
const art = bedingung?.art ?? "keine";
/** Der gewählte Schritt, falls die Bedingung auf einen zeigt. */
const gewaehlterSchritt =
bedingung?.art === "schritt"
? schritte.find(
(eintrag) => eintrag.abschnitt === bedingung.abschnitt && eintrag.schritt === bedingung.schritt,
)
: undefined;
function artWechseln(neu: string): void {
if (neu === "keine") return setzen(undefined);
if (neu === "variable") {
const erste = variablen[0];
if (!erste) return setzen(undefined);
return setzen({ art: "variable", schluessel: erste.schluessel, operator: "ist", wert: "" });
}
const erster = schritte[0];
if (!erster) return setzen(undefined);
return setzen({
art: "schritt",
abschnitt: erster.abschnitt,
schritt: erster.schritt,
zustaende: ["ERLEDIGT"],
});
}
return (
<details className="rounded-karte border border-linie">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-3 text-sm text-nebentext hover:text-text md:min-h-10">
<GitBranch className="size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
{bedingung ? "Bedingung gesetzt" : "Bedingung (optional)"}
</summary>
<div className="flex flex-col gap-3 border-t border-linie p-3">
{/*
`htmlFor` und `id` statt eines umhuellenden `label`: der zugaengliche
Name eines `select` im Label schluckt sonst den Text der gewaehlten
Option mit („VariableKontotyp“). Fuer einen Screenreader ist das eine
Bezeichnung, die sich beim Umschalten aendert — und kein Werkzeug
findet das Feld noch ueber seinen Namen.
*/}
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-art`} className="text-nebentext">
Gilt
</label>
<select
id={`${kennung}-art`}
value={art}
disabled={lesend}
onChange={(ereignis) => artWechseln(ereignis.target.value)}
className={FELD}
>
<option value="keine">immer</option>
<option value="variable" disabled={variablen.length === 0}>
abhängig von einer Variable
</option>
<option value="schritt" disabled={schritte.length === 0}>
abhängig von einem Schritt
</option>
</select>
</div>
{variablen.length === 0 && schritte.length === 0 ? (
<p className="text-xs text-nebentext">
Für eine Bedingung braucht die Vorlage eine Variable oder einen anderen Schritt.
</p>
) : null}
{bedingung?.art === "variable" ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-variable`} className="text-nebentext">
Variable
</label>
<select
id={`${kennung}-variable`}
value={bedingung.schluessel}
disabled={lesend}
onChange={(ereignis) => setzen({ ...bedingung, schluessel: ereignis.target.value })}
className={FELD}
>
{variablen.map((variable) => (
<option key={variable.schluessel} value={variable.schluessel}>
{variable.beschriftung}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-vergleich`} className="text-nebentext">
Vergleich
</label>
<select
id={`${kennung}-vergleich`}
value={bedingung.operator}
disabled={lesend}
onChange={(ereignis) =>
setzen({ ...bedingung, operator: ereignis.target.value as VariablenOperator })
}
className={FELD}
>
{VARIABLEN_OPERATOREN.map((operator) => (
<option key={operator} value={operator}>
{OPERATOR_TEXT[operator]}
</option>
))}
</select>
</div>
{/*
Nur bei „ist“ und „ist nicht“: `gesetzt` und `leer` vergleichen mit
nichts, und ein Feld, das nichts tut, verwirrt.
*/}
{bedingung.operator === "ist" || bedingung.operator === "istNicht" ? (
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-wert`} className="text-nebentext">
Wert
</label>
<input
id={`${kennung}-wert`}
value={bedingung.wert ?? ""}
disabled={lesend}
onChange={(ereignis) => setzen({ ...bedingung, wert: ereignis.target.value })}
placeholder="AD"
className={FELD}
/>
</div>
) : null}
</div>
) : null}
{bedingung?.art === "schritt" ? (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor={`${kennung}-schritt`} className="text-nebentext">
Schritt
</label>
<select
id={`${kennung}-schritt`}
value={`${bedingung.abschnitt}\u0000${bedingung.schritt}`}
disabled={lesend}
onChange={(ereignis) => {
const [abschnitt, schritt] = ereignis.target.value.split("\u0000");
if (abschnitt && schritt) setzen({ ...bedingung, abschnitt, schritt });
}}
className={FELD}
>
{schritte.map((eintrag) => (
<option
key={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
value={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
>
{eintrag.abschnitt} · {eintrag.schritt}
</option>
))}
</select>
</div>
<fieldset className="flex flex-col gap-2 text-sm">
<legend className="mb-1 text-nebentext">… ist</legend>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{BEDINGUNGS_ZUSTAENDE.map((zustand) => (
<label key={zustand} className="flex min-h-11 items-center gap-2 md:min-h-10">
<input
type="checkbox"
id={`${kennung}-${zustand}`}
checked={bedingung.zustaende.includes(zustand)}
disabled={lesend}
onChange={(ereignis) => {
const naechste = ereignis.target.checked
? [...bedingung.zustaende, zustand]
: bedingung.zustaende.filter((eintrag) => eintrag !== zustand);
/*
* Mindestens einer bleibt gesetzt: eine leere Liste wäre
* eine Bedingung, die nie zutrifft — ein Abschnitt, der
* für immer unsichtbar bleibt.
*/
if (naechste.length > 0) setzen({ ...bedingung, zustaende: naechste });
}}
className="size-4 accent-akzent"
/>
{ZUSTAND_TEXT[zustand]}
</label>
))}
</div>
</fieldset>
{gewaehlterSchritt?.spaeter ? (
/*
* Eine Warnung und kein Verbot: erlaubt ist es, nur fast immer ein
* Versehen. Der bedingte Teil wäre zunächst unsichtbar und
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat —
* also rückwärts, und damit rechnet niemand.
*/
<p role="status" className="flex items-start gap-2 text-xs text-faellig">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Dieser Schritt kommt <strong>später</strong> als der bedingte Teil. Er wäre beim Abarbeiten
zunächst unsichtbar und erschiene erst rückwärts. Meist ist ein Schritt davor gemeint.
</span>
</p>
) : null}
</div>
) : null}
</div>
</details>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { Blocks } from "lucide-react"; import { Blocks } from "lucide-react";
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen"; import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder"; import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren"; import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
import { SchrittKarte } from "@/components/vorlagen/editor-schritt"; import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
@@ -32,6 +33,8 @@ export function AbschnittKarte({
anzahl, anzahl,
lesend, lesend,
abteilungen, abteilungen,
variablen,
alleSchritte,
zuletztAngelegt, zuletztAngelegt,
anfasser, anfasser,
aendern, aendern,
@@ -48,6 +51,14 @@ export function AbschnittKarte({
lesend: boolean; lesend: boolean;
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */ /** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
abteilungen: readonly string[]; abteilungen: readonly string[];
/** Die Variablen dieser Version – für eine Bedingung (#039). */
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
/** Alle Schritte der Version in Abarbeitungsreihenfolge, mit laufender Nummer. */
alleSchritte: readonly {
readonly abschnitt: string;
readonly schritt: string;
readonly stelle: number;
}[];
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */ /** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
zuletztAngelegt: string | null; zuletztAngelegt: string | null;
anfasser: React.ReactNode; anfasser: React.ReactNode;
@@ -62,6 +73,12 @@ export function AbschnittKarte({
const [offen, setzeOffen] = useState(true); const [offen, setzeOffen] = useState(true);
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt; const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
const anzahlSchritte = abschnitt.schritte.length; const anzahlSchritte = abschnitt.schritte.length;
/*
* Die laufende Nummer des ersten Schritts dieses Abschnitts. Alles ab hier
* gilt als „spaeter“ — der Abschnitt beginnt dort.
*/
const ersteStelleDiesesAbschnitts =
alleSchritte.find((eintrag) => eintrag.abschnitt === abschnitt.titel)?.stelle ?? alleSchritte.length;
const baustein = abschnitt.baustein; const baustein = abschnitt.baustein;
return ( return (
@@ -159,6 +176,27 @@ export function AbschnittKarte({
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })} setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
/> />
{/*
Die Bedingung gehoert wie die Abteilungseinschraenkung zur **Vorlage**
und nicht zum Baustein — deshalb steht sie auch bei einem
Baustein-Abschnitt (#039, wie #031).
Als „spaeter“ gilt ein Schritt, der nach dem **ersten** Schritt dieses
Abschnitts kommt: der Abschnitt beginnt dort, und was danach steht, wuerde
ihn rueckwaerts erscheinen lassen.
*/}
<BedingungWaehlen
bedingung={abschnitt.bedingung}
variablen={variablen}
schritte={alleSchritte.map((eintrag) => ({
abschnitt: eintrag.abschnitt,
schritt: eintrag.schritt,
spaeter: eintrag.stelle >= ersteStelleDiesesAbschnitts,
}))}
lesend={lesend}
setzen={(bedingung) => aendern({ bedingung })}
/>
{baustein ? null : anzahlSchritte === 0 ? ( {baustein ? null : anzahlSchritte === 0 ? (
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p> <p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
) : ( ) : (
@@ -175,6 +213,9 @@ export function AbschnittKarte({
<SchrittKarte <SchrittKarte
schritt={schritt} schritt={schritt}
index={stelle} index={stelle}
variablen={variablen}
alleSchritte={alleSchritte}
eigeneStelle={ersteStelleDiesesAbschnitts + stelle}
anzahl={anzahlSchritte} anzahl={anzahlSchritte}
lesend={lesend} lesend={lesend}
neu={schritt.kennung === zuletztAngelegt} neu={schritt.kennung === zuletztAngelegt}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
import { import {
Auswahlfeld, Auswahlfeld,
Flaechenfeld, Flaechenfeld,
@@ -138,6 +139,9 @@ export function SchrittKarte({
anzahl, anzahl,
lesend, lesend,
neu, neu,
variablen,
alleSchritte,
eigeneStelle,
anfasser, anfasser,
aendern, aendern,
loeschen, loeschen,
@@ -149,6 +153,23 @@ export function SchrittKarte({
lesend: boolean; lesend: boolean;
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */ /** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
neu: boolean; neu: boolean;
/**
* Die Variablen dieser Version und alle Schritte – für eine Bedingung (#039).
*
* **Fehlen sie, gibt es keine Bedingung**, und das ist der Fall im
* Baustein-Editor: ein Baustein ist wiederverwendbar, und eine Bedingung auf
* einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht
* entscheidbar. Der Abschnitt, in dem er eingesetzt wird, darf eine haben; der
* Schritt im Baustein nicht.
*/
variablen?: readonly { readonly schluessel: string; readonly beschriftung: string }[];
alleSchritte?: readonly {
readonly abschnitt: string;
readonly schritt: string;
readonly stelle: number;
}[];
/** Die laufende Nummer **dieses** Schritts – alles danach gilt als „später“. */
eigeneStelle?: number;
anfasser: React.ReactNode; anfasser: React.ReactNode;
aendern: (teil: Partial<EditorSchritt>) => void; aendern: (teil: Partial<EditorSchritt>) => void;
loeschen: () => void; loeschen: () => void;
@@ -254,6 +275,26 @@ export function SchrittKarte({
vorschlag="https://intern.example/anleitung" vorschlag="https://intern.example/anleitung"
hinweis="Nur http- oder https-Adressen." hinweis="Nur http- oder https-Adressen."
/> />
{/*
Eine Bedingung **am einzelnen Schritt** (#039): sie blendet nur ihn aus,
nicht den Abschnitt. Der eigene Schritt steht in der Liste mit; eine
Bedingung auf sich selbst waere nie entscheidbar, und genau deshalb gilt
er als „spaeter“ und wird angewarnt.
*/}
{variablen && alleSchritte ? (
<BedingungWaehlen
bedingung={schritt.bedingung}
variablen={variablen}
schritte={alleSchritte.map((eintrag) => ({
abschnitt: eintrag.abschnitt,
schritt: eintrag.schritt,
spaeter: eintrag.stelle >= (eigeneStelle ?? 0),
}))}
lesend={lesend}
setzen={(bedingung) => aendern({ bedingung })}
/>
) : null}
</KlappKarte> </KlappKarte>
); );
} }
+16
View File
@@ -208,6 +208,20 @@ export function Editor({
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte; const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
/*
* Die Schritte, auf die eine Bedingung zeigen kann (#039) — **alle**, und zu
* jedem die Auskunft, ob er spaeter kommt. Einmal berechnet und an jede Karte
* gegeben: jede Karte selbst rechnen zu lassen waere dieselbe Schleife je
* Abschnitt, und die Reihenfolge ist fuer alle dieselbe.
*
* Die laufende Nummer entscheidet ueber „spaeter“, nicht die Position im
* Abschnitt: ein Schritt in Abschnitt 1 kommt vor jedem in Abschnitt 2.
*/
const reihenfolge = aufbau.abschnitte.flatMap((abschnitt) =>
abschnitt.schritte.map((schritt) => ({ abschnitt: abschnitt.titel, schritt: schritt.titel })),
);
const waehlbareSchritte = reihenfolge.map((eintrag, stelle) => ({ ...eintrag, stelle }));
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<header className="flex flex-col gap-3"> <header className="flex flex-col gap-3">
@@ -340,6 +354,8 @@ export function Editor({
index={index} index={index}
anzahl={aufbau.abschnitte.length} anzahl={aufbau.abschnitte.length}
abteilungen={abteilungen} abteilungen={abteilungen}
variablen={aufbau.variablen}
alleSchritte={waehlbareSchritte}
lesend={lesend} lesend={lesend}
zuletztAngelegt={zuletztAngelegt} zuletztAngelegt={zuletztAngelegt}
anfasser={anfasser} anfasser={anfasser}
+426
View File
@@ -0,0 +1,426 @@
import { z } from "@/lib/validierung";
/**
* Bedingte Abschnitte und Schritte (#039).
*
* Framework-frei und ohne Prisma, wie `src/lib/geltende-abschnitte.ts` — und aus
* demselben Grund: **drei Schichten** brauchen die Auswertung. Der Dienst beim
* Schreiben eines Schrittergebnisses und beim Abschluss, die Lesemodelle der
* Abarbeitung und der Druckansicht, und der Editor für seine Vorschau.
*
* ## Was eine Bedingung ist — und was sie nicht ist
*
* **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine
* Verschachtelung. Das wäre eine kleine Abfragesprache, die im Editor niemand
* pflegen kann — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
* schlimmer als einer zu viel. Wer zwei Bedingungen braucht, nimmt zwei
* Abschnitte oder zwei Vorlagen.
*
* Zwei Arten, und die Wahl entscheidet, **wann** die Antwort entsteht:
*
* - `variable` — hängt an einem Wert, der beim Start erfasst wird. Der echte
* Fall: die Onboarding-Vorlage trägt beide Konto-Abschnitte, und `konto_typ`
* sagt längst, welcher gilt.
* - `schritt` — hängt am Zustand eines anderen Schritts derselben Version. Die
* Antwort entsteht erst **während** der Arbeit; deshalb wird laufend
* ausgewertet und nicht beim Start festgeschrieben wie bei #031.
*/
/* ------------------------------------------------------------------ */
/* Form */
/* ------------------------------------------------------------------ */
/**
* Die Vergleiche auf einer Variable.
*
* `ist` und `istNicht` vergleichen als **Text**, ohne Rücksicht auf Groß- und
* Kleinschreibung und mit getrimmten Rändern: die Werte kommen aus einem
* Auswahlfeld oder aus einem Formular, und „AD“ ist derselbe Wert wie „ad“.
* Ein Zahlenvergleich (`groesser`, `kleiner`) fehlt absichtlich — er hatte
* bisher keinen Fall, und ungenutzter Code ist ungeprüfter Code.
*/
export const VARIABLEN_OPERATOREN = ["ist", "istNicht", "gesetzt", "leer"] as const;
export type VariablenOperator = (typeof VARIABLEN_OPERATOREN)[number];
/** Die Zustände, auf die eine Schritt-Bedingung zeigen kann. */
export const BEDINGUNGS_ZUSTAENDE = ["OFFEN", "ERLEDIGT", "NICHT_ZUTREFFEND", "FEHLGESCHLAGEN"] as const;
export type BedingungsZustand = (typeof BEDINGUNGS_ZUSTAENDE)[number];
const VariablenBedingungSchema = z.object({
art: z.literal("variable"),
schluessel: z.string().trim().min(1),
operator: z.enum(VARIABLEN_OPERATOREN),
/*
* Nur bei `ist` und `istNicht` von Bedeutung. Bewusst **nicht** über
* `discriminatedUnion` je Operator aufgeteilt: das ergäbe vier Schemas für
* einen Unterschied, den ein `refine` in einer Zeile sagt.
*/
wert: z.string().optional(),
});
const SchrittBedingungSchema = z.object({
art: z.literal("schritt"),
schrittId: z.string().trim().min(1),
/*
* Eine **Liste** von Zuständen, nicht einer. Das ist ein `in`-Vergleich und
* kein verstecktes `oder`: „erledigt oder nicht zutreffend“ ist eine Frage,
* nicht zwei. Leer wäre eine Bedingung, die nie zutrifft — deshalb mindestens
* einer.
*/
zustaende: z.array(z.enum(BEDINGUNGS_ZUSTAENDE)).min(1),
});
export const BedingungSchema = z
.discriminatedUnion("art", [VariablenBedingungSchema, SchrittBedingungSchema])
.refine(
(bedingung) =>
bedingung.art !== "variable" ||
(bedingung.operator !== "ist" && bedingung.operator !== "istNicht") ||
(bedingung.wert ?? "").trim().length > 0,
{ message: "Für „ist“ und „ist nicht“ gehört ein Wert dazu." },
);
export type Bedingung = z.infer<typeof BedingungSchema>;
/**
* Liest eine gespeicherte Bedingung aus dem Json-Feld.
*
* **Nachsichtig beim Lesen**: was nicht zum Schema passt, gilt als *keine*
* Bedingung — der Abschnitt wird dann angezeigt. Hier zu werfen hieße, dass eine
* einzige unlesbare Zeile eine ganze Vorlage unbenutzbar macht; und die
* Richtung, in die der Fehler dann fällt, ist die harmlose: ein Abschnitt zu
* viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
*
* Dass gar nichts Unlesbares hineinkommt, hält das Schema beim **Schreiben**.
*/
export function bedingungLesen(roh: unknown): Bedingung | null {
if (roh === null || roh === undefined) return null;
const gelesen = BedingungSchema.safeParse(roh);
return gelesen.success ? gelesen.data : null;
}
/* ------------------------------------------------------------------ */
/* Auswertung */
/* ------------------------------------------------------------------ */
/** Was zur Auswertung bekannt sein muss. */
export type Umgebung = {
/** Die Werte der Variablen dieses Laufs (`Lauf.variablen`). */
readonly variablen: Readonly<Record<string, unknown>>;
/** Der Zustand je Schritt-Kennung, soweit es ein Schrittergebnis gibt. */
readonly zustaende: ReadonlyMap<string, BedingungsZustand>;
};
function alsText(wert: unknown): string {
if (wert === null || wert === undefined) return "";
if (typeof wert === "string") return wert.trim();
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return "";
}
const gleich = (a: string, b: string): boolean =>
a.toLocaleLowerCase("de") === b.trim().toLocaleLowerCase("de");
/**
* Trifft diese Bedingung zu?
*
* `undefined` heißt **nicht entscheidbar**, und das ist nicht dasselbe wie
* „nein“. Der Aufrufer zeigt in diesem Fall an (siehe `geltendeTeile`).
*
* **Der Unterschied zwischen den beiden Arten ist wichtig:**
*
* - Eine Bedingung auf einer Variable, die **nicht gesetzt** ist, ist bei `ist`
* und `istNicht` nicht entscheidbar. Ein Lauf, bei dem niemand `konto_typ`
* angegeben hat, soll **beide** Konto-Abschnitte zeigen: ausblenden hieße,
* eine Antwort zu erfinden, die niemand gegeben hat.
* - Eine Bedingung auf einem Schritt ist **immer** entscheidbar, sobald es den
* Schritt gibt: jedes Schrittergebnis hat einen Zustand, und `OFFEN` ist eine
* Antwort und keine Lücke. Ein Abschnitt, der erst nach einem erledigten
* Schritt erscheint, ist der Sinn der Sache.
* Nur ein Schritt, den es in diesem Lauf **gar nicht** gibt (ausgefiltert
* durch #031, oder ein Verweis aus einer anderen Version), ist nicht
* entscheidbar.
*/
export function bedingungErfuellt(bedingung: Bedingung, umgebung: Umgebung): boolean | undefined {
if (bedingung.art === "variable") {
const wert = alsText(umgebung.variablen[bedingung.schluessel]);
switch (bedingung.operator) {
case "gesetzt":
return wert.length > 0;
case "leer":
return wert.length === 0;
case "ist":
return wert.length === 0 ? undefined : gleich(wert, bedingung.wert ?? "");
case "istNicht":
return wert.length === 0 ? undefined : !gleich(wert, bedingung.wert ?? "");
default: {
// Ein neuer Operator faellt beim Uebersetzen auf und nicht im Betrieb.
const unbekannt: never = bedingung.operator;
return void unbekannt as undefined;
}
}
}
const zustand = umgebung.zustaende.get(bedingung.schrittId);
if (zustand === undefined) return undefined;
return bedingung.zustaende.includes(zustand);
}
/** Ein Abschnitt oder Schritt, wie die Auswertung ihn braucht. */
export type MitBedingung = {
readonly id: string;
readonly bedingung: unknown;
};
/**
* Welche Teile gelten.
*
* **Nicht entscheidbar heißt: gilt.** Das ist die Richtung, in die der Zweifel
* fallen soll — ein Abschnitt zu viel ist sichtbar, und jemand setzt ihn auf
* `NICHT_ZUTREFFEND`. Ein fehlender Abschnitt fällt niemandem auf, und genau
* das ist bei einer Checkliste der teure Fall.
*/
export function geltendeTeile(teile: readonly MitBedingung[], umgebung: Umgebung): readonly string[] {
return teile
.filter((teil) => {
const bedingung = bedingungLesen(teil.bedingung);
if (!bedingung) return true;
return bedingungErfuellt(bedingung, umgebung) !== false;
})
.map((teil) => teil.id);
}
/**
* Die Umgebung aus einem Lauf bauen.
*
* Eine eigene Funktion, weil **jede** Schicht sie gleich bauen muss: aus den
* Variablen des Laufs und den Zuständen seiner Schrittergebnisse. Zweimal
* geschrieben lief es auseinander — dieselbe Lehre wie bei den Grenzen des
* Abgleich-Intervalls (#016).
*/
export function umgebungBauen(
variablen: unknown,
ergebnisse: readonly {
readonly vorlagenSchrittId: string;
readonly zustand: string;
}[],
): Umgebung {
const werte =
typeof variablen === "object" && variablen !== null && !Array.isArray(variablen)
? (variablen as Record<string, unknown>)
: {};
const zustaende = new Map<string, BedingungsZustand>();
for (const ergebnis of ergebnisse) {
if ((BEDINGUNGS_ZUSTAENDE as readonly string[]).includes(ergebnis.zustand)) {
zustaende.set(ergebnis.vorlagenSchrittId, ergebnis.zustand as BedingungsZustand);
}
}
return { variablen: werte, zustaende };
}
/**
* Die Struktur eines Laufs, auf das Geltende eingedampft.
*
* **Die Reihenfolge ist nicht beliebig, und sie ist der Grund für diese
* Funktion.** Zwei Filter wirken übereinander:
*
* 1. `nurGeltende` aus #031 — die Abteilungsabschnitte, **beim Start
* festgeschrieben** in `Lauf.geltendeAbschnitte`.
* 2. die Bedingungen von hier — **laufend** ausgewertet, weil eine Bedingung auf
* einem Schrittergebnis erst während der Arbeit eine Antwort hat.
*
* Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
* zweiten anwendet, umgeht die Abteilungsregel. Beide zusammen gehören an **eine**
* Stelle, sonst vergisst sie eine der drei Schichten — genau wie bei #031, und
* `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine es tut.
*
* Ein Abschnitt, der gilt, kann Schritte enthalten, die **nicht** gelten: eine
* Bedingung steht an beiden.
*/
export function geltendeStruktur<
S extends MitBedingung,
A extends MitBedingung & { readonly schritte: readonly S[] },
>(abschnitte: readonly A[], umgebung: Umgebung): readonly A[] {
const erlaubteAbschnitte = new Set(geltendeTeile(abschnitte, umgebung));
return abschnitte
.filter((abschnitt) => erlaubteAbschnitte.has(abschnitt.id))
.map((abschnitt) => {
const erlaubteSchritte = new Set(geltendeTeile(abschnitt.schritte, umgebung));
return {
...abschnitt,
schritte: abschnitt.schritte.filter((schritt) => erlaubteSchritte.has(schritt.id)),
};
});
}
/* ------------------------------------------------------------------ */
/* Titel-Pfade: Austausch und Entwurf */
/* ------------------------------------------------------------------ */
/**
* Der Schlüssel eines Schritts in einer Struktur, aus Abschnitts- und
* Schritt-Titel.
*
* **Warum Titel und nicht Kennungen.** Zwei Wege schreiben eine Struktur neu und
* geben dabei neue Kennungen: der Import einer Austauschdatei und
* `entwurfSpeichern`, das den Entwurf löscht und wieder anlegt. Eine Bedingung
* auf einer Kennung überlebt keinen von beiden. Ein Titel-Pfad überlebt beide —
* und eine von Hand geschriebene Datei kann ihn setzen.
*
* **Die Grenze, und sie ist benannt:** zwei Schritte mit demselben Titel im
* selben Abschnitt sind so nicht unterscheidbar; die Bedingung zeigt dann auf
* den ersten. Das ist ein Grenzfall, den eine Checkliste nicht braucht — zwei
* gleich benannte Schritte nebeneinander kann auch ein Mensch nicht
* unterscheiden.
*
* `\u0000` als Trenner, weil es in keinem Titel vorkommt: ein `·` oder `/` schon.
*/
export function schrittPfad(abschnittsTitel: string, schrittTitel: string): string {
return `${abschnittsTitel}\u0000${schrittTitel}`;
}
/**
* Eine Bedingung in der Pfad-Form — über Titel statt Kennungen.
*
* **Als Zod-Schema und an genau einer Stelle.** Austauschformat und
* Editor-Struktur brauchen dieselbe Form; zwei Schemas dafür liefen auseinander,
* und TypeScript hätte es erst gemeldet, als die beiden Typen aufeinandertrafen.
* `src/lib/vorlagen-format.ts` importiert es von hier.
*/
const PFAD_TEXT_MAX = 500;
export const PfadBedingungSchema = z.discriminatedUnion("art", [
z.object({
art: z.literal("variable"),
schluessel: z.string().trim().min(1).max(PFAD_TEXT_MAX),
operator: z.enum(VARIABLEN_OPERATOREN),
wert: z.string().trim().max(PFAD_TEXT_MAX).optional(),
}),
z.object({
art: z.literal("schritt"),
abschnitt: z.string().trim().min(1).max(PFAD_TEXT_MAX),
schritt: z.string().trim().min(1).max(PFAD_TEXT_MAX),
zustaende: z.array(z.enum(BEDINGUNGS_ZUSTAENDE)).min(1),
}),
]);
export type PfadBedingung = z.infer<typeof PfadBedingungSchema>;
/**
* Aus einer Pfad-Bedingung die gespeicherte Form.
*
* `null`, wenn der Pfad in dieser Struktur nicht vorkommt. Der Aufrufer lässt
* die Bedingung dann **weg**, statt eine mit einem Verweis ins Leere zu
* schreiben: die wäre nicht entscheidbar, der Abschnitt erschiene also immer —
* und niemand könnte sagen, warum seine Bedingung nicht wirkt.
*/
export function bedingungAufloesen(
bedingung: PfadBedingung,
kennungen: ReadonlyMap<string, string>,
): Bedingung | null {
if (bedingung.art === "variable") {
return {
art: "variable",
schluessel: bedingung.schluessel,
operator: bedingung.operator,
...(bedingung.wert === undefined ? {} : { wert: bedingung.wert }),
};
}
const schrittId = kennungen.get(schrittPfad(bedingung.abschnitt, bedingung.schritt));
if (!schrittId) return null;
return { art: "schritt", schrittId, zustaende: [...bedingung.zustaende] };
}
/**
* Die Gegenrichtung: aus der gespeicherten Form eine Pfad-Bedingung.
*
* `null`, wenn der Schritt nicht in der Karte steht — dann gibt es keinen Pfad,
* den man aufschreiben könnte, und die Bedingung wird weggelassen.
*/
export function bedingungZuPfad(
roh: unknown,
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): PfadBedingung | null {
const bedingung = bedingungLesen(roh);
if (!bedingung) return null;
if (bedingung.art === "variable") {
return {
art: "variable",
schluessel: bedingung.schluessel,
operator: bedingung.operator,
...(bedingung.wert === undefined ? {} : { wert: bedingung.wert }),
};
}
const pfad = pfade.get(bedingung.schrittId);
if (!pfad) return null;
return {
art: "schritt",
abschnitt: pfad.abschnitt,
schritt: pfad.schritt,
zustaende: [...bedingung.zustaende],
};
}
/* ------------------------------------------------------------------ */
/* Für den Editor */
/* ------------------------------------------------------------------ */
/**
* Zeigt diese Bedingung auf einen Schritt, der **später** kommt?
*
* Eine Warnung und kein Fehler: so etwas ist nicht verboten, nur fast immer ein
* Versehen. Der bedingte Abschnitt wäre beim Abarbeiten zunächst unsichtbar und
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also
* rückwärts, und niemand rechnet damit.
*
* `undefined`, wenn die Bedingung nicht auf einen Schritt zeigt oder der Schritt
* nicht gefunden wird; dann gibt es nichts zu warnen.
*/
export function zeigtNachVorne(
bedingung: Bedingung,
/** Alle Schritte der Version, in Abarbeitungsreihenfolge. */
reihenfolge: readonly string[],
/** Die Kennung des bedingten Teils — ein Schritt, oder der erste eines Abschnitts. */
eigeneId: string,
): boolean | undefined {
if (bedingung.art !== "schritt") return undefined;
const ziel = reihenfolge.indexOf(bedingung.schrittId);
const selbst = reihenfolge.indexOf(eigeneId);
if (ziel < 0 || selbst < 0) return undefined;
return ziel > selbst;
}
/** Eine Bedingung als Satz, für Editor und Vorschau. */
export function bedingungText(
bedingung: Bedingung,
/** Beschriftung je Variablenschlüssel bzw. Titel je Schritt-Kennung. */
beschriftung: (kennung: string) => string,
): string {
if (bedingung.art === "variable") {
const name = beschriftung(bedingung.schluessel);
switch (bedingung.operator) {
case "gesetzt":
return `gilt, wenn „${name}“ ausgefüllt ist`;
case "leer":
return `gilt, wenn „${name}“ leer ist`;
case "ist":
return `gilt, wenn „${name}“ gleich „${bedingung.wert ?? ""}“ ist`;
case "istNicht":
return `gilt, wenn „${name}“ nicht „${bedingung.wert ?? ""}“ ist`;
default: {
const unbekannt: never = bedingung.operator;
return String(unbekannt);
}
}
}
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
const zustaende = bedingung.zustaende.map((zustand) => ZUSTAND_TEXT[zustand]).join(" oder ");
return `gilt, wenn „${beschriftung(bedingung.schrittId)}“ ${zustaende} ist`;
}
+14
View File
@@ -1,3 +1,4 @@
import type { PfadBedingung } from "@/lib/bedingungen";
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter"; import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
import { import {
SCHRITT_TYPEN, SCHRITT_TYPEN,
@@ -35,6 +36,8 @@ export type EditorSchritt = {
readonly pflicht: boolean; readonly pflicht: boolean;
readonly konfig: SchrittKonfig; readonly konfig: SchrittKonfig;
readonly dokuUrl: string; readonly dokuUrl: string;
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
readonly bedingung?: PfadBedingung;
}; };
export type EditorAbschnitt = { export type EditorAbschnitt = {
@@ -43,6 +46,13 @@ export type EditorAbschnitt = {
readonly schritte: readonly EditorSchritt[]; readonly schritte: readonly EditorSchritt[];
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */ /** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
readonly nurFuerAbteilungen: readonly string[]; readonly nurFuerAbteilungen: readonly string[];
/**
* Wann dieser Abschnitt gilt (#039). Fehlt: immer.
*
* Verweist ueber **Titel**, nicht ueber Kennungen: `entwurfSpeichern` loescht
* den Entwurf und schreibt ihn neu, eine Kennung ueberlebte das nicht.
*/
readonly bedingung?: PfadBedingung;
/** /**
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030). * Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
* *
@@ -308,6 +318,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer. // Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
titel: titelOhneNummer(abschnitt.titel), titel: titelOhneNummer(abschnitt.titel),
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie // Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030). // getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
...(abschnitt.bausteinVersionId === undefined ...(abschnitt.bausteinVersionId === undefined
@@ -321,6 +332,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: schritt.konfig, konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl ?? "", dokuUrl: schritt.dokuUrl ?? "",
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
})), })),
})), })),
}; };
@@ -352,6 +364,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
nurFuerAbteilungen: [ nurFuerAbteilungen: [
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)), ...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
], ],
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
/* /*
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim * Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur * Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
@@ -367,6 +380,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: schritt.konfig, konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(), dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
})), })),
})), })),
}; };
+13
View File
@@ -1,5 +1,6 @@
import { istAlsVariableErlaubt } from "@/lib/platzhalter"; import { istAlsVariableErlaubt } from "@/lib/platzhalter";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import { PfadBedingungSchema, type PfadBedingung } from "@/lib/bedingungen";
/** /**
* Aufbau einer Vorlagen-Version (#009). * Aufbau einer Vorlagen-Version (#009).
@@ -72,6 +73,14 @@ export const SchrittKonfigSchema = z.looseObject({
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>; export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
/*
* Die Form einer Bedingung steht in `src/lib/bedingungen.ts` — **an einer
* Stelle** (#039). Austauschformat und Editor-Struktur benutzen dieselbe, und
* zwei Schemas dafuer liefen auseinander.
*/
export const AustauschBedingungSchema = PfadBedingungSchema;
export type AustauschBedingung = PfadBedingung;
export const SchrittSchema = z.object({ export const SchrittSchema = z.object({
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text), titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */ /** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
@@ -84,6 +93,8 @@ export const SchrittSchema = z.object({
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein * und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei. * gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
*/ */
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
bedingung: AustauschBedingungSchema.optional(),
dokuUrl: leerAlsUndefiniert( dokuUrl: leerAlsUndefiniert(
z.url({ z.url({
protocol: /^https?$/, protocol: /^https?$/,
@@ -119,6 +130,8 @@ export const AbschnittSchema = z.object({
.array(z.string().trim().min(1).max(200)) .array(z.string().trim().min(1).max(200))
.max(50, "Höchstens 50 Abteilungen je Abschnitt.") .max(50, "Höchstens 50 Abteilungen je Abschnitt.")
.default([]), .default([]),
/** Wann dieser Abschnitt gilt (#039). Fehlt: immer. */
bedingung: AustauschBedingungSchema.optional(),
schritte: z schritte: z
.array(SchrittSchema) .array(SchrittSchema)
.max( .max(
+27 -3
View File
@@ -1,6 +1,7 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { anhaengeDesLaufs } from "@/server/queries/anhaenge"; import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -174,6 +175,8 @@ export async function laufZumDrucken(
archiviertVon: { select: { name: true } }, archiviertVon: { select: { name: true } },
// Beim Start festgeschriebene Abschnittsauswahl (#031). // Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true, geltendeAbschnitte: true,
// Speist die Auswertung der Bedingungen (#039).
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
version: true, version: true,
@@ -183,9 +186,17 @@ export async function laufZumDrucken(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true }, select: {
id: true,
titel: true,
typ: true,
pflicht: true,
konfig: true,
bedingung: true,
},
}, },
}, },
}, },
@@ -233,8 +244,21 @@ export async function laufZumDrucken(
let anzahlSchritte = 0; let anzahlSchritte = 0;
let anzahlVomTisch = 0; let anzahlVomTisch = 0;
// Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031). /*
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte); * Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger
* (#031, #039). **Dieselbe Funktion** und nicht dieselbe Absicht: zwei
* Auswertungen liefen hier sonst auseinander, und ein Ausdruck, der andere
* Schritte zeigt als der Bildschirm, ist das Gegenteil eines Beweismittels.
*
* Ein archivierter Lauf bleibt dabei stabil: seine Variablen aendern sich
* nicht mehr, und an seinen Schrittergebnissen auch nichts
* (`laufMussVeraenderbarSein`). Was er zeigt, ist also genau das, was bei
* seinem Abschluss galt.
*/
const geltende = geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebungBauen(lauf.variablen, lauf.ergebnisse),
);
const abschnitte = geltende.map((abschnitt, index) => { const abschnitte = geltende.map((abschnitt, index) => {
const schritte = abschnitt.schritte.map((schritt) => { const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1; nummer += 1;
+18 -1
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { nurGeltende } from "@/lib/geltende-abschnitte"; import { nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte"; import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell"; import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau"; import { STANDARD_SHELL } from "@/lib/editor-aufbau";
@@ -239,6 +240,8 @@ export async function laufZumAbarbeiten(
select: { select: {
// Beim Start festgeschriebene Abschnittsauswahl (#031). // Beim Start festgeschriebene Abschnittsauswahl (#031).
geltendeAbschnitte: true, geltendeAbschnitte: true,
// Speist die Auswertung der Bedingungen (#039).
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -246,6 +249,7 @@ export async function laufZumAbarbeiten(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { select: {
@@ -256,6 +260,7 @@ export async function laufZumAbarbeiten(
pflicht: true, pflicht: true,
dokuUrl: true, dokuUrl: true,
konfig: true, konfig: true,
bedingung: true,
}, },
}, },
}, },
@@ -297,7 +302,19 @@ export async function laufZumAbarbeiten(
let anzahlPflicht = 0; let anzahlPflicht = 0;
let anzahlPflichtOffen = 0; let anzahlPflichtOffen = 0;
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte); /*
* Zwei Filter uebereinander, in dieser Reihenfolge: die beim Start
* festgeschriebene Abteilungsauswahl (#031), dann die laufend ausgewerteten
* Bedingungen (#039). Die Begruendung steht in `geltendeStruktur`.
*
* **Die Nummerierung kommt danach.** `01 · Vorbereitung` zaehlt die Abschnitte,
* die der Techniker **sieht** — ein ausgeblendeter hinterlaesst keine Luecke,
* und gespeichert wird die Nummer nie (CLAUDE.md, „Gestaltung“).
*/
const geltende = geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebungBauen(lauf.variablen, lauf.ergebnisse),
);
const abschnitte = geltende.map((abschnitt, index) => { const abschnitte = geltende.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = []; const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
+19
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungZuPfad } from "@/lib/bedingungen";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format"; import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -256,6 +257,22 @@ export async function versionMitAufbau(
}); });
if (!version) return undefined; if (!version) return undefined;
/*
* Kennung → Titel-Pfad, fuer die Bedingungen (#039). Der Editor sieht und
* schickt **Titel**, nie Kennungen: `entwurfSpeichern` loescht und schreibt
* neu, eine Kennung ueberlebte das nicht.
*/
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
for (const abschnitt of version.abschnitte) {
for (const schritt of abschnitt.schritte) {
pfade.set(schritt.id, { abschnitt: abschnitt.titel, schritt: schritt.titel });
}
}
const alsPfad = (roh: unknown) => {
const bedingung = bedingungZuPfad(roh, pfade);
return bedingung ? { bedingung } : {};
};
const struktur: Struktur = { const struktur: Struktur = {
variablen: version.variablen.map((variable) => ({ variablen: version.variablen.map((variable) => ({
schluessel: variable.schluessel, schluessel: variable.schluessel,
@@ -268,12 +285,14 @@ export async function versionMitAufbau(
titel: abschnitt.titel, titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen, nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }), ...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
...alsPfad(abschnitt.bedingung),
schritte: abschnitt.schritte.map((schritt) => ({ schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel, titel: schritt.titel,
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }), ...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
typ: schritt.typ, typ: schritt.typ,
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig: (schritt.konfig ?? {}) as Record<string, unknown>, konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
...alsPfad(schritt.bedingung),
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }), ...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
})), })),
})), })),
+16 -4
View File
@@ -2,6 +2,7 @@ import { Prisma, type Lauf } from "@/generated/prisma/client";
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen"; import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
import type { LaufAnlass } from "@/lib/meldungen"; import type { LaufAnlass } from "@/lib/meldungen";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
@@ -703,6 +704,7 @@ export async function offenePflichtschritte(
where: { id: laufId }, where: { id: laufId },
select: { select: {
geltendeAbschnitte: true, geltendeAbschnitte: true,
variablen: true,
vorlagenVersion: { vorlagenVersion: {
select: { select: {
abschnitte: { abschnitte: {
@@ -710,10 +712,11 @@ export async function offenePflichtschritte(
select: { select: {
id: true, id: true,
titel: true, titel: true,
bedingung: true,
schritte: { schritte: {
where: { pflicht: true }, where: { pflicht: true },
orderBy: { position: "asc" }, orderBy: { position: "asc" },
select: { id: true, titel: true }, select: { id: true, titel: true, bedingung: true },
}, },
}, },
}, },
@@ -726,11 +729,20 @@ export async function offenePflichtschritte(
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand])); const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
const offen: OffenerPflichtschritt[] = []; const offen: OffenerPflichtschritt[] = [];
/* /*
* Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf * Nur die geltenden Abschnitte (#031) und nur, was seine Bedingung erfuellt
* den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst * (#039). Ein ausgefilterter oder ausgeblendeter Pflichtschritt darf den
* Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
* sich nur, was in der Abarbeitung steht. * sich nur, was in der Abarbeitung steht.
*
* **Die Reihenfolge der beiden Filter steht in `geltendeStruktur`**, und sie
* ist dort begruendet: erst die festgeschriebene Abteilungsauswahl, dann die
* laufende Auswertung.
*/ */
for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) { const umgebung = umgebungBauen(lauf.variablen, lauf.ergebnisse);
for (const abschnitt of geltendeStruktur(
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
umgebung,
)) {
for (const schritt of abschnitt.schritte) { for (const schritt of abschnitt.schritte) {
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt. // Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN; const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
+119 -7
View File
@@ -2,7 +2,13 @@ import type { ZodError } from "@/lib/validierung";
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client"; import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format"; import { bedingungAufloesen, bedingungZuPfad, schrittPfad } from "@/lib/bedingungen";
import {
AustauschSchema,
FORMAT_VERSION,
type Austausch,
type AustauschBedingung,
} from "@/lib/vorlagen-format";
/** /**
* JSON-Austausch fuer Vorlagen (#009). * JSON-Austausch fuer Vorlagen (#009).
@@ -66,6 +72,22 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
} }
const { vorlage } = version; const { vorlage } = version;
/*
* Kennung → Titel-Pfad, fuer die Schritt-Bedingungen. Eine Kennung bedeutet
* in einer anderen Installation nichts; ein Titel-Pfad schon (#039).
*/
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
for (const abschnitt of version.abschnitte) {
const titel =
abschnitt.bausteinVersion && abschnitt.schritte.length === 0
? abschnitt.bausteinVersion.titel
: abschnitt.titel;
for (const schritt of abschnitt.schritte) {
pfade.set(schritt.id, { abschnitt: titel, schritt: schritt.titel });
}
}
return { return {
format: FORMAT_VERSION, format: FORMAT_VERSION,
name: vorlage.name, name: vorlage.name,
@@ -111,7 +133,10 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation // Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe. // dasselbe.
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map(schrittAbbilden), ...bedingungAbbilden(abschnitt.bedingung, pfade),
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map((schritt) =>
schrittAbbilden(schritt, pfade),
),
}; };
}), }),
}, },
@@ -129,6 +154,24 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar * nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer. * kein Objekt ist, gilt als leer.
*/ */
/**
* Eine gespeicherte Bedingung in die Austauschform.
*
* Gibt `{}` zurueck, wenn es keine gibt oder wenn eine Schritt-Bedingung auf
* einen Schritt zeigt, den der Export nicht kennt. **Weglassen und nicht
* mitschreiben**: eine Bedingung mit einem Verweis ins Leere waere in der
* zweiten Installation nicht entscheidbar, der Abschnitt erschiene also immer —
* und niemand koennte sagen, warum. Ohne Bedingung erscheint er auch immer, aber
* das sieht man in der Datei.
*/
function bedingungAbbilden(
roh: unknown,
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): { readonly bedingung?: AustauschBedingung } {
const pfadBedingung = bedingungZuPfad(roh, pfade);
return pfadBedingung ? { bedingung: pfadBedingung as AustauschBedingung } : {};
}
function schrittAbbilden( function schrittAbbilden(
/* /*
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet * Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
@@ -136,7 +179,17 @@ function schrittAbbilden(
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran * Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
* ein Schritt haengt — und das steht ohnehin in keinem Export. * ein Schritt haengt — und das steht ohnehin in keinem Export.
*/ */
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl">, schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl"> & {
/*
* Optional, weil ein **Baustein**-Schritt keine Bedingung hat — und das ist
* Absicht (#039): ein Baustein ist wiederverwendbar, und eine Bedingung auf
* einer Variable, die es in der Zielvorlage nicht gibt, waere nicht
* entscheidbar. Der Abschnitt, in dem er steht, darf eine haben; der
* einzelne Schritt daraus nicht.
*/
readonly bedingung?: unknown;
},
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
): AustauschSchritt { ): AustauschSchritt {
const roh = schritt.konfig; const roh = schritt.konfig;
const konfig = const konfig =
@@ -147,6 +200,7 @@ function schrittAbbilden(
typ: schritt.typ, typ: schritt.typ,
pflicht: schritt.pflicht, pflicht: schritt.pflicht,
konfig, konfig,
...bedingungAbbilden(schritt.bedingung, pfade),
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}), ...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
}; };
} }
@@ -202,13 +256,38 @@ export async function vorlageImportieren(
} }
let schritteGesamt = 0; let schritteGesamt = 0;
/*
* Kennungen, um die Schritt-Bedingungen danach aufzuloesen (#039). Erst
* anlegen, dann verweisen: eine Bedingung kann auf einen Schritt zeigen, der
* weiter unten steht, und der Trigger verlangt einen Schritt derselben
* Version.
*/
const neueKennungen = new Map<string, string>();
const abschnittsZeilen = new Map<string, string>();
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) { for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({ const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position }, data: {
versionId: version.id,
titel: abschnitt.titel,
position,
/*
* **Wandert mit, und das fehlte hier.** Der Export schreibt die
* Abteilungseinschraenkung seit #031, das Schema nimmt sie an — und
* der Import verwarf sie stillschweigend. Eine Vorlage, die jemand in
* eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
* sie galten dort fuer **alle**. Also mehr Schritte statt weniger, und
* niemand merkt es (gefunden bei #039).
*/
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
}); });
abschnittsZeilen.set(abschnitt.titel, zeile.id);
if (abschnitt.schritte.length === 0) continue; if (abschnitt.schritte.length === 0) continue;
await tx.vorlagenSchritt.createMany({ for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
data: abschnitt.schritte.map((schritt, schrittPosition) => ({ const angelegt = await tx.vorlagenSchritt.create({
data: {
abschnittId: zeile.id, abschnittId: zeile.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -220,11 +299,44 @@ export async function vorlageImportieren(
konfig: schritt.konfig as Prisma.InputJsonObject, konfig: schritt.konfig as Prisma.InputJsonObject,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition, position: schrittPosition,
})), },
}); });
neueKennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
schritteGesamt += abschnitt.schritte.length; schritteGesamt += abschnitt.schritte.length;
} }
/*
* Zweiter Durchgang: die Bedingungen. Ein Titel-Pfad, den es in dieser Datei
* nicht gibt, wird **uebergangen** und nicht als Fehler gemeldet — die Datei
* kann von Hand geschrieben sein, und eine Vorlage wegen eines Tippfehlers in
* einer Bedingung ganz abzuweisen waere die teurere Antwort. Uebergangen
* heisst: der Abschnitt gilt immer, und das steht sichtbar in der Oberflaeche.
*/
for (const abschnitt of austausch.struktur.abschnitte) {
const abschnittId = abschnittsZeilen.get(abschnitt.titel);
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, neueKennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = neueKennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, neueKennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId, kundeId: vorlage.kundeId,
+180 -64
View File
@@ -1,6 +1,7 @@
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client"; import { Prisma, type SchrittTyp, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums"; import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { bedingungAufloesen, bedingungLesen, schrittPfad } from "@/lib/bedingungen";
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format"; import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/** /**
@@ -82,6 +83,114 @@ export type VorlagenAenderung = {
readonly kundeId?: string | null; readonly kundeId?: string | null;
}; };
/**
* Eine tiefe Kopie der Abschnitte und Schritte einer Version.
*
* **An einer Stelle, weil es zweimal gebraucht wird** — `vorlageBearbeiten`
* macht aus der letzten veroeffentlichten Version einen Entwurf,
* `vorlageDuplizieren` eine neue Vorlage. Beide hatten die Kopie einzeln
* ausgeschrieben, und beide vergassen dabei dasselbe: `nurFuerAbteilungen`
* (seit #031) und `bedingung` (seit #039).
*
* Der Schaden war still und ernst: „Bearbeiten“ einer Vorlage verlor **alle**
* Abteilungseinschraenkungen — die neue Version galt dann fuer jeden Lauf, also
* mehr Schritte statt weniger, und niemand merkt es. Gefunden erst bei der
* Klickprobe am gebauten Stand (#039); keine Suite hat es gesehen, weil keine
* die Kopie gegen das Original verglich.
*
* **Schritt-Bedingungen werden auf die neuen Kennungen umgeschrieben.** Eine
* Kopie hat neue Kennungen; ein Verweis auf die alte waere nach dem
* Veroeffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
* stillschweigend immer. Deshalb zwei Durchgaenge, wie beim Import.
*/
async function abschnitteKopieren(
tx: Prisma.TransactionClient,
zielVersionId: string,
abschnitte: readonly {
readonly titel: string;
readonly nurFuerAbteilungen: readonly string[];
readonly bedingung: unknown;
readonly bausteinVersionId?: string | null;
readonly schritte: readonly {
readonly id: string;
readonly titel: string;
readonly hilfetext: string | null;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly konfig: unknown;
readonly dokuUrl: string | null;
readonly bedingung: unknown;
}[];
}[],
): Promise<void> {
/*
* Alte Kennung → neue. Die alten Bedingungen zeigen auf die alten Kennungen;
* aufgeloest wird im zweiten Durchgang.
*/
const neueVonAlt = new Map<string, string>();
const zeilen: { readonly id: string; readonly stelle: number }[] = [];
for (const [index, abschnitt] of abschnitte.entries()) {
const kopie = await tx.vorlagenAbschnitt.create({
data: {
versionId: zielVersionId,
titel: abschnitt.titel,
position: index,
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
...(abschnitt.bausteinVersionId === undefined
? {}
: { bausteinVersionId: abschnitt.bausteinVersionId }),
},
});
zeilen.push({ id: kopie.id, stelle: index });
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
const schrittKopie = await tx.vorlagenSchritt.create({
data: {
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
},
});
neueVonAlt.set(schritt.id, schrittKopie.id);
}
}
/** Eine Bedingung auf die neuen Kennungen umschreiben, oder weglassen. */
const umschreiben = (roh: unknown): Prisma.InputJsonObject | undefined => {
const bedingung = bedingungLesen(roh);
if (!bedingung) return undefined;
if (bedingung.art === "variable") return bedingung as unknown as Prisma.InputJsonObject;
const neueId = neueVonAlt.get(bedingung.schrittId);
if (!neueId) return undefined;
return { ...bedingung, schrittId: neueId } as unknown as Prisma.InputJsonObject;
};
for (const [index, abschnitt] of abschnitte.entries()) {
const zeile = zeilen[index];
const fuerAbschnitt = umschreiben(abschnitt.bedingung);
if (zeile && fuerAbschnitt) {
await tx.vorlagenAbschnitt.update({
where: { id: zeile.id },
data: { bedingung: fuerAbschnitt },
});
}
for (const schritt of abschnitt.schritte) {
const fuerSchritt = umschreiben(schritt.bedingung);
const neueId = neueVonAlt.get(schritt.id);
if (neueId && fuerSchritt) {
await tx.vorlagenSchritt.update({ where: { id: neueId }, data: { bedingung: fuerSchritt } });
}
}
}
}
/** /**
* Aendert die Kopfdaten einer Vorlage. * Aendert die Kopfdaten einer Vorlage.
* *
@@ -236,27 +345,27 @@ export async function entwurfHolenOderErzeugen(
} }
} }
type VersionMitInhalt = { /**
readonly version: number; * Was eine Version mitbringen muss, damit sie kopiert werden kann.
readonly variablen: readonly { *
schluessel: string; * **Aus Prisma abgeleitet und nicht von Hand geschrieben** — und das ist die
beschriftung: string; * Lehre aus zwei stillen Datenverlusten. Hier stand ein selbst getippter Typ,
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM"; * der weniger Felder nannte, als die Abfrage liefert: `nurFuerAbteilungen` (seit
optionen: string[]; * #031) und `bedingung` (seit #039) fehlten darin. Die Abfrage holte sie, der
pflicht: boolean; * Typ warf sie weg, und `abschnitteKopieren` konnte sie nicht sehen — „Bearbeiten“
}[]; * einer Vorlage verlor damit **alle** Abteilungseinschraenkungen, ohne dass
readonly abschnitte: readonly { * etwas rot wurde.
titel: string; *
schritte: readonly { * Ein abgeleiteter Typ kann das nicht: ein neues Feld am Modell ist hier
titel: string; * automatisch vorhanden, und wer es beim Kopieren vergisst, faellt im Test
hilfetext: string | null; * „die Kopie ist inhaltsgleich“ auf.
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL"; */
pflicht: boolean; type VersionMitInhalt = Prisma.VorlagenVersionGetPayload<{
konfig: unknown; include: {
dokuUrl: string | null; variablen: true;
}[]; abschnitte: { include: { schritte: true } };
}[];
}; };
}>;
async function entwurfKopieren( async function entwurfKopieren(
vorlageId: string, vorlageId: string,
@@ -289,26 +398,7 @@ async function entwurfKopieren(
}); });
} }
for (const [index, abschnitt] of letzte.abschnitte.entries()) { await abschnitteKopieren(tx, entwurf.id, letzte.abschnitte);
const kopie = await tx.vorlagenAbschnitt.create({
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: kopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Json wird als Wert kopiert, nicht als Verweis geteilt.
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
@@ -524,6 +614,23 @@ export async function entwurfSpeichern(
}); });
} }
/*
* Erst alles anlegen, dann die Bedingungen (#039).
*
* Dieser Dienst **loescht und schreibt neu** — die Schritte bekommen also
* bei jedem Speichern neue Kennungen. Eine Bedingung auf einer Kennung
* ueberlebte das nicht; in der Struktur steht deshalb ein **Titel-Pfad**,
* genau wie in einer Austauschdatei, und er wird hier aufgeloest. Dieselbe
* Funktion fuer beide Wege (`bedingungAufloesen`), sonst liefen sie
* auseinander.
*
* Zweiter Durchgang und nicht einer: eine Bedingung darf auf einen Schritt
* zeigen, der weiter unten steht — der Editor warnt davor, verbietet es aber
* nicht.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: { readonly id: string; readonly titel: string }[] = [];
for (const [index, abschnitt] of struktur.abschnitte.entries()) { for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const angelegt = await tx.vorlagenAbschnitt.create({ const angelegt = await tx.vorlagenAbschnitt.create({
data: { data: {
@@ -536,9 +643,11 @@ export async function entwurfSpeichern(
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen], nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
}, },
}); });
if (abschnitt.schritte.length > 0) { abschnittsZeilen.push({ id: angelegt.id, titel: abschnitt.titel });
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({ for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
const angelegterSchritt = await tx.vorlagenSchritt.create({
data: {
abschnittId: angelegt.id, abschnittId: angelegt.id,
titel: schritt.titel, titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null, hilfetext: schritt.hilfetext ?? null,
@@ -547,8 +656,33 @@ export async function entwurfSpeichern(
konfig: schritt.konfig as Prisma.InputJsonValue, konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl ?? null, dokuUrl: schritt.dokuUrl ?? null,
position: schrittIndex, position: schrittIndex,
})), },
}); });
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegterSchritt.id);
}
}
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
const zeile = abschnittsZeilen[index];
if (zeile && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: zeile.id },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
});
}
} }
} }
@@ -756,25 +890,7 @@ export async function vorlageDuplizieren(
})), })),
}); });
} }
for (const [index, abschnitt] of letzte.abschnitte.entries()) { await abschnitteKopieren(tx, version.id, letzte.abschnitte);
const abschnittKopie = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position: index },
});
if (abschnitt.schritte.length > 0) {
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
abschnittId: abschnittKopie.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig as Prisma.InputJsonValue,
dokuUrl: schritt.dokuUrl,
position: schrittIndex,
})),
});
}
}
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
+721
View File
@@ -0,0 +1,721 @@
import { readFileSync } from "node:fs";
import { beforeEach, describe, expect, it } from "vitest";
import { SchrittZustand, VariablenTyp, VersionStatus } from "@/generated/prisma/enums";
import { laufZumDrucken } from "@/server/queries/druck";
import { laufZumAbarbeiten } from "@/server/queries/laeufe";
import { LaufFehler, laufAbschliessen, laufAnlegen } from "@/server/services/laeufe";
import { versionMitAufbau } from "@/server/queries/vorlagen";
import {
entwurfHolenOderErzeugen,
entwurfSpeichern,
versionVeroeffentlichen,
vorlageAnlegen,
vorlageDuplizieren,
} from "@/server/services/vorlagen";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Bedingte Abschnitte und Schritte gegen die Datenbank (#039).
*
* Die reine Auswertung steht in `tests/bedingungen.test.ts`. Hier geht es um die
* Frage, die eine reine Funktion nicht beantworten kann: **greift sie überall?**
*
* Drei Schichten brauchen sie — der Dienst beim Abschluss, das Lesemodell der
* Abarbeitung und das der Druckansicht. Vergisst eine davon die Auswertung,
* sieht der Techniker andere Schritte als der Ausdruck, oder ein unsichtbarer
* Pflichtschritt hält den Abschluss auf. Beides fällt in einer Suite nicht auf,
* die nur die Logik prüft.
*/
type AbschnittsVorgabe = {
readonly titel: string;
readonly bedingung?: unknown;
readonly schritte: readonly {
readonly titel: string;
readonly pflicht?: boolean;
readonly bedingung?: unknown;
}[];
};
/**
* Legt eine Vorlage an und veröffentlicht sie.
*
* `nachtragen` wird mit den Schritt-Kennungen gerufen, **bevor** veröffentlicht
* wird — genau wie im Editor: eine Bedingung auf einem Schritt braucht dessen
* Kennung, und eine veröffentlichte Version ist unveränderlich (#009). Der
* Trigger `…_bedingung_gleiche_version` verlangt zusätzlich, dass der Schritt
* zur selben Version gehört.
*/
async function aufbau(
abschnitte: readonly AbschnittsVorgabe[],
nachtragen?: (schrittIds: ReadonlyMap<string, string>, versionId: string) => Promise<void>,
) {
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const admin = await testPrisma.benutzer.create({
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "konto_typ",
beschriftung: "Kontotyp",
typ: VariablenTyp.AUSWAHL,
optionen: ["AD", "ENTRA"],
position: 0,
},
});
const schrittIds = new Map<string, string>();
for (const [position, abschnitt] of abschnitte.entries()) {
const angelegt = await testPrisma.vorlagenAbschnitt.create({
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung as never }),
},
});
for (const [stelle, schritt] of abschnitt.schritte.entries()) {
const angelegterSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: angelegt.id,
titel: schritt.titel,
pflicht: schritt.pflicht ?? false,
position: stelle,
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung as never }),
},
});
schrittIds.set(schritt.titel, angelegterSchritt.id);
}
}
await nachtragen?.(schrittIds, version.id);
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return {
kunde,
admin: { id: admin.id, rolle: admin.rolle },
versionId: version.id,
schrittIds,
};
}
/** Der echte Fall aus dem Seed: beide Konto-Abschnitte, eine Variable entscheidet. */
const BEIDE_KONTEN: readonly AbschnittsVorgabe[] = [
{ titel: "Vorbereitung", schritte: [{ titel: "Papiere", pflicht: true }] },
{
titel: "Konto anlegen (Active Directory)",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
schritte: [
{ titel: "New-ADUser", pflicht: true },
{ titel: "Gruppen setzen", pflicht: true },
],
},
{
titel: "Konto anlegen (Entra ID)",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
schritte: [{ titel: "New-MgUser", pflicht: true }],
},
];
const titel = (abschnitte: readonly { readonly titel: string }[]): readonly string[] =>
abschnitte.map((abschnitt) => abschnitt.titel);
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit bedingten Abschnitten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("zeigt bei konto_typ = AD nur den AD-Abschnitt", async () => {
/*
* **Der Fall, für den dieser Meilenstein da ist.** Die Onboarding-Vorlage im
* Seed trägt beide Konto-Abschnitte, und `konto_typ` sagt längst, welcher
* gilt — bis hierher wertete das niemand aus, und ein Techniker klickte neun
* Schritte weg.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Active Directory)"]);
});
it("zeigt bei konto_typ = ENTRA nur den Entra-Abschnitt", async () => {
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual(["Vorbereitung", "Konto anlegen (Entra ID)"]);
});
it("zeigt bei nicht gesetzter Variable beide – und blendet nichts aus", async () => {
/*
* Eine Bedingung, die sich nicht entscheiden lässt, blendet nichts aus. Das
* ist genau das Verhalten von **vor** diesem Meilenstein, für jeden Lauf —
* eine Verschlechterung ist es also nicht. Eine erfundene Antwort wäre eine.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(titel(daten!.abschnitte)).toEqual([
"Vorbereitung",
"Konto anlegen (Active Directory)",
"Konto anlegen (Entra ID)",
]);
});
it("nummeriert die sichtbaren Abschnitte lückenlos", async () => {
/*
* `01 · Vorbereitung` zählt, was der Techniker **sieht**. Ein ausgeblendeter
* Abschnitt darf keine Lücke hinterlassen — die Nummer kommt aus der
* Position und wird nie gespeichert (CLAUDE.md, „Gestaltung“).
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "Tester");
expect(daten!.abschnitte.map((abschnitt) => abschnitt.nummer)).toEqual(["01", "02"]);
});
it("hält den Abschluss nicht an einem ausgeblendeten Pflichtschritt auf", async () => {
/*
* **Die zweite Kernzusage.** Der Entra-Abschnitt ist ausgeblendet, sein
* Schritt ist Pflicht. Blockierte er, wäre der Lauf unabschließbar: abhaken
* lässt sich nur, was in der Abarbeitung steht.
*/
const { admin, versionId, schrittIds } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
for (const name of ["Papiere", "New-ADUser", "Gruppen setzen"]) {
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schrittIds.get(name)!, zustand: SchrittZustand.ERLEDIGT },
admin,
);
}
await expect(laufAbschliessen(lauf.id, admin)).resolves.toBeDefined();
});
it("zeigt im Druck genau dieselben Abschnitte wie in der Abarbeitung", async () => {
/*
* Ein Ausdruck, der andere Schritte zeigt als der Bildschirm, ist das
* Gegenteil eines Beweismittels. Geprüft wird deshalb gegen die **andere**
* Schicht und nicht gegen eine erwartete Liste.
*/
const { admin, versionId } = await aufbau(BEIDE_KONTEN);
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, variablen: { konto_typ: "AD" } }, admin);
const abarbeitung = await laufZumAbarbeiten(lauf.id, admin, "Tester");
const druck = await laufZumDrucken(lauf.id, admin);
expect(titel(druck!.abschnitte)).toEqual(titel(abarbeitung!.abschnitte));
});
});
describe.runIf(datenbankVerfuegbar)("Eine Bedingung auf einem Schrittergebnis", () => {
beforeEach(async () => {
await datenbankLeeren();
});
/**
* Setzt am Abschnitt „Nacharbeit“ die Bedingung „Prüfen ist fehlgeschlagen“.
*
* Als Nachtrag im **Entwurf**, weil die Bedingung die Kennung des Schritts
* braucht und der erst beim Anlegen entsteht. Danach wird veröffentlicht, und
* danach ändert sich an der Version nichts mehr (#009).
*/
const nacharbeitWennFehlgeschlagen = async (
schrittIds: ReadonlyMap<string, string>,
versionId: string,
): Promise<void> => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, titel: "Nacharbeit" },
data: {
bedingung: {
art: "schritt",
schrittId: schrittIds.get("Prüfen")!,
zustaende: ["FEHLGESCHLAGEN"],
},
},
});
};
it("erscheint erst, wenn die Bedingung während des Laufs umschlägt", async () => {
/*
* **Der Grund, warum laufend ausgewertet wird und nicht beim Start.** Die
* Antwort auf „ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit;
* festgeschrieben wie bei #031 wäre sie beim Start immer „nein“.
*/
// Erst anlegen, um die Schritt-Kennung zu bekommen, dann die Bedingung setzen:
// der Trigger verlangt einen Schritt derselben Version.
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
// Zu Beginn ist „Prüfen“ OFFEN. Das ist eine **Antwort**, nicht eine Lücke:
// der Abschnitt gilt nicht.
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: pruefenId,
zustand: SchrittZustand.FEHLGESCHLAGEN,
notiz: "Sicherung nicht lesbar",
},
admin,
);
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual([
"Prüfung",
"Nacharbeit",
]);
});
it("lässt ein Schrittergebnis mit Notiz stehen, wenn seine Bedingung umschlägt", async () => {
/*
* Ausgeblendet heißt **nicht** gelöscht. Jemand hat eine Notiz geschrieben;
* sie zu verlieren, weil eine Bedingung kippt, wäre Datenverlust durch eine
* Anzeigeregel. Schlägt die Bedingung zurück, ist alles wieder da.
*/
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen" }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const ticketId = schrittIds.get("Ticket aufmachen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
admin,
);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: ticketId, zustand: SchrittZustand.ERLEDIGT, notiz: "TIC-4711" },
admin,
);
// Bedingung kippt: der Abschnitt verschwindet aus der Ansicht …
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.ERLEDIGT },
admin,
);
expect(titel((await laufZumAbarbeiten(lauf.id, admin, "T"))!.abschnitte)).toEqual(["Prüfung"]);
// … die Zeile bleibt aber in der Datenbank.
const zeile = await testPrisma.schrittergebnis.findFirstOrThrow({
where: { laufId: lauf.id, vorlagenSchrittId: ticketId },
});
expect(zeile.notiz).toBe("TIC-4711");
// Und schlägt sie zurück, ist alles wieder da.
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "y" },
admin,
);
const wieder = await laufZumAbarbeiten(lauf.id, admin, "T");
expect(titel(wieder!.abschnitte)).toEqual(["Prüfung", "Nacharbeit"]);
expect(wieder!.abschnitte[1]?.schritte[0]?.notiz).toBe("TIC-4711");
});
it("hält den Abschluss auf, solange ein sichtbarer Pflichtschritt offen ist", async () => {
// Die Gegenrichtung, und sie ist nötig: eine Abschlussregel, die **immer**
// durchlässt, sähe in den Tests oben genauso aus.
const { admin, versionId, schrittIds } = await aufbau(
[
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{ titel: "Nacharbeit", schritte: [{ titel: "Ticket aufmachen", pflicht: true }] },
],
nacharbeitWennFehlgeschlagen,
);
const pruefenId = schrittIds.get("Prüfen")!;
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: pruefenId, zustand: SchrittZustand.FEHLGESCHLAGEN, notiz: "x" },
admin,
);
// „Ticket aufmachen“ ist jetzt sichtbar und Pflicht – der Abschluss wartet.
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(LaufFehler);
await expect(laufAbschliessen(lauf.id, admin)).rejects.toThrow(/Ticket aufmachen/u);
});
});
describe.runIf(datenbankVerfuegbar)("Eine Bedingung an einem einzelnen Schritt", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("blendet den Schritt aus, nicht den ganzen Abschnitt", async () => {
const { admin, versionId } = await aufbau([
{
titel: "Konto",
schritte: [
{ titel: "Immer" },
{
titel: "Nur bei AD",
pflicht: true,
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
]);
const lauf = await laufAnlegen(
{ vorlagenVersionId: versionId, variablen: { konto_typ: "ENTRA" } },
admin,
);
const daten = await laufZumAbarbeiten(lauf.id, admin, "T");
expect(titel(daten!.abschnitte)).toEqual(["Konto"]);
expect(daten!.abschnitte[0]?.schritte.map((schritt) => schritt.titel)).toEqual(["Immer"]);
// Und der ausgeblendete Pflichtschritt hält den Abschluss nicht auf.
expect(daten!.anzahlPflichtOffen).toBe(0);
});
});
describe.runIf(datenbankVerfuegbar)("Der Weg durch den Editor", () => {
beforeEach(async () => {
await datenbankLeeren();
});
it("übersteht Speichern, Neuladen und Veröffentlichen", async () => {
/*
* **Der Weg, auf dem eine Bedingung verloren gehen würde.**
* `entwurfSpeichern` löscht den Entwurf und schreibt ihn neu — die Schritte
* bekommen dabei neue Kennungen. Eine Bedingung, die auf eine Kennung zeigt,
* überlebt das nicht; in der Struktur steht deshalb ein **Titel-Pfad**, so
* wie in einer Austauschdatei, und derselbe Code löst beide auf.
*
* Dreimal gespeichert, weil einmal nichts beweist: der Pfad muss **jedes**
* Neuschreiben überstehen.
*/
const kunde = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const handelnder = { id: redakteur.id, rolle: redakteur.rolle };
const { vorlage } = await vorlageAnlegen({ name: "Mit Bedingung" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
const struktur = {
variablen: [
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
],
abschnitte: [
{ titel: "Prüfung", schritte: [{ titel: "Prüfen", pflicht: true }] },
{
titel: "Nacharbeit",
bedingung: {
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
},
schritte: [
{
titel: "Nur bei AD",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
],
} as const;
for (let runde = 1; runde <= 3; runde += 1) {
await entwurfSpeichern(entwurf.id, struktur as never);
// Zurückgelesen steht wieder der Pfad da, nicht die Kennung.
const gelesen = await versionMitAufbau(entwurf.id, handelnder);
expect(gelesen!.struktur.abschnitte[1]?.bedingung, `Runde ${runde}`).toEqual({
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
});
expect(gelesen!.struktur.abschnitte[1]?.schritte[0]?.bedingung).toEqual({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert: "AD",
});
// Und in der Datenbank steht die **aktuelle** Kennung.
const zeilen = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId: entwurf.id },
orderBy: { position: "asc" },
include: { schritte: true },
});
expect(zeilen[1]?.bedingung).toMatchObject({
art: "schritt",
schrittId: zeilen[0]!.schritte[0]!.id,
});
}
// Veröffentlichen lässt die Bedingung stehen – und ein Lauf wertet sie aus.
await versionVeroeffentlichen(entwurf.id, null, redakteur.id);
const lauf = await laufAnlegen(
{ vorlagenVersionId: entwurf.id, kundeId: kunde.id, variablen: { konto_typ: "AD" } },
handelnder,
);
expect(titel((await laufZumAbarbeiten(lauf.id, handelnder, "T"))!.abschnitte)).toEqual(["Prüfung"]);
});
it("lässt eine Bedingung weg, deren Titel-Pfad es nicht gibt", async () => {
/*
* Ein Abschnitt, dessen Zielschritt gelöscht wurde: beim nächsten Speichern
* findet der Pfad nichts. Weggelassen heißt „gilt immer“ — sichtbar in der
* Oberfläche. Eine Bedingung mit einem Verweis ins Leere wäre dasselbe
* Ergebnis, nur unerklärlich.
*/
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const { vorlage } = await vorlageAnlegen({ name: "Verwaister Verweis" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
await entwurfSpeichern(entwurf.id, {
variablen: [],
abschnitte: [
{ titel: "Eins", schritte: [{ titel: "A" }] },
{
titel: "Zwei",
bedingung: {
art: "schritt",
abschnitt: "Gibt es nicht",
schritt: "Auch nicht",
zustaende: ["ERLEDIGT"],
},
schritte: [{ titel: "B" }],
},
],
} as never);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId: entwurf.id, titel: "Zwei" },
});
expect(zeile.bedingung).toBeNull();
});
});
describe.runIf(datenbankVerfuegbar)("Die tiefe Kopie", () => {
beforeEach(async () => {
await datenbankLeeren();
});
/**
* Legt eine veröffentlichte Version mit **allen** Struktureigenschaften an.
*
* Absichtlich vollgepackt: der Test darunter vergleicht Kopie und Original
* Feld für Feld, und was hier fehlt, prüft er nicht.
*/
async function reicheVersion() {
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global" } });
const redakteur = await testPrisma.benutzer.create({
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
});
const { vorlage } = await vorlageAnlegen({ name: "Vollgepackt" }, redakteur.id);
const entwurf = await entwurfHolenOderErzeugen(vorlage.id, redakteur.id);
await entwurfSpeichern(entwurf.id, {
variablen: [
{ schluessel: "konto_typ", beschriftung: "Kontotyp", typ: "AUSWAHL", optionen: ["AD", "ENTRA"] },
],
abschnitte: [
{
titel: "Prüfung",
nurFuerAbteilungen: ["Buchhaltung"],
schritte: [
{
titel: "Prüfen",
pflicht: true,
hilfetext: "Mit Blick aufs Protokoll.",
dokuUrl: "https://intern.example/pruefen",
typ: "ZAHL",
konfig: { min: 1, max: 9, einheit: "GB" },
},
],
},
{
titel: "Nacharbeit",
nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"],
bedingung: {
art: "schritt",
abschnitt: "Prüfung",
schritt: "Prüfen",
zustaende: ["FEHLGESCHLAGEN"],
},
schritte: [
{
titel: "Nur bei AD",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" },
},
],
},
],
} as never);
await versionVeroeffentlichen(entwurf.id, "Erste", redakteur.id);
return { vorlageId: vorlage.id, versionId: entwurf.id, redakteurId: redakteur.id };
}
/** Die Struktur einer Version, ohne alles, was sich bei einer Kopie ändern darf. */
async function struktur(versionId: string) {
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
// Kennungen wandern bei einer Kopie – verglichen wird der Inhalt.
const ohneKennung = (bedingung: unknown): unknown => {
if (typeof bedingung !== "object" || bedingung === null) return bedingung;
const kopie = { ...(bedingung as Record<string, unknown>) };
if ("schrittId" in kopie) kopie["schrittId"] = "<verweist>";
return kopie;
};
return abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
bedingung: ohneKennung(abschnitt.bedingung),
schritte: abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
hilfetext: schritt.hilfetext,
typ: schritt.typ,
pflicht: schritt.pflicht,
konfig: schritt.konfig,
dokuUrl: schritt.dokuUrl,
bedingung: ohneKennung(schritt.bedingung),
})),
}));
}
it("ist bei „Bearbeiten“ inhaltsgleich – mit allem", async () => {
/*
* **Der Test, der zwei stille Datenverluste gefunden hätte.** Die tiefe
* Kopie nahm `nurFuerAbteilungen` (seit #031) und `bedingung` (seit #039)
* nicht mit: „Bearbeiten“ verlor alle Abteilungseinschränkungen, die neue
* Version galt für jeden Lauf. Also mehr Schritte statt weniger, und niemand
* merkt es.
*
* Die Ursache war ein von Hand getippter Zwischentyp, der weniger Felder
* nannte als die Abfrage liefert. Gefunden erst bei der Klickprobe am
* gebauten Stand.
*
* Verglichen wird deshalb **Feld für Feld und nicht stichprobenweise**: so
* fällt auch das nächste Feld auf, das jemand am Modell ergänzt und beim
* Kopieren vergisst.
*/
const { vorlageId, versionId, redakteurId } = await reicheVersion();
const vorher = await struktur(versionId);
// „Bearbeiten“ in der Oberfläche ruft `entwurfHolenOderErzeugen`: das ist
// die tiefe Kopie der letzten veröffentlichten Version (#009).
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
expect(await struktur(entwurf.id)).toEqual(vorher);
});
it("ist beim Duplizieren inhaltsgleich – mit allem", async () => {
// Dieselbe Prüfung für den zweiten Weg: er hatte denselben Fehler, weil er
// dieselbe Kopie ausgeschrieben hatte. Jetzt teilen beide eine Stelle.
const { vorlageId, versionId, redakteurId } = await reicheVersion();
const vorher = await struktur(versionId);
const kopie = await vorlageDuplizieren(vorlageId, "Vollgepackt (Kopie)", redakteurId);
const neueVersion = await testPrisma.vorlagenVersion.findFirstOrThrow({
where: { vorlageId: kopie.id },
});
expect(await struktur(neueVersion.id)).toEqual(vorher);
});
it("schreibt die Schritt-Bedingung auf die neue Kennung um", async () => {
/*
* Die Kennungen sind neu; ein Verweis auf die alte wäre nach dem
* Veröffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
* stillschweigend immer. Der Trigger in der Datenbank verlangt dasselbe.
*/
const { vorlageId, redakteurId } = await reicheVersion();
const entwurf = await entwurfHolenOderErzeugen(vorlageId, redakteurId);
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId: entwurf.id },
orderBy: { position: "asc" },
include: { schritte: true },
});
expect(abschnitte[1]?.bedingung).toMatchObject({
art: "schritt",
// Die Kennung des Schritts der **neuen** Version.
schrittId: abschnitte[0]!.schritte[0]!.id,
});
});
});
describe("Die Auswertung sitzt an jeder Stelle, die filtert", () => {
it("ruft geltendeStruktur überall, wo nurGeltende gerufen wird", () => {
/*
* **Strukturell geprüft, weil eine vergessene Stelle nicht auffällt.** Drei
* Schichten dampfen die Struktur eines Laufs ein: der Dienst beim Abschluss,
* die Abarbeitung und die Druckansicht. Vergisst eine davon die Bedingungen,
* sieht der Techniker andere Schritte als der Ausdruck — oder ein
* unsichtbarer Pflichtschritt hält den Abschluss auf.
*
* Dieselbe Art Test wie „jede Seite prüft selbst“ (#038): die Zusage steht
* nicht in einer Prüfung, sondern in der Vollständigkeit einer Liste, und die
* muss jemand bewachen.
*/
const dateien = [
"src/server/services/laeufe.ts",
"src/server/queries/laeufe.ts",
"src/server/queries/druck.ts",
];
const befunde: string[] = [];
for (const datei of dateien) {
const quelle = readFileSync(datei, "utf8");
if (!quelle.includes("nurGeltende(")) {
befunde.push(`${datei} ruft nurGeltende nicht mehr – ist die Liste noch richtig?`);
continue;
}
if (!quelle.includes("geltendeStruktur(")) {
befunde.push(`${datei} filtert nach #031, wertet aber keine Bedingungen aus (#039).`);
}
}
expect(befunde).toEqual([]);
});
});
+335
View File
@@ -0,0 +1,335 @@
import { describe, expect, it } from "vitest";
import {
BedingungSchema,
type Bedingung,
bedingungErfuellt,
bedingungLesen,
bedingungText,
geltendeTeile,
umgebungBauen,
zeigtNachVorne,
} from "@/lib/bedingungen";
/**
* Bedingte Abschnitte und Schritte: die reine Logik (#039).
*
* Ohne Datenbank, weil hier alles entschieden wird, was zählt — und weil die
* Sonderfälle nur so vollständig durchgehen. Dass die Auswertung auch im Dienst,
* in der Abarbeitung und im Druck greift, steht in `tests/bedingte-laeufe.test.ts`.
*
* **Die wichtigste Zusage dieses Meilensteins:** eine Bedingung, die sich nicht
* entscheiden lässt, blendet **nichts** aus. Ein Abschnitt zu viel ist sichtbar
* und korrigierbar; ein fehlender fällt niemandem auf, und das ist bei einer
* Checkliste der teure Fall.
*/
const leer = umgebungBauen({}, []);
describe("Die Form einer Bedingung", () => {
it("nimmt die beiden Arten an", () => {
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" })
.success,
).toBe(true);
expect(
BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }).success,
).toBe(true);
});
it("verlangt bei „ist“ einen Wert", () => {
/*
* Ohne Wert wäre „gilt, wenn konto_typ gleich “ ist“ eine Bedingung, die
* niemand lesen kann — und sie träfe auf einen leeren Wert zu, also genau
* dann, wenn nichts angegeben wurde. Das ist nicht gemeint.
*/
for (const operator of ["ist", "istNicht"]) {
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator }).success,
operator,
).toBe(false);
expect(
BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator, wert: " " }).success,
`${operator} mit Leerraum`,
).toBe(false);
}
// `gesetzt` und `leer` brauchen keinen.
expect(BedingungSchema.safeParse({ art: "variable", schluessel: "k", operator: "gesetzt" }).success).toBe(
true,
);
});
it("verlangt mindestens einen Zustand", () => {
// Eine leere Liste wäre eine Bedingung, die nie zutrifft — also ein
// Abschnitt, der für immer unsichtbar bleibt.
expect(BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: [] }).success).toBe(false);
});
it("weist eine unbekannte Art und einen unbekannten Zustand ab", () => {
expect(BedingungSchema.safeParse({ art: "geraet", typ: "PC" }).success).toBe(false);
expect(
BedingungSchema.safeParse({ art: "schritt", schrittId: "s1", zustaende: ["VIELLEICHT"] }).success,
).toBe(false);
});
it("liest beim Lesen nachsichtig: was nicht passt, ist keine Bedingung", () => {
/*
* Hier zu werfen hieße, dass eine einzige unlesbare Zeile eine ganze Vorlage
* unbenutzbar macht. Die Richtung, in die der Fehler dann fällt, ist die
* harmlose: ohne Bedingung wird angezeigt.
*/
expect(bedingungLesen(null)).toBeNull();
expect(bedingungLesen(undefined)).toBeNull();
expect(bedingungLesen({})).toBeNull();
expect(bedingungLesen("Unsinn")).toBeNull();
expect(bedingungLesen({ art: "variable" })).toBeNull();
expect(bedingungLesen({ art: "variable", schluessel: "k", operator: "ist", wert: "AD" })).toEqual({
art: "variable",
schluessel: "k",
operator: "ist",
wert: "AD",
});
});
});
describe("Eine Bedingung auf einer Variable", () => {
const umgebung = umgebungBauen({ konto_typ: "AD", standort: "", anzahl: 3, aktiv: true }, []);
it("vergleicht ohne Rücksicht auf Groß- und Kleinschreibung und Ränder", () => {
// Die Werte kommen aus einem Auswahlfeld oder einem Formular; „AD“ ist
// derselbe Wert wie „ad“ und wie „ AD “.
const bedingung = (wert: string): Bedingung => ({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert,
});
expect(bedingungErfuellt(bedingung("AD"), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung("ad"), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung(" AD "), umgebung)).toBe(true);
expect(bedingungErfuellt(bedingung("ENTRA"), umgebung)).toBe(false);
});
it("liest Zahlen und Wahrheitswerte als Text", () => {
// `Lauf.variablen` ist ein Json; eine ZAHL-Variable kommt als Zahl zurück.
expect(
bedingungErfuellt({ art: "variable", schluessel: "anzahl", operator: "ist", wert: "3" }, umgebung),
).toBe(true);
expect(
bedingungErfuellt({ art: "variable", schluessel: "aktiv", operator: "ist", wert: "true" }, umgebung),
).toBe(true);
});
it("unterscheidet gesetzt von leer", () => {
expect(
bedingungErfuellt({ art: "variable", schluessel: "konto_typ", operator: "gesetzt" }, umgebung),
).toBe(true);
// Ein leerer Text zählt als nicht gesetzt — so kommt ein unausgefülltes
// Formularfeld an.
expect(
bedingungErfuellt({ art: "variable", schluessel: "standort", operator: "gesetzt" }, umgebung),
).toBe(false);
expect(bedingungErfuellt({ art: "variable", schluessel: "standort", operator: "leer" }, umgebung)).toBe(
true,
);
expect(bedingungErfuellt({ art: "variable", schluessel: "gibtsnicht", operator: "leer" }, umgebung)).toBe(
true,
);
});
it("ist bei einer nicht gesetzten Variable nicht entscheidbar", () => {
/*
* **Die Zusage dieses Meilensteins.** Ein Lauf, bei dem niemand `konto_typ`
* angegeben hat, soll beide Konto-Abschnitte zeigen: auszublenden hieße,
* eine Antwort zu erfinden, die niemand gegeben hat.
*
* `undefined` und nicht `false` — der Unterschied entscheidet in
* `geltendeTeile` darüber, ob ausgeblendet wird.
*/
expect(
bedingungErfuellt({ art: "variable", schluessel: "gibtsnicht", operator: "ist", wert: "AD" }, leer),
).toBeUndefined();
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "standort", operator: "istNicht", wert: "Nord" },
umgebung,
),
).toBeUndefined();
});
it("kehrt istNicht wirklich um", () => {
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "konto_typ", operator: "istNicht", wert: "ENTRA" },
umgebung,
),
).toBe(true);
expect(
bedingungErfuellt(
{ art: "variable", schluessel: "konto_typ", operator: "istNicht", wert: "AD" },
umgebung,
),
).toBe(false);
});
});
describe("Eine Bedingung auf einem Schritt", () => {
const umgebung = umgebungBauen({}, [
{ vorlagenSchrittId: "s1", zustand: "ERLEDIGT" },
{ vorlagenSchrittId: "s2", zustand: "OFFEN" },
{ vorlagenSchrittId: "s3", zustand: "NICHT_ZUTREFFEND" },
]);
it("trifft zu, wenn der Zustand in der Liste steht", () => {
expect(bedingungErfuellt({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
true,
);
expect(
bedingungErfuellt(
{ art: "schritt", schrittId: "s3", zustaende: ["ERLEDIGT", "NICHT_ZUTREFFEND"] },
umgebung,
),
).toBe(true);
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
false,
);
});
it("liest OFFEN als Antwort und nicht als Lücke", () => {
/*
* **Der Unterschied zur Variable**, und er ist wichtig: jedes
* Schrittergebnis hat einen Zustand. `OFFEN` heißt „noch nicht erfüllt“ und
* nicht „weiß nicht“. Ein Abschnitt, der erst nach einem erledigten Schritt
* erscheint, ist der Sinn der Sache — wäre `OFFEN` nicht entscheidbar, wäre
* er von Anfang an sichtbar.
*/
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["ERLEDIGT"] }, umgebung)).toBe(
false,
);
expect(bedingungErfuellt({ art: "schritt", schrittId: "s2", zustaende: ["OFFEN"] }, umgebung)).toBe(true);
});
it("ist bei einem Schritt, den es in diesem Lauf nicht gibt, nicht entscheidbar", () => {
// Etwa ein Schritt, den #031 ausgefiltert hat, oder ein Verweis aus einer
// anderen Version. Dann gibt es nichts zu entscheiden – und nichts
// auszublenden.
expect(
bedingungErfuellt({ art: "schritt", schrittId: "gibtsnicht", zustaende: ["ERLEDIGT"] }, umgebung),
).toBeUndefined();
});
});
describe("Welche Teile gelten", () => {
it("nimmt Teile ohne Bedingung immer", () => {
expect(
geltendeTeile(
[
{ id: "a", bedingung: null },
{ id: "b", bedingung: {} },
],
leer,
),
).toEqual(["a", "b"]);
});
it("blendet aus, was nachweislich nicht zutrifft", () => {
const umgebung = umgebungBauen({ konto_typ: "AD" }, []);
const teile = [
{ id: "ad", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" } },
{
id: "entra",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
},
{ id: "immer", bedingung: null },
];
expect(geltendeTeile(teile, umgebung)).toEqual(["ad", "immer"]);
});
it("blendet bei nicht entscheidbarer Bedingung nichts aus", () => {
/*
* Der echte Fall: ein Lauf ohne `konto_typ`. Beide Konto-Abschnitte
* erscheinen, und jemand setzt den falschen auf NICHT_ZUTREFFEND — genau so,
* wie es vor diesem Meilenstein für **jeden** Lauf war. Eine Verschlechterung
* ist das also nicht; eine erfundene Antwort wäre eine.
*/
const teile = [
{ id: "ad", bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" } },
{
id: "entra",
bedingung: { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "ENTRA" },
},
];
expect(geltendeTeile(teile, leer)).toEqual(["ad", "entra"]);
});
it("behält die Reihenfolge der Eingabe", () => {
// Die Reihenfolge ist die der Version; sie darf hier nicht durcheinander
// geraten, sonst stimmt die Abschnittsnummerierung nicht.
const teile = [
{ id: "eins", bedingung: null },
{ id: "zwei", bedingung: { art: "variable", schluessel: "x", operator: "gesetzt" } },
{ id: "drei", bedingung: null },
];
expect(geltendeTeile(teile, umgebungBauen({ x: "ja" }, []))).toEqual(["eins", "zwei", "drei"]);
});
});
describe("Hilfen für den Editor", () => {
const reihenfolge = ["s1", "s2", "s3", "s4"];
it("erkennt eine Bedingung, die nach vorne zeigt", () => {
/*
* Fast immer ein Versehen: der bedingte Abschnitt wäre zunächst unsichtbar
* und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also
* rückwärts.
*/
expect(
zeigtNachVorne({ art: "schritt", schrittId: "s4", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBe(true);
expect(
zeigtNachVorne({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBe(false);
});
it("warnt nicht, wo es nichts zu warnen gibt", () => {
expect(
zeigtNachVorne({ art: "variable", schluessel: "k", operator: "gesetzt" }, reihenfolge, "s2"),
).toBeUndefined();
expect(
zeigtNachVorne({ art: "schritt", schrittId: "fremd", zustaende: ["ERLEDIGT"] }, reihenfolge, "s2"),
).toBeUndefined();
});
it("schreibt eine Bedingung als Satz", () => {
const namen = (kennung: string) =>
({ konto_typ: "Kontotyp", s1: "Benutzer anlegen" })[kennung] ?? kennung;
expect(
bedingungText({ art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" }, namen),
).toBe("gilt, wenn „Kontotyp“ gleich „AD“ ist");
expect(bedingungText({ art: "variable", schluessel: "konto_typ", operator: "leer" }, namen)).toBe(
"gilt, wenn „Kontotyp“ leer ist",
);
expect(
bedingungText({ art: "schritt", schrittId: "s1", zustaende: ["ERLEDIGT", "NICHT_ZUTREFFEND"] }, namen),
).toBe("gilt, wenn „Benutzer anlegen“ erledigt oder nicht zutreffend ist");
});
});
describe("Die Umgebung", () => {
it("verträgt ein Json, das kein Objekt ist", () => {
// `Lauf.variablen` ist ein Json-Feld und kann alles enthalten.
for (const roh of [null, undefined, "Text", 42, ["a"]]) {
expect(umgebungBauen(roh, []).variablen).toEqual({});
}
});
it("übergeht einen Zustand, den das Enum nicht kennt", () => {
// Eine Zeile aus einer späteren Fassung soll die Auswertung nicht anhalten.
const umgebung = umgebungBauen({}, [
{ vorlagenSchrittId: "s1", zustand: "ERLEDIGT" },
{ vorlagenSchrittId: "s2", zustand: "WARTET_AUF_AKTION" },
]);
expect(umgebung.zustaende.get("s1")).toBe("ERLEDIGT");
expect(umgebung.zustaende.has("s2")).toBe(false);
});
});
+17
View File
@@ -70,7 +70,17 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
*/ */
"Mitarbeiter_konto_mitziehen", "Mitarbeiter_konto_mitziehen",
"VorlagenAbschnitt_baustein_veroeffentlicht", "VorlagenAbschnitt_baustein_veroeffentlicht",
/*
* Eine Schritt-Bedingung zeigt auf einen Schritt **derselben Version**
* (#039). Ein CHECK kann das nicht: es verlangt einen Blick in zwei andere
* Tabellen. Ohne diese Pruefung zeigt eine Bedingung nach dem
* Veroeffentlichen ins Leere — „Bearbeiten“ erzeugt eine tiefe Kopie mit
* neuen Kennungen —, und der Abschnitt erscheint dann stillschweigend
* immer.
*/
"VorlagenAbschnitt_bedingung_gleiche_version",
"VorlagenAbschnitt_unveraenderlich", "VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_bedingung_gleiche_version",
"VorlagenSchritt_unveraenderlich", "VorlagenSchritt_unveraenderlich",
"VorlagenVariable_unveraenderlich", "VorlagenVariable_unveraenderlich",
"VorlagenVersion_status_einbahn", "VorlagenVersion_status_einbahn",
@@ -159,6 +169,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Verzeichnisquelle_scimtoken_gehasht", "Verzeichnisquelle_scimtoken_gehasht",
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen. // Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
"Verzeichnisquelle_scimtoken_mit_zeitpunkt", "Verzeichnisquelle_scimtoken_mit_zeitpunkt",
/*
* Die Form einer Bedingung (#039). Eine mit unbekannter `art` wuerde beim
* Lesen als „keine Bedingung“ gelten — der Abschnitt waere sichtbar, und
* niemand koennte sagen, warum seine Bedingung nicht wirkt.
*/
"VorlagenAbschnitt_bedingung_form",
"VorlagenSchritt_bedingung_form",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt", "VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
"Zustellung_bezug_passt_zum_anlass", "Zustellung_bezug_passt_zum_anlass",
"Zustellung_empfaenger_nicht_leer", "Zustellung_empfaenger_nicht_leer",
+40 -6
View File
@@ -150,13 +150,47 @@ describe("Die sieben Vorlagen", () => {
expect(gruppen).toContain('-Members "{{sam}}"'); expect(gruppen).toContain('-Members "{{sam}}"');
}); });
it("enthaelt beide Konto-Abschnitte des Onboardings", () => { it("enthaelt beide Konto-Abschnitte des Onboardings – jeden mit seiner Bedingung", () => {
// Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht /*
// zutreffende als Ganzes auf „nicht zutreffend“ gesetzt. * **Der Fall, für den #039 gebaut wurde.** Beide Abschnitte bleiben in der
* Vorlage — sie schließen sich über `konto_typ` gegenseitig aus. Bis Phase 3
* standen sie ohne Bedingung drin, und ein Techniker setzte den nicht
* zutreffenden von Hand auf „nicht zutreffend“: neun Schritte bei jedem
* Onboarding.
*
* Geprüft wird hier die **Vorlage**; dass die Auswertung greift, steht in
* `tests/bedingte-laeufe.test.ts`.
*/
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding"); const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
const titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel); const abschnitte = onboarding?.dokument.struktur.abschnitte ?? [];
expect(titel).toContain("Konto anlegen (Active Directory)"); const nach = (titel: string) => abschnitte.find((abschnitt) => abschnitt.titel === titel);
expect(titel).toContain("Konto anlegen (Entra ID)");
const optionen =
onboarding?.dokument.struktur.variablen.find((variable) => variable.schluessel === "konto_typ")
?.optionen ?? [];
for (const [titel, wert] of [
["Konto anlegen (Active Directory)", "Active Directory"],
["Konto anlegen (Entra ID)", "Entra ID"],
] as const) {
const abschnitt = nach(titel);
expect(abschnitt, titel).toBeDefined();
expect(abschnitt?.bedingung, titel).toEqual({
art: "variable",
schluessel: "konto_typ",
operator: "ist",
wert,
});
/*
* **Der Wert muss eine der Optionen sein.** Ein Tippfehler hier wäre
* stillschweigend: die Bedingung träfe nie zu, der Abschnitt erschiene
* nie, und die Hälfte der Checkliste fehlte — ohne Fehlermeldung.
*/
expect(optionen, `${titel}: „${wert}“ ist keine Option von konto_typ`).toContain(wert);
}
// Und der gemeinsame Abschnitt hat keine: er gilt immer.
expect(nach("Konto, gemeinsam")?.bedingung).toBeUndefined();
}); });
it("speichert nirgends ein Passwort im Klartext", () => { it("speichert nirgends ein Passwort im Klartext", () => {
+156 -1
View File
@@ -34,7 +34,15 @@ async function redakteurAnlegen() {
* der Export, dass er nach `position` sortiert und nicht danach, wie die * der Export, dass er nach `position` sortiert und nicht danach, wie die
* Datenbank die Zeilen gerade zurueckgibt. * Datenbank die Zeilen gerade zurueckgibt.
*/ */
async function beispielVersionAnlegen(): Promise<string> { /**
* Legt die Beispielvorlage an und veröffentlicht sie.
*
* `nachtragen` wird **vor** dem Veröffentlichen gerufen: an einer
* veröffentlichten Version lässt sich nichts mehr ändern, dafür sorgen Trigger
* in der Datenbank (#009). Wer eine Abteilungseinschränkung oder eine Bedingung
* braucht, setzt sie dort — genau wie im Editor.
*/
async function beispielVersionAnlegen(nachtragen?: (versionId: string) => Promise<void>): Promise<string> {
const vorlage = await testPrisma.vorlage.create({ const vorlage = await testPrisma.vorlage.create({
data: { data: {
name: "Onboarding Notebook", name: "Onboarding Notebook",
@@ -128,6 +136,8 @@ async function beispielVersionAnlegen(): Promise<string> {
], ],
}); });
await nachtragen?.(version.id);
await testPrisma.vorlagenVersion.update({ await testPrisma.vorlagenVersion.update({
where: { id: version.id }, where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
@@ -136,6 +146,15 @@ async function beispielVersionAnlegen(): Promise<string> {
return version.id; return version.id;
} }
/** Die Abschnitte einer Version samt Schritten, in Reihenfolge. */
async function abschnitteVon(versionId: string) {
return testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
}
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */ /** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> { function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
return { return {
@@ -503,6 +522,142 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] }); expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
}); });
it("bewahrt die Abteilungseinschränkung eines Abschnitts", async () => {
/*
* **Das ging stillschweigend verloren.** Der Export schreibt
* `nurFuerAbteilungen` seit #031, das Schema nimmt es an — und der Import
* verwarf es. Eine Vorlage, die jemand in eine zweite Installation
* brachte, verlor ihre Abteilungsabschnitte: sie galten dort für **alle**.
* Also mehr Schritte statt weniger, und niemand merkt es. Gefunden bei
* #039, als derselbe Weg für Bedingungen gebaut wurde.
*/
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen(async (versionId) => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, position: 0 },
data: { nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
});
});
const erst = await vorlageExportieren(quelle);
expect(erst.struktur.abschnitte[0]?.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 0 },
});
expect(zeile.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
});
it("schreibt eine Schritt-Bedingung als Titel-Pfad und löst sie wieder auf", async () => {
/*
* Eine Bedingung zeigt gespeichert auf eine **Kennung**, und die bedeutet
* in einer anderen Installation nichts. Im Austausch steht deshalb ein
* Titel-Pfad; beim Import wird daraus die neue Kennung — sonst wäre die
* Bedingung dort nicht entscheidbar, und der Abschnitt erschiene immer,
* ohne dass jemand sagen könnte, warum (#039).
*/
const redakteur = await redakteurAnlegen();
let abschnitte: Awaited<ReturnType<typeof abschnitteVon>> = [];
const quelle = await beispielVersionAnlegen(async (versionId) => {
abschnitte = await abschnitteVon(versionId);
await testPrisma.vorlagenAbschnitt.update({
where: { id: abschnitte[1]!.id },
data: {
bedingung: {
art: "schritt",
schrittId: abschnitte[0]!.schritte[0]!.id,
zustaende: ["FEHLGESCHLAGEN"],
},
},
});
});
const ziel = abschnitte[0]!.schritte[0]!;
// Im Export steht der Pfad, nicht die Kennung.
const erst = await vorlageExportieren(quelle);
const ausgetauscht = erst.struktur.abschnitte[1]?.bedingung;
expect(ausgetauscht).toEqual({
art: "schritt",
abschnitt: abschnitte[0]!.titel,
schritt: ziel.titel,
zustaende: ["FEHLGESCHLAGEN"],
});
expect(JSON.stringify(erst)).not.toContain(ziel.id);
// Und beim Import wird daraus die neue Kennung.
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const neu = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
expect(neu[1]?.bedingung).toEqual({
art: "schritt",
schrittId: neu[0]!.schritte[0]!.id,
zustaende: ["FEHLGESCHLAGEN"],
});
});
it("übergeht eine Bedingung, deren Titel-Pfad in der Datei fehlt", async () => {
/*
* Eine von Hand geschriebene Datei kann sich vertippen. Die ganze Vorlage
* deswegen abzuweisen wäre die teurere Antwort — übergangen heißt: der
* Abschnitt gilt immer, und das steht sichtbar in der Oberfläche.
*/
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const erst = await vorlageExportieren(quelle);
const { versionId } = await vorlageImportieren(
{
...erst,
name: "Mit Tippfehler",
struktur: {
...erst.struktur,
abschnitte: erst.struktur.abschnitte.map((abschnitt, stelle) =>
stelle === 1
? {
...abschnitt,
bedingung: {
art: "schritt",
abschnitt: "Gibt es nicht",
schritt: "Auch nicht",
zustaende: ["ERLEDIGT"],
},
}
: abschnitt,
),
},
},
redakteur.id,
);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 1 },
});
expect(zeile.bedingung).toBeNull();
});
it("bewahrt eine Variablen-Bedingung unverändert", async () => {
const redakteur = await redakteurAnlegen();
const bedingung = { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" };
const quelle = await beispielVersionAnlegen(async (versionId) => {
await testPrisma.vorlagenAbschnitt.updateMany({
where: { versionId, position: 0 },
data: { bedingung },
});
});
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
where: { versionId, position: 0 },
});
// Ein Variablenschluessel ist ein Name, den die Vorlage selbst mitbringt —
// er wandert unveraendert.
expect(zeile.bedingung).toEqual(bedingung);
});
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => { it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
const redakteur = await redakteurAnlegen(); const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen(); const quelle = await beispielVersionAnlegen();