feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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

Entscheidungen, alle im Issue begruendet:

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

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

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

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

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

Closes #39

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-23 14:54:10 +02:00
co-authored by Claude Opus 5
parent 2f247b3617
commit a2330ef682
29 changed files with 3051 additions and 146 deletions
+63 -17
View File
@@ -1,4 +1,5 @@
import { readFileSync, readdirSync } from "node:fs";
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
@@ -199,26 +200,71 @@ export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteV
});
}
/*
* Erst alles anlegen, dann die Bedingungen (#039) — wie beim Import und
* aus demselben Grund: eine Schritt-Bedingung verweist ueber einen
* Titel-Pfad, und der laesst sich erst aufloesen, wenn die Schritte
* stehen. Die Seed-Vorlagen benutzen bisher nur Variablen-Bedingungen;
* der zweite Durchgang steht trotzdem hier, damit eine kuenftige Vorlage
* mit Schritt-Bedingung nicht stillschweigend ohne sie landet.
*/
const kennungen = new Map<string, string>();
const abschnittsZeilen: string[] = [];
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const zeile = await tx.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: abschnitt.titel, position },
});
if (abschnitt.schritte.length === 0) continue;
await tx.vorlagenSchritt.createMany({
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
abschnittId: zeile.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert;
// das nachsichtige Schema traegt nur eine Index-Signatur auf
// `unknown`, die Prisma nicht als solchen erkennt.
konfig: schritt.konfig as object,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
})),
data: {
versionId: version.id,
titel: abschnitt.titel,
position,
// Wandert mit, auch wenn bisher keine Seed-Vorlage eine hat (#031).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
},
});
abschnittsZeilen.push(zeile.id);
for (const [schrittPosition, schritt] of abschnitt.schritte.entries()) {
const angelegt = await tx.vorlagenSchritt.create({
data: {
abschnittId: zeile.id,
titel: schritt.titel,
hilfetext: schritt.hilfetext ?? null,
typ: schritt.typ,
pflicht: schritt.pflicht,
// Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert;
// das nachsichtige Schema traegt nur eine Index-Signatur auf
// `unknown`, die Prisma nicht als solchen erkennt.
konfig: schritt.konfig as object,
dokuUrl: schritt.dokuUrl ?? null,
position: schrittPosition,
},
});
kennungen.set(schrittPfad(abschnitt.titel, schritt.titel), angelegt.id);
}
}
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
const abschnittId = abschnittsZeilen[position];
if (abschnittId && abschnitt.bedingung) {
const aufgeloest = bedingungAufloesen(abschnitt.bedingung, kennungen);
if (aufgeloest) {
await tx.vorlagenAbschnitt.update({
where: { id: abschnittId },
data: { bedingung: aufgeloest as object },
});
}
}
for (const schritt of abschnitt.schritte) {
if (!schritt.bedingung) continue;
const schrittId = kennungen.get(schrittPfad(abschnitt.titel, schritt.titel));
const aufgeloest = bedingungAufloesen(schritt.bedingung, kennungen);
if (schrittId && aufgeloest) {
await tx.vorlagenSchritt.update({
where: { id: schrittId },
data: { bedingung: aufgeloest as object },
});
}
}
}
await tx.vorlagenVersion.update({
@@ -94,6 +94,12 @@
},
{
"titel": "Konto anlegen (Active Directory)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Active Directory"
},
"schritte": [
{
"titel": "Konto anlegen",
@@ -141,6 +147,12 @@
},
{
"titel": "Konto anlegen (Entra ID)",
"bedingung": {
"art": "variable",
"schluessel": "konto_typ",
"operator": "ist",
"wert": "Entra ID"
},
"schritte": [
{
"titel": "Konto anlegen",