Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d30bb6d235 | ||
|
|
a2330ef682 | ||
|
|
2f247b3617 |
+45
-6
@@ -7,6 +7,45 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Vier-Augen-Freigabe.** Verlangt eine Vorlagen-Version sie, lässt sich ein Lauf erst abschließen,
|
||||||
|
wenn ein **Zweiter** ihn freigegeben hat — jemand mit dem neuen Recht `freigeben` (`ADMIN` und
|
||||||
|
`REDAKTEUR`, **nicht** `TECHNIKER`), der nicht an dem Lauf gearbeitet hat. Als gearbeitet zählt:
|
||||||
|
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein nicht.
|
||||||
|
**Auch ein Administrator kann sich nicht selbst freigeben** — das ist der ganze Sinn, und dieselbe
|
||||||
|
Regel hält ein CHECK in der Datenbank. Zurückweisen verlangt eine Begründung, setzt den Lauf
|
||||||
|
zurück auf *in Arbeit* und nimmt eine erteilte Freigabe zurück.
|
||||||
|
Sobald jemand abzuschließen versucht und die Freigabe fehlt, bekommen die Freigabeberechtigten
|
||||||
|
eine Mail — einmal, auch bei mehreren Versuchen. Das Häkchen steht im Editor beim Veröffentlichen
|
||||||
|
und wandert beim Bearbeiten in den neuen Entwurf mit (#040)
|
||||||
|
- **Bedingte Abschnitte und Schritte.** Ein Abschnitt oder ein einzelner Schritt erscheint nur,
|
||||||
|
wenn seine Bedingung zutrifft — abhängig von einer **Variable** des Laufs oder vom **Zustand
|
||||||
|
eines anderen Schritts**. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt beide
|
||||||
|
Konto-Abschnitte, fünf AD-Schritte und vier für Entra ID, und `Kontotyp` sagt beim Start, welcher
|
||||||
|
gilt. Bisher klickte ein Techniker die anderen neun bei jedem Onboarding weg.
|
||||||
|
**Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil** — hat niemand den Kontotyp
|
||||||
|
angegeben, stehen beide Abschnitte da, genau wie vorher. Ein ausgeblendeter Pflichtschritt hält
|
||||||
|
den Abschluss nicht auf, ausgeblendet heißt nicht gelöscht (eine Notiz bleibt stehen), und die
|
||||||
|
Nummerierung zählt nur, was man sieht. Ausgewertet wird **laufend**: ein Abschnitt „Nacharbeit“,
|
||||||
|
der erst erscheint, wenn der Restore-Test fehlschlägt, kommt bei einer offenen zweiten Sitzung
|
||||||
|
sofort dazu. Es gibt bewusst **kein** „und“ und kein „oder“ (#039)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Der Import einer Vorlage verwarf die Abteilungseinschränkung der Abschnitte.** Der Export
|
||||||
|
schreibt sie seit #031, das Schema nimmt sie an — der Import setzte sie nicht. Eine Vorlage, die
|
||||||
|
jemand in eine zweite Installation brachte, verlor ihre Abteilungsabschnitte: sie galten dort für
|
||||||
|
**alle**, also mehr Schritte statt weniger. Niemand hätte es gemerkt (#039)
|
||||||
|
- **„Bearbeiten“ einer Vorlage verlor ebenfalls alle Abteilungseinschränkungen.** Die tiefe Kopie,
|
||||||
|
aus der der Entwurf entsteht, nahm sie nicht mit — und `Vorlage duplizieren` genauso wenig, weil
|
||||||
|
beide die Kopie einzeln ausgeschrieben hatten. Ursache war ein von Hand getippter Zwischentyp, der
|
||||||
|
weniger Felder nannte als die Abfrage liefert; er ist jetzt aus Prisma abgeleitet, beide Wege
|
||||||
|
teilen eine Stelle, und ein Test vergleicht Kopie und Original Feld für Feld (#039)
|
||||||
|
- Die Auswahlfelder der neuen Bedingung trugen ihren Namen über ein umhüllendes `label`, wodurch
|
||||||
|
der zugängliche Name den Text der gewählten Option mitschluckte — für einen Screenreader eine
|
||||||
|
Bezeichnung, die sich beim Umschalten ändert (#039)
|
||||||
|
|
||||||
## [0.2.0] – 2026-10-11
|
## [0.2.0] – 2026-10-11
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
@@ -44,15 +83,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
|
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
|
||||||
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
|
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
|
||||||
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
|
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
|
||||||
Begründung geht ins Protokoll des Prozesses (#039)
|
Begründung geht ins Protokoll des Prozesses (#025)
|
||||||
- Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen —
|
- Die Konvention „nie auf rohe Hexwerte“ nennt ihre sechs begründeten Ausnahmen beim Namen —
|
||||||
Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war
|
Druckansichten, QR-Code und `themeColor` entscheiden nicht nach unserem Farbschema. Die Regel war
|
||||||
strenger formuliert als gemeint (#040)
|
strenger formuliert als gemeint (#026)
|
||||||
- `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne
|
- `CLAUDE.md` und `README.md` nennen den LDAP-Testcontainer als Teil des Tors vor dem Commit. Ohne
|
||||||
ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr,
|
ihn überspringt die Suite **22 Tests** — den ganzen Verzeichnis-Abgleich —, sagt das auf stderr,
|
||||||
und der Lauf sieht trotzdem grün aus (#040)
|
und der Lauf sieht trotzdem grün aus (#026)
|
||||||
- Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010
|
- Im README fehlte `format:pruefen` in den Qualitätsschranken, obwohl `CLAUDE.md` es seit #010
|
||||||
verlangt (#040)
|
verlangt (#026)
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|
||||||
@@ -235,9 +274,9 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen
|
eingespielte Kopie bekommt **keinen** Schlüssel: sie soll nicht den Platz des Originals einnehmen
|
||||||
(#021)
|
(#021)
|
||||||
- `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von
|
- `pnpm pruefe:doku` vergleicht die Schranken-Befehle in `CLAUDE.md` und `README.md` und verlangt von
|
||||||
beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#040)
|
beiden den LDAP-Container. Genau diese Abweichung war unbemerkt entstanden (#026)
|
||||||
- Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll
|
- Fünf Tests für den Healthcheck, darunter einer, der belegt, dass die Begründung ins Protokoll
|
||||||
geschrieben und nicht bloß verschluckt wird (#039)
|
geschrieben und nicht bloß verschluckt wird (#025)
|
||||||
|
|
||||||
## [0.1.0] – 2026-09-26
|
## [0.1.0] – 2026-09-26
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Enums: `Rolle {ADMIN REDAKTEUR TECHNIKER LESER MITARBEITER}` · `Betriebsmodus {
|
|||||||
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
`VersionStatus {ENTWURF VEROEFFENTLICHT}` · `Herkunft {ABGLEICH MANUELL}` ·
|
||||||
`QuellenArt {LDAP GRAPH SCIM}` ·
|
`QuellenArt {LDAP GRAPH SCIM}` ·
|
||||||
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
`Zustellweg {MAIL WEBHOOK}` · `Zustellzustand {OFFEN GESENDET FEHLGESCHLAGEN AUFGEGEBEN}` ·
|
||||||
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN ABGLEICH_FEHLGESCHLAGEN}`.
|
`Meldeanlass {LAUF_ZUGEWIESEN LAUF_UEBERFAELLIG LAUF_ABGESCHLOSSEN LAUF_FREIGABE_OFFEN ABGLEICH_FEHLGESCHLAGEN}`.
|
||||||
|
|
||||||
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
|
`SEED_MODE` akzeptiert `DIENSTLEISTUNG` und `SERVICE` als Alias.
|
||||||
|
|
||||||
@@ -77,6 +77,13 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
|||||||
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
|
Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen. Zurückholen nur `ADMIN`.
|
||||||
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
|
- **Abschluss:** nur wenn alle Pflichtschritte `ERLEDIGT` oder `NICHT_ZUTREFFEND` sind.
|
||||||
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
|
`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.
|
||||||
|
- **Vier-Augen-Freigabe:** Verlangt eine `VorlagenVersion` sie (`freigabePflicht`), lässt sich ein
|
||||||
|
Lauf erst nach der Freigabe abschließen (#040). **Wer daran gearbeitet hat, gibt nicht frei** —
|
||||||
|
angelegt, einen Schritt gesetzt oder abgeschlossen; eine Zuweisung allein zählt nicht. Das prüft
|
||||||
|
`hatDaranGearbeitet` an genau einer Stelle, wie `laufMussVeraenderbarSein`, und ein CHECK hält die
|
||||||
|
wichtigste Hälfte (der Ersteller) auch gegen Schreiben an der Dienstschicht vorbei. Das Recht
|
||||||
|
`freigeben` hat **nicht** `TECHNIKER`: sonst hängt die Regel daran, dass zufällig ein zweiter
|
||||||
|
greifbar ist. Die Pflicht wandert bei „Bearbeiten“ und „Duplizieren“ in die Kopie mit.
|
||||||
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
|
- **Mandant:** Jede kundenbezogene Zeile trägt `kundeId`. `kundeErmitteln()` liefert im Modus
|
||||||
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
|
`INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`.
|
||||||
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
|
- **Rechte:** nur serverseitig, aus der `RECHTE`-Tabelle über `darf()` / `rechtErforderlich()`.
|
||||||
@@ -105,6 +112,16 @@ Dreizeiler: Recht prüfen, Zod-Schema anwenden, Service rufen.
|
|||||||
gedruckte Aufkleber wertlos.
|
gedruckte Aufkleber wertlos.
|
||||||
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
|
- **`{{generiertes_passwort}}`:** wird je Anzeige neu erzeugt und **nie** gespeichert — nicht in
|
||||||
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
|
`Schrittergebnis`, `Kommentar`, `Protokoll` oder der Druckansicht.
|
||||||
|
- **Bedingte Teile:** Eine Bedingung an `VorlagenAbschnitt` oder `VorlagenSchritt` sagt, wann der
|
||||||
|
Teil gilt (#039). **Nicht entscheidbar heißt: gilt** — ein Abschnitt zu viel ist sichtbar und
|
||||||
|
korrigierbar, ein fehlender fällt niemandem auf. Eine Bedingung, kein Ausdrucksbaum: kein `und`,
|
||||||
|
kein `oder`. Die Auswertung sitzt in `src/lib/bedingungen.ts` und wird von **drei** Schichten
|
||||||
|
gerufen (Abschlussregel, Abarbeitung, Druck); `geltendeStruktur` legt die Reihenfolge der zwei
|
||||||
|
Filter fest — erst die beim Start festgeschriebene Abteilungsauswahl aus #031, dann die laufende
|
||||||
|
Auswertung. `tests/bedingte-laeufe.test.ts` prüft strukturell, dass keine Schicht sie vergisst.
|
||||||
|
**Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Austausch, Import, Seed und
|
||||||
|
`entwurfSpeichern` schreiben eine Struktur neu und geben neue Kennungen; ein Titel-Pfad überlebt
|
||||||
|
das. `bedingungAufloesen` ist für alle vier dieselbe Stelle.
|
||||||
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
|
- **Erweiterbarkeit:** Kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt
|
||||||
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
|
wird. `SchrittTyp` bekommt später `AKTION`, `konfig` und `wert` sind Json, und
|
||||||
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
|
`Lauf.erstelltVonId` wie `Schrittergebnis.aktualisiertVonId` dürfen leer sein — ein Systemlauf
|
||||||
@@ -257,7 +274,7 @@ geprüft. In CI bricht sie stattdessen ab, absichtlich.
|
|||||||
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
|
| `mailpit` | **3 Tests** (nachgezählt: 36 statt 33) | der echte SMTP-Versand in `tests/benachrichtigungen.test.ts` — die vier Anlässe, die Passwort-Prüfung über alle Rumpftexte und „nichts zweimal“ |
|
||||||
|
|
||||||
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
|
`ldap` fiel bei der Abnahme zu #019 auf, `mailpit` kam mit #034 dazu. Beide Zahlen sind **gemessen**,
|
||||||
nicht geschätzt — in #040 hätte beinahe eine geratene in dieser Datei gestanden.
|
nicht geschätzt — in #026 hätte beinahe eine geratene in dieser Datei gestanden.
|
||||||
|
|
||||||
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
|
`format:pruefen` gehört dazu, seit der Review in #010 darauf gestoßen ist: `lint-staged` formatiert
|
||||||
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
|
beim Commit automatisch nach, und das Ergebnis wäre ein Commit mit Änderungen, die niemand gesehen
|
||||||
|
|||||||
@@ -242,6 +242,58 @@ Knopf gesperrt und darunter steht, was fehlt.
|
|||||||
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
|
Wird danach eine Änderung nachgetragen, die einen Pflichtschritt wieder offen lässt, nimmt sich der
|
||||||
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
|
Abschluss selbst zurück. Damit lügt „abgeschlossen“ nie.
|
||||||
|
|
||||||
|
## Vier-Augen-Freigabe
|
||||||
|
|
||||||
|
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
|
||||||
|
Restore-Test, das Löschen eines Kontos. Verlangt die Vorlage eine Freigabe, steht das oben am Lauf —
|
||||||
|
und **Lauf abschließen** wartet, bis jemand sie erteilt hat.
|
||||||
|
|
||||||
|
### Wer freigeben darf
|
||||||
|
|
||||||
|
Es braucht zweierlei, und beides zusammen:
|
||||||
|
|
||||||
|
1. das Recht **freigeben** — das haben `ADMIN` und `REDAKTEUR`, **nicht** `TECHNIKER`;
|
||||||
|
2. und man darf **nicht an dem Lauf gearbeitet** haben.
|
||||||
|
|
||||||
|
Als „gearbeitet“ zählt: ihn angelegt, einen Schritt gesetzt oder ihn abgeschlossen zu haben. Nur
|
||||||
|
**zugewiesen** zu sein zählt nicht — wer zugewiesen wurde und nichts getan hat, darf freigeben.
|
||||||
|
|
||||||
|
**Auch ein Administrator kann sich nicht selbst freigeben.** Das ist der ganze Sinn, und es ist eine
|
||||||
|
bewusste Härte: wer allein im Haus ist, kommt damit nicht weiter und braucht einen Zweiten mit dem
|
||||||
|
Recht. Dieselbe Regel hält auch die Datenbank fest, nicht nur die Anwendung.
|
||||||
|
|
||||||
|
### Freigeben oder zurückweisen
|
||||||
|
|
||||||
|
Oben am Lauf steht eine Karte mit einem Feld für die **Begründung** und zwei Knöpfen:
|
||||||
|
|
||||||
|
- **Freigeben** — die Begründung ist freiwillig. Danach lässt sich der Lauf abschließen.
|
||||||
|
- **Zurückweisen** — die Begründung ist **Pflicht**. Der Lauf geht zurück auf *in Arbeit*, ein
|
||||||
|
bereits erteilter Abschluss wird aufgehoben, und der Bearbeiter sieht die Begründung an derselben
|
||||||
|
Stelle. Ohne Begründung steht er vor derselben Frage wie vorher.
|
||||||
|
|
||||||
|
Eine Zurückweisung nimmt auch eine **erteilte** Freigabe zurück. Damit lässt sich ein Lauf, der
|
||||||
|
schon abgeschlossen war, in die Arbeit zurückholen — ohne `archivVerwalten`, solange er nicht
|
||||||
|
archiviert ist.
|
||||||
|
|
||||||
|
### Wer erfährt davon
|
||||||
|
|
||||||
|
Sobald jemand den Lauf abzuschließen versucht und die Freigabe fehlt, bekommen die
|
||||||
|
**Freigabeberechtigten** eine Mail — nicht der Zuständige, wie bei allen anderen Meldungen. Wer an
|
||||||
|
dem Lauf gearbeitet hat, bekommt sie nicht: er dürfte ohnehin nicht freigeben.
|
||||||
|
|
||||||
|
Das geht **einmal** hinaus, auch wenn jemand es mehrmals versucht. Nach einer Zurückweisung wird
|
||||||
|
nicht erneut gemahnt — der Zurückweisende weiß, dass er wartet.
|
||||||
|
|
||||||
|
### Einschalten
|
||||||
|
|
||||||
|
Das Häkchen steht im Editor beim **Veröffentlichen**: „Läufe dieser Version brauchen eine Freigabe
|
||||||
|
durch einen Zweiten“. Es gehört zur **Version**, nicht zur Vorlage — ein laufender Lauf kann seine
|
||||||
|
Regel also nicht unter den Füßen verlieren, und in der Versionsliste steht an jeder Fassung, ob sie
|
||||||
|
eine Freigabe verlangt.
|
||||||
|
|
||||||
|
Beim Bearbeiten wandert das Häkchen in den neuen Entwurf mit. Eine Vorlage, die eine Freigabe
|
||||||
|
verlangt, verliert sie also nicht durch eine Textkorrektur.
|
||||||
|
|
||||||
## Archivieren — das ist endgültig
|
## Archivieren — das ist endgültig
|
||||||
|
|
||||||
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
|
**archivieren** fragt nach: „Nach dem Archivieren ändert sich an diesem Lauf nichts mehr — kein
|
||||||
|
|||||||
@@ -219,6 +219,73 @@ Import: ein Abteilungsname bedeutet in einer anderen Installation dasselbe.
|
|||||||
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
|
**Lässt für eine Abteilung kein Abschnitt übrig, wird der Lauf abgewiesen** — es gäbe nichts
|
||||||
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
|
abzuarbeiten. Das passiert nur, wenn jeder Abschnitt einer Vorlage eingeschränkt ist.
|
||||||
|
|
||||||
|
## Bedingte Abschnitte und Schritte
|
||||||
|
|
||||||
|
Die Onboarding-Vorlage enthält **beide** Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit
|
||||||
|
fünf Schritten und „Konto anlegen (Entra ID)“ mit vier. In jedem einzelnen Lauf gilt nur einer, und
|
||||||
|
die Variable **Kontotyp** sagt beim Start, welcher. Bis hierher klickte ein Techniker die anderen
|
||||||
|
neun Schritte weg.
|
||||||
|
|
||||||
|
Mit einer **Bedingung** erscheint ein Abschnitt oder ein einzelner Schritt nur dann, wenn er
|
||||||
|
zutrifft. Im Editor steht an jedem Abschnitt und an jedem Schritt **Bedingung (optional)**; ohne
|
||||||
|
eine gilt er immer.
|
||||||
|
|
||||||
|
### Zwei Arten, und die Wahl entscheidet, wann die Antwort entsteht
|
||||||
|
|
||||||
|
| Die Bedingung hängt an … | Beispiel | Wann sie entschieden wird |
|
||||||
|
|---|---|---|
|
||||||
|
| einer **Variable** | „gilt, wenn Kontotyp gleich Active Directory ist“ | beim Start, wenn die Variablen erfasst werden |
|
||||||
|
| einem **Schritt** | „gilt, wenn Prüfen fehlgeschlagen ist“ | während der Arbeit, sobald jemand den Schritt setzt |
|
||||||
|
|
||||||
|
Die zweite ist die interessantere: ein Abschnitt „Nacharbeit“, der erst erscheint, wenn der
|
||||||
|
Restore-Test fehlgeschlagen ist. Er ist zu Beginn nicht da und kommt in dem Moment dazu, in dem der
|
||||||
|
Schritt auf „fehlgeschlagen“ geht — bei einer offenen zweiten Sitzung sofort.
|
||||||
|
|
||||||
|
### Eine Bedingung, nicht mehr
|
||||||
|
|
||||||
|
Es gibt **kein** „und“ und kein „oder“. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte oder
|
||||||
|
zwei Vorlagen. Das ist Absicht: ein Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre
|
||||||
|
eine kleine Programmiersprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
|
||||||
|
schlimmer als einer zu viel.
|
||||||
|
|
||||||
|
Bei einer Schritt-Bedingung lassen sich dagegen **mehrere Zustände** ankreuzen: „erledigt oder nicht
|
||||||
|
zutreffend“ ist eine Frage, nicht zwei.
|
||||||
|
|
||||||
|
### Vier Dinge, die man dazu wissen muss
|
||||||
|
|
||||||
|
- **Lässt sich eine Bedingung nicht entscheiden, erscheint der Teil.** Hat niemand den Kontotyp
|
||||||
|
angegeben, stehen **beide** Konto-Abschnitte da — genau wie vorher. Auszublenden hieße, eine
|
||||||
|
Antwort zu erfinden, die niemand gegeben hat. Ein Abschnitt zu viel ist sichtbar und
|
||||||
|
korrigierbar; ein fehlender fällt niemandem auf.
|
||||||
|
- **Ein ausgeblendeter Pflichtschritt blockiert den Abschluss nicht** — dieselbe Regel wie bei den
|
||||||
|
Abteilungsabschnitten. Er steht nicht in der Abarbeitung, also lässt er sich nicht abhaken.
|
||||||
|
- **Ausgeblendet heißt nicht gelöscht.** Hat jemand in einem Schritt eine Notiz hinterlassen und
|
||||||
|
kippt dessen Bedingung danach, verschwindet der Schritt aus der Ansicht — die Notiz bleibt. Kippt
|
||||||
|
die Bedingung zurück, ist alles wieder da.
|
||||||
|
- **Die Nummerierung zählt, was man sieht.** Ein ausgeblendeter Abschnitt hinterlässt keine Lücke;
|
||||||
|
aus `01 · 02 · 03` wird nicht `01 · 03`.
|
||||||
|
|
||||||
|
### Die Warnung „dieser Schritt kommt später“
|
||||||
|
|
||||||
|
Zeigt eine Bedingung auf einen Schritt, der **nach** dem bedingten Teil steht, warnt der Editor.
|
||||||
|
Verboten ist es nicht, aber fast immer ein Versehen: der Abschnitt wäre beim Abarbeiten zunächst
|
||||||
|
unsichtbar und erschiene erst, nachdem jemand weiter unten etwas abgehakt hat — also rückwärts.
|
||||||
|
Gemeint ist meist ein Schritt davor.
|
||||||
|
|
||||||
|
### Export und Import
|
||||||
|
|
||||||
|
Eine Bedingung wandert mit. Im Austauschdokument steht sie über **Titel** statt über Kennungen
|
||||||
|
(`"abschnitt": "Prüfung", "schritt": "Prüfen"`), denn eine Kennung bedeutet in einer anderen
|
||||||
|
Installation nichts. Beim Import wird daraus wieder der richtige Schritt.
|
||||||
|
|
||||||
|
Findet sich der Titel-Pfad in der Datei nicht — etwa nach einem Tippfehler in einer von Hand
|
||||||
|
geschriebenen Vorlage —, wird die Bedingung **weggelassen**. Der Abschnitt gilt dann immer, und das
|
||||||
|
steht sichtbar im Editor. Die ganze Vorlage deswegen abzuweisen wäre die teurere Antwort.
|
||||||
|
|
||||||
|
**Ein Baustein-Schritt hat keine Bedingung.** Ein Baustein ist wiederverwendbar, und eine Bedingung
|
||||||
|
auf einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht entscheidbar. Der
|
||||||
|
Abschnitt, in dem der Baustein eingesetzt wird, darf eine haben.
|
||||||
|
|
||||||
## Bausteine: ein Abschnitt für mehrere Vorlagen
|
## Bausteine: ein Abschnitt für mehrere Vorlagen
|
||||||
|
|
||||||
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
|
„Datensicherung prüfen“ steht in vier der sieben mitgelieferten Vorlagen, wortgleich. Wer den Ablauf
|
||||||
|
|||||||
@@ -387,6 +387,76 @@ Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
|
|||||||
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
|
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
|
||||||
was darauf steht, ist aus der Hand gegeben.
|
was darauf steht, ist aus der Hand gegeben.
|
||||||
|
|
||||||
|
## 5f-2. Bedingte Teile: zwei Filter, eine Reihenfolge
|
||||||
|
|
||||||
|
Was ein Lauf zeigt, entsteht aus **zwei** Filtern uebereinander, und ihre
|
||||||
|
Reihenfolge ist der ganze Inhalt von `geltendeStruktur` in
|
||||||
|
`src/lib/bedingungen.ts`:
|
||||||
|
|
||||||
|
1. **Die Abteilungsauswahl (#031)** — beim Start festgeschrieben in
|
||||||
|
`Lauf.geltendeAbschnitte`. Festgeschrieben, weil ein archivierter Lauf genau
|
||||||
|
die Schritte zeigen muss, die damals abgearbeitet wurden, und ein spaeterer
|
||||||
|
Abteilungswechsel keinen Schritt entfernen darf, an dem eine Notiz haengt.
|
||||||
|
2. **Die Bedingungen (#039)** — **laufend** ausgewertet. Eine Bedingung auf einer
|
||||||
|
Variable liesse sich auch festschreiben; eine auf einem Schrittergebnis nicht,
|
||||||
|
denn ihre Antwort entsteht erst waehrend der Arbeit. Ein Abschnitt
|
||||||
|
„Nacharbeit“, der erscheint, wenn der Restore-Test fehlschlaegt, ist der Fall,
|
||||||
|
fuer den es gebaut ist.
|
||||||
|
|
||||||
|
Wer nur den ersten anwendet, bekommt Abschnitte, die nicht gelten; wer nur den
|
||||||
|
zweiten anwendet, umgeht die Abteilungsregel. Drei Schichten brauchen beide — die
|
||||||
|
Abschlussregel im Dienst, die Abarbeitung und die Druckansicht —, und
|
||||||
|
`tests/bedingte-laeufe.test.ts` prueft **strukturell**, dass keine davon die
|
||||||
|
zweite vergisst. Dieselbe Art Test wie „jede Seite prueft selbst“ (#038): die
|
||||||
|
Zusage steht in der Vollstaendigkeit einer Liste, und die muss jemand bewachen.
|
||||||
|
|
||||||
|
**Nicht entscheidbar heisst: gilt.** Hat niemand den Kontotyp angegeben, stehen
|
||||||
|
beide Konto-Abschnitte da — genau wie vor #039. Auszublenden hiesse, eine Antwort
|
||||||
|
zu erfinden, die niemand gegeben hat; und die Richtung, in die der Zweifel
|
||||||
|
faellt, soll die harmlose sein.
|
||||||
|
|
||||||
|
**Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen.** Vier Wege schreiben
|
||||||
|
eine Struktur neu und geben dabei neue Kennungen: Import, Export, Seed und
|
||||||
|
`entwurfSpeichern`. Eine Bedingung auf einer Kennung ueberlebte keinen davon —
|
||||||
|
ein Titel-Pfad ueberlebt alle vier, und `bedingungAufloesen` ist fuer alle
|
||||||
|
dieselbe Stelle. Dass eine Schritt-Bedingung auf **dieselbe Version** zeigt,
|
||||||
|
haelt ein Trigger: nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
|
||||||
|
entscheidbar, und der Abschnitt erschiene stillschweigend immer.
|
||||||
|
|
||||||
|
## 5f-3. Die Vier-Augen-Freigabe: eine Stelle, zwei Netze
|
||||||
|
|
||||||
|
Verlangt eine Version eine Freigabe, haengt der Abschluss daran — und die Regel
|
||||||
|
dahinter ist **eine Funktion**: `hatDaranGearbeitet` in
|
||||||
|
`src/server/services/laeufe.ts`. Sie wird vor jeder Freigabe und vor jeder
|
||||||
|
Zurueckweisung gerufen, genau wie `laufMussVeraenderbarSein` vor jeder Aenderung
|
||||||
|
an einem Lauf. Zwei Stellen liefen auseinander, und die erste, die es vergisst,
|
||||||
|
hebt die Regel auf.
|
||||||
|
|
||||||
|
Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
|
||||||
|
**Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
|
||||||
|
Zustaendige gilt bewusst **nicht** als Bearbeiter — eine Zuweisung ist noch keine
|
||||||
|
Arbeit, und waere die Regel hier strenger, blockierte sie kleine Teams ohne
|
||||||
|
Gewinn.
|
||||||
|
|
||||||
|
**Zwei Netze, nicht eines.** Die wichtigste Haelfte steht als CHECK in der
|
||||||
|
Datenbank (`Lauf_freigabe_nicht_selbst`): der Ersteller gibt nicht frei, auch
|
||||||
|
dann nicht, wenn jemand an der Dienstschicht vorbei schreibt. Der Dienst prueft
|
||||||
|
strenger, weil er in zwei weitere Tabellen sehen kann; ein CHECK braeuchte dafuer
|
||||||
|
eine Unterabfrage.
|
||||||
|
|
||||||
|
**Das Recht `freigeben` hat nicht `TECHNIKER`**, und das ist eine Entscheidung
|
||||||
|
gegen Bequemlichkeit: sonst haengt die Vier-Augen-Regel daran, dass zufaellig ein
|
||||||
|
zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
|
||||||
|
Rechten — und das steht im Protokoll.
|
||||||
|
|
||||||
|
**Die Aufforderung entsteht ausserhalb der Transaktion.** Der Abschlussversuch
|
||||||
|
scheitert mit `FreigabeFehlt`; `laufAbschliessen` faengt genau diesen Typ,
|
||||||
|
reiht die Meldung an die Freigabeberechtigten ein und wirft weiter. Innerhalb
|
||||||
|
der Transaktion wuerde sie mitsamt dem Fehler zurueckgerollt — und dann wartet
|
||||||
|
ein Lauf auf etwas, von dem niemand weiss. Ein partieller Unique-Index
|
||||||
|
(`Zustellung_freigabe_einmal`) sorgt dafuer, dass mehrere Versuche nicht mehrere
|
||||||
|
Mails ergeben.
|
||||||
|
|
||||||
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
|
## 5g. Der Verzeichnis-Abgleich: vier Schichten und eine Notbremse
|
||||||
|
|
||||||
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
|
Eine Verzeichnisquelle hat seit #028 eine **Art** — `LDAP`, `GRAPH` oder `SCIM` —,
|
||||||
|
|||||||
+133
-2
@@ -40,12 +40,15 @@ als Unterpunkte mit Aufgabe und Dauer.
|
|||||||
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
|
| #027 | Die Editor-Abläufe lassen ihre Vorlagen stehen | 2 h 10 min |
|
||||||
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
|
| #038 | Die E2E-Abläufe laufen in keinem Tor | 1 h 35 min |
|
||||||
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
|
| #037 | Zwei Nachzügler aus der Durchsicht von Phase 2 | 1 h 50 min |
|
||||||
| | **Summe** | **73 h 55 min** |
|
| #039 | M23 – Bedingte Schritte und Abschnitte | 5 h 20 min |
|
||||||
|
| #040 | M24 – Vier-Augen-Freigabe | 3 h 30 min |
|
||||||
|
| | **Summe** | **82 h 45 min** |
|
||||||
|
|
||||||
| Meilenstein | Tatsächlich | Status |
|
| Meilenstein | Tatsächlich | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||||
| v0.2.0 | 38 h 35 min | in Arbeit |
|
| v0.2.0 | 38 h 35 min | veröffentlicht |
|
||||||
|
| v0.3.0 | 8 h 50 min | in Arbeit |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1764,3 +1767,131 @@ Entra-Mandanten** und **gegen echte SCIM-Anbieter** ist nichts gelaufen. Die For
|
|||||||
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
|
stammen aus der Dokumentation, nicht aus Mitschnitten. Stimmt eine Form nicht, stimmen Test und
|
||||||
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
|
Wirklichkeit nicht — die Prüflisten stehen in `docs/betrieb/entra-id.md` und
|
||||||
`docs/betrieb/scim.md`.
|
`docs/betrieb/scim.md`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-23 · Sitzung 25 · 11:00–15:10
|
||||||
|
|
||||||
|
**Issue:** #039 (M23)
|
||||||
|
**Version:** 0.3.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Bedingte Abschnitte und Schritte**, der erste Meilenstein von Phase 3. Eine Bedingung hängt an
|
||||||
|
einer Variable des Laufs oder am Zustand eines anderen Schritts; die Auswertung läuft bei jedem
|
||||||
|
Lesen und sitzt in `src/lib/bedingungen.ts`.
|
||||||
|
- **Der Seed nutzt es am echten Fall**: „Mitarbeiter-Onboarding“ hat die Bedingung auf `konto_typ`,
|
||||||
|
beide Konto-Abschnitte bleiben drin und schließen sich aus. Neun Schritte, die niemand mehr
|
||||||
|
wegklickt.
|
||||||
|
- Editor, Abarbeitung, Druck, Export, Import, Entwurf und Seed — sieben Wege, alle mit Tests.
|
||||||
|
- 1496 Vitest-Tests (vorher 1457), davon 29 neue. Klickprobe am gebauten Stand: Bildschirm, Druck
|
||||||
|
und 390 px zeigen dasselbe.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Laufend auswerten, nicht beim Start festschreiben.** Für die Abteilungsabschnitte (#031) ist
|
||||||
|
Festschreiben richtig: die Abteilung steht beim Start fest. Für Bedingungen nicht — die Antwort auf
|
||||||
|
„ist Prüfen fehlgeschlagen?“ entsteht erst während der Arbeit. `geltendeStruktur` legt die
|
||||||
|
Reihenfolge der zwei Filter fest, und ein struktureller Test prüft, dass alle drei Schichten sie
|
||||||
|
rufen.
|
||||||
|
- **Nicht entscheidbar heißt: gilt.** Ein Lauf ohne `konto_typ` zeigt beide Konto-Abschnitte. Das ist
|
||||||
|
genau das Verhalten von vorher; eine erfundene Antwort wäre die Verschlechterung. Ein Abschnitt zu
|
||||||
|
viel ist sichtbar und korrigierbar, ein fehlender fällt niemandem auf.
|
||||||
|
- **`OFFEN` ist eine Antwort, keine Lücke** — der scharfe Gegensatz zur nicht gesetzten Variable.
|
||||||
|
Wäre `OFFEN` unentscheidbar, wäre ein bedingter Abschnitt von Anfang an sichtbar, also das
|
||||||
|
Gegenteil des Zwecks.
|
||||||
|
- **Eine Bedingung, kein Ausdrucksbaum.** Kein `und`, kein `oder`, keine Verschachtelung. Wer zwei
|
||||||
|
braucht, nimmt zwei Abschnitte. Ein Formular für beliebige Ausdrücke wäre eine kleine
|
||||||
|
Abfragesprache, und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist schlimmer als einer zu
|
||||||
|
viel.
|
||||||
|
- **Verwiesen wird über Titel-Pfade, nicht über Kennungen.** Vier Wege schreiben eine Struktur neu
|
||||||
|
und geben neue Kennungen: Import, Export, Seed, `entwurfSpeichern`. Eine Kennung überlebt keinen
|
||||||
|
davon.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Zwei stille Datenverluste, und der zweite war älter als dieser Meilenstein.**
|
||||||
|
- Der **Import** verwarf `nurFuerAbteilungen`: der Export schreibt es seit #031, das Schema nimmt
|
||||||
|
es an, der Import setzte es nicht. Eine exportierte Vorlage verlor ihre Abteilungsabschnitte.
|
||||||
|
- Die **tiefe Kopie** verwarf es ebenfalls — damit verlor **jedes „Bearbeiten“** einer Vorlage alle
|
||||||
|
Abteilungseinschränkungen. Beide Kopierstellen hatten den Code einzeln ausgeschrieben und
|
||||||
|
vergaßen dieselben Felder.
|
||||||
|
|
||||||
|
**Die Ursache war ein von Hand getippter Zwischentyp.** `VersionMitInhalt` nannte weniger Felder,
|
||||||
|
als die Abfrage liefert; die Daten waren da, der Code konnte sie nicht sehen. Jetzt aus Prisma
|
||||||
|
abgeleitet — ein neues Feld ist dort automatisch vorhanden. Und bewacht von einem Test, der Kopie
|
||||||
|
und Original **Feld für Feld** vergleicht, nicht stichprobenweise.
|
||||||
|
- **Gefunden hat beides die Klickprobe am gebauten Stand**, nicht die Suite: im Editor stand
|
||||||
|
„Bedingung gesetzt“, und nach „Bearbeiten“ war sie weg. Genau dafür steht diese Probe in
|
||||||
|
`CLAUDE.md`. Dreizehn Gegenproben liefen in dieser Sitzung, alle wurden rot — und trotzdem lag der
|
||||||
|
teuerste Befund außerhalb von allen.
|
||||||
|
- **Zwei Schemas für dieselbe Form** liefen fast auseinander: Austauschformat und Editor-Struktur
|
||||||
|
brauchen die Pfad-Form, und TypeScript meldete die Abweichung erst, als die Typen aufeinandertrafen.
|
||||||
|
Jetzt eine Stelle.
|
||||||
|
- **Ein Mangel an der neuen Komponente**, beim Klicken aufgefallen: die Auswahlfelder standen in
|
||||||
|
einem umhüllenden `label`, und ihr zugänglicher Name schluckte den Text der gewählten Option mit.
|
||||||
|
Derselbe Fehler wie bei der Adresse im Verzeichnis-Formular (#016) — dort war es ein Reviewbefund.
|
||||||
|
- **Zwei eigene Testfehler**: dreimal versucht, eine Bedingung an einer **veröffentlichten** Version
|
||||||
|
zu setzen (die ist unveränderlich, dafür sorgen Trigger), und `vorlageBearbeiten` mit
|
||||||
|
`entwurfHolenOderErzeugen` verwechselt. Beide Male hat die Datenbank es gesagt.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **M24 (#040)** Vier-Augen-Freigabe. Der Abschluss wird dort ohnehin angefasst, und M25 braucht den
|
||||||
|
Freigeber für das Blatt.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-24 · Sitzung 26 · 22:30–01:40
|
||||||
|
|
||||||
|
**Issue:** #040 (M24)
|
||||||
|
**Version:** 0.3.0 (unveröffentlicht)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- **Vier-Augen-Freigabe.** Neues Recht `freigeben`, Pflicht an der Version, Freigeben und
|
||||||
|
Zurückweisen samt Begründung, Aufforderung an die Berechtigten, Häkchen im Editor, Anzeige an der
|
||||||
|
Versionsliste.
|
||||||
|
- 1526 Vitest-Tests (vorher 1496), davon 22 neue in `tests/freigabe.test.ts`.
|
||||||
|
- Klickprobe am gebauten Stand: elf Prüfungen, alle grün.
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Das Recht `freigeben` hat nicht `TECHNIKER`.** Sonst hängt die Vier-Augen-Regel daran, dass
|
||||||
|
zufällig ein zweiter Techniker greifbar ist. Wer sie umgehen will, braucht ein Konto mit mehr
|
||||||
|
Rechten — und das steht im Protokoll.
|
||||||
|
- **Das Recht allein genügt nicht.** Wer an dem Lauf gearbeitet hat, gibt ihn nicht frei, auch nicht
|
||||||
|
als Administrator. Die Prüfung sitzt in `hatDaranGearbeitet`, an **einer** Stelle, wie
|
||||||
|
`laufMussVeraenderbarSein`; die wichtigste Hälfte (der Ersteller) hält zusätzlich ein CHECK.
|
||||||
|
- **Eine Zuweisung ist noch keine Arbeit.** Wer zugewiesen wurde und nichts getan hat, darf
|
||||||
|
freigeben — wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn.
|
||||||
|
- **Kein fünfter `LaufStatus`** für „zurückgewiesen“: der Lauf geht auf `IN_ARBEIT`, die Begründung
|
||||||
|
steht in einem Feld und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad betroffen, der
|
||||||
|
heute vier Fälle kennt.
|
||||||
|
- **Die Pflicht hängt an der Version**, nicht an der Vorlage: eine veröffentlichte Version ist
|
||||||
|
unveränderlich, ein laufender Lauf behält also seine Regel.
|
||||||
|
|
||||||
|
### Probleme
|
||||||
|
|
||||||
|
- **Die Aufforderung muss außerhalb der Transaktion entstehen.** Innerhalb würde sie mit dem Fehler
|
||||||
|
zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand weiß. Gelöst mit einem eigenen
|
||||||
|
Fehlertyp `FreigabeFehlt`, den `laufAbschliessen` außen fängt; dafür musste die Transaktion in
|
||||||
|
eine eigene Funktion. Ein Zeichenkettenvergleich auf die Meldung wäre eine Zusage über ihren
|
||||||
|
Wortlaut gewesen.
|
||||||
|
- **Drei rote Punkte in der Klickprobe waren meine eigenen Testfehler**, und einer ist lehrreich:
|
||||||
|
ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand, weil es den
|
||||||
|
Router-Cache von Next trifft. Die Action revalidiert den Pfad; ein Benutzer sieht das Ergebnis
|
||||||
|
**ohne** Neuladen. Nachgewiesen, bevor ich etwas geändert habe: in einem frischen Browser-Kontext
|
||||||
|
stand alles richtig da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
|
||||||
|
Die beiden anderen: ein Selektor traf versteckten Protokolltext, und ein Suchmuster `/freigeb/`
|
||||||
|
traf nichts — in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge gar nicht vor. Dreimal
|
||||||
|
gesucht, bis es auffiel.
|
||||||
|
- **Die Freigabepflicht wäre bei „Bearbeiten“ verloren gegangen**, hätte #039 es mir nicht gerade
|
||||||
|
gezeigt: dort verwarf die tiefe Kopie `nurFuerAbteilungen` und `bedingung`. Diesmal von Anfang an
|
||||||
|
mitgenommen — und das ist der ganze Wert eines Befundes, der verstanden statt nur behoben wurde.
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
- **M25 (#041)** PDF-Übergabeprotokoll. Der Freigeber steht jetzt am Lauf und gehört auf das Blatt.
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ Nicht im MVP umsetzen. Hier gesammelt, damit das Datenmodell nichts verbaut.
|
|||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
|
|
||||||
- [ ] PDF-Übergabeprotokoll mit Unterschrift
|
Geschnitten am 2026-10-11, nach dem Tag von v0.2.0. Vier Meilensteine, 34 h geschätzt; die
|
||||||
- [ ] Bedingte Schritte und Abschnitte
|
Reihenfolge ist nicht beliebig.
|
||||||
- [ ] Vier-Augen-Freigabe
|
|
||||||
- [ ] Weitere Schritt-Typen
|
| | Issue | Warum an dieser Stelle |
|
||||||
|
|---|---|---|
|
||||||
|
| M23 | [Bedingte Schritte und Abschnitte](039-bedingte-schritte.md) | zuerst, weil es als einziges am Datenmodell und an der Abschlussregel arbeitet — und weil der echte Fall im Seed steht: „Mitarbeiter-Onboarding“ trägt beide Konto-Abschnitte |
|
||||||
|
| M24 | [Vier-Augen-Freigabe](040-vier-augen-freigabe.md) | danach, weil der Abschluss dann einmal angefasst ist |
|
||||||
|
| M25 | [PDF-Übergabeprotokoll](041-pdf-uebergabeprotokoll.md) | nach M24, damit das Blatt den Freigeber nennen kann |
|
||||||
|
| M26 | [Weitere Schritt-Typen](042-weitere-schritt-typen.md) | zuletzt, weil `UNTERSCHRIFT` erst im PDF einen Zweck hat |
|
||||||
|
|
||||||
## Vorleistung im MVP
|
## Vorleistung im MVP
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# #039 M23 – Bedingte Schritte und Abschnitte
|
||||||
|
|
||||||
|
**Status:** erledigt
|
||||||
|
**Im Tracker:** –
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** phase-3, backend, frontend
|
||||||
|
**Geschätzt:** 12 h **Tatsächlich:** 5 h 20 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Eine Checkliste enthält heute **alles**, und was nicht zutrifft, wird von Hand auf
|
||||||
|
`NICHT_ZUTREFFEND` gesetzt. Der echte Fall steht im Seed: „Mitarbeiter-Onboarding“ trägt **beide**
|
||||||
|
Konto-Abschnitte — „Konto anlegen (Active Directory)“ mit 5 Schritten und „Konto anlegen (Entra
|
||||||
|
ID)“ mit 4 —, obwohl in jedem einzelnen Lauf nur einer gilt. Die Variable `konto_typ` sagt es
|
||||||
|
bereits; niemand wertet sie aus.
|
||||||
|
|
||||||
|
Neun Schritte, die ein Techniker bei jedem Onboarding wegklickt. Die Abschnitts-Aktion aus #013
|
||||||
|
macht es erträglich, aber sie ist ein Pflaster: der Fortschrittsbalken zählt die Schritte mit, und
|
||||||
|
im Archiv steht später eine Checkliste mit einem Abschnitt, den niemand je gebraucht hat.
|
||||||
|
|
||||||
|
Setzt [#031](031-abteilungsabschnitte.md) voraus: `geltendeAbschnitte` und `nurGeltende` sind die
|
||||||
|
Stelle, an der das sitzt.
|
||||||
|
|
||||||
|
## Zu entscheiden
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
Für Bedingungen reicht das **nicht**: eine Bedingung auf einer Variable (`konto_typ`) ließe
|
||||||
|
sich beim Start auswerten, eine Bedingung auf einem **Schrittergebnis** („wenn Schritt 3 =
|
||||||
|
nein, dann Abschnitt B“) nicht — die Antwort entsteht erst während der Arbeit.
|
||||||
|
|
||||||
|
Empfehlung: **laufend auswerten, Ergebnis mitschreiben.** Die Auswertung läuft bei jeder
|
||||||
|
Ä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.
|
||||||
|
- [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.
|
||||||
|
- [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.
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
- [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`
|
||||||
|
- [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)
|
||||||
|
- [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 …“
|
||||||
|
- [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
|
||||||
|
- [x] Abarbeitung: ausgeblendete Abschnitte verschwinden, ohne die Nummerierung zu verschieben
|
||||||
|
(`01 · Vorbereitung` kommt aus der Position und wird nie gespeichert)
|
||||||
|
- [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
|
||||||
|
- [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.
|
||||||
|
- [x] Anleitung Kapitel 04 (Vorlagen): wann eine Bedingung das richtige Mittel ist und wann eine
|
||||||
|
zweite Vorlage
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [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
|
||||||
|
- [x] Test: eine Bedingung auf einem Schrittergebnis schlägt um, **während** der Lauf läuft, und der
|
||||||
|
Abschnitt erscheint
|
||||||
|
- [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
|
||||||
|
- [x] Test: Export und Import einer Vorlage mit Bedingungen ergeben dieselbe Struktur, und der
|
||||||
|
Schritt-Verweis zeigt auf den richtigen Schritt der **neuen** Version
|
||||||
|
- [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.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# #040 M24 – Vier-Augen-Freigabe
|
||||||
|
|
||||||
|
**Status:** erledigt
|
||||||
|
**Im Tracker:** –
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** phase-3, backend, frontend
|
||||||
|
**Geschätzt:** 8 h **Tatsächlich:** 3 h 30 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Manche Arbeiten soll nicht derselbe abschließen, der sie gemacht hat: ein Firewall-Rollout, ein
|
||||||
|
Restore-Test, das Löschen eines Kontos. Heute kann jeder `TECHNIKER` jeden Lauf abschließen und
|
||||||
|
archivieren.
|
||||||
|
|
||||||
|
Gebraucht wird: eine Vorlage kann sagen „dieser Lauf braucht eine Freigabe“, und dann schließt ihn
|
||||||
|
ein **Zweiter** ab — nachweisbar im Protokoll.
|
||||||
|
|
||||||
|
## Zu entscheiden
|
||||||
|
|
||||||
|
- [x] **Freigabe am Lauf oder am Schritt?** Empfehlung: **am Lauf**, gesetzt von der Vorlage.
|
||||||
|
Am Schritt wäre feiner und verführt zu Checklisten, in denen an fünf Stellen jemand warten
|
||||||
|
muss; am Lauf ist es eine Aussage über die Arbeit als Ganzes und erklärt sich von selbst.
|
||||||
|
Schritt-Freigaben bleiben möglich, wenn sich der Bedarf zeigt — `VorlagenSchritt.konfig` ist
|
||||||
|
ein Json.
|
||||||
|
- [x] **Wer darf freigeben?** Empfehlung: ein eigenes Recht `freigeben`, standardmäßig bei `ADMIN`
|
||||||
|
und `REDAKTEUR` — **nicht** bei `TECHNIKER`. Begründung: die Vier-Augen-Regel soll nicht
|
||||||
|
davon abhängen, dass zufällig ein zweiter Techniker greifbar ist; und wer sie umgehen will,
|
||||||
|
braucht dann ein zweites Konto mit mehr Rechten, was im Protokoll steht.
|
||||||
|
- [x] **Darf der Bearbeiter sich selbst freigeben?** **Nein**, und das ist der ganze Sinn. Geprüft
|
||||||
|
wird gegen `Schrittergebnis.aktualisiertVonId` **und** `Lauf.erstelltVonId`: wer irgendetwas
|
||||||
|
daran getan hat, gibt nicht frei. Ein `ADMIN`, der allein im Haus ist, kommt damit nicht
|
||||||
|
weiter — das ist eine bewusste Härte, und die Anleitung sagt es.
|
||||||
|
- [x] **Was, wenn die Freigabe verweigert wird?** Empfehlung: ein Zustand „zurückgewiesen“ mit
|
||||||
|
Pflichtnotiz. Der Lauf geht zurück auf `IN_ARBEIT`, die Notiz steht im Protokoll und an der
|
||||||
|
Abarbeitungsansicht. Kein eigener `LaufStatus` — das wäre eine Änderung an einem Enum, das
|
||||||
|
vier Jahre halten soll; es genügt ein Feld plus Protokolleintrag.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [x] Migration: `freigabePflicht Boolean` an `VorlagenVersion`, dazu `freigegebenVonId`,
|
||||||
|
`freigegebenAm` und `freigabeNotiz` am `Lauf`; CHECK, dass `freigegebenVonId` und
|
||||||
|
`freigegebenAm` zusammen gesetzt sind (wie bei `abgeschlossenAm`)
|
||||||
|
- [x] CHECK: `freigegebenVonId` ist **nicht** `erstelltVonId` — die Regel gehört in die Datenbank,
|
||||||
|
nicht nur in den Dienst (CLAUDE.md)
|
||||||
|
- [x] Recht `freigeben` in `RECHTE`, Rollenmatrix-Test erweitern
|
||||||
|
- [x] Dienst: `laufFreigeben(laufId, benutzer, notiz?)` und `laufZurueckweisen(laufId, benutzer,
|
||||||
|
notiz)`; Abschluss verlangt die Freigabe, wenn die Version sie fordert
|
||||||
|
- [x] Die Prüfung „wer hat daran gearbeitet“ an **einer** Stelle, wie `laufMussVeraenderbarSein`
|
||||||
|
- [x] Benachrichtigung: ein neuer `Meldeanlass` `LAUF_FREIGABE_OFFEN` an die Rollen mit dem Recht —
|
||||||
|
sonst wartet ein Lauf, von dem niemand weiß
|
||||||
|
- [x] Oberfläche: Hinweis am Lauf („wartet auf Freigabe durch einen Zweiten“), Knöpfe für Freigeben
|
||||||
|
und Zurückweisen, Notizfeld; die Übersicht bekommt einen Filter „wartet auf meine Freigabe“
|
||||||
|
- [x] Editor: Häkchen „dieser Lauf braucht eine Freigabe“ an der Version
|
||||||
|
- [x] Druckansicht und PDF nennen Freigeber und Zeitpunkt
|
||||||
|
- [x] Anleitung: Kapitel 02 (Läufe abarbeiten) und 04 (Vorlagen), mit dem Hinweis, dass ein
|
||||||
|
Einzelkämpfer sich nicht selbst freigeben kann
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [x] Test: ein Lauf mit Freigabepflicht lässt sich nicht abschließen, solange keine Freigabe steht
|
||||||
|
- [x] Test: der Bearbeiter kann **nicht** freigeben — auch nicht als `ADMIN`
|
||||||
|
- [x] Test: die Datenbank weist es ebenfalls ab (CHECK), nicht nur der Dienst
|
||||||
|
- [x] Test: eine Rolle ohne `freigeben` wird abgewiesen, über jede Server Action
|
||||||
|
- [x] Test: Zurückweisen verlangt eine Notiz und setzt den Lauf auf `IN_ARBEIT`
|
||||||
|
- [x] Test: die Meldung geht an die Rollen mit dem Recht und nicht an alle
|
||||||
|
- [x] Test: ein Lauf **ohne** Freigabepflicht verhält sich unverändert — bestehende Läufe und
|
||||||
|
Vorlagen merken nichts
|
||||||
|
- [x] Am Handy kein horizontales Scrollen
|
||||||
|
|
||||||
|
## Beim Umsetzen dazugekommen
|
||||||
|
|
||||||
|
- **Die vier Entscheidungen sind so gefallen wie empfohlen.** Zwei haben sich im Code als besonders
|
||||||
|
tragend erwiesen:
|
||||||
|
- **Die Freigabe hängt an der Version, nicht an der Vorlage.** Eine veröffentlichte Version ist
|
||||||
|
unveränderlich — ein laufender Lauf kann seine Regel damit nicht unter den Füßen verlieren. In
|
||||||
|
der Versionsliste steht an jeder Fassung, ob sie eine Freigabe verlangt.
|
||||||
|
- **Kein fünfter `LaufStatus` für „zurückgewiesen“.** Der Lauf geht auf `IN_ARBEIT`, die
|
||||||
|
Begründung steht in `freigabeNotiz` und im Protokoll. Ein neuer Enum-Wert hätte jeden Codepfad
|
||||||
|
betroffen, der heute vier Fälle kennt.
|
||||||
|
- **Als „gearbeitet“ zählt dreierlei** — angelegt, ein Schrittergebnis gesetzt, abgeschlossen.
|
||||||
|
Der **Zuständige** gilt bewusst nicht als Bearbeiter: eine Zuweisung ist noch keine Arbeit, und
|
||||||
|
wäre die Regel hier strenger, blockierte sie kleine Teams ohne Gewinn. Die Prüfung sitzt in
|
||||||
|
`hatDaranGearbeitet`, an einer Stelle — wie `laufMussVeraenderbarSein`.
|
||||||
|
- **Die Aufforderung entsteht außerhalb der Transaktion**, und das war die einzige knifflige Stelle:
|
||||||
|
der Abschlussversuch scheitert mit einem eigenen Fehlertyp `FreigabeFehlt`, den
|
||||||
|
`laufAbschliessen` außen fängt, um die Meldung einzureihen und dann weiterzuwerfen. Innerhalb
|
||||||
|
würde sie mit dem Fehler zurückgerollt — und dann wartet ein Lauf auf etwas, von dem niemand
|
||||||
|
weiß. Dafür musste die Transaktion in eine eigene Funktion (`abschlussVersuchen`).
|
||||||
|
- **Ein anderer Empfängerkreis als bei jeder anderen Meldung**: nicht der Zuständige, sondern jeder
|
||||||
|
mit dem Recht `freigeben` — ohne die, die gearbeitet haben. Eine Aufforderung zu etwas, das der
|
||||||
|
Dienst abweist, ist schlimmer als keine.
|
||||||
|
- **Ein partieller Unique-Index** (`Zustellung_freigabe_einmal`) hält die Mahnung bei einem Lauf
|
||||||
|
und Empfänger auf einmal. Ein Techniker versucht es mehrmals; ohne den Index wäre jede Mail für
|
||||||
|
sich korrekt und in Summe eine Belästigung — derselbe Fall wie „überfällig“ in #034, nur
|
||||||
|
schneller erreicht.
|
||||||
|
- **Die Freigabepflicht wandert bei „Bearbeiten“ und „Duplizieren“ mit.** Das war kein Zufall,
|
||||||
|
sondern die unmittelbare Lehre aus #039: dort hatte die tiefe Kopie `nurFuerAbteilungen` und
|
||||||
|
`bedingung` stillschweigend verworfen. Eine Vorlage soll ihre Freigabepflicht nicht durch eine
|
||||||
|
Textkorrektur verlieren.
|
||||||
|
|
||||||
|
## Was die Klickprobe gezeigt hat
|
||||||
|
|
||||||
|
Elf Prüfungen am gebauten Stand, alle grün — von „Freigabe-Karte sichtbar“ über „Zurückweisen
|
||||||
|
verlangt eine Begründung“ bis „Häkchen aus der Vorversion übernommen“ und 390 px.
|
||||||
|
|
||||||
|
**Drei rote Punkte waren meine eigenen Testfehler**, und der dritte ist festzuhalten:
|
||||||
|
|
||||||
|
1. Ein Selektor traf den **versteckten** Protokolltext „Lauf abgeschlossen“ statt der Erfolgsmeldung.
|
||||||
|
2. Ein Suchmuster `/freigeb/` traf nichts: in „Freigabe“ und „freigegeben“ kommt diese Zeichenfolge
|
||||||
|
gar nicht vor. Dreimal gesucht, bis es auffiel.
|
||||||
|
3. **Ein `reload()` direkt nach einer Server Action zeigt kurz den alten Stand.** Die Action
|
||||||
|
revalidiert den Pfad, und der Benutzer sieht das Ergebnis ohne Neuladen — ein Reload im selben
|
||||||
|
Kontext trifft dagegen den Router-Cache von Next. Das ist Next und kein Fehler dieser Anwendung,
|
||||||
|
aber es ist auch nicht, was ein Mensch tut. Die Probe prüft jetzt das echte Verhalten.
|
||||||
|
|
||||||
|
Nachgewiesen, bevor etwas geändert wurde: in einem frischen Browser-Kontext stand alles richtig
|
||||||
|
da, und die Datenbank trug Freigeber, Zeitpunkt und Notiz.
|
||||||
|
|
||||||
|
## Was offen bleibt
|
||||||
|
|
||||||
|
- **Freigaben an einzelnen Schritten** gibt es nicht, bewusst. Am Lauf ist es eine Aussage über die
|
||||||
|
Arbeit als Ganzes und erklärt sich von selbst; am Schritt verführte es zu Checklisten, in denen an
|
||||||
|
fünf Stellen jemand warten muss. `VorlagenSchritt.konfig` ist ein Json — wenn sich der Bedarf
|
||||||
|
zeigt, ist der Platz da.
|
||||||
|
- **Ein Einzelkämpfer kommt nicht weiter**: wer allein ist und an dem Lauf gearbeitet hat, kann ihn
|
||||||
|
nicht freigeben, auch nicht als Administrator. Das ist die Härte, die die Regel ausmacht, und die
|
||||||
|
Anleitung sagt es ausdrücklich.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# #041 M25 – PDF-Übergabeprotokoll mit Unterschrift
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Im Tracker:** –
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** phase-3, backend, frontend
|
||||||
|
**Geschätzt:** 8 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Wer ein Notebook übergibt, braucht ein Blatt, das der Empfänger unterschreibt — und das danach
|
||||||
|
auffindbar ist. Heute gibt es die Druckansicht eines Laufs (`/drucken/lauf/[id]`, #015): sie ist
|
||||||
|
hell, vollständig und als Vorstufe genau dafür gebaut. Was fehlt, ist die Datei.
|
||||||
|
|
||||||
|
Ein Browser-Ausdruck genügt nicht: er hängt am Drucker-Dialog des Betrachters, trägt dessen
|
||||||
|
Kopfzeilen und lässt sich nicht an ein Ticket anhängen.
|
||||||
|
|
||||||
|
## Zu entscheiden
|
||||||
|
|
||||||
|
- [ ] **Wie entsteht das PDF?** Empfehlung: **Chromium aus `@playwright/test`**, wie
|
||||||
|
`scripts/anleitung-pdf.ts` es für die Anleitung tut. Keine neue Abhängigkeit, keine zweite
|
||||||
|
Auslegung des Layouts — gedruckt wird dieselbe Seite, die die Druckansicht zeigt.
|
||||||
|
**Grenze, die genannt gehört:** damit braucht der Server einen Chromium. Im Compose-Stand ist
|
||||||
|
das eine Ergänzung am Image, nativ ein Paket. Die Alternative (`pdf-lib` oder ein
|
||||||
|
Dienst) wäre ein zweites Layout, das auseinanderläuft — der Preis ist höher.
|
||||||
|
- [ ] **Unterschrift auf Papier oder auf dem Gerät?** Empfehlung: **beides, in dieser Reihenfolge.**
|
||||||
|
Zuerst ein Unterschriftsfeld im PDF (eine Linie, Name, Datum) — das löst den Fall heute und
|
||||||
|
kostet fast nichts. Eine Unterschrift mit dem Finger auf dem Handy ist ein eigener Schritt-Typ
|
||||||
|
und gehört zu [#042](042-weitere-schritt-typen.md); sie landet dann als Bild im PDF.
|
||||||
|
Eine digitale **Signatur** im kryptografischen Sinn ist ausdrücklich **nicht** gemeint und
|
||||||
|
wäre eine eigene Phase.
|
||||||
|
- [ ] **Wird das PDF gespeichert oder bei jedem Abruf erzeugt?** Empfehlung: **bei jedem Abruf
|
||||||
|
erzeugt**, nicht gespeichert. Ein gespeichertes PDF ist eine zweite Wahrheit neben dem Lauf;
|
||||||
|
wird eine Notiz später berichtigt, stimmt es nicht mehr. Für den Fall „wir brauchen das Blatt
|
||||||
|
von damals“ gibt es das Archiv, das den Lauf unveränderlich hält.
|
||||||
|
**`{{generiertes_passwort}}` erscheint auch hier nicht** — die Druckansicht zeigt
|
||||||
|
`[Passwort – nicht gespeichert]`, und das PDF übernimmt das.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] `GET /api/laeufe/[id]/protokoll.pdf` — Route Handler, prüft sein Recht **selbst**
|
||||||
|
(`tests/zugang.test.ts` erzwingt das seit #038)
|
||||||
|
- [ ] Erzeugung über Chromium, gegen die eigene Druckansicht; eine Seitenvorlage mit Kopf (Kunde,
|
||||||
|
Lauf, Vorlage, Version), Inhalt und Fuß (Bearbeiter, Freigeber aus
|
||||||
|
[#040](040-vier-augen-freigabe.md), Unterschriftsfeld)
|
||||||
|
- [ ] Ein **Chromium-Vorrat** im Prozess: ihn je Anfrage zu starten kostet Sekunden. Eine Instanz,
|
||||||
|
die nach Leerlauf schließt — und eine Obergrenze gleichzeitiger Erzeugungen, wie
|
||||||
|
`SSE_MAX_STROEME` in #033
|
||||||
|
- [ ] Knopf „Protokoll als PDF“ an Lauf und Archiv
|
||||||
|
- [ ] `Dockerfile` und `deploy/install.sh` um Chromium ergänzen, **mit gemessener Bildgröße** im
|
||||||
|
Changelog — eine Ergänzung am Image, die niemand bemerkt, ist eine böse Überraschung beim
|
||||||
|
nächsten Rollout
|
||||||
|
- [ ] `docs/betrieb/` sagt, was die Erzeugung braucht und was passiert, wenn Chromium fehlt:
|
||||||
|
**eine Meldung, kein stilles leeres PDF**
|
||||||
|
- [ ] Anleitung Kapitel 02 und 10 (Archiv)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test: das PDF enthält Titel, Vorlage mit Version, alle geltenden Schritte mit Zustand, Notizen
|
||||||
|
und Kommentare — geprüft am extrahierten Text, nicht am Vorhandensein der Datei
|
||||||
|
- [ ] Test: es enthält **kein** erzeugtes Passwort, über alle Schritt-Typen
|
||||||
|
- [ ] Test: ein Lauf eines fremden Kunden ist über die Adresse nicht abrufbar (wie #032 nach #036)
|
||||||
|
- [ ] Test: ohne Recht `401`, mit falschem Kunden `404`
|
||||||
|
- [ ] Test: fehlt Chromium, kommt eine Meldung mit Hinweis auf die Betriebsdoku — kein leeres PDF
|
||||||
|
- [ ] Test: zwei gleichzeitige Erzeugungen laufen, die dritte wartet oder wird abgewiesen (die
|
||||||
|
Obergrenze gilt)
|
||||||
|
- [ ] Ein Blatt ist ausgedruckt und gegengesehen worden — das ist eine Abnahme von Hand und steht
|
||||||
|
als solche im Devlog
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# #042 M26 – Weitere Schritt-Typen
|
||||||
|
|
||||||
|
**Status:** offen
|
||||||
|
**Im Tracker:** –
|
||||||
|
**Meilenstein:** v0.3.0
|
||||||
|
**Labels:** phase-3, backend, frontend
|
||||||
|
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
`SchrittTyp` hat heute sechs Werte: `HAKEN TEXT ZAHL AUSWAHL DATUM BEFEHL`. Das Enum und das
|
||||||
|
Json-Feld `konfig` sind seit #006 genau dafür gebaut, dass neue Typen ohne Migration der
|
||||||
|
bestehenden dazukommen.
|
||||||
|
|
||||||
|
Die Frage ist nicht, **wie** ein Typ dazukommt, sondern **welche** — und das Issue nennt sie
|
||||||
|
absichtlich noch nicht als beschlossen: ein Typ, der im Editor steht und in keiner Vorlage
|
||||||
|
auftaucht, ist ungenutzter und damit ungeprüfter Code.
|
||||||
|
|
||||||
|
## Zu entscheiden
|
||||||
|
|
||||||
|
- [ ] **Welche Typen?** Vorschlag, nach Nutzen geordnet, jeder mit dem Fall, der ihn verlangt:
|
||||||
|
|
||||||
|
| Typ | Der Fall | Ohne ihn |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATEI` | „Foto des Typenschilds“ als **Pflicht** | ein Anhang ist seit #032 möglich, aber nie verlangt — ein Lauf schließt ohne ihn ab |
|
||||||
|
| `UNTERSCHRIFT` | Übergabe mit dem Finger auf dem Handy | das Blatt muss gedruckt und wieder eingescannt werden ([#041](041-pdf-uebergabeprotokoll.md)) |
|
||||||
|
| `MEHRFACHAUSWAHL` | „welche Lizenzen zugewiesen“ | heute ein Textfeld, also nicht auswertbar |
|
||||||
|
| `URL` | „Link zum Ticket“ | ein Textfeld ohne Prüfung und ohne Klick |
|
||||||
|
| `UEBERSCHRIFT` | eine Zwischenüberschrift ohne Zustand | ein `HAKEN`, den jemand abhaken muss, damit der Lauf fertig wird |
|
||||||
|
|
||||||
|
**Empfehlung: `DATEI` und `UNTERSCHRIFT`**, und die drei anderen nur, wenn eine Seed-Vorlage
|
||||||
|
sie wirklich benutzt. `UEBERSCHRIFT` ist der zweifelhafteste: ein Schritt ohne Zustand bricht
|
||||||
|
die Annahme „jeder Schritt hat ein `Schrittergebnis`“, und die steckt an vielen Stellen.
|
||||||
|
- [ ] **Bleibt `Schrittergebnis.wert` ein Json?** Ja — es ist seit #006 eines, genau dafür. Eine
|
||||||
|
Mehrfachauswahl ist ein Array, eine Unterschrift ein Verweis auf einen `Anhang`.
|
||||||
|
- [ ] **Wie verträgt sich `DATEI` mit dem Archiv-Schreibschutz?** Ein Pflicht-Anhang muss vor dem
|
||||||
|
Abschluss da sein; danach kommt keiner mehr weg (#032). Das ist bereits so und braucht nur
|
||||||
|
einen Test.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
|
||||||
|
- [ ] Je gewählten Typ: Enum-Wert, Zod-Schema für `konfig` und `wert`, Darstellung im Editor,
|
||||||
|
Darstellung in der Abarbeitung, Darstellung im Druck und im PDF
|
||||||
|
- [ ] Die Abschlussregel kennt den neuen Typ: was zählt als „erledigt“? Bei `DATEI` ein Anhang, bei
|
||||||
|
`UNTERSCHRIFT` ein Bild, bei `MEHRFACHAUSWAHL` mindestens eine Wahl
|
||||||
|
- [ ] `UNTERSCHRIFT`: Zeichenfläche am Handy (Pointer-Ereignisse, nicht Touch-spezifisch), Ergebnis
|
||||||
|
als PNG über den Anhänge-Weg aus #032 — **keine neue Ablage**
|
||||||
|
- [ ] Export/Import und die Vorlagen-Austauschform um die neuen Typen erweitern, nachsichtig: eine
|
||||||
|
Datei mit einem unbekannten Typ wird abgewiesen **mit Nennung des Typs**, nicht stillschweigend
|
||||||
|
verändert
|
||||||
|
- [ ] Mindestens eine Seed-Vorlage benutzt jeden neuen Typ — sonst ist er ungeprüfter Code
|
||||||
|
- [ ] Anleitung Kapitel 04, mit dem Satz, wann ein Typ das richtige Mittel ist
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
|
- [ ] Test je Typ: anlegen, abarbeiten, abschließen, drucken
|
||||||
|
- [ ] Test: ein `DATEI`-Pflichtschritt ohne Anhang hält den Abschluss auf
|
||||||
|
- [ ] Test: eine Unterschrift landet als `Anhang` und nicht als Json-Blob in `wert`
|
||||||
|
- [ ] Test: ein Lauf einer **älteren** Version verhält sich unverändert — die neuen Typen tauchen
|
||||||
|
dort nicht auf, und keine Migration hat Daten angefasst
|
||||||
|
- [ ] Test: eine Austauschdatei mit unbekanntem Typ wird abgewiesen und nennt den Typ
|
||||||
|
- [ ] Am Handy bedienbar, bei 390 px geprüft — besonders die Zeichenfläche
|
||||||
@@ -43,4 +43,8 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
|||||||
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min |
|
| #036 | [Reviewbefunde aus der Durchsicht von Phase 2](036-reviewbefunde-phase-2.md) | erledigt | v0.2.0 | 4 h | 3 h 40 min |
|
||||||
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
|
| #037 | [Zwei Nachzügler aus der Durchsicht von Phase 2](037-anhaenge-lesemodell.md) | erledigt | v0.2.0 | 2 h | 1 h 50 min |
|
||||||
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
|
| #038 | [Die E2E-Abläufe laufen in keinem Tor und waren monatelang rot](038-e2e-im-tor.md) | erledigt | v0.2.0 | 3 h | 1 h 35 min |
|
||||||
| | **Summe** | | | **238 h** | **73 h 55 min** |
|
| #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) | erledigt | v0.3.0 | 8 h | 3 h 30 min |
|
||||||
|
| #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** | **82 h 45 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 $$;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 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.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Vier-Augen-Freigabe (#040).
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein neuer Meldeanlass. Ohne ihn wartet ein Lauf auf eine Freigabe, von der
|
||||||
|
* niemand weiss — und das ist der Fall, der die ganze Regel wertlos macht:
|
||||||
|
* sie haelt den Abschluss auf, und keiner merkt es.
|
||||||
|
*/
|
||||||
|
ALTER TYPE "Meldeanlass" ADD VALUE IF NOT EXISTS 'LAUF_FREIGABE_OFFEN' BEFORE 'ABGLEICH_FEHLGESCHLAGEN';
|
||||||
|
|
||||||
|
ALTER TABLE "VorlagenVersion" ADD COLUMN "freigabePflicht" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD COLUMN "freigegebenVonId" TEXT,
|
||||||
|
ADD COLUMN "freigegebenAm" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "freigabeNotiz" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_freigegebenVonId_fkey"
|
||||||
|
FOREIGN KEY ("freigegebenVonId") REFERENCES "Benutzer"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "Lauf_freigegebenVonId_idx" ON "Lauf"("freigegebenVonId");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Freigeber und Zeitpunkt gehoeren zusammen, in beide Richtungen — wie bei
|
||||||
|
* `abgeschlossenAm`. Ohne den Zeitpunkt liesse sich nicht sagen, wann die
|
||||||
|
* Freigabe erteilt wurde, und ein Zeitpunkt ohne Freigeber behauptete eine
|
||||||
|
* Freigabe, die niemand gegeben hat.
|
||||||
|
*/
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_freigabe_mit_zeitpunkt"
|
||||||
|
CHECK (("freigegebenVonId" IS NULL) = ("freigegebenAm" IS NULL));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Der Kern der Vier-Augen-Regel, in der Datenbank.**
|
||||||
|
*
|
||||||
|
* Wer den Lauf angelegt hat, gibt ihn nicht frei. Das prueft auch der Dienst,
|
||||||
|
* und zwar strenger: er sieht ausserdem nach, wer an den Schrittergebnissen
|
||||||
|
* gearbeitet hat (`Schrittergebnis.aktualisiertVonId`). Dafuer braeuchte ein
|
||||||
|
* CHECK eine Unterabfrage, und die gehoert nicht hinein.
|
||||||
|
*
|
||||||
|
* Diese Haelfte steht trotzdem hier, weil sie die wichtigere ist: der Ersteller
|
||||||
|
* ist der naheliegende Fall, und die Regel muss auch dann gelten, wenn jemand an
|
||||||
|
* der Service-Schicht vorbei schreibt (CLAUDE.md). Eine Freigabe, die in der
|
||||||
|
* Datenbank steht, ist damit nachweislich nicht vom Ersteller.
|
||||||
|
*/
|
||||||
|
ALTER TABLE "Lauf"
|
||||||
|
ADD CONSTRAINT "Lauf_freigabe_nicht_selbst"
|
||||||
|
CHECK ("freigegebenVonId" IS NULL OR "freigegebenVonId" <> "erstelltVonId");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Die Freigabe-Aufforderung geht je Lauf und Empfaenger genau einmal hinaus.**
|
||||||
|
*
|
||||||
|
* Derselbe Grund wie bei „ueberfaellig“ (#034): sie entsteht bei einem
|
||||||
|
* gescheiterten Abschlussversuch, und ein Techniker versucht es mehrmals — ohne
|
||||||
|
* diesen Index bekaeme jeder Freigabeberechtigte bei jedem Versuch eine Mail,
|
||||||
|
* und jede davon waere fuer sich korrekt.
|
||||||
|
*
|
||||||
|
* Partiell, weil es nur fuer diesen einen Anlass gilt: nach einer Zurueckweisung
|
||||||
|
* und erneuter Fertigstellung soll **nicht** wieder gemahnt werden — der
|
||||||
|
* Zurueckweisende weiss ja, dass er wartet. Dafuer traegt die Zurueckweisung
|
||||||
|
* ihre eigene Notiz an der Abarbeitung.
|
||||||
|
*/
|
||||||
|
CREATE UNIQUE INDEX "Zustellung_freigabe_einmal"
|
||||||
|
ON "Zustellung"("laufId", "empfaenger")
|
||||||
|
WHERE "anlass" = 'LAUF_FREIGABE_OFFEN';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gegenprobe: die neuen Spalten sind ueberall leer, und keine bestehende Version
|
||||||
|
* verlangt eine Freigabe. `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
|
||||||
|
anzahl INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO anzahl FROM "Lauf" WHERE "freigegebenVonId" IS NOT NULL;
|
||||||
|
IF anzahl > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Unerwartet % Laeufe mit Freigabe vor der Migration.', anzahl;
|
||||||
|
END IF;
|
||||||
|
SELECT count(*) INTO anzahl FROM "VorlagenVersion" WHERE "freigabePflicht";
|
||||||
|
IF anzahl > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Unerwartet % Versionen mit Freigabepflicht vor der Migration.', anzahl;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -162,6 +162,8 @@ enum Meldeanlass {
|
|||||||
LAUF_ZUGEWIESEN
|
LAUF_ZUGEWIESEN
|
||||||
LAUF_UEBERFAELLIG
|
LAUF_UEBERFAELLIG
|
||||||
LAUF_ABGESCHLOSSEN
|
LAUF_ABGESCHLOSSEN
|
||||||
|
/// Ein Lauf wartet auf eine Freigabe durch einen Zweiten (#040).
|
||||||
|
LAUF_FREIGABE_OFFEN
|
||||||
ABGLEICH_FEHLGESCHLAGEN
|
ABGLEICH_FEHLGESCHLAGEN
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +275,7 @@ model Benutzer {
|
|||||||
geaendertAm DateTime @default(now()) @updatedAt
|
geaendertAm DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
|
abgeschlosseneLaeufe Lauf[] @relation("LaufAbgeschlossenVon")
|
||||||
|
freigegebeneLaeufe Lauf[] @relation("LaufFreigegebenVon")
|
||||||
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
|
archivierteLaeufe Lauf[] @relation("LaufArchiviertVon")
|
||||||
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
|
erstellteLaeufe Lauf[] @relation("LaufErstelltVon")
|
||||||
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
|
zugewieseneLaeufe Lauf[] @relation("LaufZugewiesenAn")
|
||||||
@@ -578,6 +581,13 @@ model VorlagenVersion {
|
|||||||
vorlageId String
|
vorlageId String
|
||||||
version Int
|
version Int
|
||||||
status VersionStatus @default(ENTWURF)
|
status VersionStatus @default(ENTWURF)
|
||||||
|
/// Verlangt dieser Lauf eine Freigabe durch einen Zweiten (#040)?
|
||||||
|
///
|
||||||
|
/// An der **Version** und nicht an der Vorlage: eine veroeffentlichte Version
|
||||||
|
/// ist unveraenderlich, also kann ein laufender Lauf seine Regel nicht unter
|
||||||
|
/// den Fuessen verlieren. Wer die Pflicht aendert, veroeffentlicht eine neue
|
||||||
|
/// Fassung — bestehende Laeufe behalten ihre.
|
||||||
|
freigabePflicht Boolean @default(false)
|
||||||
aenderungsnotiz String?
|
aenderungsnotiz String?
|
||||||
veroeffentlichtAm DateTime?
|
veroeffentlichtAm DateTime?
|
||||||
veroeffentlichtVonId String?
|
veroeffentlichtVonId String?
|
||||||
@@ -636,6 +646,17 @@ model VorlagenAbschnitt {
|
|||||||
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
|
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
|
||||||
/// Feld dafuer waere eine zweite Wahrheit.
|
/// Feld dafuer waere eine zweite Wahrheit.
|
||||||
bausteinVersionId String?
|
bausteinVersionId String?
|
||||||
|
/// Wann dieser Abschnitt gilt (#039). Leer heisst: immer.
|
||||||
|
///
|
||||||
|
/// Form und Auswertung stehen in `src/lib/bedingungen.ts`; ein CHECK haelt die
|
||||||
|
/// Form auch gegen Schreiben an der Dienstschicht vorbei fest, und ein Trigger
|
||||||
|
/// haelt fest, dass eine Schritt-Bedingung auf **dieselbe Version** zeigt —
|
||||||
|
/// nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
|
||||||
|
/// entscheidbar, und der Abschnitt erschiene stillschweigend immer.
|
||||||
|
///
|
||||||
|
/// **Nicht entscheidbar heisst: gilt.** Ein Abschnitt zu viel ist sichtbar und
|
||||||
|
/// korrigierbar; ein fehlender faellt niemandem auf.
|
||||||
|
bedingung Json?
|
||||||
|
|
||||||
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||||
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
|
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
|
||||||
@@ -719,6 +740,9 @@ model VorlagenSchritt {
|
|||||||
/// Parameter fuer Schritte vom Typ AKTION.
|
/// Parameter fuer Schritte vom Typ AKTION.
|
||||||
konfig Json @default("{}")
|
konfig Json @default("{}")
|
||||||
dokuUrl String?
|
dokuUrl String?
|
||||||
|
/// Wann dieser Schritt gilt (#039). Leer heisst: immer. Siehe
|
||||||
|
/// `VorlagenAbschnitt.bedingung` — dieselbe Form, dieselben Regeln.
|
||||||
|
bedingung Json?
|
||||||
|
|
||||||
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
|
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
|
||||||
ergebnisse Schrittergebnis[]
|
ergebnisse Schrittergebnis[]
|
||||||
@@ -771,6 +795,20 @@ model Lauf {
|
|||||||
gestartetAm DateTime?
|
gestartetAm DateTime?
|
||||||
abgeschlossenAm DateTime?
|
abgeschlossenAm DateTime?
|
||||||
abgeschlossenVonId String?
|
abgeschlossenVonId String?
|
||||||
|
/// Wer diesen Lauf freigegeben hat (#040), und wann.
|
||||||
|
///
|
||||||
|
/// **Nicht der Ersteller** — das haelt ein CHECK fest
|
||||||
|
/// (`Lauf_freigabe_nicht_selbst`). Der Dienst prueft strenger: er sieht auch
|
||||||
|
/// nach, wer an den Schrittergebnissen gearbeitet hat; dafuer braeuchte ein
|
||||||
|
/// CHECK eine Unterabfrage.
|
||||||
|
///
|
||||||
|
/// `freigabeNotiz` traegt die Begruendung — bei einer Freigabe freiwillig, bei
|
||||||
|
/// einer Zurueckweisung Pflicht. Sie bleibt stehen, wenn der Lauf danach
|
||||||
|
/// wieder freigegeben wird: das Protokoll haelt beide Vorgaenge fest, und die
|
||||||
|
/// letzte Notiz ist die, die jemand an der Abarbeitung lesen soll.
|
||||||
|
freigegebenVonId String?
|
||||||
|
freigegebenAm DateTime?
|
||||||
|
freigabeNotiz String?
|
||||||
archiviertAm DateTime?
|
archiviertAm DateTime?
|
||||||
archiviertVonId String?
|
archiviertVonId String?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
@@ -784,6 +822,7 @@ model Lauf {
|
|||||||
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
|
erstelltVon Benutzer? @relation("LaufErstelltVon", fields: [erstelltVonId], references: [id], onDelete: Restrict)
|
||||||
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
|
zugewiesenAn Benutzer? @relation("LaufZugewiesenAn", fields: [zugewiesenAnId], references: [id], onDelete: Restrict)
|
||||||
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict)
|
abgeschlossenVon Benutzer? @relation("LaufAbgeschlossenVon", fields: [abgeschlossenVonId], references: [id], onDelete: Restrict)
|
||||||
|
freigegebenVon Benutzer? @relation("LaufFreigegebenVon", fields: [freigegebenVonId], references: [id], onDelete: Restrict)
|
||||||
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
|
archiviertVon Benutzer? @relation("LaufArchiviertVon", fields: [archiviertVonId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
ergebnisse Schrittergebnis[]
|
ergebnisse Schrittergebnis[]
|
||||||
@@ -798,6 +837,7 @@ model Lauf {
|
|||||||
@@index([kundeId, status])
|
@@index([kundeId, status])
|
||||||
@@index([geraetId, status])
|
@@index([geraetId, status])
|
||||||
@@index([mitarbeiterId])
|
@@index([mitarbeiterId])
|
||||||
|
@@index([freigegebenVonId])
|
||||||
@@index([zugewiesenAnId, status])
|
@@index([zugewiesenAnId, status])
|
||||||
@@index([status, faelligAm])
|
@@index([status, faelligAm])
|
||||||
@@index([vorlagenVersionId])
|
@@index([vorlagenVersionId])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { readFileSync, readdirSync } from "node:fs";
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
|
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
|
||||||
@@ -199,13 +200,32 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Erst alles anlegen, dann die Bedingungen (#039) — wie beim Import und
|
||||||
|
* aus demselben Grund: eine Schritt-Bedingung verweist ueber einen
|
||||||
|
* Titel-Pfad, und der laesst sich erst aufloesen, wenn die Schritte
|
||||||
|
* stehen. Die Seed-Vorlagen benutzen bisher nur Variablen-Bedingungen;
|
||||||
|
* der zweite Durchgang steht trotzdem hier, damit eine kuenftige Vorlage
|
||||||
|
* mit Schritt-Bedingung nicht stillschweigend ohne sie landet.
|
||||||
|
*/
|
||||||
|
const kennungen = new Map<string, string>();
|
||||||
|
const abschnittsZeilen: string[] = [];
|
||||||
|
|
||||||
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
|
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
|
||||||
const zeile = await tx.vorlagenAbschnitt.create({
|
const zeile = await tx.vorlagenAbschnitt.create({
|
||||||
data: { versionId: version.id, titel: abschnitt.titel, position },
|
data: {
|
||||||
|
versionId: version.id,
|
||||||
|
titel: abschnitt.titel,
|
||||||
|
position,
|
||||||
|
// Wandert mit, auch wenn bisher keine Seed-Vorlage eine hat (#031).
|
||||||
|
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (abschnitt.schritte.length === 0) continue;
|
abschnittsZeilen.push(zeile.id);
|
||||||
await tx.vorlagenSchritt.createMany({
|
|
||||||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
|
||||||
|
const angelegt = await tx.vorlagenSchritt.create({
|
||||||
|
data: {
|
||||||
abschnittId: zeile.id,
|
abschnittId: zeile.id,
|
||||||
titel: schritt.titel,
|
titel: schritt.titel,
|
||||||
hilfetext: schritt.hilfetext ?? null,
|
hilfetext: schritt.hilfetext ?? null,
|
||||||
@@ -217,8 +237,34 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
|
|||||||
konfig: schritt.konfig as object,
|
konfig: schritt.konfig as object,
|
||||||
dokuUrl: schritt.dokuUrl ?? null,
|
dokuUrl: schritt.dokuUrl ?? null,
|
||||||
position: schrittPosition,
|
position: schrittPosition,
|
||||||
})),
|
},
|
||||||
});
|
});
|
||||||
|
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
|
||||||
|
const abschnittId = abschnittsZeilen[position];
|
||||||
|
if (abschnittId && abschnitt.bedingung) {
|
||||||
|
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
|
||||||
|
if (aufgeloest) {
|
||||||
|
await tx.vorlagenAbschnitt.update({
|
||||||
|
where: { id: abschnittId },
|
||||||
|
data: { bedingung: aufgeloest as object },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const schritt of abschnitt.schritte) {
|
||||||
|
if (!schritt.bedingung) continue;
|
||||||
|
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
|
||||||
|
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
|
||||||
|
if (schrittId && aufgeloest) {
|
||||||
|
await tx.vorlagenSchritt.update({
|
||||||
|
where: { id: schrittId },
|
||||||
|
data: { bedingung: aufgeloest as object },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.vorlagenVersion.update({
|
await tx.vorlagenVersion.update({
|
||||||
|
|||||||
@@ -94,6 +94,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"titel": "Konto anlegen (Active Directory)",
|
"titel": "Konto anlegen (Active Directory)",
|
||||||
|
"bedingung": {
|
||||||
|
"art": "variable",
|
||||||
|
"schluessel": "konto_typ",
|
||||||
|
"operator": "ist",
|
||||||
|
"wert": "Active Directory"
|
||||||
|
},
|
||||||
"schritte": [
|
"schritte": [
|
||||||
{
|
{
|
||||||
"titel": "Konto anlegen",
|
"titel": "Konto anlegen",
|
||||||
@@ -141,6 +147,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"titel": "Konto anlegen (Entra ID)",
|
"titel": "Konto anlegen (Entra ID)",
|
||||||
|
"bedingung": {
|
||||||
|
"art": "variable",
|
||||||
|
"schluessel": "konto_typ",
|
||||||
|
"operator": "ist",
|
||||||
|
"wert": "Entra ID"
|
||||||
|
},
|
||||||
"schritte": [
|
"schritte": [
|
||||||
{
|
{
|
||||||
"titel": "Konto anlegen",
|
"titel": "Konto anlegen",
|
||||||
|
|||||||
+55
-1
@@ -141,7 +141,7 @@ function feld(text, name) {
|
|||||||
// README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne
|
// README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne
|
||||||
// den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu
|
// den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu
|
||||||
// #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der
|
// #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der
|
||||||
// Befehlsblock selbst, nicht seine Umgebung (#040).
|
// Befehlsblock selbst, nicht seine Umgebung (#026).
|
||||||
{
|
{
|
||||||
/** Der erste eingezaeunte Block nach einer Ueberschrift. */
|
/** Der erste eingezaeunte Block nach einer Ueberschrift. */
|
||||||
const blockNach = (text, ueberschrift) => {
|
const blockNach = (text, ueberschrift) => {
|
||||||
@@ -197,6 +197,60 @@ function feld(text, name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 7. Jeder (#NNN)-Verweis zeigt auf eine Issue-Datei --------------------
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* Verweise wie `(#032)` stehen in Kommentaren, im Changelog und in CLAUDE.md
|
||||||
|
* und sind die einzige Bruecke zwischen einer Codestelle und ihrer
|
||||||
|
* Begruendung. Zeigt einer ins Leere, ist die Begruendung verloren — und
|
||||||
|
* schlimmer: zeigt er auf die **falsche** Datei, fuehrt er jemanden in die
|
||||||
|
* Irre, der ihm vertraut.
|
||||||
|
*
|
||||||
|
* Genau das war passiert: vier Codestellen und sechs Changelog-Zeilen nannten
|
||||||
|
* dreistellig die **Tracker**-Nummern 39 und 40 — gemeint waren die
|
||||||
|
* Issue-Dateien 025 und 026. Solange es keine Dateien mit diesen Nummern gab,
|
||||||
|
* fiel es niemandem auf; mit Phase 3 waere es eine falsche Spur geworden.
|
||||||
|
* Verbindlich ist die **Dateinummer**; die Tracker-Nummer steht in der
|
||||||
|
* Issue-Datei unter „Im Tracker“.
|
||||||
|
*
|
||||||
|
* Diese Datei nennt deshalb selbst keine Nummer in der dreistelligen Form —
|
||||||
|
* sonst meldete die Pruefung ihren eigenen Kommentar.
|
||||||
|
*/
|
||||||
|
const verz = "docs/issues";
|
||||||
|
const nummern = new Set(
|
||||||
|
readdirSync(join(wurzel, verz))
|
||||||
|
.filter((n) => /^\d{3}-.+\.md$/.test(n))
|
||||||
|
.map((n) => n.slice(0, 3)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sammeln = (ordner) =>
|
||||||
|
readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => {
|
||||||
|
if (["node_modules", ".git", ".next", "generated", "test-results"].includes(eintrag.name)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const pfad = join(ordner, eintrag.name);
|
||||||
|
if (eintrag.isDirectory()) return sammeln(pfad);
|
||||||
|
return /\.(ts|tsx|mjs|md|sql)$/.test(eintrag.name) ? [pfad] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const datei of sammeln(wurzel)) {
|
||||||
|
const kurz = datei.replace(`${wurzel}/`, "");
|
||||||
|
// Der Devlog zitiert Tracker-Nummern aus Pull Requests; dort gilt die Regel nicht.
|
||||||
|
if (kurz === "docs/devlog.md") continue;
|
||||||
|
const text = readFileSync(datei, "utf8");
|
||||||
|
const gemeldet = new Set();
|
||||||
|
for (const treffer of text.matchAll(/#(\d{3})\b/g)) {
|
||||||
|
const nummer = treffer[1];
|
||||||
|
if (nummern.has(nummer) || gemeldet.has(nummer)) continue;
|
||||||
|
gemeldet.add(nummer);
|
||||||
|
melde(
|
||||||
|
`${kurz} verweist auf #${nummer}, aber ${verz}/${nummer}-*.md gibt es nicht. ` +
|
||||||
|
`Gemeint ist vielleicht eine Tracker-Nummer — verbindlich ist die Dateinummer.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Ergebnis --------------------------------------------------------------
|
// ---- Ergebnis --------------------------------------------------------------
|
||||||
if (befunde.length > 0) {
|
if (befunde.length > 0) {
|
||||||
console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`);
|
console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const ANLASS_TEXT: Record<Meldeanlass, string> = {
|
|||||||
LAUF_ZUGEWIESEN: "zugewiesen",
|
LAUF_ZUGEWIESEN: "zugewiesen",
|
||||||
LAUF_UEBERFAELLIG: "überfällig",
|
LAUF_UEBERFAELLIG: "überfällig",
|
||||||
LAUF_ABGESCHLOSSEN: "abgeschlossen",
|
LAUF_ABGESCHLOSSEN: "abgeschlossen",
|
||||||
|
LAUF_FREIGABE_OFFEN: "wartet auf Freigabe",
|
||||||
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
|
ABGLEICH_FEHLGESCHLAGEN: "Abgleich fehlgeschlagen",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
KommentarSchreiben,
|
KommentarSchreiben,
|
||||||
LaufAbschliessen,
|
LaufAbschliessen,
|
||||||
LaufArchivieren,
|
LaufArchivieren,
|
||||||
|
LaufFreigabe,
|
||||||
LaufBeginnen,
|
LaufBeginnen,
|
||||||
LaufMitAdKontoVerknuepfen,
|
LaufMitAdKontoVerknuepfen,
|
||||||
LaufZurueckholen,
|
LaufZurueckholen,
|
||||||
@@ -378,6 +379,36 @@ export default async function LaufSeite({
|
|||||||
|
|
||||||
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
|
<Fortschrittsbalken erledigt={lauf.anzahlErledigt} gesamt={lauf.anzahlSchritte} />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Die Freigabe-Karte (#040), und zwar **ueber** der Knopfreihe: sie ist
|
||||||
|
* eine Entscheidung und kein Knopf unter anderen.
|
||||||
|
*
|
||||||
|
* Gezeigt wird sie, wenn die Version eine Freigabe verlangt und der Lauf
|
||||||
|
* noch nicht archiviert ist — danach aendert sich nichts mehr. Wer kein
|
||||||
|
* `freigeben` hat, sieht sie nur als Auskunft; das Formular bleibt ihm
|
||||||
|
* verborgen, denn ein Knopf, der nichts tut, ist kein Knopf.
|
||||||
|
*/}
|
||||||
|
{lauf.freigabePflicht && !archiviert ? (
|
||||||
|
darf(benutzer.rolle, "freigeben") ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<LaufFreigabe
|
||||||
|
laufId={lauf.id}
|
||||||
|
bereitsFreigegeben={lauf.freigegebenAm !== null}
|
||||||
|
letzteNotiz={lauf.freigabeNotiz}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 max-w-prose rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext">
|
||||||
|
{lauf.freigegebenAm
|
||||||
|
? `Freigegeben${lauf.freigegebenVon ? ` von ${lauf.freigegebenVon}` : ""}.`
|
||||||
|
: "Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen " +
|
||||||
|
"werden kann. Die Freigabeberechtigten sind benachrichtigt, sobald du ihn " +
|
||||||
|
"abzuschließen versuchst."}
|
||||||
|
{lauf.freigabeNotiz ? ` Letzte Begründung: ${lauf.freigabeNotiz}` : ""}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
|
{lauf.status === LaufStatus.OFFEN && darfArbeiten ? <LaufBeginnen laufId={lauf.id} /> : null}
|
||||||
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
|
{lauf.status === LaufStatus.IN_ARBEIT && darfArbeiten ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ArrowLeft, Eye } from "lucide-react";
|
import { ArrowLeft, Eye, ShieldCheck } from "lucide-react";
|
||||||
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
|
import { VorlageKopfdaten } from "@/components/vorlage-kopfdaten";
|
||||||
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
|
import { VorlageBearbeitenKnopf } from "@/components/vorlage-bearbeiten-knopf";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +50,17 @@ function HistorienZeile({ zeile, darfBearbeiten }: { zeile: VersionsZeile; darfB
|
|||||||
<span className="text-xs text-nebentext">
|
<span className="text-xs text-nebentext">
|
||||||
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
|
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
|
||||||
</span>
|
</span>
|
||||||
|
{/*
|
||||||
|
Steht an der **Version**, nicht an der Vorlage (#040): verschiedene
|
||||||
|
Versionen koennen es verschieden halten, und ein laufender Lauf behaelt
|
||||||
|
die Regel seiner Version.
|
||||||
|
*/}
|
||||||
|
{zeile.freigabePflicht ? (
|
||||||
|
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs text-nebentext">
|
||||||
|
<ShieldCheck className="size-3" strokeWidth={2} aria-hidden="true" />
|
||||||
|
Freigabe nötig
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{zeile.aenderungsnotiz ? (
|
{zeile.aenderungsnotiz ? (
|
||||||
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
|
<p className="mt-1 max-w-prose text-sm text-nebentext">{zeile.aenderungsnotiz}</p>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default async function VersionSeite({ params }: { params: Promise<{ versi
|
|||||||
struktur: version.struktur,
|
struktur: version.struktur,
|
||||||
stand: version.stand.toISOString(),
|
stand: version.stand.toISOString(),
|
||||||
aenderungsnotiz: version.aenderungsnotiz,
|
aenderungsnotiz: version.aenderungsnotiz,
|
||||||
|
freigabePflicht: version.freigabePflicht,
|
||||||
}}
|
}}
|
||||||
lesend={veroeffentlicht || !darfBearbeiten}
|
lesend={veroeffentlicht || !darfBearbeiten}
|
||||||
darfBearbeiten={darfBearbeiten}
|
darfBearbeiten={darfBearbeiten}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { prisma } from "@/lib/db";
|
|||||||
* Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im
|
* Datenbanknamen. Nach aussen genuegt, **ob** es geht; warum nicht, steht im
|
||||||
* Protokoll des Prozesses (`docker compose logs web`,
|
* Protokoll des Prozesses (`docker compose logs web`,
|
||||||
* `journalctl -u laufzettel`). Dasselbe Muster wie in
|
* `journalctl -u laufzettel`). Dasselbe Muster wie in
|
||||||
* `src/server/actions/verzeichnis.ts` (#039).
|
* `src/server/actions/verzeichnis.ts` (#025).
|
||||||
*/
|
*/
|
||||||
export async function GET(): Promise<Response> {
|
export async function GET(): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import { Archive, CheckCircle2, Link2, PlayCircle, Undo2 } from "lucide-react";
|
import { Archive, CheckCircle2, Link2, PlayCircle, ShieldCheck, Undo2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
abschnittNichtZutreffendAktion,
|
abschnittNichtZutreffendAktion,
|
||||||
kommentarHinzufuegenAktion,
|
kommentarHinzufuegenAktion,
|
||||||
laufAbschliessenAktion,
|
laufAbschliessenAktion,
|
||||||
|
laufFreigebenAktion,
|
||||||
|
laufZurueckweisenAktion,
|
||||||
laufArchivierenAktion,
|
laufArchivierenAktion,
|
||||||
laufMitAdKontoVerknuepfenAktion,
|
laufMitAdKontoVerknuepfenAktion,
|
||||||
laufOeffnenAktion,
|
laufOeffnenAktion,
|
||||||
@@ -93,6 +95,107 @@ export function LaufAbschliessen({ laufId, offen }: { laufId: string; offen: num
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigeben und Zurueckweisen (#040).
|
||||||
|
*
|
||||||
|
* **Eine Komponente fuer beides**, weil es dieselbe Entscheidung ist: der
|
||||||
|
* Freigebende sieht den Lauf an und sagt ja oder nein. Zwei getrennte Karten
|
||||||
|
* haetten dieselbe Notiz zweimal.
|
||||||
|
*
|
||||||
|
* Die Notiz ist bei der Freigabe freiwillig und bei der Zurueckweisung Pflicht —
|
||||||
|
* geprueft wird das in der Server Action und im Dienst, nicht hier: eine Regel,
|
||||||
|
* die nur das Formular kennt, gilt nicht fuer den naechsten Aufrufer.
|
||||||
|
*/
|
||||||
|
export function LaufFreigabe({
|
||||||
|
laufId,
|
||||||
|
bereitsFreigegeben,
|
||||||
|
letzteNotiz,
|
||||||
|
}: {
|
||||||
|
laufId: string;
|
||||||
|
bereitsFreigegeben: boolean;
|
||||||
|
/** Die letzte Begruendung – nach einer Zurueckweisung steht sie hier. */
|
||||||
|
letzteNotiz: string | null;
|
||||||
|
}) {
|
||||||
|
const [freigabe, freigeben, freigabeLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
laufFreigebenAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [rueckweisung, zurueckweisen, rueckweisungLaeuft] = useActionState<AktionsErgebnis, FormData>(
|
||||||
|
laufZurueckweisenAktion,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [notiz, setzeNotiz] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4">
|
||||||
|
<p className="flex items-start gap-2 text-sm">
|
||||||
|
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{bereitsFreigegeben ? (
|
||||||
|
<>Dieser Lauf ist freigegeben. Zurückweisen nimmt die Freigabe wieder zurück.</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Dieser Lauf verlangt eine <strong>Freigabe durch einen Zweiten</strong>. Wer daran gearbeitet
|
||||||
|
hat, kann sie nicht erteilen.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{letzteNotiz ? (
|
||||||
|
<p className="max-w-prose rounded-karte bg-flaeche-hoch p-3 text-sm text-nebentext">
|
||||||
|
Letzte Begründung: {letzteNotiz}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span className="text-nebentext">Begründung (bei einer Zurückweisung Pflicht)</span>
|
||||||
|
<textarea
|
||||||
|
name="notiz"
|
||||||
|
rows={2}
|
||||||
|
value={notiz}
|
||||||
|
onChange={(ereignis) => setzeNotiz(ereignis.target.value)}
|
||||||
|
className={`${FELD} py-2`}
|
||||||
|
placeholder="Prüfsumme fehlt im Protokoll"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{bereitsFreigegeben ? null : (
|
||||||
|
<form action={freigeben}>
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<input type="hidden" name="notiz" value={notiz} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={freigabeLaeuft || rueckweisungLaeuft}
|
||||||
|
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||||
|
{freigabeLaeuft ? "Wird freigegeben …" : "Freigeben"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={zurueckweisen}>
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<input type="hidden" name="notiz" value={notiz} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={freigabeLaeuft || rueckweisungLaeuft}
|
||||||
|
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
|
||||||
|
>
|
||||||
|
<Undo2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||||||
|
{rueckweisungLaeuft ? "Wird zurückgewiesen …" : "Zurückweisen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Meldung ergebnis={freigabe} />
|
||||||
|
<Meldung ergebnis={rueckweisung} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archivieren – die zweite, ausdrueckliche Handlung.
|
* Archivieren – die zweite, ausdrueckliche Handlung.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
import { AlertTriangle, GitBranch } from "lucide-react";
|
||||||
|
import { FELD } from "@/lib/formular";
|
||||||
|
import {
|
||||||
|
BEDINGUNGS_ZUSTAENDE,
|
||||||
|
type BedingungsZustand,
|
||||||
|
type PfadBedingung,
|
||||||
|
VARIABLEN_OPERATOREN,
|
||||||
|
type VariablenOperator,
|
||||||
|
} from "@/lib/bedingungen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Bedingung an einem Abschnitt oder Schritt setzen (#039).
|
||||||
|
*
|
||||||
|
* **Eine Bedingung, kein Ausdrucksbaum** — das ist die Entscheidung aus dem
|
||||||
|
* Dienst, und sie zeigt sich hier: es gibt kein „und“, kein „oder“ und keine
|
||||||
|
* zweite Zeile. Wer zwei Bedingungen braucht, nimmt zwei Abschnitte. Ein
|
||||||
|
* Formular, in dem sich beliebige Ausdrücke bauen lassen, wäre eine kleine
|
||||||
|
* Abfragesprache — und ein Abschnitt, der aus unerklärlichen Gründen fehlt, ist
|
||||||
|
* schlimmer als einer zu viel.
|
||||||
|
*
|
||||||
|
* **Verwiesen wird über Titel, nicht über Kennungen.** `entwurfSpeichern`
|
||||||
|
* löscht den Entwurf und schreibt ihn neu; eine Kennung überlebte das nicht.
|
||||||
|
* Titel sind außerdem das, was ein Redakteur ohnehin liest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OPERATOR_TEXT: Record<VariablenOperator, string> = {
|
||||||
|
ist: "ist gleich",
|
||||||
|
istNicht: "ist nicht",
|
||||||
|
gesetzt: "ist ausgefüllt",
|
||||||
|
leer: "ist leer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUSTAND_TEXT: Record<BedingungsZustand, string> = {
|
||||||
|
OFFEN: "offen",
|
||||||
|
ERLEDIGT: "erledigt",
|
||||||
|
NICHT_ZUTREFFEND: "nicht zutreffend",
|
||||||
|
FEHLGESCHLAGEN: "fehlgeschlagen",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ein Schritt, auf den sich eine Bedingung beziehen kann. */
|
||||||
|
export type WaehlbarerSchritt = {
|
||||||
|
readonly abschnitt: string;
|
||||||
|
readonly schritt: string;
|
||||||
|
/** Steht dieser Schritt **nach** dem bedingten Teil? Dann wird gewarnt. */
|
||||||
|
readonly spaeter: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BedingungWaehlen({
|
||||||
|
bedingung,
|
||||||
|
variablen,
|
||||||
|
schritte,
|
||||||
|
lesend,
|
||||||
|
setzen,
|
||||||
|
}: {
|
||||||
|
bedingung: PfadBedingung | undefined;
|
||||||
|
/** Die Variablen dieser Version: Schlüssel und Beschriftung. */
|
||||||
|
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
|
||||||
|
schritte: readonly WaehlbarerSchritt[];
|
||||||
|
lesend: boolean;
|
||||||
|
setzen: (bedingung: PfadBedingung | undefined) => void;
|
||||||
|
}) {
|
||||||
|
const kennung = useId();
|
||||||
|
const art = bedingung?.art ?? "keine";
|
||||||
|
|
||||||
|
/** Der gewählte Schritt, falls die Bedingung auf einen zeigt. */
|
||||||
|
const gewaehlterSchritt =
|
||||||
|
bedingung?.art === "schritt"
|
||||||
|
? schritte.find(
|
||||||
|
(eintrag) => eintrag.abschnitt === bedingung.abschnitt && eintrag.schritt === bedingung.schritt,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function artWechseln(neu: string): void {
|
||||||
|
if (neu === "keine") return setzen(undefined);
|
||||||
|
if (neu === "variable") {
|
||||||
|
const erste = variablen[0];
|
||||||
|
if (!erste) return setzen(undefined);
|
||||||
|
return setzen({ art: "variable", schluessel: erste.schluessel, operator: "ist", wert: "" });
|
||||||
|
}
|
||||||
|
const erster = schritte[0];
|
||||||
|
if (!erster) return setzen(undefined);
|
||||||
|
return setzen({
|
||||||
|
art: "schritt",
|
||||||
|
abschnitt: erster.abschnitt,
|
||||||
|
schritt: erster.schritt,
|
||||||
|
zustaende: ["ERLEDIGT"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="rounded-karte border border-linie">
|
||||||
|
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-3 text-sm text-nebentext hover:text-text md:min-h-10">
|
||||||
|
<GitBranch className="size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
{bedingung ? "Bedingung gesetzt" : "Bedingung (optional)"}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-linie p-3">
|
||||||
|
{/*
|
||||||
|
`htmlFor` und `id` statt eines umhuellenden `label`: der zugaengliche
|
||||||
|
Name eines `select` im Label schluckt sonst den Text der gewaehlten
|
||||||
|
Option mit („VariableKontotyp“). Fuer einen Screenreader ist das eine
|
||||||
|
Bezeichnung, die sich beim Umschalten aendert — und kein Werkzeug
|
||||||
|
findet das Feld noch ueber seinen Namen.
|
||||||
|
*/}
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor={`${kennung}-art`} className="text-nebentext">
|
||||||
|
Gilt
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${kennung}-art`}
|
||||||
|
value={art}
|
||||||
|
disabled={lesend}
|
||||||
|
onChange={(ereignis) => artWechseln(ereignis.target.value)}
|
||||||
|
className={FELD}
|
||||||
|
>
|
||||||
|
<option value="keine">immer</option>
|
||||||
|
<option value="variable" disabled={variablen.length === 0}>
|
||||||
|
abhängig von einer Variable
|
||||||
|
</option>
|
||||||
|
<option value="schritt" disabled={schritte.length === 0}>
|
||||||
|
abhängig von einem Schritt
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variablen.length === 0 && schritte.length === 0 ? (
|
||||||
|
<p className="text-xs text-nebentext">
|
||||||
|
Für eine Bedingung braucht die Vorlage eine Variable oder einen anderen Schritt.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{bedingung?.art === "variable" ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor={`${kennung}-variable`} className="text-nebentext">
|
||||||
|
Variable
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${kennung}-variable`}
|
||||||
|
value={bedingung.schluessel}
|
||||||
|
disabled={lesend}
|
||||||
|
onChange={(ereignis) => setzen({ ...bedingung, schluessel: ereignis.target.value })}
|
||||||
|
className={FELD}
|
||||||
|
>
|
||||||
|
{variablen.map((variable) => (
|
||||||
|
<option key={variable.schluessel} value={variable.schluessel}>
|
||||||
|
{variable.beschriftung}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor={`${kennung}-vergleich`} className="text-nebentext">
|
||||||
|
Vergleich
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${kennung}-vergleich`}
|
||||||
|
value={bedingung.operator}
|
||||||
|
disabled={lesend}
|
||||||
|
onChange={(ereignis) =>
|
||||||
|
setzen({ ...bedingung, operator: ereignis.target.value as VariablenOperator })
|
||||||
|
}
|
||||||
|
className={FELD}
|
||||||
|
>
|
||||||
|
{VARIABLEN_OPERATOREN.map((operator) => (
|
||||||
|
<option key={operator} value={operator}>
|
||||||
|
{OPERATOR_TEXT[operator]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/*
|
||||||
|
Nur bei „ist“ und „ist nicht“: `gesetzt` und `leer` vergleichen mit
|
||||||
|
nichts, und ein Feld, das nichts tut, verwirrt.
|
||||||
|
*/}
|
||||||
|
{bedingung.operator === "ist" || bedingung.operator === "istNicht" ? (
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor={`${kennung}-wert`} className="text-nebentext">
|
||||||
|
Wert
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${kennung}-wert`}
|
||||||
|
value={bedingung.wert ?? ""}
|
||||||
|
disabled={lesend}
|
||||||
|
onChange={(ereignis) => setzen({ ...bedingung, wert: ereignis.target.value })}
|
||||||
|
placeholder="AD"
|
||||||
|
className={FELD}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{bedingung?.art === "schritt" ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm">
|
||||||
|
<label htmlFor={`${kennung}-schritt`} className="text-nebentext">
|
||||||
|
Schritt
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${kennung}-schritt`}
|
||||||
|
value={`${bedingung.abschnitt}\u0000${bedingung.schritt}`}
|
||||||
|
disabled={lesend}
|
||||||
|
onChange={(ereignis) => {
|
||||||
|
const [abschnitt, schritt] = ereignis.target.value.split("\u0000");
|
||||||
|
if (abschnitt && schritt) setzen({ ...bedingung, abschnitt, schritt });
|
||||||
|
}}
|
||||||
|
className={FELD}
|
||||||
|
>
|
||||||
|
{schritte.map((eintrag) => (
|
||||||
|
<option
|
||||||
|
key={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
|
||||||
|
value={`${eintrag.abschnitt}\u0000${eintrag.schritt}`}
|
||||||
|
>
|
||||||
|
{eintrag.abschnitt} · {eintrag.schritt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-2 text-sm">
|
||||||
|
<legend className="mb-1 text-nebentext">… ist</legend>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
{BEDINGUNGS_ZUSTAENDE.map((zustand) => (
|
||||||
|
<label key={zustand} className="flex min-h-11 items-center gap-2 md:min-h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${kennung}-${zustand}`}
|
||||||
|
checked={bedingung.zustaende.includes(zustand)}
|
||||||
|
disabled={lesend}
|
||||||
|
onChange={(ereignis) => {
|
||||||
|
const naechste = ereignis.target.checked
|
||||||
|
? [...bedingung.zustaende, zustand]
|
||||||
|
: bedingung.zustaende.filter((eintrag) => eintrag !== zustand);
|
||||||
|
/*
|
||||||
|
* Mindestens einer bleibt gesetzt: eine leere Liste wäre
|
||||||
|
* eine Bedingung, die nie zutrifft — ein Abschnitt, der
|
||||||
|
* für immer unsichtbar bleibt.
|
||||||
|
*/
|
||||||
|
if (naechste.length > 0) setzen({ ...bedingung, zustaende: naechste });
|
||||||
|
}}
|
||||||
|
className="size-4 accent-akzent"
|
||||||
|
/>
|
||||||
|
{ZUSTAND_TEXT[zustand]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{gewaehlterSchritt?.spaeter ? (
|
||||||
|
/*
|
||||||
|
* Eine Warnung und kein Verbot: erlaubt ist es, nur fast immer ein
|
||||||
|
* Versehen. Der bedingte Teil wäre zunächst unsichtbar und
|
||||||
|
* erschiene erst, nachdem jemand weiter unten etwas abgehakt hat —
|
||||||
|
* also rückwärts, und damit rechnet niemand.
|
||||||
|
*/
|
||||||
|
<p role="status" className="flex items-start gap-2 text-xs text-faellig">
|
||||||
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Dieser Schritt kommt <strong>später</strong> als der bedingte Teil. Er wäre beim Abarbeiten
|
||||||
|
zunächst unsichtbar und erschiene erst rückwärts. Meist ist ein Schritt davor gemeint.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Blocks } from "lucide-react";
|
import { Blocks } from "lucide-react";
|
||||||
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
|
import { AbteilungenWaehlen } from "@/components/vorlagen/abteilungen-waehlen";
|
||||||
|
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
|
||||||
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
import { KlappKarte, LoeschKnopf, TextKnopf, Textfeld } from "@/components/vorlagen/editor-felder";
|
||||||
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
import { SortierBereich, SortierZeile, VerschiebeKnoepfe } from "@/components/vorlagen/editor-sortieren";
|
||||||
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
import { SchrittKarte } from "@/components/vorlagen/editor-schritt";
|
||||||
@@ -32,6 +33,8 @@ export function AbschnittKarte({
|
|||||||
anzahl,
|
anzahl,
|
||||||
lesend,
|
lesend,
|
||||||
abteilungen,
|
abteilungen,
|
||||||
|
variablen,
|
||||||
|
alleSchritte,
|
||||||
zuletztAngelegt,
|
zuletztAngelegt,
|
||||||
anfasser,
|
anfasser,
|
||||||
aendern,
|
aendern,
|
||||||
@@ -48,6 +51,14 @@ export function AbschnittKarte({
|
|||||||
lesend: boolean;
|
lesend: boolean;
|
||||||
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
|
/** Die gepflegten Abteilungsnamen – für die Einschränkung (#031). */
|
||||||
abteilungen: readonly string[];
|
abteilungen: readonly string[];
|
||||||
|
/** Die Variablen dieser Version – für eine Bedingung (#039). */
|
||||||
|
variablen: readonly { readonly schluessel: string; readonly beschriftung: string }[];
|
||||||
|
/** Alle Schritte der Version in Abarbeitungsreihenfolge, mit laufender Nummer. */
|
||||||
|
alleSchritte: readonly {
|
||||||
|
readonly abschnitt: string;
|
||||||
|
readonly schritt: string;
|
||||||
|
readonly stelle: number;
|
||||||
|
}[];
|
||||||
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
|
/** Kennung der zuletzt angelegten Zeile — die wird aufgeklappt gezeigt. */
|
||||||
zuletztAngelegt: string | null;
|
zuletztAngelegt: string | null;
|
||||||
anfasser: React.ReactNode;
|
anfasser: React.ReactNode;
|
||||||
@@ -62,6 +73,12 @@ export function AbschnittKarte({
|
|||||||
const [offen, setzeOffen] = useState(true);
|
const [offen, setzeOffen] = useState(true);
|
||||||
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
const voll = abschnitt.schritte.length >= GRENZEN.schritteJeAbschnitt;
|
||||||
const anzahlSchritte = abschnitt.schritte.length;
|
const anzahlSchritte = abschnitt.schritte.length;
|
||||||
|
/*
|
||||||
|
* Die laufende Nummer des ersten Schritts dieses Abschnitts. Alles ab hier
|
||||||
|
* gilt als „spaeter“ — der Abschnitt beginnt dort.
|
||||||
|
*/
|
||||||
|
const ersteStelleDiesesAbschnitts =
|
||||||
|
alleSchritte.find((eintrag) => eintrag.abschnitt === abschnitt.titel)?.stelle ?? alleSchritte.length;
|
||||||
const baustein = abschnitt.baustein;
|
const baustein = abschnitt.baustein;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +176,27 @@ export function AbschnittKarte({
|
|||||||
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
|
setzen={(nurFuerAbteilungen) => aendern({ nurFuerAbteilungen })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Die Bedingung gehoert wie die Abteilungseinschraenkung zur **Vorlage**
|
||||||
|
und nicht zum Baustein — deshalb steht sie auch bei einem
|
||||||
|
Baustein-Abschnitt (#039, wie #031).
|
||||||
|
|
||||||
|
Als „spaeter“ gilt ein Schritt, der nach dem **ersten** Schritt dieses
|
||||||
|
Abschnitts kommt: der Abschnitt beginnt dort, und was danach steht, wuerde
|
||||||
|
ihn rueckwaerts erscheinen lassen.
|
||||||
|
*/}
|
||||||
|
<BedingungWaehlen
|
||||||
|
bedingung={abschnitt.bedingung}
|
||||||
|
variablen={variablen}
|
||||||
|
schritte={alleSchritte.map((eintrag) => ({
|
||||||
|
abschnitt: eintrag.abschnitt,
|
||||||
|
schritt: eintrag.schritt,
|
||||||
|
spaeter: eintrag.stelle >= ersteStelleDiesesAbschnitts,
|
||||||
|
}))}
|
||||||
|
lesend={lesend}
|
||||||
|
setzen={(bedingung) => aendern({ bedingung })}
|
||||||
|
/>
|
||||||
|
|
||||||
{baustein ? null : anzahlSchritte === 0 ? (
|
{baustein ? null : anzahlSchritte === 0 ? (
|
||||||
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
<p className="text-sm text-nebentext">Noch kein Schritt in diesem Abschnitt.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -175,6 +213,9 @@ export function AbschnittKarte({
|
|||||||
<SchrittKarte
|
<SchrittKarte
|
||||||
schritt={schritt}
|
schritt={schritt}
|
||||||
index={stelle}
|
index={stelle}
|
||||||
|
variablen={variablen}
|
||||||
|
alleSchritte={alleSchritte}
|
||||||
|
eigeneStelle={ersteStelleDiesesAbschnitts + stelle}
|
||||||
anzahl={anzahlSchritte}
|
anzahl={anzahlSchritte}
|
||||||
lesend={lesend}
|
lesend={lesend}
|
||||||
neu={schritt.kennung === zuletztAngelegt}
|
neu={schritt.kennung === zuletztAngelegt}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { BedingungWaehlen } from "@/components/vorlagen/bedingung-waehlen";
|
||||||
import {
|
import {
|
||||||
Auswahlfeld,
|
Auswahlfeld,
|
||||||
Flaechenfeld,
|
Flaechenfeld,
|
||||||
@@ -138,6 +139,9 @@ export function SchrittKarte({
|
|||||||
anzahl,
|
anzahl,
|
||||||
lesend,
|
lesend,
|
||||||
neu,
|
neu,
|
||||||
|
variablen,
|
||||||
|
alleSchritte,
|
||||||
|
eigeneStelle,
|
||||||
anfasser,
|
anfasser,
|
||||||
aendern,
|
aendern,
|
||||||
loeschen,
|
loeschen,
|
||||||
@@ -149,6 +153,23 @@ export function SchrittKarte({
|
|||||||
lesend: boolean;
|
lesend: boolean;
|
||||||
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
|
/** Gerade angelegt: dann aufgeklappt, denn man will ihn ausfuellen. */
|
||||||
neu: boolean;
|
neu: boolean;
|
||||||
|
/**
|
||||||
|
* Die Variablen dieser Version und alle Schritte – für eine Bedingung (#039).
|
||||||
|
*
|
||||||
|
* **Fehlen sie, gibt es keine Bedingung**, und das ist der Fall im
|
||||||
|
* Baustein-Editor: ein Baustein ist wiederverwendbar, und eine Bedingung auf
|
||||||
|
* einer Variable, die es in der Zielvorlage nicht gibt, wäre dort nicht
|
||||||
|
* entscheidbar. Der Abschnitt, in dem er eingesetzt wird, darf eine haben; der
|
||||||
|
* Schritt im Baustein nicht.
|
||||||
|
*/
|
||||||
|
variablen?: readonly { readonly schluessel: string; readonly beschriftung: string }[];
|
||||||
|
alleSchritte?: readonly {
|
||||||
|
readonly abschnitt: string;
|
||||||
|
readonly schritt: string;
|
||||||
|
readonly stelle: number;
|
||||||
|
}[];
|
||||||
|
/** Die laufende Nummer **dieses** Schritts – alles danach gilt als „später“. */
|
||||||
|
eigeneStelle?: number;
|
||||||
anfasser: React.ReactNode;
|
anfasser: React.ReactNode;
|
||||||
aendern: (teil: Partial<EditorSchritt>) => void;
|
aendern: (teil: Partial<EditorSchritt>) => void;
|
||||||
loeschen: () => void;
|
loeschen: () => void;
|
||||||
@@ -254,6 +275,26 @@ export function SchrittKarte({
|
|||||||
vorschlag="https://intern.example/anleitung"
|
vorschlag="https://intern.example/anleitung"
|
||||||
hinweis="Nur http- oder https-Adressen."
|
hinweis="Nur http- oder https-Adressen."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Eine Bedingung **am einzelnen Schritt** (#039): sie blendet nur ihn aus,
|
||||||
|
nicht den Abschnitt. Der eigene Schritt steht in der Liste mit; eine
|
||||||
|
Bedingung auf sich selbst waere nie entscheidbar, und genau deshalb gilt
|
||||||
|
er als „spaeter“ und wird angewarnt.
|
||||||
|
*/}
|
||||||
|
{variablen && alleSchritte ? (
|
||||||
|
<BedingungWaehlen
|
||||||
|
bedingung={schritt.bedingung}
|
||||||
|
variablen={variablen}
|
||||||
|
schritte={alleSchritte.map((eintrag) => ({
|
||||||
|
abschnitt: eintrag.abschnitt,
|
||||||
|
schritt: eintrag.schritt,
|
||||||
|
spaeter: eintrag.stelle >= (eigeneStelle ?? 0),
|
||||||
|
}))}
|
||||||
|
lesend={lesend}
|
||||||
|
setzen={(bedingung) => aendern({ bedingung })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</KlappKarte>
|
</KlappKarte>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type EditorVariable,
|
type EditorVariable,
|
||||||
} from "@/lib/editor-aufbau";
|
} from "@/lib/editor-aufbau";
|
||||||
import { unbekanntePlatzhalter } from "@/lib/platzhalter";
|
import { unbekanntePlatzhalter } from "@/lib/platzhalter";
|
||||||
|
import { KAESTCHEN } from "@/lib/formular";
|
||||||
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
|
import { GRENZEN, type Struktur } from "@/lib/vorlagen-format";
|
||||||
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
|
import { entwurfSpeichernAktion, veroeffentlichenAktion } from "@/server/actions/vorlagen";
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ export type EditorVersion = {
|
|||||||
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
|
/** Stand beim Laden als ISO-Zeichenkette; geht beim Speichern zurueck. */
|
||||||
readonly stand: string;
|
readonly stand: string;
|
||||||
readonly aenderungsnotiz: string | null;
|
readonly aenderungsnotiz: string | null;
|
||||||
|
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
|
||||||
|
readonly freigabePflicht: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +211,20 @@ export function Editor({
|
|||||||
|
|
||||||
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
|
const abschnitteVoll = aufbau.abschnitte.length >= GRENZEN.abschnitte;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Schritte, auf die eine Bedingung zeigen kann (#039) — **alle**, und zu
|
||||||
|
* jedem die Auskunft, ob er spaeter kommt. Einmal berechnet und an jede Karte
|
||||||
|
* gegeben: jede Karte selbst rechnen zu lassen waere dieselbe Schleife je
|
||||||
|
* Abschnitt, und die Reihenfolge ist fuer alle dieselbe.
|
||||||
|
*
|
||||||
|
* Die laufende Nummer entscheidet ueber „spaeter“, nicht die Position im
|
||||||
|
* Abschnitt: ein Schritt in Abschnitt 1 kommt vor jedem in Abschnitt 2.
|
||||||
|
*/
|
||||||
|
const reihenfolge = aufbau.abschnitte.flatMap((abschnitt) =>
|
||||||
|
abschnitt.schritte.map((schritt) => ({ abschnitt: abschnitt.titel, schritt: schritt.titel })),
|
||||||
|
);
|
||||||
|
const waehlbareSchritte = reihenfolge.map((eintrag, stelle) => ({ ...eintrag, stelle }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header className="flex flex-col gap-3">
|
<header className="flex flex-col gap-3">
|
||||||
@@ -340,6 +357,8 @@ export function Editor({
|
|||||||
index={index}
|
index={index}
|
||||||
anzahl={aufbau.abschnitte.length}
|
anzahl={aufbau.abschnitte.length}
|
||||||
abteilungen={abteilungen}
|
abteilungen={abteilungen}
|
||||||
|
variablen={aufbau.variablen}
|
||||||
|
alleSchritte={waehlbareSchritte}
|
||||||
lesend={lesend}
|
lesend={lesend}
|
||||||
zuletztAngelegt={zuletztAngelegt}
|
zuletztAngelegt={zuletztAngelegt}
|
||||||
anfasser={anfasser}
|
anfasser={anfasser}
|
||||||
@@ -451,6 +470,32 @@ export function Editor({
|
|||||||
</p>
|
</p>
|
||||||
<form action={veroeffentlichen} className="flex flex-col gap-3">
|
<form action={veroeffentlichen} className="flex flex-col gap-3">
|
||||||
<input type="hidden" name="versionId" value={version.id} />
|
<input type="hidden" name="versionId" value={version.id} />
|
||||||
|
{/*
|
||||||
|
**Die Freigabepflicht gehoert ans Veroeffentlichen** (#040), nicht
|
||||||
|
ans Speichern: sie ist eine Aussage ueber die **Version**, und eine
|
||||||
|
veroeffentlichte Version ist unveraenderlich. Ein laufender Lauf
|
||||||
|
kann seine Regel damit nicht unter den Fuessen verlieren.
|
||||||
|
|
||||||
|
Vorbelegt mit dem Stand des Entwurfs — er ist beim Bearbeiten von
|
||||||
|
der Vorversion mitgewandert.
|
||||||
|
*/}
|
||||||
|
<label className="flex min-h-11 items-start gap-2 text-sm md:min-h-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="freigabePflicht"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={version.freigabePflicht}
|
||||||
|
className={KAESTCHEN}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Läufe dieser Version brauchen eine <strong>Freigabe durch einen Zweiten</strong>
|
||||||
|
<span className="block text-xs text-nebentext">
|
||||||
|
Wer am Lauf gearbeitet hat, kann sie nicht erteilen — auch nicht als Administrator.
|
||||||
|
Benachrichtigt werden die Freigabeberechtigten, sobald jemand den Lauf abzuschließen
|
||||||
|
versucht.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="flex flex-col gap-1.5 text-sm">
|
<label className="flex flex-col gap-1.5 text-sm">
|
||||||
<span className="text-nebentext">Änderungsnotiz (optional)</span>
|
<span className="text-nebentext">Änderungsnotiz (optional)</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -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 { EINGEBAUTE_PLATZHALTER, type EingebauterPlatzhalter } from "@/lib/platzhalter";
|
||||||
import {
|
import {
|
||||||
SCHRITT_TYPEN,
|
SCHRITT_TYPEN,
|
||||||
@@ -35,6 +36,8 @@ export type EditorSchritt = {
|
|||||||
readonly pflicht: boolean;
|
readonly pflicht: boolean;
|
||||||
readonly konfig: SchrittKonfig;
|
readonly konfig: SchrittKonfig;
|
||||||
readonly dokuUrl: string;
|
readonly dokuUrl: string;
|
||||||
|
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
|
||||||
|
readonly bedingung?: PfadBedingung;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorAbschnitt = {
|
export type EditorAbschnitt = {
|
||||||
@@ -43,6 +46,13 @@ export type EditorAbschnitt = {
|
|||||||
readonly schritte: readonly EditorSchritt[];
|
readonly schritte: readonly EditorSchritt[];
|
||||||
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
|
/** Nur fuer diese Abteilungen; leer heisst fuer alle (#031). */
|
||||||
readonly nurFuerAbteilungen: readonly string[];
|
readonly nurFuerAbteilungen: readonly string[];
|
||||||
|
/**
|
||||||
|
* Wann dieser Abschnitt gilt (#039). Fehlt: immer.
|
||||||
|
*
|
||||||
|
* Verweist ueber **Titel**, nicht ueber Kennungen: `entwurfSpeichern` loescht
|
||||||
|
* den Entwurf und schreibt ihn neu, eine Kennung ueberlebte das nicht.
|
||||||
|
*/
|
||||||
|
readonly bedingung?: PfadBedingung;
|
||||||
/**
|
/**
|
||||||
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
|
* Gesetzt, wenn dieser Abschnitt ein eingesetzter **Baustein** ist (#030).
|
||||||
*
|
*
|
||||||
@@ -308,6 +318,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
|||||||
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
// Die Nummer gehoert zur Position, nicht in den Titel — siehe titelOhneNummer.
|
||||||
titel: titelOhneNummer(abschnitt.titel),
|
titel: titelOhneNummer(abschnitt.titel),
|
||||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||||
|
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
|
||||||
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
|
// Den Namen und die Fassung kennt die Struktur nicht; die Seite reicht sie
|
||||||
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
|
// getrennt nach und setzt sie ueber `bausteineAnreichern` ein (#030).
|
||||||
...(abschnitt.bausteinVersionId === undefined
|
...(abschnitt.bausteinVersionId === undefined
|
||||||
@@ -321,6 +332,7 @@ export function alsEditorAufbau(struktur: Struktur): EditorAufbau {
|
|||||||
pflicht: schritt.pflicht,
|
pflicht: schritt.pflicht,
|
||||||
konfig: schritt.konfig,
|
konfig: schritt.konfig,
|
||||||
dokuUrl: schritt.dokuUrl ?? "",
|
dokuUrl: schritt.dokuUrl ?? "",
|
||||||
|
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -352,6 +364,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
|||||||
nurFuerAbteilungen: [
|
nurFuerAbteilungen: [
|
||||||
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
|
...new Set(abschnitt.nurFuerAbteilungen.map((name) => name.trim()).filter(Boolean)),
|
||||||
],
|
],
|
||||||
|
...(abschnitt.bedingung === undefined ? {} : { bedingung: abschnitt.bedingung }),
|
||||||
/*
|
/*
|
||||||
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
|
* Ein Baustein-Abschnitt gibt **keine** Schritte mit: sie kommen beim
|
||||||
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
|
* Veroeffentlichen aus dem Baustein. Die Schritte im Zustand sind nur
|
||||||
@@ -367,6 +380,7 @@ export function alsStruktur(aufbau: EditorAufbau): Struktur {
|
|||||||
pflicht: schritt.pflicht,
|
pflicht: schritt.pflicht,
|
||||||
konfig: schritt.konfig,
|
konfig: schritt.konfig,
|
||||||
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
|
dokuUrl: schritt.dokuUrl.trim() === "" ? undefined : schritt.dokuUrl.trim(),
|
||||||
|
...(schritt.bedingung === undefined ? {} : { bedingung: schritt.bedingung }),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
+20
-1
@@ -113,9 +113,28 @@ export function laufMeldung(anlass: LaufAnlass, kopf: LaufKopf, link: string): M
|
|||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case Meldeanlass.LAUF_FREIGABE_OFFEN:
|
||||||
|
return {
|
||||||
|
betreff: `Laufzettel wartet auf Freigabe: „${kopf.titel}“`,
|
||||||
|
rumpf: zusammensetzen([
|
||||||
|
/*
|
||||||
|
* **Der Satz nennt die Regel**, denn der Empfaenger ist oft nicht
|
||||||
|
* derjenige, der gearbeitet hat — und soll nicht raten muessen, warum
|
||||||
|
* er gefragt wird.
|
||||||
|
*/
|
||||||
|
kopf.handelnder
|
||||||
|
? `${kopf.handelnder} hat diesen Laufzettel abgearbeitet. Er verlangt eine Freigabe ` +
|
||||||
|
"durch einen Zweiten, der nicht daran gearbeitet hat."
|
||||||
|
: "Dieser Laufzettel verlangt eine Freigabe durch einen Zweiten, der nicht daran " +
|
||||||
|
"gearbeitet hat.",
|
||||||
|
daten,
|
||||||
|
link,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
/*
|
/*
|
||||||
* Kaeme ein fuenfter Anlass dazu, der zu einem Lauf gehoert, ist diese
|
* Kaeme ein weiterer Anlass dazu, der zu einem Lauf gehoert, ist diese
|
||||||
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
|
* Zuweisung der Compilerfehler, der daran erinnert — ohne eine Zeile
|
||||||
* totem Code im Ergebnis.
|
* totem Code im Ergebnis.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
|
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
|
||||||
import { z } from "@/lib/validierung";
|
import { z } from "@/lib/validierung";
|
||||||
|
import { PfadBedingungSchema, type PfadBedingung } from "@/lib/bedingungen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufbau einer Vorlagen-Version (#009).
|
* Aufbau einer Vorlagen-Version (#009).
|
||||||
@@ -72,6 +73,14 @@ export const SchrittKonfigSchema = z.looseObject({
|
|||||||
|
|
||||||
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
|
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Form einer Bedingung steht in `src/lib/bedingungen.ts` — **an einer
|
||||||
|
* Stelle** (#039). Austauschformat und Editor-Struktur benutzen dieselbe, und
|
||||||
|
* zwei Schemas dafuer liefen auseinander.
|
||||||
|
*/
|
||||||
|
export const AustauschBedingungSchema = PfadBedingungSchema;
|
||||||
|
export type AustauschBedingung = PfadBedingung;
|
||||||
|
|
||||||
export const SchrittSchema = z.object({
|
export const SchrittSchema = z.object({
|
||||||
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
|
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
|
||||||
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
|
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
|
||||||
@@ -84,6 +93,8 @@ export const SchrittSchema = z.object({
|
|||||||
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
|
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
|
||||||
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
|
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
|
||||||
*/
|
*/
|
||||||
|
/** Wann dieser Schritt gilt (#039). Fehlt: immer. */
|
||||||
|
bedingung: AustauschBedingungSchema.optional(),
|
||||||
dokuUrl: leerAlsUndefiniert(
|
dokuUrl: leerAlsUndefiniert(
|
||||||
z.url({
|
z.url({
|
||||||
protocol: /^https?$/,
|
protocol: /^https?$/,
|
||||||
@@ -119,6 +130,8 @@ export const AbschnittSchema = z.object({
|
|||||||
.array(z.string().trim().min(1).max(200))
|
.array(z.string().trim().min(1).max(200))
|
||||||
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
|
.max(50, "Höchstens 50 Abteilungen je Abschnitt.")
|
||||||
.default([]),
|
.default([]),
|
||||||
|
/** Wann dieser Abschnitt gilt (#039). Fehlt: immer. */
|
||||||
|
bedingung: AustauschBedingungSchema.optional(),
|
||||||
schritte: z
|
schritte: z
|
||||||
.array(SchrittSchema)
|
.array(SchrittSchema)
|
||||||
.max(
|
.max(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
|||||||
import {
|
import {
|
||||||
LaufFehler,
|
LaufFehler,
|
||||||
laufAbschliessen,
|
laufAbschliessen,
|
||||||
|
laufFreigeben,
|
||||||
|
laufZurueckweisen,
|
||||||
laufAnlegen,
|
laufAnlegen,
|
||||||
laufArchivieren,
|
laufArchivieren,
|
||||||
laufMitAdKontoVerknuepfen,
|
laufMitAdKontoVerknuepfen,
|
||||||
@@ -377,6 +379,57 @@ export async function laufAbschliessenAktion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function laufFreigebenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
/*
|
||||||
|
* **`freigeben` und nicht `laeufeBearbeiten`** (#040): das ist der ganze Sinn
|
||||||
|
* des eigenen Rechts. Ob der Freigebende ausserdem nicht an dem Lauf
|
||||||
|
* gearbeitet hat, entscheidet der Dienst — hier wird genau ein Recht geprueft
|
||||||
|
* (CLAUDE.md).
|
||||||
|
*/
|
||||||
|
const zugang = await angemeldetOderMeldung("freigeben");
|
||||||
|
if ("fehler" in zugang) return zugang;
|
||||||
|
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
|
||||||
|
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
|
||||||
|
const notiz = formular.get("notiz");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await laufFreigeben(laufId.data, zugang.benutzer, typeof notiz === "string" ? notiz : undefined);
|
||||||
|
neuRendern(laufId.data);
|
||||||
|
return { erfolg: "Lauf freigegeben. Er lässt sich jetzt abschließen." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Lauf konnte nicht freigegeben werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function laufZurueckweisenAktion(
|
||||||
|
_vorher: AktionsErgebnis,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<AktionsErgebnis> {
|
||||||
|
const zugang = await angemeldetOderMeldung("freigeben");
|
||||||
|
if ("fehler" in zugang) return zugang;
|
||||||
|
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
|
||||||
|
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
|
||||||
|
const notiz = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Eine Zurückweisung braucht eine Begründung.")
|
||||||
|
.safeParse(formular.get("notiz"));
|
||||||
|
if (!notiz.success) {
|
||||||
|
return { fehler: notiz.error.issues[0]?.message ?? "Eine Zurückweisung braucht eine Begründung." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await laufZurueckweisen(laufId.data, zugang.benutzer, notiz.data);
|
||||||
|
neuRendern(laufId.data);
|
||||||
|
return { erfolg: "Lauf zurückgewiesen. Der Bearbeiter sieht deine Begründung." };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgewiesen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function laufArchivierenAktion(
|
export async function laufArchivierenAktion(
|
||||||
_vorher: AktionsErgebnis,
|
_vorher: AktionsErgebnis,
|
||||||
formular: FormData,
|
formular: FormData,
|
||||||
|
|||||||
@@ -267,10 +267,17 @@ export async function veroeffentlichenAktion(
|
|||||||
const zugang = await angemeldetOderMeldung();
|
const zugang = await angemeldetOderMeldung();
|
||||||
if ("fehler" in zugang) return zugang;
|
if ("fehler" in zugang) return zugang;
|
||||||
const eingabe = z
|
const eingabe = z
|
||||||
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
|
.object({
|
||||||
|
versionId: z.string().min(1),
|
||||||
|
aenderungsnotiz: z.string().trim().optional(),
|
||||||
|
freigabePflicht: z.boolean(),
|
||||||
|
})
|
||||||
.safeParse({
|
.safeParse({
|
||||||
versionId: formular.get("versionId"),
|
versionId: formular.get("versionId"),
|
||||||
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
|
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
|
||||||
|
// Ein nicht gesetztes Kaestchen schickt gar nichts mit — das ist `false`
|
||||||
|
// und nicht „unveraendert“: das Formular zeigt den aktuellen Stand an.
|
||||||
|
freigabePflicht: formular.get("freigabePflicht") === "1",
|
||||||
});
|
});
|
||||||
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
|
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
|
||||||
|
|
||||||
@@ -279,6 +286,7 @@ export async function veroeffentlichenAktion(
|
|||||||
eingabe.data.versionId,
|
eingabe.data.versionId,
|
||||||
eingabe.data.aenderungsnotiz ?? null,
|
eingabe.data.aenderungsnotiz ?? null,
|
||||||
zugang.benutzerId,
|
zugang.benutzerId,
|
||||||
|
eingabe.data.freigabePflicht,
|
||||||
);
|
);
|
||||||
revalidatePath("/vorlagen");
|
revalidatePath("/vorlagen");
|
||||||
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ export const RECHTE = {
|
|||||||
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
|
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
|
||||||
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
|
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
|
||||||
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
|
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
|
||||||
|
/**
|
||||||
|
* Einen Lauf freigeben oder zurueckweisen, der eine Freigabe verlangt (#040).
|
||||||
|
*
|
||||||
|
* **Nicht `TECHNIKER`**, und das ist der ganze Sinn: die Vier-Augen-Regel soll
|
||||||
|
* nicht davon abhaengen, dass zufaellig ein zweiter Techniker greifbar ist.
|
||||||
|
* Wer sie umgehen will, braucht dann ein Konto mit mehr Rechten — und das
|
||||||
|
* steht im Protokoll.
|
||||||
|
*
|
||||||
|
* Das Recht allein genuegt nicht: wer an dem Lauf **gearbeitet** hat, gibt ihn
|
||||||
|
* auch mit diesem Recht nicht frei. Das prueft der Dienst, und ein CHECK in der
|
||||||
|
* Datenbank haelt es ebenfalls fest.
|
||||||
|
*/
|
||||||
|
freigeben: ["ADMIN", "REDAKTEUR"],
|
||||||
/**
|
/**
|
||||||
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
|
* Einen archivierten Lauf zurueckholen. Nur `ADMIN`: das Archiv ist die Zusage, dass ein
|
||||||
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
|
* abgeschlossener Lauf sich nicht mehr aendert -- diese Zusage darf nur eine Stelle aufheben.
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export async function tokenHolen(zugang: GraphZugang, jetzt = Date.now()): Promi
|
|||||||
/*
|
/*
|
||||||
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
|
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
|
||||||
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
|
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
|
||||||
* Healthcheck (#039): eine Meldung, die Mandant und Anwendungs-ID nennt,
|
* Healthcheck (#025): eine Meldung, die Mandant und Anwendungs-ID nennt,
|
||||||
* gehört nicht in eine Oberfläche.
|
* gehört nicht in eine Oberfläche.
|
||||||
*/
|
*/
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||||
|
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
|
||||||
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
@@ -174,6 +175,8 @@ export async function laufZumDrucken(
|
|||||||
archiviertVon: { select: { name: true } },
|
archiviertVon: { select: { name: true } },
|
||||||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||||||
geltendeAbschnitte: true,
|
geltendeAbschnitte: true,
|
||||||
|
// Speist die Auswertung der Bedingungen (#039).
|
||||||
|
variablen: true,
|
||||||
vorlagenVersion: {
|
vorlagenVersion: {
|
||||||
select: {
|
select: {
|
||||||
version: true,
|
version: true,
|
||||||
@@ -183,9 +186,17 @@ export async function laufZumDrucken(
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
titel: true,
|
titel: true,
|
||||||
|
bedingung: true,
|
||||||
schritte: {
|
schritte: {
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
typ: true,
|
||||||
|
pflicht: true,
|
||||||
|
konfig: true,
|
||||||
|
bedingung: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -233,8 +244,21 @@ export async function laufZumDrucken(
|
|||||||
let anzahlSchritte = 0;
|
let anzahlSchritte = 0;
|
||||||
let anzahlVomTisch = 0;
|
let anzahlVomTisch = 0;
|
||||||
|
|
||||||
// Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031).
|
/*
|
||||||
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
|
* Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger
|
||||||
|
* (#031, #039). **Dieselbe Funktion** und nicht dieselbe Absicht: zwei
|
||||||
|
* Auswertungen liefen hier sonst auseinander, und ein Ausdruck, der andere
|
||||||
|
* Schritte zeigt als der Bildschirm, ist das Gegenteil eines Beweismittels.
|
||||||
|
*
|
||||||
|
* Ein archivierter Lauf bleibt dabei stabil: seine Variablen aendern sich
|
||||||
|
* nicht mehr, und an seinen Schrittergebnissen auch nichts
|
||||||
|
* (`laufMussVeraenderbarSein`). Was er zeigt, ist also genau das, was bei
|
||||||
|
* seinem Abschluss galt.
|
||||||
|
*/
|
||||||
|
const geltende = geltendeStruktur(
|
||||||
|
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
|
||||||
|
umgebungBauen(lauf.variablen, lauf.ergebnisse),
|
||||||
|
);
|
||||||
const abschnitte = geltende.map((abschnitt, index) => {
|
const abschnitte = geltende.map((abschnitt, index) => {
|
||||||
const schritte = abschnitt.schritte.map((schritt) => {
|
const schritte = abschnitt.schritte.map((schritt) => {
|
||||||
nummer += 1;
|
nummer += 1;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
|||||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||||||
|
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
|
||||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||||
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
|
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
|
||||||
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
|
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
|
||||||
@@ -49,6 +50,11 @@ export type LaufZeile = {
|
|||||||
readonly kundeName: string;
|
readonly kundeName: string;
|
||||||
readonly erstelltAm: Date;
|
readonly erstelltAm: Date;
|
||||||
readonly abgeschlossenAm: Date | null;
|
readonly abgeschlossenAm: Date | null;
|
||||||
|
/** Freigabe-Zustand (#040). `freigabePflicht` kommt von der Version. */
|
||||||
|
readonly freigabePflicht: boolean;
|
||||||
|
readonly freigegebenVon: string | null;
|
||||||
|
readonly freigegebenAm: Date | null;
|
||||||
|
readonly freigabeNotiz: string | null;
|
||||||
readonly anzahlSchritte: number;
|
readonly anzahlSchritte: number;
|
||||||
readonly anzahlErledigt: number;
|
readonly anzahlErledigt: number;
|
||||||
};
|
};
|
||||||
@@ -73,12 +79,17 @@ const ZEILEN_AUSWAHL = {
|
|||||||
faelligAm: true,
|
faelligAm: true,
|
||||||
erstelltAm: true,
|
erstelltAm: true,
|
||||||
abgeschlossenAm: true,
|
abgeschlossenAm: true,
|
||||||
|
freigegebenAm: true,
|
||||||
|
freigabeNotiz: true,
|
||||||
|
freigegebenVon: { select: { name: true, email: true } },
|
||||||
mitarbeiterName: true,
|
mitarbeiterName: true,
|
||||||
kunde: { select: { name: true } },
|
kunde: { select: { name: true } },
|
||||||
zugewiesenAn: { select: { name: true } },
|
zugewiesenAn: { select: { name: true } },
|
||||||
mitarbeiter: { select: { anzeigename: true } },
|
mitarbeiter: { select: { anzeigename: true } },
|
||||||
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
|
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
|
||||||
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
|
vorlagenVersion: {
|
||||||
|
select: { version: true, freigabePflicht: true, vorlage: { select: { name: true } } },
|
||||||
|
},
|
||||||
ergebnisse: { select: { zustand: true } },
|
ergebnisse: { select: { zustand: true } },
|
||||||
} satisfies Prisma.LaufSelect;
|
} satisfies Prisma.LaufSelect;
|
||||||
|
|
||||||
@@ -112,6 +123,11 @@ function zeileBauen(zeile: Rohzeile): LaufZeile {
|
|||||||
kundeName: zeile.kunde.name,
|
kundeName: zeile.kunde.name,
|
||||||
erstelltAm: zeile.erstelltAm,
|
erstelltAm: zeile.erstelltAm,
|
||||||
abgeschlossenAm: zeile.abgeschlossenAm,
|
abgeschlossenAm: zeile.abgeschlossenAm,
|
||||||
|
freigabePflicht: zeile.vorlagenVersion.freigabePflicht,
|
||||||
|
// Name, sonst Mailadresse – wie ueberall, wo ein Mensch genannt wird.
|
||||||
|
freigegebenVon: zeile.freigegebenVon?.name ?? zeile.freigegebenVon?.email ?? null,
|
||||||
|
freigegebenAm: zeile.freigegebenAm,
|
||||||
|
freigabeNotiz: zeile.freigabeNotiz,
|
||||||
anzahlSchritte: zeile.ergebnisse.length,
|
anzahlSchritte: zeile.ergebnisse.length,
|
||||||
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
|
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
|
||||||
};
|
};
|
||||||
@@ -239,6 +255,8 @@ export async function laufZumAbarbeiten(
|
|||||||
select: {
|
select: {
|
||||||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||||||
geltendeAbschnitte: true,
|
geltendeAbschnitte: true,
|
||||||
|
// Speist die Auswertung der Bedingungen (#039).
|
||||||
|
variablen: true,
|
||||||
vorlagenVersion: {
|
vorlagenVersion: {
|
||||||
select: {
|
select: {
|
||||||
abschnitte: {
|
abschnitte: {
|
||||||
@@ -246,6 +264,7 @@ export async function laufZumAbarbeiten(
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
titel: true,
|
titel: true,
|
||||||
|
bedingung: true,
|
||||||
schritte: {
|
schritte: {
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
select: {
|
select: {
|
||||||
@@ -256,6 +275,7 @@ export async function laufZumAbarbeiten(
|
|||||||
pflicht: true,
|
pflicht: true,
|
||||||
dokuUrl: true,
|
dokuUrl: true,
|
||||||
konfig: true,
|
konfig: true,
|
||||||
|
bedingung: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -297,7 +317,19 @@ export async function laufZumAbarbeiten(
|
|||||||
let anzahlPflicht = 0;
|
let anzahlPflicht = 0;
|
||||||
let anzahlPflichtOffen = 0;
|
let anzahlPflichtOffen = 0;
|
||||||
|
|
||||||
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
|
/*
|
||||||
|
* Zwei Filter uebereinander, in dieser Reihenfolge: die beim Start
|
||||||
|
* festgeschriebene Abteilungsauswahl (#031), dann die laufend ausgewerteten
|
||||||
|
* Bedingungen (#039). Die Begruendung steht in `geltendeStruktur`.
|
||||||
|
*
|
||||||
|
* **Die Nummerierung kommt danach.** `01 · Vorbereitung` zaehlt die Abschnitte,
|
||||||
|
* die der Techniker **sieht** — ein ausgeblendeter hinterlaesst keine Luecke,
|
||||||
|
* und gespeichert wird die Nummer nie (CLAUDE.md, „Gestaltung“).
|
||||||
|
*/
|
||||||
|
const geltende = geltendeStruktur(
|
||||||
|
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
|
||||||
|
umgebungBauen(lauf.variablen, lauf.ergebnisse),
|
||||||
|
);
|
||||||
const abschnitte = geltende.map((abschnitt, index) => {
|
const abschnitte = geltende.map((abschnitt, index) => {
|
||||||
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
|
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { VersionStatus } from "@/generated/prisma/enums";
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { bedingungZuPfad } from "@/lib/bedingungen";
|
||||||
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
||||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||||
|
|
||||||
@@ -180,6 +181,8 @@ export type VersionsZeile = {
|
|||||||
readonly version: number;
|
readonly version: number;
|
||||||
readonly status: VersionStatus;
|
readonly status: VersionStatus;
|
||||||
readonly aenderungsnotiz: string | null;
|
readonly aenderungsnotiz: string | null;
|
||||||
|
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
|
||||||
|
readonly freigabePflicht: boolean;
|
||||||
readonly veroeffentlichtAm: Date | null;
|
readonly veroeffentlichtAm: Date | null;
|
||||||
readonly veroeffentlichtVon: string | null;
|
readonly veroeffentlichtVon: string | null;
|
||||||
readonly anzahlLaeufe: number;
|
readonly anzahlLaeufe: number;
|
||||||
@@ -205,6 +208,7 @@ export async function versionsHistorie(
|
|||||||
version: true,
|
version: true,
|
||||||
status: true,
|
status: true,
|
||||||
aenderungsnotiz: true,
|
aenderungsnotiz: true,
|
||||||
|
freigabePflicht: true,
|
||||||
veroeffentlichtAm: true,
|
veroeffentlichtAm: true,
|
||||||
veroeffentlichtVon: { select: { name: true } },
|
veroeffentlichtVon: { select: { name: true } },
|
||||||
_count: { select: { laeufe: true } },
|
_count: { select: { laeufe: true } },
|
||||||
@@ -215,6 +219,7 @@ export async function versionsHistorie(
|
|||||||
version: version.version,
|
version: version.version,
|
||||||
status: version.status,
|
status: version.status,
|
||||||
aenderungsnotiz: version.aenderungsnotiz,
|
aenderungsnotiz: version.aenderungsnotiz,
|
||||||
|
freigabePflicht: version.freigabePflicht,
|
||||||
veroeffentlichtAm: version.veroeffentlichtAm,
|
veroeffentlichtAm: version.veroeffentlichtAm,
|
||||||
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
|
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
|
||||||
anzahlLaeufe: version._count.laeufe,
|
anzahlLaeufe: version._count.laeufe,
|
||||||
@@ -233,6 +238,8 @@ export type VersionMitAufbau = {
|
|||||||
*/
|
*/
|
||||||
readonly stand: Date;
|
readonly stand: Date;
|
||||||
readonly aenderungsnotiz: string | null;
|
readonly aenderungsnotiz: string | null;
|
||||||
|
/** Verlangen Laeufe dieser Version eine Freigabe (#040)? */
|
||||||
|
readonly freigabePflicht: boolean;
|
||||||
readonly struktur: Struktur;
|
readonly struktur: Struktur;
|
||||||
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
||||||
readonly platzhalter: readonly string[];
|
readonly platzhalter: readonly string[];
|
||||||
@@ -256,6 +263,22 @@ export async function versionMitAufbau(
|
|||||||
});
|
});
|
||||||
if (!version) return undefined;
|
if (!version) return undefined;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kennung → Titel-Pfad, fuer die Bedingungen (#039). Der Editor sieht und
|
||||||
|
* schickt **Titel**, nie Kennungen: `entwurfSpeichern` loescht und schreibt
|
||||||
|
* neu, eine Kennung ueberlebte das nicht.
|
||||||
|
*/
|
||||||
|
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
|
||||||
|
for (const abschnitt of version.abschnitte) {
|
||||||
|
for (const schritt of abschnitt.schritte) {
|
||||||
|
pfade.set(schritt.id, { abschnitt: abschnitt.titel, schritt: schritt.titel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const alsPfad = (roh: unknown) => {
|
||||||
|
const bedingung = bedingungZuPfad(roh, pfade);
|
||||||
|
return bedingung ? { bedingung } : {};
|
||||||
|
};
|
||||||
|
|
||||||
const struktur: Struktur = {
|
const struktur: Struktur = {
|
||||||
variablen: version.variablen.map((variable) => ({
|
variablen: version.variablen.map((variable) => ({
|
||||||
schluessel: variable.schluessel,
|
schluessel: variable.schluessel,
|
||||||
@@ -268,12 +291,14 @@ export async function versionMitAufbau(
|
|||||||
titel: abschnitt.titel,
|
titel: abschnitt.titel,
|
||||||
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
|
nurFuerAbteilungen: abschnitt.nurFuerAbteilungen,
|
||||||
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
|
...(abschnitt.bausteinVersionId === null ? {} : { bausteinVersionId: abschnitt.bausteinVersionId }),
|
||||||
|
...alsPfad(abschnitt.bedingung),
|
||||||
schritte: abschnitt.schritte.map((schritt) => ({
|
schritte: abschnitt.schritte.map((schritt) => ({
|
||||||
titel: schritt.titel,
|
titel: schritt.titel,
|
||||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||||
typ: schritt.typ,
|
typ: schritt.typ,
|
||||||
pflicht: schritt.pflicht,
|
pflicht: schritt.pflicht,
|
||||||
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
|
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
|
||||||
|
...alsPfad(schritt.bedingung),
|
||||||
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
|
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
@@ -287,6 +312,7 @@ export async function versionMitAufbau(
|
|||||||
status: version.status,
|
status: version.status,
|
||||||
stand: version.geaendertAm,
|
stand: version.geaendertAm,
|
||||||
aenderungsnotiz: version.aenderungsnotiz,
|
aenderungsnotiz: version.aenderungsnotiz,
|
||||||
|
freigabePflicht: version.freigabePflicht,
|
||||||
struktur,
|
struktur,
|
||||||
platzhalter: platzhalterDerStruktur(struktur),
|
platzhalter: platzhalterDerStruktur(struktur),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function alsScimAntwort(fehler: unknown): Response {
|
|||||||
/*
|
/*
|
||||||
* Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht
|
* Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht
|
||||||
* ins Protokoll des Prozesses und **nicht** in die Antwort: eine
|
* ins Protokoll des Prozesses und **nicht** in die Antwort: eine
|
||||||
* Datenbankmeldung nennt Host, Port und Benutzernamen (#039).
|
* Datenbankmeldung nennt Host, Port und Benutzernamen (#025).
|
||||||
*/
|
*/
|
||||||
console.error(
|
console.error(
|
||||||
"SCIM-Anfrage fehlgeschlagen:",
|
"SCIM-Anfrage fehlgeschlagen:",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createTransport } from "nodemailer";
|
|||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
|
import { LaufStatus, Meldeanlass, Zustellweg, Zustellzustand } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { RECHTE } from "@/server/auth/rollen";
|
||||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
|
import { abgleichMeldung, laufMeldung, webhookRumpf, type LaufAnlass } from "@/lib/meldungen";
|
||||||
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
|
import { SIGNATUR_KOPF, signaturBauen } from "@/lib/signatur";
|
||||||
@@ -382,10 +383,41 @@ async function laufEmpfaenger(
|
|||||||
handelnderId: string | null,
|
handelnderId: string | null,
|
||||||
): Promise<{ id: string; email: string; name: string }[]> {
|
): Promise<{ id: string; email: string; name: string }[]> {
|
||||||
const kennungen = new Set<string>();
|
const kennungen = new Set<string>();
|
||||||
|
|
||||||
|
if (anlass === Meldeanlass.LAUF_FREIGABE_OFFEN) {
|
||||||
|
/*
|
||||||
|
* **Ein anderer Empfaengerkreis als bei allen anderen Anlaessen** (#040): nicht
|
||||||
|
* der Zustaendige, sondern **jeder mit dem Recht `freigeben`**. Eine Freigabe
|
||||||
|
* ist die Aufgabe eines Dritten; ihm die Meldung nicht zu schicken hiesse,
|
||||||
|
* dass ein Lauf auf etwas wartet, von dem niemand weiss — und genau das macht
|
||||||
|
* die ganze Regel wertlos.
|
||||||
|
*
|
||||||
|
* Wer **gearbeitet** hat, bekommt sie nicht: er darf ohnehin nicht freigeben,
|
||||||
|
* und eine Aufforderung zu etwas, das der Dienst abweist, ist schlimmer als
|
||||||
|
* keine.
|
||||||
|
*/
|
||||||
|
const berechtigte = await prisma.benutzer.findMany({
|
||||||
|
where: { aktiv: true, meldungenAktiv: true, rolle: { in: [...RECHTE.freigeben] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const bearbeiter = new Set<string>(lauf.erstelltVonId ? [lauf.erstelltVonId] : []);
|
||||||
|
for (const zeile of await prisma.schrittergebnis.findMany({
|
||||||
|
where: { laufId: lauf.id, aktualisiertVonId: { not: null } },
|
||||||
|
select: { aktualisiertVonId: true },
|
||||||
|
distinct: ["aktualisiertVonId"],
|
||||||
|
})) {
|
||||||
|
if (zeile.aktualisiertVonId) bearbeiter.add(zeile.aktualisiertVonId);
|
||||||
|
}
|
||||||
|
for (const person of berechtigte) {
|
||||||
|
if (!bearbeiter.has(person.id)) kennungen.add(person.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
|
if (lauf.zugewiesenAnId) kennungen.add(lauf.zugewiesenAnId);
|
||||||
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
|
if (anlass === Meldeanlass.LAUF_ABGESCHLOSSEN && lauf.erstelltVonId) {
|
||||||
kennungen.add(lauf.erstelltVonId);
|
kennungen.add(lauf.erstelltVonId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (handelnderId) kennungen.delete(handelnderId);
|
if (handelnderId) kennungen.delete(handelnderId);
|
||||||
if (kennungen.size === 0) return [];
|
if (kennungen.size === 0) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Prisma, type Lauf } from "@/generated/prisma/client";
|
|||||||
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
|
import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
|
import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte";
|
||||||
|
import { geltendeStruktur, umgebungBauen } from "@/lib/bedingungen";
|
||||||
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen";
|
||||||
import type { LaufAnlass } from "@/lib/meldungen";
|
import type { LaufAnlass } from "@/lib/meldungen";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
@@ -703,6 +704,7 @@ export async function offenePflichtschritte(
|
|||||||
where: { id: laufId },
|
where: { id: laufId },
|
||||||
select: {
|
select: {
|
||||||
geltendeAbschnitte: true,
|
geltendeAbschnitte: true,
|
||||||
|
variablen: true,
|
||||||
vorlagenVersion: {
|
vorlagenVersion: {
|
||||||
select: {
|
select: {
|
||||||
abschnitte: {
|
abschnitte: {
|
||||||
@@ -710,10 +712,11 @@ export async function offenePflichtschritte(
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
titel: true,
|
titel: true,
|
||||||
|
bedingung: true,
|
||||||
schritte: {
|
schritte: {
|
||||||
where: { pflicht: true },
|
where: { pflicht: true },
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
select: { id: true, titel: true },
|
select: { id: true, titel: true, bedingung: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -726,11 +729,20 @@ export async function offenePflichtschritte(
|
|||||||
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
|
const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand]));
|
||||||
const offen: OffenerPflichtschritt[] = [];
|
const offen: OffenerPflichtschritt[] = [];
|
||||||
/*
|
/*
|
||||||
* Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf
|
* Nur die geltenden Abschnitte (#031) und nur, was seine Bedingung erfuellt
|
||||||
* den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
|
* (#039). Ein ausgefilterter oder ausgeblendeter Pflichtschritt darf den
|
||||||
|
* Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst
|
||||||
* sich nur, was in der Abarbeitung steht.
|
* sich nur, was in der Abarbeitung steht.
|
||||||
|
*
|
||||||
|
* **Die Reihenfolge der beiden Filter steht in `geltendeStruktur`**, und sie
|
||||||
|
* ist dort begruendet: erst die festgeschriebene Abteilungsauswahl, dann die
|
||||||
|
* laufende Auswertung.
|
||||||
*/
|
*/
|
||||||
for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) {
|
const umgebung = umgebungBauen(lauf.variablen, lauf.ergebnisse);
|
||||||
|
for (const abschnitt of geltendeStruktur(
|
||||||
|
nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte),
|
||||||
|
umgebung,
|
||||||
|
)) {
|
||||||
for (const schritt of abschnitt.schritte) {
|
for (const schritt of abschnitt.schritte) {
|
||||||
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
|
// Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt.
|
||||||
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
|
const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN;
|
||||||
@@ -793,6 +805,174 @@ export async function abschlussPruefenNachAenderung(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Vier-Augen-Freigabe (#040) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hat dieser Benutzer an dem Lauf **gearbeitet**?
|
||||||
|
*
|
||||||
|
* **Die eine Stelle, an der die Vier-Augen-Regel haengt** — wie
|
||||||
|
* `laufMussVeraenderbarSein` fuer das Archiv. Sie wird vor jeder Freigabe und
|
||||||
|
* vor jeder Zurueckweisung gerufen; zwei Stellen liefen auseinander, und die
|
||||||
|
* erste, die es vergisst, hebt die Regel auf.
|
||||||
|
*
|
||||||
|
* Gezaehlt wird dreierlei: wer den Lauf **angelegt** hat, wer ein
|
||||||
|
* **Schrittergebnis** gesetzt hat, und wer ihn **abgeschlossen** hat. Der
|
||||||
|
* Zustaendige (`zugewiesenAnId`) gilt bewusst **nicht** als Bearbeiter: eine
|
||||||
|
* Zuweisung ist noch keine Arbeit, und wer zugewiesen wurde, ohne etwas zu tun,
|
||||||
|
* soll freigeben koennen, wenn es jemand anderes abgearbeitet hat.
|
||||||
|
*/
|
||||||
|
async function hatDaranGearbeitet(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
laufId: string,
|
||||||
|
benutzerId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const lauf = await tx.lauf.findUniqueOrThrow({
|
||||||
|
where: { id: laufId },
|
||||||
|
select: { erstelltVonId: true, abgeschlossenVonId: true },
|
||||||
|
});
|
||||||
|
if (lauf.erstelltVonId === benutzerId) return true;
|
||||||
|
if (lauf.abgeschlossenVonId === benutzerId) return true;
|
||||||
|
|
||||||
|
const eigene = await tx.schrittergebnis.count({
|
||||||
|
where: { laufId, aktualisiertVonId: benutzerId },
|
||||||
|
});
|
||||||
|
return eigene > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verlangt die Version dieses Laufs eine Freigabe? */
|
||||||
|
async function freigabePflichtig(tx: Prisma.TransactionClient, laufId: string): Promise<boolean> {
|
||||||
|
const lauf = await tx.lauf.findUniqueOrThrow({
|
||||||
|
where: { id: laufId },
|
||||||
|
select: { vorlagenVersion: { select: { freigabePflicht: true } } },
|
||||||
|
});
|
||||||
|
return lauf.vorlagenVersion.freigabePflicht;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt einen Lauf frei.
|
||||||
|
*
|
||||||
|
* Verlangt das Recht `freigeben` — und dass der Freigebende **nicht** daran
|
||||||
|
* gearbeitet hat. Beides wird auch dann geprueft, wenn die Version gar keine
|
||||||
|
* Freigabe fordert: eine Freigabe an einem Lauf, der keine braucht, ist kein
|
||||||
|
* Fehler, aber sie soll denselben Regeln folgen. Sonst waere sie ein Weg, einen
|
||||||
|
* Freigeber in die Akte zu schreiben, der dort nicht hingehoert.
|
||||||
|
*/
|
||||||
|
export async function laufFreigeben(
|
||||||
|
laufId: string,
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
notiz?: string,
|
||||||
|
): Promise<Lauf> {
|
||||||
|
if (!darf(benutzer.rolle, "freigeben")) {
|
||||||
|
throw new LaufFehler(
|
||||||
|
`Einen Lauf freizugeben verlangt das Recht "freigeben"; die Rolle ${benutzer.rolle} hat es nicht.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
|
||||||
|
if (lauf.freigegebenVonId) {
|
||||||
|
throw new LaufFehler("Dieser Lauf ist bereits freigegeben.");
|
||||||
|
}
|
||||||
|
if (await hatDaranGearbeitet(tx, laufId, benutzer.id)) {
|
||||||
|
throw new LaufFehler(
|
||||||
|
"Du hast an diesem Lauf gearbeitet und kannst ihn deshalb nicht selbst freigeben. " +
|
||||||
|
"Das ist der Sinn der Vier-Augen-Regel — es braucht jemand anderen mit dem Recht " +
|
||||||
|
"zur Freigabe.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jetzt = new Date();
|
||||||
|
const nachher = await tx.lauf.update({
|
||||||
|
where: { id: laufId },
|
||||||
|
data: {
|
||||||
|
freigegebenVonId: benutzer.id,
|
||||||
|
freigegebenAm: jetzt,
|
||||||
|
// Bei einer Freigabe freiwillig; eine vorherige Zurueckweisung wird damit
|
||||||
|
// ueberschrieben, und das ist richtig: die letzte Notiz ist die geltende.
|
||||||
|
freigabeNotiz: notiz?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
laufId,
|
||||||
|
entitaet: "Lauf",
|
||||||
|
entitaetId: laufId,
|
||||||
|
aktion: "Lauf freigegeben",
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
nachher: { freigegebenAm: jetzt.toISOString(), notiz: notiz?.trim() || null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return nachher;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weist einen Lauf zurueck. Die Notiz ist **Pflicht**.
|
||||||
|
*
|
||||||
|
* Kein eigener `LaufStatus`: der Lauf geht zurueck auf `IN_ARBEIT`, und die
|
||||||
|
* Begruendung steht in `freigabeNotiz` und im Protokoll. Ein fuenfter Status
|
||||||
|
* waere eine Aenderung an einem Enum, das vier Jahre halten soll, und jeder
|
||||||
|
* Codepfad, der heute vier Faelle kennt, muesste ihn lernen.
|
||||||
|
*
|
||||||
|
* **Eine Zurueckweisung ohne Begruendung ist keine.** Dieselbe Regel wie bei
|
||||||
|
* `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` (CLAUDE.md): wer etwas zurueckgibt,
|
||||||
|
* sagt warum — sonst steht der naechste vor derselben Frage.
|
||||||
|
*/
|
||||||
|
export async function laufZurueckweisen(
|
||||||
|
laufId: string,
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
notiz: string,
|
||||||
|
): Promise<Lauf> {
|
||||||
|
if (!darf(benutzer.rolle, "freigeben")) {
|
||||||
|
throw new LaufFehler(
|
||||||
|
`Einen Lauf zurueckzuweisen verlangt das Recht "freigeben"; die Rolle ${benutzer.rolle} hat es nicht.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const begruendung = notiz.trim();
|
||||||
|
if (!begruendung) {
|
||||||
|
throw new LaufFehler(
|
||||||
|
"Eine Zurückweisung braucht eine Begründung. Ohne sie steht der Bearbeiter vor derselben " +
|
||||||
|
"Frage wie vorher.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
|
||||||
|
if (await hatDaranGearbeitet(tx, laufId, benutzer.id)) {
|
||||||
|
throw new LaufFehler(
|
||||||
|
"Du hast an diesem Lauf gearbeitet und kannst ihn deshalb nicht selbst zurückweisen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachher = await tx.lauf.update({
|
||||||
|
where: { id: laufId },
|
||||||
|
data: {
|
||||||
|
// Eine Zurueckweisung nimmt eine erteilte Freigabe zurueck.
|
||||||
|
freigegebenVonId: null,
|
||||||
|
freigegebenAm: null,
|
||||||
|
freigabeNotiz: begruendung,
|
||||||
|
status: LaufStatus.IN_ARBEIT,
|
||||||
|
abgeschlossenAm: null,
|
||||||
|
abgeschlossenVonId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.protokoll.create({
|
||||||
|
data: {
|
||||||
|
laufId,
|
||||||
|
entitaet: "Lauf",
|
||||||
|
entitaetId: laufId,
|
||||||
|
aktion: "Lauf zurückgewiesen",
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
vorher: { status: lauf.status },
|
||||||
|
nachher: { status: LaufStatus.IN_ARBEIT, notiz: begruendung },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return nachher;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schliesst einen Lauf ab.
|
* Schliesst einen Lauf ab.
|
||||||
*
|
*
|
||||||
@@ -804,14 +984,38 @@ export async function abschlussPruefenNachAenderung(
|
|||||||
* abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten
|
* abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten
|
||||||
* Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben.
|
* Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben.
|
||||||
*/
|
*/
|
||||||
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
|
/**
|
||||||
const abgeschlossen = await prisma.$transaction(async (tx) => {
|
* Der eigentliche Abschluss, in einer Transaktion.
|
||||||
|
*
|
||||||
|
* Getrennt von `laufAbschliessen`, weil die Aufforderung an die
|
||||||
|
* Freigabeberechtigten **ausserhalb** der Transaktion entstehen muss: innerhalb
|
||||||
|
* wuerde sie mit dem Fehler zurueckgerollt, und dann wartet der Lauf auf etwas,
|
||||||
|
* von dem niemand weiss.
|
||||||
|
*/
|
||||||
|
async function abschlussVersuchen(
|
||||||
|
laufId: string,
|
||||||
|
benutzer: HandelnderBenutzer,
|
||||||
|
): Promise<{ lauf: Lauf; warSchonFertig: boolean }> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
|
const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer);
|
||||||
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true };
|
if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true };
|
||||||
|
|
||||||
const offen = await offenePflichtschritte(tx, laufId);
|
const offen = await offenePflichtschritte(tx, laufId);
|
||||||
if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen));
|
if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* **Die Freigabe wird vor dem Abschluss geprueft, nicht danach** (#040):
|
||||||
|
* ein abgeschlossener Lauf ohne Freigabe waere genau der Zustand, den die
|
||||||
|
* Regel verhindern soll, und ihn wieder aufzubrechen verlangte
|
||||||
|
* `archivVerwalten`.
|
||||||
|
*/
|
||||||
|
if (!lauf.freigegebenVonId && (await freigabePflichtig(tx, laufId))) {
|
||||||
|
throw new FreigabeFehlt(
|
||||||
|
"Dieser Lauf verlangt eine Freigabe durch einen Zweiten, bevor er abgeschlossen werden " +
|
||||||
|
"kann. Die Freigabeberechtigten sind benachrichtigt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
const nachher = await tx.lauf.update({
|
const nachher = await tx.lauf.update({
|
||||||
where: { id: laufId },
|
where: { id: laufId },
|
||||||
@@ -839,6 +1043,46 @@ export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenut
|
|||||||
});
|
});
|
||||||
return { lauf: nachher, warSchonFertig: false };
|
return { lauf: nachher, warSchonFertig: false };
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Abschluss scheitert an der fehlenden Freigabe (#040).
|
||||||
|
*
|
||||||
|
* Ein eigener Typ, damit der Aufrufer **ausserhalb** der Transaktion erkennt,
|
||||||
|
* dass genau dieser Fall eingetreten ist: die Aufforderung an die
|
||||||
|
* Freigabeberechtigten darf nicht mit der Transaktion zurueckgerollt werden,
|
||||||
|
* und ein Zeichenkettenvergleich auf die Meldung waere eine Zusage ueber ihren
|
||||||
|
* Wortlaut.
|
||||||
|
*
|
||||||
|
* Erbt von `LaufFehler`, damit jeder bestehende Aufrufer ihn weiter faengt.
|
||||||
|
*/
|
||||||
|
export class FreigabeFehlt extends LaufFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "FreigabeFehlt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise<Lauf> {
|
||||||
|
let abgeschlossen: { lauf: Lauf; warSchonFertig: boolean };
|
||||||
|
try {
|
||||||
|
abgeschlossen = await abschlussVersuchen(laufId, benutzer);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof FreigabeFehlt) {
|
||||||
|
/*
|
||||||
|
* **Hier entsteht die Aufforderung**, und zwar genau hier: der Techniker
|
||||||
|
* sagt mit dem Abschlussversuch „ich bin fertig“. Ein eigener Knopf
|
||||||
|
* „Freigabe anfordern“ waere ein Schritt mehr, den jemand vergisst — und
|
||||||
|
* ein Lauf, der auf etwas wartet, von dem niemand weiss, macht die ganze
|
||||||
|
* Regel wertlos.
|
||||||
|
*
|
||||||
|
* Der partielle Unique-Index `Zustellung_freigabe_einmal` sorgt dafuer,
|
||||||
|
* dass mehrere Versuche nicht mehrere Mails ergeben.
|
||||||
|
*/
|
||||||
|
await meldenOhneBruch(Meldeanlass.LAUF_FREIGABE_OFFEN, laufId, benutzer.id);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf
|
* Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import type { ZodError } from "@/lib/validierung";
|
|||||||
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
|
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
|
||||||
import { VersionStatus } from "@/generated/prisma/enums";
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
|
import { bedingungAufloesen, bedingungZuPfad, schrittPfad } from "@/lib/bedingungen";
|
||||||
|
import {
|
||||||
|
AustauschSchema,
|
||||||
|
FORMAT_VERSION,
|
||||||
|
type Austausch,
|
||||||
|
type AustauschBedingung,
|
||||||
|
} from "@/lib/vorlagen-format";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-Austausch fuer Vorlagen (#009).
|
* JSON-Austausch fuer Vorlagen (#009).
|
||||||
@@ -66,6 +72,22 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { vorlage } = version;
|
const { vorlage } = version;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kennung → Titel-Pfad, fuer die Schritt-Bedingungen. Eine Kennung bedeutet
|
||||||
|
* in einer anderen Installation nichts; ein Titel-Pfad schon (#039).
|
||||||
|
*/
|
||||||
|
const pfade = new Map<string, { readonly abschnitt: string; readonly schritt: string }>();
|
||||||
|
for (const abschnitt of version.abschnitte) {
|
||||||
|
const titel =
|
||||||
|
abschnitt.bausteinVersion && abschnitt.schritte.length === 0
|
||||||
|
? abschnitt.bausteinVersion.titel
|
||||||
|
: abschnitt.titel;
|
||||||
|
for (const schritt of abschnitt.schritte) {
|
||||||
|
pfade.set(schritt.id, { abschnitt: titel, schritt: schritt.titel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
format: FORMAT_VERSION,
|
format: FORMAT_VERSION,
|
||||||
name: vorlage.name,
|
name: vorlage.name,
|
||||||
@@ -111,7 +133,10 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
|||||||
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
|
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
|
||||||
// dasselbe.
|
// dasselbe.
|
||||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||||
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map(schrittAbbilden),
|
...bedingungAbbilden(abschnitt.bedingung, pfade),
|
||||||
|
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map((schritt) =>
|
||||||
|
schrittAbbilden(schritt, pfade),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -129,6 +154,24 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
|
|||||||
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
|
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
|
||||||
* kein Objekt ist, gilt als leer.
|
* kein Objekt ist, gilt als leer.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Eine gespeicherte Bedingung in die Austauschform.
|
||||||
|
*
|
||||||
|
* Gibt `{}` zurueck, wenn es keine gibt oder wenn eine Schritt-Bedingung auf
|
||||||
|
* einen Schritt zeigt, den der Export nicht kennt. **Weglassen und nicht
|
||||||
|
* mitschreiben**: eine Bedingung mit einem Verweis ins Leere waere in der
|
||||||
|
* zweiten Installation nicht entscheidbar, der Abschnitt erschiene also immer —
|
||||||
|
* und niemand koennte sagen, warum. Ohne Bedingung erscheint er auch immer, aber
|
||||||
|
* das sieht man in der Datei.
|
||||||
|
*/
|
||||||
|
function bedingungAbbilden(
|
||||||
|
roh: unknown,
|
||||||
|
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
|
||||||
|
): { readonly bedingung?: AustauschBedingung } {
|
||||||
|
const pfadBedingung = bedingungZuPfad(roh, pfade);
|
||||||
|
return pfadBedingung ? { bedingung: pfadBedingung as AustauschBedingung } : {};
|
||||||
|
}
|
||||||
|
|
||||||
function schrittAbbilden(
|
function schrittAbbilden(
|
||||||
/*
|
/*
|
||||||
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
|
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
|
||||||
@@ -136,7 +179,17 @@ function schrittAbbilden(
|
|||||||
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
|
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
|
||||||
* ein Schritt haengt — und das steht ohnehin in keinem Export.
|
* ein Schritt haengt — und das steht ohnehin in keinem Export.
|
||||||
*/
|
*/
|
||||||
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl">,
|
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl"> & {
|
||||||
|
/*
|
||||||
|
* Optional, weil ein **Baustein**-Schritt keine Bedingung hat — und das ist
|
||||||
|
* Absicht (#039): ein Baustein ist wiederverwendbar, und eine Bedingung auf
|
||||||
|
* einer Variable, die es in der Zielvorlage nicht gibt, waere nicht
|
||||||
|
* entscheidbar. Der Abschnitt, in dem er steht, darf eine haben; der
|
||||||
|
* einzelne Schritt daraus nicht.
|
||||||
|
*/
|
||||||
|
readonly bedingung?: unknown;
|
||||||
|
},
|
||||||
|
pfade: ReadonlyMap<string, { readonly abschnitt: string; readonly schritt: string }>,
|
||||||
): AustauschSchritt {
|
): AustauschSchritt {
|
||||||
const roh = schritt.konfig;
|
const roh = schritt.konfig;
|
||||||
const konfig =
|
const konfig =
|
||||||
@@ -147,6 +200,7 @@ function schrittAbbilden(
|
|||||||
typ: schritt.typ,
|
typ: schritt.typ,
|
||||||
pflicht: schritt.pflicht,
|
pflicht: schritt.pflicht,
|
||||||
konfig,
|
konfig,
|
||||||
|
...bedingungAbbilden(schritt.bedingung, pfade),
|
||||||
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
|
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -202,13 +256,38 @@ export async function vorlageImportieren(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let schritteGesamt = 0;
|
let schritteGesamt = 0;
|
||||||
|
/*
|
||||||
|
* Kennungen, um die Schritt-Bedingungen danach aufzuloesen (#039). Erst
|
||||||
|
* anlegen, dann verweisen: eine Bedingung kann auf einen Schritt zeigen, der
|
||||||
|
* weiter unten steht, und der Trigger verlangt einen Schritt derselben
|
||||||
|
* Version.
|
||||||
|
*/
|
||||||
|
const neueKennungen = new Map<string, string>();
|
||||||
|
const abschnittsZeilen = new Map<string, string>();
|
||||||
|
|
||||||
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
|
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
|
||||||
const zeile = await tx.vorlagenAbschnitt.create({
|
const zeile = await tx.vorlagenAbschnitt.create({
|
||||||
data: { versionId: version.id, titel: abschnitt.titel, position },
|
data: {
|
||||||
|
versionId: version.id,
|
||||||
|
titel: abschnitt.titel,
|
||||||
|
position,
|
||||||
|
/*
|
||||||
|
* **Wandert mit, und das fehlte hier.** Der Export schreibt die
|
||||||
|
* Abteilungseinschraenkung seit #031, das Schema nimmt sie an — und
|
||||||
|
* der Import verwarf sie stillschweigend. Eine Vorlage, die jemand in
|
||||||
|
* eine zweite Installation brachte, verlor ihre Abteilungsabschnitte:
|
||||||
|
* sie galten dort fuer **alle**. Also mehr Schritte statt weniger, und
|
||||||
|
* niemand merkt es (gefunden bei #039).
|
||||||
|
*/
|
||||||
|
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
abschnittsZeilen.set(abschnitt.titel, zeile.id);
|
||||||
|
|
||||||
if (abschnitt.schritte.length === 0) continue;
|
if (abschnitt.schritte.length === 0) continue;
|
||||||
await tx.vorlagenSchritt.createMany({
|
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
|
||||||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
const angelegt = await tx.vorlagenSchritt.create({
|
||||||
|
data: {
|
||||||
abschnittId: zeile.id,
|
abschnittId: zeile.id,
|
||||||
titel: schritt.titel,
|
titel: schritt.titel,
|
||||||
hilfetext: schritt.hilfetext ?? null,
|
hilfetext: schritt.hilfetext ?? null,
|
||||||
@@ -220,11 +299,44 @@ export async function vorlageImportieren(
|
|||||||
konfig: schritt.konfig as Prisma.InputJsonObject,
|
konfig: schritt.konfig as Prisma.InputJsonObject,
|
||||||
dokuUrl: schritt.dokuUrl ?? null,
|
dokuUrl: schritt.dokuUrl ?? null,
|
||||||
position: schrittPosition,
|
position: schrittPosition,
|
||||||
})),
|
},
|
||||||
});
|
});
|
||||||
|
neueKennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
|
||||||
|
}
|
||||||
schritteGesamt += abschnitt.schritte.length;
|
schritteGesamt += abschnitt.schritte.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zweiter Durchgang: die Bedingungen. Ein Titel-Pfad, den es in dieser Datei
|
||||||
|
* nicht gibt, wird **uebergangen** und nicht als Fehler gemeldet — die Datei
|
||||||
|
* kann von Hand geschrieben sein, und eine Vorlage wegen eines Tippfehlers in
|
||||||
|
* einer Bedingung ganz abzuweisen waere die teurere Antwort. Uebergangen
|
||||||
|
* heisst: der Abschnitt gilt immer, und das steht sichtbar in der Oberflaeche.
|
||||||
|
*/
|
||||||
|
for (const abschnitt of austausch.struktur.abschnitte) {
|
||||||
|
const abschnittId = abschnittsZeilen.get(abschnitt.titel);
|
||||||
|
if (abschnittId && abschnitt.bedingung) {
|
||||||
|
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, neueKennungen);
|
||||||
|
if (aufgeloest) {
|
||||||
|
await tx.vorlagenAbschnitt.update({
|
||||||
|
where: { id: abschnittId },
|
||||||
|
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const schritt of abschnitt.schritte) {
|
||||||
|
if (!schritt.bedingung) continue;
|
||||||
|
const schrittId = neueKennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
|
||||||
|
const aufgeloest = bedingungAufloesen(schritt.bedingung, neueKennungen);
|
||||||
|
if (schrittId && aufgeloest) {
|
||||||
|
await tx.vorlagenSchritt.update({
|
||||||
|
where: { id: schrittId },
|
||||||
|
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
kundeId: vorlage.kundeId,
|
kundeId: vorlage.kundeId,
|
||||||
|
|||||||
+210
-66
@@ -1,6 +1,7 @@
|
|||||||
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
import { Prisma, type SchrittTyp, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
||||||
import { VersionStatus } from "@/generated/prisma/enums";
|
import { VersionStatus } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { bedingungAufloesen, bedingungLesen, schrittPfad } from "@/lib/bedingungen";
|
||||||
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +83,114 @@ export type VorlagenAenderung = {
|
|||||||
readonly kundeId?: string | null;
|
readonly kundeId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine tiefe Kopie der Abschnitte und Schritte einer Version.
|
||||||
|
*
|
||||||
|
* **An einer Stelle, weil es zweimal gebraucht wird** — `vorlageBearbeiten`
|
||||||
|
* macht aus der letzten veroeffentlichten Version einen Entwurf,
|
||||||
|
* `vorlageDuplizieren` eine neue Vorlage. Beide hatten die Kopie einzeln
|
||||||
|
* ausgeschrieben, und beide vergassen dabei dasselbe: `nurFuerAbteilungen`
|
||||||
|
* (seit #031) und `bedingung` (seit #039).
|
||||||
|
*
|
||||||
|
* Der Schaden war still und ernst: „Bearbeiten“ einer Vorlage verlor **alle**
|
||||||
|
* Abteilungseinschraenkungen — die neue Version galt dann fuer jeden Lauf, also
|
||||||
|
* mehr Schritte statt weniger, und niemand merkt es. Gefunden erst bei der
|
||||||
|
* Klickprobe am gebauten Stand (#039); keine Suite hat es gesehen, weil keine
|
||||||
|
* die Kopie gegen das Original verglich.
|
||||||
|
*
|
||||||
|
* **Schritt-Bedingungen werden auf die neuen Kennungen umgeschrieben.** Eine
|
||||||
|
* Kopie hat neue Kennungen; ein Verweis auf die alte waere nach dem
|
||||||
|
* Veroeffentlichen nicht mehr entscheidbar, und der Abschnitt erschiene
|
||||||
|
* stillschweigend immer. Deshalb zwei Durchgaenge, wie beim Import.
|
||||||
|
*/
|
||||||
|
async function abschnitteKopieren(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
zielVersionId: string,
|
||||||
|
abschnitte: readonly {
|
||||||
|
readonly titel: string;
|
||||||
|
readonly nurFuerAbteilungen: readonly string[];
|
||||||
|
readonly bedingung: unknown;
|
||||||
|
readonly bausteinVersionId?: string | null;
|
||||||
|
readonly schritte: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly titel: string;
|
||||||
|
readonly hilfetext: string | null;
|
||||||
|
readonly typ: SchrittTyp;
|
||||||
|
readonly pflicht: boolean;
|
||||||
|
readonly konfig: unknown;
|
||||||
|
readonly dokuUrl: string | null;
|
||||||
|
readonly bedingung: unknown;
|
||||||
|
}[];
|
||||||
|
}[],
|
||||||
|
): Promise<void> {
|
||||||
|
/*
|
||||||
|
* Alte Kennung → neue. Die alten Bedingungen zeigen auf die alten Kennungen;
|
||||||
|
* aufgeloest wird im zweiten Durchgang.
|
||||||
|
*/
|
||||||
|
const neueVonAlt = new Map<string, string>();
|
||||||
|
const zeilen: { readonly id: string; readonly stelle: number }[] = [];
|
||||||
|
|
||||||
|
for (const [index, abschnitt] of abschnitte.entries()) {
|
||||||
|
const kopie = await tx.vorlagenAbschnitt.create({
|
||||||
|
data: {
|
||||||
|
versionId: zielVersionId,
|
||||||
|
titel: abschnitt.titel,
|
||||||
|
position: index,
|
||||||
|
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||||
|
...(abschnitt.bausteinVersionId === undefined
|
||||||
|
? {}
|
||||||
|
: { bausteinVersionId: abschnitt.bausteinVersionId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
zeilen.push({ id: kopie.id, stelle: index });
|
||||||
|
|
||||||
|
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
|
||||||
|
const schrittKopie = await tx.vorlagenSchritt.create({
|
||||||
|
data: {
|
||||||
|
abschnittId: kopie.id,
|
||||||
|
titel: schritt.titel,
|
||||||
|
hilfetext: schritt.hilfetext,
|
||||||
|
typ: schritt.typ,
|
||||||
|
pflicht: schritt.pflicht,
|
||||||
|
// Json wird als Wert kopiert, nicht als Verweis geteilt.
|
||||||
|
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||||
|
dokuUrl: schritt.dokuUrl,
|
||||||
|
position: schrittIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
neueVonAlt.set(schritt.id, schrittKopie.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Bedingung auf die neuen Kennungen umschreiben, oder weglassen. */
|
||||||
|
const umschreiben = (roh: unknown): Prisma.InputJsonObject | undefined => {
|
||||||
|
const bedingung = bedingungLesen(roh);
|
||||||
|
if (!bedingung) return undefined;
|
||||||
|
if (bedingung.art === "variable") return bedingung as unknown as Prisma.InputJsonObject;
|
||||||
|
const neueId = neueVonAlt.get(bedingung.schrittId);
|
||||||
|
if (!neueId) return undefined;
|
||||||
|
return { ...bedingung, schrittId: neueId } as unknown as Prisma.InputJsonObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [index, abschnitt] of abschnitte.entries()) {
|
||||||
|
const zeile = zeilen[index];
|
||||||
|
const fuerAbschnitt = umschreiben(abschnitt.bedingung);
|
||||||
|
if (zeile && fuerAbschnitt) {
|
||||||
|
await tx.vorlagenAbschnitt.update({
|
||||||
|
where: { id: zeile.id },
|
||||||
|
data: { bedingung: fuerAbschnitt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const schritt of abschnitt.schritte) {
|
||||||
|
const fuerSchritt = umschreiben(schritt.bedingung);
|
||||||
|
const neueId = neueVonAlt.get(schritt.id);
|
||||||
|
if (neueId && fuerSchritt) {
|
||||||
|
await tx.vorlagenSchritt.update({ where: { id: neueId }, data: { bedingung: fuerSchritt } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aendert die Kopfdaten einer Vorlage.
|
* Aendert die Kopfdaten einer Vorlage.
|
||||||
*
|
*
|
||||||
@@ -236,27 +345,27 @@ export async function entwurfHolenOderErzeugen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type VersionMitInhalt = {
|
/**
|
||||||
readonly version: number;
|
* Was eine Version mitbringen muss, damit sie kopiert werden kann.
|
||||||
readonly variablen: readonly {
|
*
|
||||||
schluessel: string;
|
* **Aus Prisma abgeleitet und nicht von Hand geschrieben** — und das ist die
|
||||||
beschriftung: string;
|
* Lehre aus zwei stillen Datenverlusten. Hier stand ein selbst getippter Typ,
|
||||||
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
|
* der weniger Felder nannte, als die Abfrage liefert: `nurFuerAbteilungen` (seit
|
||||||
optionen: string[];
|
* #031) und `bedingung` (seit #039) fehlten darin. Die Abfrage holte sie, der
|
||||||
pflicht: boolean;
|
* Typ warf sie weg, und `abschnitteKopieren` konnte sie nicht sehen — „Bearbeiten“
|
||||||
}[];
|
* einer Vorlage verlor damit **alle** Abteilungseinschraenkungen, ohne dass
|
||||||
readonly abschnitte: readonly {
|
* etwas rot wurde.
|
||||||
titel: string;
|
*
|
||||||
schritte: readonly {
|
* Ein abgeleiteter Typ kann das nicht: ein neues Feld am Modell ist hier
|
||||||
titel: string;
|
* automatisch vorhanden, und wer es beim Kopieren vergisst, faellt im Test
|
||||||
hilfetext: string | null;
|
* „die Kopie ist inhaltsgleich“ auf.
|
||||||
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
|
*/
|
||||||
pflicht: boolean;
|
type VersionMitInhalt = Prisma.VorlagenVersionGetPayload<{
|
||||||
konfig: unknown;
|
include: {
|
||||||
dokuUrl: string | null;
|
variablen: true;
|
||||||
}[];
|
abschnitte: { include: { schritte: true } };
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
async function entwurfKopieren(
|
async function entwurfKopieren(
|
||||||
vorlageId: string,
|
vorlageId: string,
|
||||||
@@ -272,7 +381,19 @@ async function entwurfKopieren(
|
|||||||
select: { kundeId: true },
|
select: { kundeId: true },
|
||||||
});
|
});
|
||||||
const entwurf = await tx.vorlagenVersion.create({
|
const entwurf = await tx.vorlagenVersion.create({
|
||||||
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
|
data: {
|
||||||
|
vorlageId,
|
||||||
|
version: naechste,
|
||||||
|
status: VersionStatus.ENTWURF,
|
||||||
|
/*
|
||||||
|
* **Die Freigabepflicht wandert mit** (#040). Ohne diese Zeile verlöre
|
||||||
|
* jedes „Bearbeiten“ sie — genau der Fehler, den #039 bei
|
||||||
|
* `nurFuerAbteilungen` und `bedingung` aufgedeckt hat, nur an einer
|
||||||
|
* anderen Spalte. Eine Vorlage, die eine Freigabe verlangt, soll sie
|
||||||
|
* nach einer Textkorrektur nicht verlieren.
|
||||||
|
*/
|
||||||
|
freigabePflicht: letzte.freigabePflicht,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (letzte.variablen.length > 0) {
|
if (letzte.variablen.length > 0) {
|
||||||
@@ -289,26 +410,7 @@ async function entwurfKopieren(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
await abschnitteKopieren(tx, entwurf.id, letzte.abschnitte);
|
||||||
const kopie = await tx.vorlagenAbschnitt.create({
|
|
||||||
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
|
|
||||||
});
|
|
||||||
if (abschnitt.schritte.length > 0) {
|
|
||||||
await tx.vorlagenSchritt.createMany({
|
|
||||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
|
||||||
abschnittId: kopie.id,
|
|
||||||
titel: schritt.titel,
|
|
||||||
hilfetext: schritt.hilfetext,
|
|
||||||
typ: schritt.typ,
|
|
||||||
pflicht: schritt.pflicht,
|
|
||||||
// Json wird als Wert kopiert, nicht als Verweis geteilt.
|
|
||||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
|
||||||
dokuUrl: schritt.dokuUrl,
|
|
||||||
position: schrittIndex,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -524,6 +626,23 @@ export async function entwurfSpeichern(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Erst alles anlegen, dann die Bedingungen (#039).
|
||||||
|
*
|
||||||
|
* Dieser Dienst **loescht und schreibt neu** — die Schritte bekommen also
|
||||||
|
* bei jedem Speichern neue Kennungen. Eine Bedingung auf einer Kennung
|
||||||
|
* ueberlebte das nicht; in der Struktur steht deshalb ein **Titel-Pfad**,
|
||||||
|
* genau wie in einer Austauschdatei, und er wird hier aufgeloest. Dieselbe
|
||||||
|
* Funktion fuer beide Wege (`bedingungAufloesen`), sonst liefen sie
|
||||||
|
* auseinander.
|
||||||
|
*
|
||||||
|
* Zweiter Durchgang und nicht einer: eine Bedingung darf auf einen Schritt
|
||||||
|
* zeigen, der weiter unten steht — der Editor warnt davor, verbietet es aber
|
||||||
|
* nicht.
|
||||||
|
*/
|
||||||
|
const kennungen = new Map<string, string>();
|
||||||
|
const abschnittsZeilen: { readonly id: string; readonly titel: string }[] = [];
|
||||||
|
|
||||||
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
||||||
const angelegt = await tx.vorlagenAbschnitt.create({
|
const angelegt = await tx.vorlagenAbschnitt.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -536,9 +655,11 @@ export async function entwurfSpeichern(
|
|||||||
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (abschnitt.schritte.length > 0) {
|
abschnittsZeilen.push({ id: angelegt.id, titel: abschnitt.titel });
|
||||||
await tx.vorlagenSchritt.createMany({
|
|
||||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
for (const [schrittIndex, schritt] of abschnitt.schritte.entries()) {
|
||||||
|
const angelegterSchritt = await tx.vorlagenSchritt.create({
|
||||||
|
data: {
|
||||||
abschnittId: angelegt.id,
|
abschnittId: angelegt.id,
|
||||||
titel: schritt.titel,
|
titel: schritt.titel,
|
||||||
hilfetext: schritt.hilfetext ?? null,
|
hilfetext: schritt.hilfetext ?? null,
|
||||||
@@ -547,8 +668,33 @@ export async function entwurfSpeichern(
|
|||||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||||
dokuUrl: schritt.dokuUrl ?? null,
|
dokuUrl: schritt.dokuUrl ?? null,
|
||||||
position: schrittIndex,
|
position: schrittIndex,
|
||||||
})),
|
},
|
||||||
});
|
});
|
||||||
|
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegterSchritt.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
||||||
|
const zeile = abschnittsZeilen[index];
|
||||||
|
if (zeile && abschnitt.bedingung) {
|
||||||
|
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
|
||||||
|
if (aufgeloest) {
|
||||||
|
await tx.vorlagenAbschnitt.update({
|
||||||
|
where: { id: zeile.id },
|
||||||
|
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const schritt of abschnitt.schritte) {
|
||||||
|
if (!schritt.bedingung) continue;
|
||||||
|
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
|
||||||
|
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
|
||||||
|
if (schrittId && aufgeloest) {
|
||||||
|
await tx.vorlagenSchritt.update({
|
||||||
|
where: { id: schrittId },
|
||||||
|
data: { bedingung: aufgeloest as Prisma.InputJsonObject },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +721,14 @@ export async function versionVeroeffentlichen(
|
|||||||
versionId: string,
|
versionId: string,
|
||||||
aenderungsnotiz: string | null,
|
aenderungsnotiz: string | null,
|
||||||
durchBenutzerId: string,
|
durchBenutzerId: string,
|
||||||
|
/**
|
||||||
|
* Verlangt diese Version eine Freigabe (#040)?
|
||||||
|
*
|
||||||
|
* `undefined` laesst den Wert des Entwurfs stehen — so verhalten sich alle
|
||||||
|
* Aufrufer von vor #040 unveraendert, und der Wert, der beim Bearbeiten
|
||||||
|
* mitgewandert ist, bleibt erhalten.
|
||||||
|
*/
|
||||||
|
freigabePflicht?: boolean,
|
||||||
): Promise<VorlagenVersion> {
|
): Promise<VorlagenVersion> {
|
||||||
const notiz = aenderungsnotiz?.trim() || null;
|
const notiz = aenderungsnotiz?.trim() || null;
|
||||||
|
|
||||||
@@ -615,6 +769,8 @@ export async function versionVeroeffentlichen(
|
|||||||
aenderungsnotiz: notiz,
|
aenderungsnotiz: notiz,
|
||||||
veroeffentlichtAm: new Date(),
|
veroeffentlichtAm: new Date(),
|
||||||
veroeffentlichtVonId: durchBenutzerId,
|
veroeffentlichtVonId: durchBenutzerId,
|
||||||
|
// `undefined` laesst den Wert des Entwurfs stehen (#040).
|
||||||
|
...(freigabePflicht === undefined ? {} : { freigabePflicht }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (getroffen.count === 0) {
|
if (getroffen.count === 0) {
|
||||||
@@ -740,7 +896,13 @@ export async function vorlageDuplizieren(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const version = await tx.vorlagenVersion.create({
|
const version = await tx.vorlagenVersion.create({
|
||||||
data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF },
|
data: {
|
||||||
|
vorlageId: kopie.id,
|
||||||
|
version: 1,
|
||||||
|
status: VersionStatus.ENTWURF,
|
||||||
|
// Wandert mit, wie beim Entwurf (#040).
|
||||||
|
freigabePflicht: letzte.freigabePflicht,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (letzte.variablen.length > 0) {
|
if (letzte.variablen.length > 0) {
|
||||||
@@ -756,25 +918,7 @@ export async function vorlageDuplizieren(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
await abschnitteKopieren(tx, version.id, letzte.abschnitte);
|
||||||
const abschnittKopie = await tx.vorlagenAbschnitt.create({
|
|
||||||
data: { versionId: version.id, titel: abschnitt.titel, position: index },
|
|
||||||
});
|
|
||||||
if (abschnitt.schritte.length > 0) {
|
|
||||||
await tx.vorlagenSchritt.createMany({
|
|
||||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
|
||||||
abschnittId: abschnittKopie.id,
|
|
||||||
titel: schritt.titel,
|
|
||||||
hilfetext: schritt.hilfetext,
|
|
||||||
typ: schritt.typ,
|
|
||||||
pflicht: schritt.pflicht,
|
|
||||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
|
||||||
dokuUrl: schritt.dokuUrl,
|
|
||||||
position: schrittIndex,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.protokoll.create({
|
await tx.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -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",
|
"Mitarbeiter_konto_mitziehen",
|
||||||
"VorlagenAbschnitt_baustein_veroeffentlicht",
|
"VorlagenAbschnitt_baustein_veroeffentlicht",
|
||||||
|
/*
|
||||||
|
* Eine Schritt-Bedingung zeigt auf einen Schritt **derselben Version**
|
||||||
|
* (#039). Ein CHECK kann das nicht: es verlangt einen Blick in zwei andere
|
||||||
|
* Tabellen. Ohne diese Pruefung zeigt eine Bedingung nach dem
|
||||||
|
* Veroeffentlichen ins Leere — „Bearbeiten“ erzeugt eine tiefe Kopie mit
|
||||||
|
* neuen Kennungen —, und der Abschnitt erscheint dann stillschweigend
|
||||||
|
* immer.
|
||||||
|
*/
|
||||||
|
"VorlagenAbschnitt_bedingung_gleiche_version",
|
||||||
"VorlagenAbschnitt_unveraenderlich",
|
"VorlagenAbschnitt_unveraenderlich",
|
||||||
|
"VorlagenSchritt_bedingung_gleiche_version",
|
||||||
"VorlagenSchritt_unveraenderlich",
|
"VorlagenSchritt_unveraenderlich",
|
||||||
"VorlagenVariable_unveraenderlich",
|
"VorlagenVariable_unveraenderlich",
|
||||||
"VorlagenVersion_status_einbahn",
|
"VorlagenVersion_status_einbahn",
|
||||||
@@ -110,6 +120,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
* Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein
|
* Wochenende ueberfaellig steht, 2880 Mails — und zwar ohne dass ein
|
||||||
* einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034).
|
* einziger Test rot waere, denn jede davon ist fuer sich korrekt (#034).
|
||||||
*/
|
*/
|
||||||
|
/*
|
||||||
|
* Die Freigabe-Aufforderung geht je Lauf und Empfaenger einmal hinaus
|
||||||
|
* (#040). Sie entsteht bei einem gescheiterten Abschlussversuch, und
|
||||||
|
* jemand versucht es mehrmals — ohne den Index eine Mail je Versuch, und
|
||||||
|
* jede davon fuer sich korrekt.
|
||||||
|
*/
|
||||||
|
"Zustellung_freigabe_einmal",
|
||||||
"Zustellung_ueberfaellig_einmal",
|
"Zustellung_ueberfaellig_einmal",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -139,6 +156,14 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
"Lauf_abgeschlossen_mit_zeitpunkt",
|
"Lauf_abgeschlossen_mit_zeitpunkt",
|
||||||
"Lauf_archiviert_mit_zeitpunkt",
|
"Lauf_archiviert_mit_zeitpunkt",
|
||||||
"Lauf_begonnen_mit_zeitpunkt",
|
"Lauf_begonnen_mit_zeitpunkt",
|
||||||
|
/*
|
||||||
|
* Die Vier-Augen-Regel in der Datenbank (#040): wer den Lauf angelegt hat,
|
||||||
|
* gibt ihn nicht frei. Der Dienst prueft strenger — er sieht auch nach, wer
|
||||||
|
* an den Schrittergebnissen gearbeitet hat —, aber diese Haelfte gilt auch
|
||||||
|
* dann, wenn jemand an der Dienstschicht vorbei schreibt.
|
||||||
|
*/
|
||||||
|
"Lauf_freigabe_mit_zeitpunkt",
|
||||||
|
"Lauf_freigabe_nicht_selbst",
|
||||||
"Lauf_mitarbeiter_eindeutig",
|
"Lauf_mitarbeiter_eindeutig",
|
||||||
"Mitarbeiter_vorgesetzter_nicht_selbst",
|
"Mitarbeiter_vorgesetzter_nicht_selbst",
|
||||||
"Schrittergebnis_begruendung_noetig",
|
"Schrittergebnis_begruendung_noetig",
|
||||||
@@ -159,6 +184,13 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
|
|||||||
"Verzeichnisquelle_scimtoken_gehasht",
|
"Verzeichnisquelle_scimtoken_gehasht",
|
||||||
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
|
// Und ein Token gehoert zu einem Zeitpunkt, in beide Richtungen.
|
||||||
"Verzeichnisquelle_scimtoken_mit_zeitpunkt",
|
"Verzeichnisquelle_scimtoken_mit_zeitpunkt",
|
||||||
|
/*
|
||||||
|
* Die Form einer Bedingung (#039). Eine mit unbekannter `art` wuerde beim
|
||||||
|
* Lesen als „keine Bedingung“ gelten — der Abschnitt waere sichtbar, und
|
||||||
|
* niemand koennte sagen, warum seine Bedingung nicht wirkt.
|
||||||
|
*/
|
||||||
|
"VorlagenAbschnitt_bedingung_form",
|
||||||
|
"VorlagenSchritt_bedingung_form",
|
||||||
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
|
||||||
"Zustellung_bezug_passt_zum_anlass",
|
"Zustellung_bezug_passt_zum_anlass",
|
||||||
"Zustellung_empfaenger_nicht_leer",
|
"Zustellung_empfaenger_nicht_leer",
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { LaufStatus, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
|
||||||
|
import {
|
||||||
|
LaufFehler,
|
||||||
|
laufAbschliessen,
|
||||||
|
laufAnlegen,
|
||||||
|
laufFreigeben,
|
||||||
|
laufZurueckweisen,
|
||||||
|
} from "@/server/services/laeufe";
|
||||||
|
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vier-Augen-Freigabe (#040).
|
||||||
|
*
|
||||||
|
* **Eine Zusage trägt diesen Meilenstein:** wer an einem Lauf gearbeitet hat,
|
||||||
|
* gibt ihn nicht frei. Alles andere — das Recht, die Pflicht an der Version, die
|
||||||
|
* Zurückweisung — hängt daran.
|
||||||
|
*
|
||||||
|
* Geprüft wird sie von drei Seiten, weil sie auf drei Wegen umgangen werden
|
||||||
|
* könnte: über das Anlegen, über ein Schrittergebnis und über den Abschluss. Und
|
||||||
|
* ein CHECK in der Datenbank hält die wichtigste Hälfte selbst dann, wenn jemand
|
||||||
|
* an der Dienstschicht vorbei schreibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function aufbau(freigabePflicht: boolean) {
|
||||||
|
const kunde = await testPrisma.kunde.create({
|
||||||
|
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||||
|
});
|
||||||
|
await testPrisma.einstellung.create({ data: { id: "global" } });
|
||||||
|
|
||||||
|
const techniker = await testPrisma.benutzer.create({
|
||||||
|
data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" },
|
||||||
|
});
|
||||||
|
const redakteur = await testPrisma.benutzer.create({
|
||||||
|
data: { email: "red@firma.example", name: "Red", rolle: "REDAKTEUR" },
|
||||||
|
});
|
||||||
|
const admin = await testPrisma.benutzer.create({
|
||||||
|
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const vorlage = await testPrisma.vorlage.create({ data: { name: "Restore-Test" } });
|
||||||
|
const version = await testPrisma.vorlagenVersion.create({
|
||||||
|
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF, freigabePflicht },
|
||||||
|
});
|
||||||
|
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
|
||||||
|
data: { versionId: version.id, titel: "Prüfung", position: 0 },
|
||||||
|
});
|
||||||
|
const schritt = await testPrisma.vorlagenSchritt.create({
|
||||||
|
data: { abschnittId: abschnitt.id, titel: "Sicherung zurückspielen", pflicht: true, position: 0 },
|
||||||
|
});
|
||||||
|
await testPrisma.vorlagenVersion.update({
|
||||||
|
where: { id: version.id },
|
||||||
|
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
kunde,
|
||||||
|
versionId: version.id,
|
||||||
|
schrittId: schritt.id,
|
||||||
|
techniker: { id: techniker.id, rolle: techniker.rolle },
|
||||||
|
redakteur: { id: redakteur.id, rolle: redakteur.rolle },
|
||||||
|
admin: { id: admin.id, rolle: admin.rolle },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Lauf, den der Techniker angelegt und abgearbeitet hat. */
|
||||||
|
async function abgearbeitet(freigabePflicht: boolean) {
|
||||||
|
const stand = await aufbau(freigabePflicht);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: stand.versionId }, stand.techniker);
|
||||||
|
await schrittergebnisSetzen(
|
||||||
|
{ laufId: lauf.id, vorlagenSchrittId: stand.schrittId, zustand: SchrittZustand.ERLEDIGT },
|
||||||
|
stand.techniker,
|
||||||
|
);
|
||||||
|
return { ...stand, laufId: lauf.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Ein Lauf mit Freigabepflicht", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt sich nicht abschließen, solange keine Freigabe steht", async () => {
|
||||||
|
const { laufId, techniker } = await abgearbeitet(true);
|
||||||
|
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
|
||||||
|
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(/Freigabe durch einen Zweiten/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt sich nach der Freigabe abschließen", async () => {
|
||||||
|
const { laufId, techniker, redakteur } = await abgearbeitet(true);
|
||||||
|
await laufFreigeben(laufId, redakteur);
|
||||||
|
const fertig = await laufAbschliessen(laufId, techniker);
|
||||||
|
expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hält die Freigabe mit Freigeber und Zeitpunkt fest", async () => {
|
||||||
|
const { laufId, redakteur } = await abgearbeitet(true);
|
||||||
|
const nachher = await laufFreigeben(laufId, redakteur, "Sichtprüfung am 24.10. durch Red");
|
||||||
|
|
||||||
|
expect(nachher.freigegebenVonId).toBe(redakteur.id);
|
||||||
|
expect(nachher.freigegebenAm).toBeInstanceOf(Date);
|
||||||
|
expect(nachher.freigabeNotiz).toBe("Sichtprüfung am 24.10. durch Red");
|
||||||
|
|
||||||
|
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { laufId, aktion: "Lauf freigegeben" },
|
||||||
|
});
|
||||||
|
expect(eintrag.benutzerId).toBe(redakteur.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Wer gearbeitet hat, gibt nicht frei", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist den Techniker ab, der den Lauf angelegt und abgearbeitet hat", async () => {
|
||||||
|
// Er hätte das Recht ohnehin nicht — der nächste Test zeigt den Fall mit Recht.
|
||||||
|
const { laufId, techniker } = await abgearbeitet(true);
|
||||||
|
await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist auch einen ADMIN ab, der den Lauf angelegt hat", async () => {
|
||||||
|
/*
|
||||||
|
* **Der wichtigste Fall.** Das Recht allein genügt nicht; sonst wäre die
|
||||||
|
* Vier-Augen-Regel für jeden Administrator eine Formsache. Ein Einzelkämpfer
|
||||||
|
* kommt damit nicht weiter — das ist eine bewusste Härte, und die Anleitung
|
||||||
|
* sagt es.
|
||||||
|
*/
|
||||||
|
const { versionId, admin } = await aufbau(true);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||||||
|
|
||||||
|
await expect(laufFreigeben(lauf.id, admin)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ab, wer nur ein Schrittergebnis gesetzt hat", async () => {
|
||||||
|
/*
|
||||||
|
* Angelegt hat ihn der Techniker, abgearbeitet der Redakteur. Der Redakteur
|
||||||
|
* hat das Recht — aber er hat gearbeitet, also gibt er nicht frei.
|
||||||
|
*/
|
||||||
|
const { versionId, schrittId, techniker, redakteur } = await aufbau(true);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
|
||||||
|
await schrittergebnisSetzen(
|
||||||
|
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
|
||||||
|
redakteur,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt den frei, der nur zugewiesen war", async () => {
|
||||||
|
/*
|
||||||
|
* Die Gegenrichtung, und sie ist Absicht: eine **Zuweisung** ist noch keine
|
||||||
|
* Arbeit. Wer zugewiesen wurde und nichts getan hat, soll freigeben können,
|
||||||
|
* wenn jemand anderes abgearbeitet hat — sonst wäre die Regel strenger als
|
||||||
|
* gemeint und blockierte kleine Teams unnötig.
|
||||||
|
*/
|
||||||
|
const { versionId, schrittId, techniker, redakteur } = await aufbau(true);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId, zugewiesenAnId: redakteur.id }, techniker);
|
||||||
|
await schrittergebnisSetzen(
|
||||||
|
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
|
||||||
|
techniker,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(laufFreigeben(lauf.id, redakteur)).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist ab, wer ihn abgeschlossen hat", async () => {
|
||||||
|
// Ohne Freigabepflicht lässt sich abschließen; danach ist der Abschließende
|
||||||
|
// Bearbeiter und gibt nicht frei.
|
||||||
|
const { versionId, schrittId, techniker, redakteur } = await aufbau(false);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
|
||||||
|
await schrittergebnisSetzen(
|
||||||
|
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
|
||||||
|
techniker,
|
||||||
|
);
|
||||||
|
await laufAbschliessen(lauf.id, redakteur);
|
||||||
|
|
||||||
|
await expect(laufFreigeben(lauf.id, redakteur)).rejects.toThrow(/an diesem Lauf gearbeitet/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wird auch von der Datenbank abgewiesen, nicht nur vom Dienst", async () => {
|
||||||
|
/*
|
||||||
|
* An der Dienstschicht vorbei: der CHECK `Lauf_freigabe_nicht_selbst` hält
|
||||||
|
* die wichtigste Hälfte der Regel — der Ersteller gibt nicht frei (CLAUDE.md,
|
||||||
|
* „Regeln stehen in der Datenbank“).
|
||||||
|
*/
|
||||||
|
const { versionId, admin } = await aufbau(true);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, admin);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.update({
|
||||||
|
where: { id: lauf.id },
|
||||||
|
data: { freigegebenVonId: admin.id, freigegebenAm: new Date() },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Lauf_freigabe_nicht_selbst/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt Freigeber und Zeitpunkt zusammen", async () => {
|
||||||
|
const { versionId, techniker, redakteur } = await aufbau(true);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenVonId: redakteur.id } }),
|
||||||
|
).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u);
|
||||||
|
await expect(
|
||||||
|
testPrisma.lauf.update({ where: { id: lauf.id }, data: { freigegebenAm: new Date() } }),
|
||||||
|
).rejects.toThrow(/Lauf_freigabe_mit_zeitpunkt/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Zurückweisen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt eine Begründung", async () => {
|
||||||
|
// Dieselbe Regel wie bei NICHT_ZUTREFFEND und FEHLGESCHLAGEN: wer etwas
|
||||||
|
// zurückgibt, sagt warum.
|
||||||
|
const { laufId, redakteur } = await abgearbeitet(true);
|
||||||
|
await expect(laufZurueckweisen(laufId, redakteur, " ")).rejects.toThrow(/Begründung/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt den Lauf zurück auf IN_ARBEIT und hält die Notiz fest", async () => {
|
||||||
|
const { laufId, redakteur } = await abgearbeitet(true);
|
||||||
|
const nachher = await laufZurueckweisen(laufId, redakteur, "Prüfsumme fehlt im Protokoll");
|
||||||
|
|
||||||
|
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
|
||||||
|
expect(nachher.freigabeNotiz).toBe("Prüfsumme fehlt im Protokoll");
|
||||||
|
expect(nachher.freigegebenVonId).toBeNull();
|
||||||
|
|
||||||
|
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { laufId, aktion: "Lauf zurückgewiesen" },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(eintrag.nachher)).toContain("Prüfsumme fehlt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt eine erteilte Freigabe zurück", async () => {
|
||||||
|
const { laufId, redakteur, admin } = await abgearbeitet(true);
|
||||||
|
await laufFreigeben(laufId, redakteur);
|
||||||
|
await laufZurueckweisen(laufId, admin, "Doch nicht in Ordnung");
|
||||||
|
|
||||||
|
const zeile = await testPrisma.lauf.findUniqueOrThrow({ where: { id: laufId } });
|
||||||
|
expect(zeile.freigegebenVonId).toBeNull();
|
||||||
|
expect(zeile.freigegebenAm).toBeNull();
|
||||||
|
// Und der Abschluss wartet wieder.
|
||||||
|
await expect(laufAbschliessen(laufId, redakteur)).rejects.toThrow(/Freigabe/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holt einen abgeschlossenen Lauf zurück in die Arbeit", async () => {
|
||||||
|
const { laufId, techniker, redakteur, admin } = await abgearbeitet(true);
|
||||||
|
await laufFreigeben(laufId, redakteur);
|
||||||
|
await laufAbschliessen(laufId, techniker);
|
||||||
|
|
||||||
|
const nachher = await laufZurueckweisen(laufId, admin, "Nacharbeit nötig");
|
||||||
|
expect(nachher.status).toBe(LaufStatus.IN_ARBEIT);
|
||||||
|
expect(nachher.abgeschlossenAm).toBeNull();
|
||||||
|
expect(nachher.abgeschlossenVonId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt das Recht freigeben", async () => {
|
||||||
|
const { laufId, techniker } = await abgearbeitet(true);
|
||||||
|
await expect(laufZurueckweisen(laufId, techniker, "Grund")).rejects.toThrow(/Recht "freigeben"/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Die Aufforderung an die Freigabeberechtigten", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geht beim gescheiterten Abschlussversuch hinaus – an die mit dem Recht", async () => {
|
||||||
|
/*
|
||||||
|
* **Der Fall, der die ganze Regel wertlos machen würde:** ein Lauf wartet auf
|
||||||
|
* eine Freigabe, und niemand weiß es. Die Aufforderung entsteht deshalb genau
|
||||||
|
* dort, wo der Bearbeiter „ich bin fertig“ sagt — beim Abschlussversuch.
|
||||||
|
*
|
||||||
|
* Empfänger ist **nicht** der Zuständige, sondern jeder mit dem Recht
|
||||||
|
* `freigeben`. Das ist der einzige Anlass mit diesem Kreis.
|
||||||
|
*/
|
||||||
|
const { laufId, techniker, redakteur, admin } = await abgearbeitet(true);
|
||||||
|
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
|
||||||
|
|
||||||
|
const zustellungen = await testPrisma.zustellung.findMany({
|
||||||
|
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
|
||||||
|
select: { empfaenger: true },
|
||||||
|
});
|
||||||
|
const empfaenger = zustellungen.map((zeile) => zeile.empfaenger).sort();
|
||||||
|
|
||||||
|
const redZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: redakteur.id } });
|
||||||
|
const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
|
||||||
|
const tinaZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: techniker.id } });
|
||||||
|
|
||||||
|
expect(empfaenger).toEqual([adminZeile.email, redZeile.email].sort());
|
||||||
|
// Der Techniker hat kein `freigeben` – ihn aufzufordern wäre eine
|
||||||
|
// Aufforderung zu etwas, das der Dienst abweist.
|
||||||
|
expect(empfaenger).not.toContain(tinaZeile.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt den aus, der an dem Lauf gearbeitet hat", async () => {
|
||||||
|
// Der Redakteur hat das Recht, aber er hat abgearbeitet: er darf nicht
|
||||||
|
// freigeben, also bekommt er auch keine Aufforderung.
|
||||||
|
const { versionId, schrittId, techniker, redakteur, admin } = await aufbau(true);
|
||||||
|
const lauf = await laufAnlegen({ vorlagenVersionId: versionId }, techniker);
|
||||||
|
await schrittergebnisSetzen(
|
||||||
|
{ laufId: lauf.id, vorlagenSchrittId: schrittId, zustand: SchrittZustand.ERLEDIGT },
|
||||||
|
redakteur,
|
||||||
|
);
|
||||||
|
await expect(laufAbschliessen(lauf.id, techniker)).rejects.toThrow(LaufFehler);
|
||||||
|
|
||||||
|
const zustellungen = await testPrisma.zustellung.findMany({
|
||||||
|
where: { laufId: lauf.id, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
|
||||||
|
select: { empfaenger: true },
|
||||||
|
});
|
||||||
|
const adminZeile = await testPrisma.benutzer.findUniqueOrThrow({ where: { id: admin.id } });
|
||||||
|
expect(zustellungen.map((z) => z.empfaenger)).toEqual([adminZeile.email]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geht bei mehreren Versuchen nur einmal hinaus", async () => {
|
||||||
|
/*
|
||||||
|
* Ein Techniker versucht es mehrmals — und ohne den partiellen Unique-Index
|
||||||
|
* bekäme jeder Berechtigte bei jedem Versuch eine Mail, jede für sich
|
||||||
|
* korrekt. Derselbe Fall wie „überfällig“ in #034, nur schneller erreicht.
|
||||||
|
*/
|
||||||
|
const { laufId, techniker } = await abgearbeitet(true);
|
||||||
|
for (let versuch = 0; versuch < 3; versuch += 1) {
|
||||||
|
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anzahl = await testPrisma.zustellung.count({
|
||||||
|
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN", weg: "MAIL" },
|
||||||
|
});
|
||||||
|
// Zwei Berechtigte, drei Versuche – zwei Zeilen.
|
||||||
|
expect(anzahl).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nennt im Rumpf die Regel und keinen Schrittinhalt", async () => {
|
||||||
|
// Der Empfänger ist meist nicht der Bearbeiter und soll nicht raten müssen,
|
||||||
|
// warum er gefragt wird. Schrittinhalte gehören auch hier nicht hinein (#034).
|
||||||
|
const { laufId, techniker } = await abgearbeitet(true);
|
||||||
|
await expect(laufAbschliessen(laufId, techniker)).rejects.toThrow(LaufFehler);
|
||||||
|
|
||||||
|
const zeile = await testPrisma.zustellung.findFirstOrThrow({
|
||||||
|
where: { laufId, anlass: "LAUF_FREIGABE_OFFEN" },
|
||||||
|
});
|
||||||
|
expect(zeile.betreff).toContain("wartet auf Freigabe");
|
||||||
|
expect(zeile.rumpf).toMatch(/nicht daran gearbeitet/u);
|
||||||
|
expect(zeile.rumpf).not.toContain("Sicherung zurückspielen");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Ein Lauf ohne Freigabepflicht", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verhält sich unverändert", async () => {
|
||||||
|
/*
|
||||||
|
* **Die Zusage an alles Bestehende.** Jede Vorlage und jeder Lauf von vor
|
||||||
|
* diesem Meilenstein hat `freigabePflicht = false`; nichts daran darf sich
|
||||||
|
* ändern, sonst wäre es ein kaputtes Update.
|
||||||
|
*/
|
||||||
|
const { laufId, techniker } = await abgearbeitet(false);
|
||||||
|
const fertig = await laufAbschliessen(laufId, techniker);
|
||||||
|
expect(fertig.status).toBe(LaufStatus.ABGESCHLOSSEN);
|
||||||
|
expect(fertig.freigegebenVonId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt sich trotzdem freigeben – nach denselben Regeln", async () => {
|
||||||
|
/*
|
||||||
|
* Eine Freigabe an einem Lauf, der keine braucht, ist kein Fehler. Sie folgt
|
||||||
|
* aber denselben Regeln: sonst wäre sie ein Weg, einen Freigeber in die Akte
|
||||||
|
* zu schreiben, der dort nicht hingehört.
|
||||||
|
*/
|
||||||
|
const { laufId, techniker, redakteur } = await abgearbeitet(false);
|
||||||
|
await expect(laufFreigeben(laufId, redakteur)).resolves.toBeDefined();
|
||||||
|
await expect(laufFreigeben(laufId, techniker)).rejects.toThrow(/Recht "freigeben"/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt sich nicht zweimal freigeben", async () => {
|
||||||
|
const { laufId, redakteur, admin } = await abgearbeitet(false);
|
||||||
|
await laufFreigeben(laufId, redakteur);
|
||||||
|
await expect(laufFreigeben(laufId, admin)).rejects.toThrow(/bereits freigegeben/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Healthcheck (#039).
|
* Der Healthcheck (#025).
|
||||||
*
|
*
|
||||||
* Dieser Pfad ist der einzige, der ohne Anmeldung **und** mit Datenbankzugriff
|
* Dieser Pfad ist der einzige, der ohne Anmeldung **und** mit Datenbankzugriff
|
||||||
* antwortet. Geprueft wird deshalb nicht, dass er funktioniert — das tun der
|
* antwortet. Geprueft wird deshalb nicht, dass er funktioniert — das tun der
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const MATRIX: Record<Recht, Record<Rolle, boolean>> = {
|
|||||||
MITARBEITER: false,
|
MITARBEITER: false,
|
||||||
},
|
},
|
||||||
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
vorlagenBearbeiten: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||||
|
/*
|
||||||
|
* Die Vier-Augen-Freigabe (#040) liegt bewusst **nicht** beim TECHNIKER: sonst
|
||||||
|
* haengt sie daran, dass zufaellig ein zweiter greifbar ist.
|
||||||
|
*/
|
||||||
|
freigeben: { ADMIN: true, REDAKTEUR: true, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||||
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
archivVerwalten: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||||
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
verwaltung: { ADMIN: true, REDAKTEUR: false, TECHNIKER: false, LESER: false, MITARBEITER: false },
|
||||||
};
|
};
|
||||||
@@ -34,11 +39,11 @@ describe("Rechtematrix", () => {
|
|||||||
|
|
||||||
it("prueft jede Rolle gegen jedes Recht", () => {
|
it("prueft jede Rolle gegen jedes Recht", () => {
|
||||||
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
|
// Haelt fest, dass die Tabelle oben wirklich vollstaendig ist: fuenf Rollen,
|
||||||
// acht Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
|
// neun Rechte. Die Zahlen stehen hier ausgeschrieben, damit eine neue Rolle
|
||||||
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
|
// oder ein neues Recht **hier** auffaellt und nicht erst im Betrieb (#035).
|
||||||
expect(ALLE_ROLLEN).toHaveLength(5);
|
expect(ALLE_ROLLEN).toHaveLength(5);
|
||||||
expect(ALLE_RECHTE).toHaveLength(8);
|
expect(ALLE_RECHTE).toHaveLength(9);
|
||||||
expect(faelle).toHaveLength(40);
|
expect(faelle).toHaveLength(45);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
|
it.each(faelle)("%s: %s -> %s", (recht, rolle, erwartet) => {
|
||||||
@@ -58,6 +63,7 @@ describe("Rollen im Einzelnen", () => {
|
|||||||
expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true);
|
expect(darf("TECHNIKER", "laeufeBearbeiten")).toBe(true);
|
||||||
expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true);
|
expect(darf("TECHNIKER", "geraeteBearbeiten")).toBe(true);
|
||||||
expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false);
|
expect(darf("TECHNIKER", "vorlagenBearbeiten")).toBe(false);
|
||||||
|
expect(darf("TECHNIKER", "freigeben")).toBe(false);
|
||||||
expect(darf("TECHNIKER", "verwaltung")).toBe(false);
|
expect(darf("TECHNIKER", "verwaltung")).toBe(false);
|
||||||
expect(darf("TECHNIKER", "archivVerwalten")).toBe(false);
|
expect(darf("TECHNIKER", "archivVerwalten")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-6
@@ -150,13 +150,47 @@ describe("Die sieben Vorlagen", () => {
|
|||||||
expect(gruppen).toContain('-Members "{{sam}}"');
|
expect(gruppen).toContain('-Members "{{sam}}"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enthaelt beide Konto-Abschnitte des Onboardings", () => {
|
it("enthaelt beide Konto-Abschnitte des Onboardings – jeden mit seiner Bedingung", () => {
|
||||||
// Bedingte Abschnitte kommen erst in Phase 3; bis dahin wird der nicht
|
/*
|
||||||
// zutreffende als Ganzes auf „nicht zutreffend“ gesetzt.
|
* **Der Fall, für den #039 gebaut wurde.** Beide Abschnitte bleiben in der
|
||||||
|
* Vorlage — sie schließen sich über `konto_typ` gegenseitig aus. Bis Phase 3
|
||||||
|
* standen sie ohne Bedingung drin, und ein Techniker setzte den nicht
|
||||||
|
* zutreffenden von Hand auf „nicht zutreffend“: neun Schritte bei jedem
|
||||||
|
* Onboarding.
|
||||||
|
*
|
||||||
|
* Geprüft wird hier die **Vorlage**; dass die Auswertung greift, steht in
|
||||||
|
* `tests/bedingte-laeufe.test.ts`.
|
||||||
|
*/
|
||||||
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
|
const onboarding = VORLAGEN.find((eintrag) => eintrag.dokument.name === "Mitarbeiter-Onboarding");
|
||||||
const titel = (onboarding?.dokument.struktur.abschnitte ?? []).map((abschnitt) => abschnitt.titel);
|
const abschnitte = onboarding?.dokument.struktur.abschnitte ?? [];
|
||||||
expect(titel).toContain("Konto anlegen (Active Directory)");
|
const nach = (titel: string) => abschnitte.find((abschnitt) => abschnitt.titel === titel);
|
||||||
expect(titel).toContain("Konto anlegen (Entra ID)");
|
|
||||||
|
const optionen =
|
||||||
|
onboarding?.dokument.struktur.variablen.find((variable) => variable.schluessel === "konto_typ")
|
||||||
|
?.optionen ?? [];
|
||||||
|
|
||||||
|
for (const [titel, wert] of [
|
||||||
|
["Konto anlegen (Active Directory)", "Active Directory"],
|
||||||
|
["Konto anlegen (Entra ID)", "Entra ID"],
|
||||||
|
] as const) {
|
||||||
|
const abschnitt = nach(titel);
|
||||||
|
expect(abschnitt, titel).toBeDefined();
|
||||||
|
expect(abschnitt?.bedingung, titel).toEqual({
|
||||||
|
art: "variable",
|
||||||
|
schluessel: "konto_typ",
|
||||||
|
operator: "ist",
|
||||||
|
wert,
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
* **Der Wert muss eine der Optionen sein.** Ein Tippfehler hier wäre
|
||||||
|
* stillschweigend: die Bedingung träfe nie zu, der Abschnitt erschiene
|
||||||
|
* nie, und die Hälfte der Checkliste fehlte — ohne Fehlermeldung.
|
||||||
|
*/
|
||||||
|
expect(optionen, `${titel}: „${wert}“ ist keine Option von konto_typ`).toContain(wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und der gemeinsame Abschnitt hat keine: er gilt immer.
|
||||||
|
expect(nach("Konto, gemeinsam")?.bedingung).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("speichert nirgends ein Passwort im Klartext", () => {
|
it("speichert nirgends ein Passwort im Klartext", () => {
|
||||||
|
|||||||
@@ -34,7 +34,15 @@ async function redakteurAnlegen() {
|
|||||||
* der Export, dass er nach `position` sortiert und nicht danach, wie die
|
* der Export, dass er nach `position` sortiert und nicht danach, wie die
|
||||||
* Datenbank die Zeilen gerade zurueckgibt.
|
* Datenbank die Zeilen gerade zurueckgibt.
|
||||||
*/
|
*/
|
||||||
async function beispielVersionAnlegen(): Promise<string> {
|
/**
|
||||||
|
* Legt die Beispielvorlage an und veröffentlicht sie.
|
||||||
|
*
|
||||||
|
* `nachtragen` wird **vor** dem Veröffentlichen gerufen: an einer
|
||||||
|
* veröffentlichten Version lässt sich nichts mehr ändern, dafür sorgen Trigger
|
||||||
|
* in der Datenbank (#009). Wer eine Abteilungseinschränkung oder eine Bedingung
|
||||||
|
* braucht, setzt sie dort — genau wie im Editor.
|
||||||
|
*/
|
||||||
|
async function beispielVersionAnlegen(nachtragen?: (versionId: string) => Promise<void>): Promise<string> {
|
||||||
const vorlage = await testPrisma.vorlage.create({
|
const vorlage = await testPrisma.vorlage.create({
|
||||||
data: {
|
data: {
|
||||||
name: "Onboarding Notebook",
|
name: "Onboarding Notebook",
|
||||||
@@ -128,6 +136,8 @@ async function beispielVersionAnlegen(): Promise<string> {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await nachtragen?.(version.id);
|
||||||
|
|
||||||
await testPrisma.vorlagenVersion.update({
|
await testPrisma.vorlagenVersion.update({
|
||||||
where: { id: version.id },
|
where: { id: version.id },
|
||||||
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
|
||||||
@@ -136,6 +146,15 @@ async function beispielVersionAnlegen(): Promise<string> {
|
|||||||
return version.id;
|
return version.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Abschnitte einer Version samt Schritten, in Reihenfolge. */
|
||||||
|
async function abschnitteVon(versionId: string) {
|
||||||
|
return testPrisma.vorlagenAbschnitt.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { position: "asc" },
|
||||||
|
include: { schritte: { orderBy: { position: "asc" } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
|
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
|
||||||
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
|
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
@@ -503,6 +522,142 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
|
|||||||
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
|
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bewahrt die Abteilungseinschränkung eines Abschnitts", async () => {
|
||||||
|
/*
|
||||||
|
* **Das ging stillschweigend verloren.** Der Export schreibt
|
||||||
|
* `nurFuerAbteilungen` seit #031, das Schema nimmt es an — und der Import
|
||||||
|
* verwarf es. Eine Vorlage, die jemand in eine zweite Installation
|
||||||
|
* brachte, verlor ihre Abteilungsabschnitte: sie galten dort für **alle**.
|
||||||
|
* Also mehr Schritte statt weniger, und niemand merkt es. Gefunden bei
|
||||||
|
* #039, als derselbe Weg für Bedingungen gebaut wurde.
|
||||||
|
*/
|
||||||
|
const redakteur = await redakteurAnlegen();
|
||||||
|
const quelle = await beispielVersionAnlegen(async (versionId) => {
|
||||||
|
await testPrisma.vorlagenAbschnitt.updateMany({
|
||||||
|
where: { versionId, position: 0 },
|
||||||
|
data: { nurFuerAbteilungen: ["Buchhaltung", "Vertrieb"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const erst = await vorlageExportieren(quelle);
|
||||||
|
expect(erst.struktur.abschnitte[0]?.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
|
||||||
|
|
||||||
|
const { versionId } = await vorlageImportieren(erst, redakteur.id);
|
||||||
|
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||||
|
where: { versionId, position: 0 },
|
||||||
|
});
|
||||||
|
expect(zeile.nurFuerAbteilungen).toEqual(["Buchhaltung", "Vertrieb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schreibt eine Schritt-Bedingung als Titel-Pfad und löst sie wieder auf", async () => {
|
||||||
|
/*
|
||||||
|
* Eine Bedingung zeigt gespeichert auf eine **Kennung**, und die bedeutet
|
||||||
|
* in einer anderen Installation nichts. Im Austausch steht deshalb ein
|
||||||
|
* Titel-Pfad; beim Import wird daraus die neue Kennung — sonst wäre die
|
||||||
|
* Bedingung dort nicht entscheidbar, und der Abschnitt erschiene immer,
|
||||||
|
* ohne dass jemand sagen könnte, warum (#039).
|
||||||
|
*/
|
||||||
|
const redakteur = await redakteurAnlegen();
|
||||||
|
let abschnitte: Awaited<ReturnType<typeof abschnitteVon>> = [];
|
||||||
|
const quelle = await beispielVersionAnlegen(async (versionId) => {
|
||||||
|
abschnitte = await abschnitteVon(versionId);
|
||||||
|
await testPrisma.vorlagenAbschnitt.update({
|
||||||
|
where: { id: abschnitte[1]!.id },
|
||||||
|
data: {
|
||||||
|
bedingung: {
|
||||||
|
art: "schritt",
|
||||||
|
schrittId: abschnitte[0]!.schritte[0]!.id,
|
||||||
|
zustaende: ["FEHLGESCHLAGEN"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const ziel = abschnitte[0]!.schritte[0]!;
|
||||||
|
|
||||||
|
// Im Export steht der Pfad, nicht die Kennung.
|
||||||
|
const erst = await vorlageExportieren(quelle);
|
||||||
|
const ausgetauscht = erst.struktur.abschnitte[1]?.bedingung;
|
||||||
|
expect(ausgetauscht).toEqual({
|
||||||
|
art: "schritt",
|
||||||
|
abschnitt: abschnitte[0]!.titel,
|
||||||
|
schritt: ziel.titel,
|
||||||
|
zustaende: ["FEHLGESCHLAGEN"],
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(erst)).not.toContain(ziel.id);
|
||||||
|
|
||||||
|
// Und beim Import wird daraus die neue Kennung.
|
||||||
|
const { versionId } = await vorlageImportieren(erst, redakteur.id);
|
||||||
|
const neu = await testPrisma.vorlagenAbschnitt.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
orderBy: { position: "asc" },
|
||||||
|
include: { schritte: { orderBy: { position: "asc" } } },
|
||||||
|
});
|
||||||
|
expect(neu[1]?.bedingung).toEqual({
|
||||||
|
art: "schritt",
|
||||||
|
schrittId: neu[0]!.schritte[0]!.id,
|
||||||
|
zustaende: ["FEHLGESCHLAGEN"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übergeht eine Bedingung, deren Titel-Pfad in der Datei fehlt", async () => {
|
||||||
|
/*
|
||||||
|
* Eine von Hand geschriebene Datei kann sich vertippen. Die ganze Vorlage
|
||||||
|
* deswegen abzuweisen wäre die teurere Antwort — übergangen heißt: der
|
||||||
|
* Abschnitt gilt immer, und das steht sichtbar in der Oberfläche.
|
||||||
|
*/
|
||||||
|
const redakteur = await redakteurAnlegen();
|
||||||
|
const quelle = await beispielVersionAnlegen();
|
||||||
|
const erst = await vorlageExportieren(quelle);
|
||||||
|
|
||||||
|
const { versionId } = await vorlageImportieren(
|
||||||
|
{
|
||||||
|
...erst,
|
||||||
|
name: "Mit Tippfehler",
|
||||||
|
struktur: {
|
||||||
|
...erst.struktur,
|
||||||
|
abschnitte: erst.struktur.abschnitte.map((abschnitt, stelle) =>
|
||||||
|
stelle === 1
|
||||||
|
? {
|
||||||
|
...abschnitt,
|
||||||
|
bedingung: {
|
||||||
|
art: "schritt",
|
||||||
|
abschnitt: "Gibt es nicht",
|
||||||
|
schritt: "Auch nicht",
|
||||||
|
zustaende: ["ERLEDIGT"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: abschnitt,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
redakteur.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||||
|
where: { versionId, position: 1 },
|
||||||
|
});
|
||||||
|
expect(zeile.bedingung).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bewahrt eine Variablen-Bedingung unverändert", async () => {
|
||||||
|
const redakteur = await redakteurAnlegen();
|
||||||
|
const bedingung = { art: "variable", schluessel: "konto_typ", operator: "ist", wert: "AD" };
|
||||||
|
const quelle = await beispielVersionAnlegen(async (versionId) => {
|
||||||
|
await testPrisma.vorlagenAbschnitt.updateMany({
|
||||||
|
where: { versionId, position: 0 },
|
||||||
|
data: { bedingung },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
|
||||||
|
const zeile = await testPrisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||||
|
where: { versionId, position: 0 },
|
||||||
|
});
|
||||||
|
// Ein Variablenschluessel ist ein Name, den die Vorlage selbst mitbringt —
|
||||||
|
// er wandert unveraendert.
|
||||||
|
expect(zeile.bedingung).toEqual(bedingung);
|
||||||
|
});
|
||||||
|
|
||||||
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
|
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
|
||||||
const redakteur = await redakteurAnlegen();
|
const redakteur = await redakteurAnlegen();
|
||||||
const quelle = await beispielVersionAnlegen();
|
const quelle = await beispielVersionAnlegen();
|
||||||
|
|||||||
Reference in New Issue
Block a user