feat(vorlagen): bedingte Abschnitte und Schritte (#039)
Ein Abschnitt oder ein einzelner Schritt erscheint nur, wenn seine Bedingung zutrifft — abhaengig von einer Variable des Laufs oder vom Zustand eines anderen Schritts. Der echte Fall steht im Seed: "Mitarbeiter-Onboarding" traegt beide Konto-Abschnitte, fuenf AD-Schritte und vier fuer Entra ID, und `konto_typ` sagt beim Start, welcher gilt. Bisher klickte ein Techniker die anderen neun bei jedem Onboarding weg. Entscheidungen, alle im Issue begruendet: - Laufend ausgewertet, nicht beim Start festgeschrieben. Fuer die Abteilungsabschnitte (#031) ist Festschreiben richtig; fuer Bedingungen nicht — die Antwort auf "ist Pruefen fehlgeschlagen?" entsteht erst waehrend der Arbeit. Ein struktureller Test prueft, dass alle drei Schichten (Abschlussregel, Abarbeitung, Druck) die Auswertung rufen. - Nicht entscheidbar heisst: gilt. Ein Lauf ohne konto_typ zeigt beide Abschnitte — genau wie vorher. Ein Abschnitt zu viel ist sichtbar und korrigierbar, ein fehlender faellt niemandem auf. - OFFEN ist eine Antwort, keine Luecke: waere es unentscheidbar, waere ein bedingter Abschnitt von Anfang an sichtbar. - Eine Bedingung, kein Ausdrucksbaum. Kein und, kein oder. - Verwiesen wird ueber Titel-Pfade, nicht ueber Kennungen: vier Wege schreiben eine Struktur neu und geben neue Kennungen. Zwei stille Datenverluste gefunden, der zweite aelter als dieser Meilenstein: - Der Import verwarf `nurFuerAbteilungen`. Der Export schreibt es seit #031, das Schema nimmt es an, der Import setzte es nicht — eine exportierte Vorlage verlor ihre Abteilungsabschnitte und galt dort fuer alle. - Die tiefe Kopie verwarf es ebenfalls, womit jedes "Bearbeiten" einer Vorlage alle Einschraenkungen verlor. Ursache war ein von Hand getippter Zwischentyp, der weniger Felder nannte als die Abfrage liefert; jetzt aus Prisma abgeleitet, beide Kopierwege teilen eine Stelle, und ein Test vergleicht Kopie und Original Feld fuer Feld. Gefunden hat beides die Klickprobe am gebauten Stand, nicht die Suite: im Editor stand "Bedingung gesetzt", nach "Bearbeiten" war sie weg. Dreizehn Gegenproben liefen, alle wurden rot — und der teuerste Befund lag ausserhalb von allen. 1499 Vitest-Tests (vorher 1457), 115 E2E-Ablaeufe gruen. Closes #39 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2f247b3617
commit
a2330ef682
@@ -0,0 +1,161 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
|
||||
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
|
||||
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
|
||||
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
|
||||
-- Loeschbefehle sind bewusst gestrichen.
|
||||
--
|
||||
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
|
||||
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Bedingte Abschnitte und Schritte (#039).
|
||||
|
||||
ALTER TABLE "VorlagenAbschnitt" ADD COLUMN "bedingung" JSONB;
|
||||
ALTER TABLE "VorlagenSchritt" ADD COLUMN "bedingung" JSONB;
|
||||
|
||||
/*
|
||||
* Die **Form** der Bedingung, in der Datenbank.
|
||||
*
|
||||
* Das vollstaendige Schema steht in `src/lib/bedingungen.ts` und wird beim
|
||||
* Schreiben angewandt. Hier steht, was auch dann gelten muss, wenn jemand an
|
||||
* der Service-Schicht vorbei schreibt (CLAUDE.md) — und das ist mehr als
|
||||
* Formsache: eine Bedingung mit unbekannter `art` wuerde beim Lesen als „keine
|
||||
* Bedingung“ gelten, der Abschnitt waere also sichtbar. Das ist die harmlose
|
||||
* Richtung, aber es waere **stillschweigend** falsch, und niemand koennte sagen,
|
||||
* warum ein Abschnitt, der eine Bedingung hat, immer erscheint.
|
||||
*
|
||||
* Geprueft wird deshalb: `art` ist einer der beiden Werte, und die Felder, die
|
||||
* diese Art braucht, sind da. Nicht geprueft wird der Inhalt von `zustaende` —
|
||||
* ein Array-Element einzeln zu pruefen ginge nur mit einer Unterabfrage, und
|
||||
* die gehoert nicht in einen CHECK.
|
||||
*/
|
||||
ALTER TABLE "VorlagenAbschnitt"
|
||||
ADD CONSTRAINT "VorlagenAbschnitt_bedingung_form"
|
||||
CHECK (
|
||||
"bedingung" IS NULL
|
||||
OR (
|
||||
"bedingung" ->> 'art' = 'variable'
|
||||
AND "bedingung" ->> 'schluessel' IS NOT NULL
|
||||
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
|
||||
AND (
|
||||
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
|
||||
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
|
||||
)
|
||||
)
|
||||
OR (
|
||||
"bedingung" ->> 'art' = 'schritt'
|
||||
AND "bedingung" ->> 'schrittId' IS NOT NULL
|
||||
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
|
||||
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "VorlagenSchritt"
|
||||
ADD CONSTRAINT "VorlagenSchritt_bedingung_form"
|
||||
CHECK (
|
||||
"bedingung" IS NULL
|
||||
OR (
|
||||
"bedingung" ->> 'art' = 'variable'
|
||||
AND "bedingung" ->> 'schluessel' IS NOT NULL
|
||||
AND "bedingung" ->> 'operator' IN ('ist', 'istNicht', 'gesetzt', 'leer')
|
||||
AND (
|
||||
"bedingung" ->> 'operator' IN ('gesetzt', 'leer')
|
||||
OR btrim(COALESCE("bedingung" ->> 'wert', '')) <> ''
|
||||
)
|
||||
)
|
||||
OR (
|
||||
"bedingung" ->> 'art' = 'schritt'
|
||||
AND "bedingung" ->> 'schrittId' IS NOT NULL
|
||||
AND jsonb_typeof("bedingung" -> 'zustaende') = 'array'
|
||||
AND jsonb_array_length("bedingung" -> 'zustaende') > 0
|
||||
)
|
||||
);
|
||||
|
||||
/*
|
||||
* **Eine Schritt-Bedingung zeigt auf einen Schritt derselben Version.**
|
||||
*
|
||||
* Das ist die eigentliche Regel, und ein CHECK kann sie nicht halten: sie
|
||||
* verlangt einen Blick in zwei andere Tabellen. Ein Fremdschluessel auf
|
||||
* `VorlagenSchritt` ginge auch nicht — der Wert steckt in einem Json, und
|
||||
* „derselbe Version“ steht dort nicht.
|
||||
*
|
||||
* Ohne diese Pruefung zeigt eine Bedingung nach dem Veroeffentlichen ins Leere:
|
||||
* „Bearbeiten“ erzeugt eine **tiefe Kopie** mit neuen Kennungen (CLAUDE.md), und
|
||||
* ein Verweis auf die alte Kennung waere dann nicht mehr entscheidbar. Die
|
||||
* Auswertung zeigt in diesem Fall an, der Abschnitt erscheint also immer — und
|
||||
* niemand koennte sagen, warum die Bedingung nicht wirkt. Genau die Art Fehler,
|
||||
* die eine gruene Suite nicht sieht.
|
||||
*
|
||||
* Geprueft wird beim Schreiben der **Bedingung**, nicht beim Loeschen des
|
||||
* Ziels: einen Schritt zu loeschen, auf den eine Bedingung zeigt, ist nur im
|
||||
* Entwurf moeglich, und dort faengt es der Dienst mit einer besseren Meldung.
|
||||
*/
|
||||
CREATE OR REPLACE FUNCTION laufzettel_bedingung_gleiche_version() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
eigene_version TEXT;
|
||||
ziel_version TEXT;
|
||||
ziel_id TEXT;
|
||||
BEGIN
|
||||
IF NEW."bedingung" IS NULL OR NEW."bedingung" ->> 'art' <> 'schritt' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
ziel_id := NEW."bedingung" ->> 'schrittId';
|
||||
|
||||
-- Die eigene Version: am Abschnitt steht sie direkt, am Schritt eine Ebene hoeher.
|
||||
IF TG_TABLE_NAME = 'VorlagenAbschnitt' THEN
|
||||
eigene_version := NEW."versionId";
|
||||
ELSE
|
||||
SELECT "versionId" INTO eigene_version
|
||||
FROM "VorlagenAbschnitt" WHERE id = NEW."abschnittId";
|
||||
END IF;
|
||||
|
||||
SELECT a."versionId" INTO ziel_version
|
||||
FROM "VorlagenSchritt" s
|
||||
JOIN "VorlagenAbschnitt" a ON a.id = s."abschnittId"
|
||||
WHERE s.id = ziel_id;
|
||||
|
||||
IF ziel_version IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Bedingung zeigt auf den Schritt %, den es nicht gibt.', ziel_id
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
|
||||
IF ziel_version <> eigene_version THEN
|
||||
RAISE EXCEPTION
|
||||
'Die Bedingung zeigt auf einen Schritt einer anderen Version. Nach dem Veroeffentlichen waere sie nicht mehr entscheidbar.'
|
||||
USING ERRCODE = 'integrity_constraint_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "VorlagenAbschnitt_bedingung_gleiche_version"
|
||||
BEFORE INSERT OR UPDATE ON "VorlagenAbschnitt"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
|
||||
|
||||
CREATE TRIGGER "VorlagenSchritt_bedingung_gleiche_version"
|
||||
BEFORE INSERT OR UPDATE ON "VorlagenSchritt"
|
||||
FOR EACH ROW EXECUTE FUNCTION laufzettel_bedingung_gleiche_version();
|
||||
|
||||
/*
|
||||
* Gegenprobe: die neuen Spalten sind ueberall leer, und die CHECKs lassen die
|
||||
* bestehenden Zeilen zu. `ALTER TABLE ... ADD CONSTRAINT` prueft das von sich
|
||||
* aus — aber eine Migration, die ihre eigene Annahme nicht nachrechnet, ist
|
||||
* genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
|
||||
*/
|
||||
DO $$
|
||||
DECLARE
|
||||
mit_bedingung INTEGER;
|
||||
BEGIN
|
||||
SELECT count(*) INTO mit_bedingung FROM "VorlagenAbschnitt" WHERE "bedingung" IS NOT NULL;
|
||||
IF mit_bedingung > 0 THEN
|
||||
RAISE EXCEPTION 'Unerwartet % Abschnitte mit Bedingung vor der Migration.', mit_bedingung;
|
||||
END IF;
|
||||
SELECT count(*) INTO mit_bedingung FROM "VorlagenSchritt" WHERE "bedingung" IS NOT NULL;
|
||||
IF mit_bedingung > 0 THEN
|
||||
RAISE EXCEPTION 'Unerwartet % Schritte mit Bedingung vor der Migration.', mit_bedingung;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -636,6 +636,17 @@ model VorlagenAbschnitt {
|
||||
/// Welche der beiden Lesarten gilt, sagt der Status der Version; ein zweites
|
||||
/// Feld dafuer waere eine zweite Wahrheit.
|
||||
bausteinVersionId String?
|
||||
/// Wann dieser Abschnitt gilt (#039). Leer heisst: immer.
|
||||
///
|
||||
/// Form und Auswertung stehen in `src/lib/bedingungen.ts`; ein CHECK haelt die
|
||||
/// Form auch gegen Schreiben an der Dienstschicht vorbei fest, und ein Trigger
|
||||
/// haelt fest, dass eine Schritt-Bedingung auf **dieselbe Version** zeigt —
|
||||
/// nach dem Veroeffentlichen waere ein fremder Verweis nicht mehr
|
||||
/// entscheidbar, und der Abschnitt erschiene stillschweigend immer.
|
||||
///
|
||||
/// **Nicht entscheidbar heisst: gilt.** Ein Abschnitt zu viel ist sichtbar und
|
||||
/// korrigierbar; ein fehlender faellt niemandem auf.
|
||||
bedingung Json?
|
||||
|
||||
version VorlagenVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||
bausteinVersion BausteinVersion? @relation(fields: [bausteinVersionId], references: [id], onDelete: Restrict)
|
||||
@@ -719,6 +730,9 @@ model VorlagenSchritt {
|
||||
/// Parameter fuer Schritte vom Typ AKTION.
|
||||
konfig Json @default("{}")
|
||||
dokuUrl String?
|
||||
/// Wann dieser Schritt gilt (#039). Leer heisst: immer. Siehe
|
||||
/// `VorlagenAbschnitt.bedingung` — dieselbe Form, dieselben Regeln.
|
||||
bedingung Json?
|
||||
|
||||
abschnitt VorlagenAbschnitt @relation(fields: [abschnittId], references: [id], onDelete: Cascade)
|
||||
ergebnisse Schrittergebnis[]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { bedingungAufloesen, schrittPfad } from "@/lib/bedingungen";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
|
||||
@@ -199,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",
|
||||
|
||||
Reference in New Issue
Block a user