feat(vorlagen): bedingte Abschnitte und Schritte (#039)
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:
co-authored by
Claude Opus 5
parent
2f247b3617
commit
a2330ef682
@@ -7,6 +7,35 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [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
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -105,6 +105,16 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
||||
gedruckte Aufkleber wertlos.
|
||||
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
|
||||
`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
|
||||
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
|
||||
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
|
||||
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
|
||||
|
||||
+77
-2
@@ -40,12 +40,14 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #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 |
|
||||
| #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 |
|
||||
|---|---|---|
|
||||
| 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
|
||||
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
|
||||
`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.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# #039 M23 – Bedingte Schritte und Abschnitte
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** –
|
||||
**Meilenstein:** v0.3.0
|
||||
**Labels:** phase-3, backend, frontend
|
||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 5 h 20 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -23,7 +23,7 @@ Stelle, an der das sitzt.
|
||||
|
||||
## 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
|
||||
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
|
||||
`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.
|
||||
- [ ] **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
|
||||
Abteilungen gibt es #031, und je mehr Quellen, desto schwerer zu erklären, warum ein Schritt
|
||||
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,
|
||||
wert }` oder `{ art: "schritt", schrittId, zustand }`. Json, weil `konfig` es schon so macht
|
||||
und ein neuer Operator dann keine Migration braucht.
|
||||
**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
|
||||
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
|
||||
Notiz zu verlieren, die jemand geschrieben hat; und schlägt die Bedingung zurück, ist sie
|
||||
wieder da.
|
||||
|
||||
## 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
|
||||
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
|
||||
`src/lib/geltende-abschnitte.ts`
|
||||
- [ ] Auswertung bei jedem Schreiben an einem `Schrittergebnis`; das Ergebnis in den Lauf
|
||||
- [ ] Abschlussregel: ein ausgeblendeter Pflichtschritt hält den Abschluss **nicht** auf (dieselbe
|
||||
- [x] Auswertung bei jedem Schreiben an einem `Schrittergebnis`; das Ergebnis in den Lauf
|
||||
- [x] Abschlussregel: ein ausgeblendeter Pflichtschritt hält den Abschluss **nicht** auf (dieselbe
|
||||
Regel wie bei #031, dieselbe Funktion)
|
||||
- [ ] 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] Fortschrittsbalken und Zählwerte rechnen nur mit dem, was gilt
|
||||
- [x] Editor: Bedingung an Abschnitt und Schritt setzen, mit Auswahl der Variable bzw. des Schritts
|
||||
**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
|
||||
- [ ] 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)
|
||||
- [ ] Druckansicht und Archiv zeigen, was galt
|
||||
- [ ] Export/Import: die Bedingung wandert mit, und ein Verweis auf einen Schritt wird dabei auf die
|
||||
- [x] Druckansicht und Archiv zeigen, was galt
|
||||
- [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
|
||||
- [ ] 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
|
||||
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
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] 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: ein Lauf mit `konto_typ = AD` hat die 5 AD-Schritte und **keinen** der 4 Entra-Schritte
|
||||
- [x] Test: dasselbe umgekehrt, und bei nicht gesetzter Variable gelten **beide** — eine Bedingung,
|
||||
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
|
||||
- [ ] 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 ausgeblendeter Pflichtschritt hält den Abschluss nicht auf
|
||||
- [x] Test: ein Schrittergebnis mit Notiz bleibt stehen, wenn seine Bedingung umschlägt, und ist
|
||||
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
|
||||
- [ ] Test: ein archivierter Lauf zeigt genau die Schritte, die bei seinem Abschluss galten
|
||||
- [ ] Am Handy kein horizontales Scrollen
|
||||
- [x] Test: ein archivierter Lauf zeigt genau die Schritte, die bei seinem Abschluss galten
|
||||
- [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.
|
||||
|
||||
@@ -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 |
|
||||
| #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 |
|
||||
| #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 | – |
|
||||
| #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 | – |
|
||||
| | **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 $$;
|
||||
@@ -636,6 +636,17 @@ model VorlagenAbschnitt {
|
||||
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
|
||||
/// Feld dafuer waere eine zweite Wahrheit.
|
||||
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)
|
||||
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
|
||||
@@ -719,6 +730,9 @@ model VorlagenSchritt {
|
||||
/// Parameter fuer Schritte vom Typ AKTION.
|
||||
konfig Json @default("{}")
|
||||
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)
|
||||
ergebnisse Schrittergebnis[]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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()) {
|
||||
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;
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
||||
abschnittsZeilen.push(zeile.id);
|
||||
|
||||
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
|
||||
const angelegt = await tx.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: zeile.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
@@ -217,8 +237,34 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
|
||||
konfig: schritt.konfig as object,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
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({
|
||||
|
||||
@@ -94,6 +94,12 @@
|
||||
},
|
||||
{
|
||||
"titel": "Konto anlegen (Active Directory)",
|
||||
"bedingung": {
|
||||
"art": "variable",
|
||||
"schluessel": "konto_typ",
|
||||
"operator": "ist",
|
||||
"wert": "Active Directory"
|
||||
},
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto anlegen",
|
||||
@@ -141,6 +147,12 @@
|
||||
},
|
||||
{
|
||||
"titel": "Konto anlegen (Entra ID)",
|
||||
"bedingung": {
|
||||
"art": "variable",
|
||||
"schluessel": "konto_typ",
|
||||
"operator": "ist",
|
||||
"wert": "Entra ID"
|
||||
},
|
||||
"schritte": [
|
||||
{
|
||||
"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 { Blocks } from "lucide-react";
|
||||
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 { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||
@@ -32,6 +33,8 @@ export function AbschnittKarte({
|
||||
anzahl,
|
||||
lesend,
|
||||
abteilungen,
|
||||
variablen,
|
||||
alleSchritte,
|
||||
zuletztAngelegt,
|
||||
anfasser,
|
||||
aendern,
|
||||
@@ -48,6 +51,14 @@ export function AbschnittKarte({
|
||||
lesend: boolean;
|
||||
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
|
||||
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. */
|
||||
zuletztAngelegt: string | null;
|
||||
anfasser: React.ReactNode;
|
||||
@@ -62,6 +73,12 @@ export function AbschnittKarte({
|
||||
const [offen, setzeOffen] = useState(true);
|
||||
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -159,6 +176,27 @@ export function AbschnittKarte({
|
||||
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 ? (
|
||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||
) : (
|
||||
@@ -175,6 +213,9 @@ export function AbschnittKarte({
|
||||
<SchrittKarte
|
||||
schritt={schritt}
|
||||
index={stelle}
|
||||
variablen={variablen}
|
||||
alleSchritte={alleSchritte}
|
||||
eigeneStelle={ersteStelleDiesesAbschnitts + stelle}
|
||||
anzahl={anzahlSchritte}
|
||||
lesend={lesend}
|
||||
neu={schritt.kennung === zuletztAngelegt}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
|
||||
import {
|
||||
Auswahlfeld,
|
||||
Flaechenfeld,
|
||||
@@ -138,6 +139,9 @@ export function SchrittKarte({
|
||||
anzahl,
|
||||
lesend,
|
||||
neu,
|
||||
variablen,
|
||||
alleSchritte,
|
||||
eigeneStelle,
|
||||
anfasser,
|
||||
aendern,
|
||||
loeschen,
|
||||
@@ -149,6 +153,23 @@ export function SchrittKarte({
|
||||
lesend: boolean;
|
||||
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
|
||||
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;
|
||||
aendern: (teil: Partial<EditorSchritt>) => void;
|
||||
loeschen: () => void;
|
||||
@@ -254,6 +275,26 @@ export function SchrittKarte({
|
||||
vorschlag="https://intern.example/anleitung"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,20 @@ export function Editor({
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-3">
|
||||
@@ -340,6 +354,8 @@ export function Editor({
|
||||
index={index}
|
||||
anzahl={aufbau.abschnitte.length}
|
||||
abteilungen={abteilungen}
|
||||
variablen={aufbau.variablen}
|
||||
alleSchritte={waehlbareSchritte}
|
||||
lesend={lesend}
|
||||
zuletztAngelegt={zuletztAngelegt}
|
||||
anfasser={anfasser}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PfadBedingung } from "@/lib/bedingungen";
|
||||
import { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
|
||||
import {
|
||||
SCHRITT_TYPEN,
|
||||
@@ -35,6 +36,8 @@ export type EditorSchritt = {
|
||||
readonly pflicht: boolean;
|
||||
readonly konfig: SchrittKonfig;
|
||||
readonly dokuUrl: string;
|
||||
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
|
||||
readonly bedingung?: PfadBedingung;
|
||||
};
|
||||
|
||||
export type EditorAbschnitt = {
|
||||
@@ -43,6 +46,13 @@ export type EditorAbschnitt = {
|
||||
readonly schritte: readonly EditorSchritt[];
|
||||
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
|
||||
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).
|
||||
*
|
||||
@@ -308,6 +318,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
||||
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
||||
titel: titelOhneNummer(abschnitt.titel),
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
|
||||
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
|
||||
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
|
||||
...(abschnitt.bausteinVersionId === undefined
|
||||
@@ -321,6 +332,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig,
|
||||
dokuUrl: schritt.dokuUrl ?? "",
|
||||
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
@@ -352,6 +364,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
||||
nurFuerAbteilungen: [
|
||||
...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
|
||||
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
|
||||
@@ -367,6 +380,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig,
|
||||
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
|
||||
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { PfadBedingungSchema, type PfadBedingung } from "@/lib/bedingungen";
|
||||
|
||||
/**
|
||||
* Aufbau einer Vorlagen-Version (#009).
|
||||
@@ -72,6 +73,14 @@ export const SchrittKonfigSchema = z.looseObject({
|
||||
|
||||
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({
|
||||
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
|
||||
/** 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
|
||||
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
|
||||
*/
|
||||
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
|
||||
bedingung: AustauschBedingungSchema.optional(),
|
||||
dokuUrl: leerAlsUndefiniert(
|
||||
z.url({
|
||||
protocol: /^https?$/,
|
||||
@@ -119,6 +130,8 @@ export const AbschnittSchema = z.object({
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
|
||||
.default([]),
|
||||
/** Wann dieser Abschnitt gilt (#039). Fehlt: immer. */
|
||||
bedingung: AustauschBedingungSchema.optional(),
|
||||
schritte: z
|
||||
.array(SchrittSchema)
|
||||
.max(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
|
||||
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
@@ -174,6 +175,8 @@ export async function laufZumDrucken(
|
||||
archiviertVon: { select: { name: true } },
|
||||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||||
geltendeAbschnitte: true,
|
||||
// Speist die Auswertung der Bedingungen (#039).
|
||||
variablen: true,
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
@@ -183,9 +186,17 @@ export async function laufZumDrucken(
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
bedingung: true,
|
||||
schritte: {
|
||||
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 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 schritte = abschnitt.schritte.map((schritt) => {
|
||||
nummer += 1;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
|
||||
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
|
||||
@@ -239,6 +240,8 @@ export async function laufZumAbarbeiten(
|
||||
select: {
|
||||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||||
geltendeAbschnitte: true,
|
||||
// Speist die Auswertung der Bedingungen (#039).
|
||||
variablen: true,
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
abschnitte: {
|
||||
@@ -246,6 +249,7 @@ export async function laufZumAbarbeiten(
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
bedingung: true,
|
||||
schritte: {
|
||||
orderBy: { position: "asc" },
|
||||
select: {
|
||||
@@ -256,6 +260,7 @@ export async function laufZumAbarbeiten(
|
||||
pflicht: true,
|
||||
dokuUrl: true,
|
||||
konfig: true,
|
||||
bedingung: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -297,7 +302,19 @@ export async function laufZumAbarbeiten(
|
||||
let anzahlPflicht = 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 befehle: { titel: string; shell: Shell; befehl: string }[] = [];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bedingungZuPfad } from "@/lib/bedingungen";
|
||||
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
@@ -256,6 +257,22 @@ export async function versionMitAufbau(
|
||||
});
|
||||
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 = {
|
||||
variablen: version.variablen.map((variable) => ({
|
||||
schluessel: variable.schluessel,
|
||||
@@ -268,12 +285,14 @@ export async function versionMitAufbau(
|
||||
titel: abschnitt.titel,
|
||||
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
|
||||
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
|
||||
...alsPfad(abschnitt.bedingung),
|
||||
schritte: abschnitt.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
|
||||
...alsPfad(schritt.bedingung),
|
||||
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
|
||||
})),
|
||||
})),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prisma, type Lauf } from "@/generated/prisma/client";
|
||||
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
|
||||
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
|
||||
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
||||
import type { LaufAnlass } from "@/lib/meldungen";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
@@ -703,6 +704,7 @@ export async function offenePflichtschritte(
|
||||
where: { id: laufId },
|
||||
select: {
|
||||
geltendeAbschnitte: true,
|
||||
variablen: true,
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
abschnitte: {
|
||||
@@ -710,10 +712,11 @@ export async function offenePflichtschritte(
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
bedingung: true,
|
||||
schritte: {
|
||||
where: { pflicht: true },
|
||||
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 offen: OffenerPflichtschritt[] = [];
|
||||
/*
|
||||
* Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf
|
||||
* den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
|
||||
* Nur die geltenden Abschnitte (#031) und nur, was seine Bedingung erfuellt
|
||||
* (#039). Ein ausgefilterter oder ausgeblendeter Pflichtschritt darf den
|
||||
* Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
|
||||
* 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) {
|
||||
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
|
||||
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { ZodError } from "@/lib/validierung";
|
||||
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
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).
|
||||
@@ -66,6 +72,22 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
||||
}
|
||||
|
||||
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 {
|
||||
format: FORMAT_VERSION,
|
||||
name: vorlage.name,
|
||||
@@ -111,7 +133,10 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
||||
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
|
||||
// dasselbe.
|
||||
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
|
||||
* 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(
|
||||
/*
|
||||
* 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
|
||||
* 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 {
|
||||
const roh = schritt.konfig;
|
||||
const konfig =
|
||||
@@ -147,6 +200,7 @@ function schrittAbbilden(
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig,
|
||||
...bedingungAbbilden(schritt.bedingung, pfade),
|
||||
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
|
||||
};
|
||||
}
|
||||
@@ -202,13 +256,38 @@ export async function vorlageImportieren(
|
||||
}
|
||||
|
||||
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()) {
|
||||
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;
|
||||
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,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
@@ -220,11 +299,44 @@ export async function vorlageImportieren(
|
||||
konfig: schritt.konfig as Prisma.InputJsonObject,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
position: schrittPosition,
|
||||
})),
|
||||
},
|
||||
});
|
||||
neueKennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
|
||||
}
|
||||
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({
|
||||
data: {
|
||||
kundeId: vorlage.kundeId,
|
||||
|
||||
+180
-64
@@ -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 { prisma } from "@/lib/db";
|
||||
import { bedingungAufloesen, bedingungLesen, schrittPfad } from "@/lib/bedingungen";
|
||||
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,114 @@ export type VorlagenAenderung = {
|
||||
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.
|
||||
*
|
||||
@@ -236,27 +345,27 @@ export async function entwurfHolenOderErzeugen(
|
||||
}
|
||||
}
|
||||
|
||||
type VersionMitInhalt = {
|
||||
readonly version: number;
|
||||
readonly variablen: readonly {
|
||||
schluessel: string;
|
||||
beschriftung: string;
|
||||
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
|
||||
optionen: string[];
|
||||
pflicht: boolean;
|
||||
}[];
|
||||
readonly abschnitte: readonly {
|
||||
titel: string;
|
||||
schritte: readonly {
|
||||
titel: string;
|
||||
hilfetext: string | null;
|
||||
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
|
||||
pflicht: boolean;
|
||||
konfig: unknown;
|
||||
dokuUrl: string | null;
|
||||
}[];
|
||||
}[];
|
||||
/**
|
||||
* Was eine Version mitbringen muss, damit sie kopiert werden kann.
|
||||
*
|
||||
* **Aus Prisma abgeleitet und nicht von Hand geschrieben** — und das ist die
|
||||
* Lehre aus zwei stillen Datenverlusten. Hier stand ein selbst getippter Typ,
|
||||
* der weniger Felder nannte, als die Abfrage liefert: `nurFuerAbteilungen` (seit
|
||||
* #031) und `bedingung` (seit #039) fehlten darin. Die Abfrage holte sie, der
|
||||
* Typ warf sie weg, und `abschnitteKopieren` konnte sie nicht sehen — „Bearbeiten“
|
||||
* einer Vorlage verlor damit **alle** Abteilungseinschraenkungen, ohne dass
|
||||
* etwas rot wurde.
|
||||
*
|
||||
* Ein abgeleiteter Typ kann das nicht: ein neues Feld am Modell ist hier
|
||||
* automatisch vorhanden, und wer es beim Kopieren vergisst, faellt im Test
|
||||
* „die Kopie ist inhaltsgleich“ auf.
|
||||
*/
|
||||
type VersionMitInhalt = Prisma.VorlagenVersionGetPayload<{
|
||||
include: {
|
||||
variablen: true;
|
||||
abschnitte: { include: { schritte: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
async function entwurfKopieren(
|
||||
vorlageId: string,
|
||||
@@ -289,26 +398,7 @@ async function entwurfKopieren(
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
||||
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 abschnitteKopieren(tx, entwurf.id, letzte.abschnitte);
|
||||
|
||||
await tx.protokoll.create({
|
||||
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()) {
|
||||
const angelegt = await tx.vorlagenAbschnitt.create({
|
||||
data: {
|
||||
@@ -536,9 +643,11 @@ export async function entwurfSpeichern(
|
||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||
},
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittsZeilen.push({ id: angelegt.id, titel: abschnitt.titel });
|
||||
|
||||
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
|
||||
const angelegterSchritt = await tx.vorlagenSchritt.create({
|
||||
data: {
|
||||
abschnittId: angelegt.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
@@ -547,8 +656,33 @@ export async function entwurfSpeichern(
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
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()) {
|
||||
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 abschnitteKopieren(tx, version.id, letzte.abschnitte);
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,17 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
*/
|
||||
"Mitarbeiter_konto_mitziehen",
|
||||
"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",
|
||||
"VorlagenSchritt_bedingung_gleiche_version",
|
||||
"VorlagenSchritt_unveraenderlich",
|
||||
"VorlagenVariable_unveraenderlich",
|
||||
"VorlagenVersion_status_einbahn",
|
||||
@@ -159,6 +169,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
||||
"Verzeichnisquelle_scimtoken_gehasht",
|
||||
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
|
||||
"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",
|
||||
"Zustellung_bezug_passt_zum_anlass",
|
||||
"Zustellung_empfaenger_nicht_leer",
|
||||
|
||||
+40
-6
@@ -150,13 +150,47 @@ describe("Die sieben Vorlagen", () => {
|
||||
expect(gruppen).toContain('-Members "{{sam}}"');
|
||||
});
|
||||
|
||||
it("enthaelt beide Konto-Abschnitte des Onboardings", () => {
|
||||
// Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht
|
||||
// zutreffende als Ganzes auf „nicht zutreffend“ gesetzt.
|
||||
it("enthaelt beide Konto-Abschnitte des Onboardings – jeden mit seiner Bedingung", () => {
|
||||
/*
|
||||
* **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 titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel);
|
||||
expect(titel).toContain("Konto anlegen (Active Directory)");
|
||||
expect(titel).toContain("Konto anlegen (Entra ID)");
|
||||
const abschnitte = onboarding?.dokument.struktur.abschnitte ?? [];
|
||||
const nach = (titel: string) => abschnitte.find((abschnitt) => abschnitt.titel === titel);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -34,7 +34,15 @@ async function redakteurAnlegen() {
|
||||
* der Export, dass er nach `position` sortiert und nicht danach, wie die
|
||||
* 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({
|
||||
data: {
|
||||
name: "Onboarding Notebook",
|
||||
@@ -128,6 +136,8 @@ async function beispielVersionAnlegen(): Promise<string> {
|
||||
],
|
||||
});
|
||||
|
||||
await nachtragen?.(version.id);
|
||||
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||
@@ -136,6 +146,15 @@ async function beispielVersionAnlegen(): Promise<string> {
|
||||
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. */
|
||||
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -503,6 +522,142 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
|
||||
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 () => {
|
||||
const redakteur = await redakteurAnlegen();
|
||||
const quelle = await beispielVersionAnlegen();
|
||||
|
||||
Reference in New Issue
Block a user