feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der
Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung
an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt
aenderbar, archiviert nicht.

Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn
Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort
keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes
Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen
und Einfuegen aus.

Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene
Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht
zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche
behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten.

Closes #13

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 18:55:59 +02:00
co-authored by Claude Opus 5
parent eec5de404a
commit ed6e6b81eb
44 changed files with 7440 additions and 61 deletions
+314
View File
@@ -0,0 +1,314 @@
import { Prisma, type Schrittergebnis } from "@/generated/prisma/client";
import { SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import {
LaufFehler,
abschlussPruefenNachAenderung,
laufStartenFallsOffen,
laufZumSchreibenHolen,
} from "@/server/services/laeufe";
/**
* Schrittergebnisse (#013).
*
* Ein Schritt wird **einzeln** gesetzt, nicht als Gesamtformular: der Techniker
* arbeitet einen Laufzettel von oben nach unten ab, oft am Handy, oft
* unterbrochen. Ein Formular, das am Ende alles auf einmal schickt, verliert
* genau dann die Arbeit, wenn es darauf ankommt.
*
* Drei Regeln stehen hier:
*
* 1. **`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.** Sonst
* stuende im Archiv ein uebersprungener Schritt ohne Begruendung — und genau
* das ist die Luecke, die ein Archiv wertlos macht. Die Bedingung
* `Schrittergebnis_begruendung_noetig` haelt dasselbe in der Datenbank fest.
* 2. **Jede Aenderung bekommt einen Protokolleintrag**, in derselben
* Transaktion wie die Aenderung selbst.
* 3. **Der Schreibschutz des Archivs laeuft durch `laufZumSchreibenHolen`** —
* die eine Stelle, die `laufMussVeraenderbarSein` ruft und die Laufzeile
* sperrt.
*/
/** Wie ein Zustand im Protokoll heisst. Die Oberflaeche hat ihre eigenen Texte. */
const ZUSTAND_AKTION: Readonly<Record<SchrittZustand, string>> = {
OFFEN: "Schritt zurückgesetzt",
ERLEDIGT: "Schritt erledigt",
NICHT_ZUTREFFEND: "Schritt als nicht zutreffend eingetragen",
FEHLGESCHLAGEN: "Schritt als fehlgeschlagen eingetragen",
};
export type SchrittergebnisEingabe = {
readonly laufId: string;
readonly vorlagenSchrittId: string;
readonly zustand: SchrittZustand;
/**
* Der erfasste Wert. Fehlt das Feld, bleibt der vorhandene Wert stehen;
* `null` loescht ihn. Ein Haken, der einen Text mitbringt, und ein Zustand, der
* ohne Wert wechselt, sind verschiedene Vorgaenge.
*/
readonly wert?: Prisma.InputJsonValue | null;
/** Fehlt das Feld, bleibt die vorhandene Notiz stehen; `null` loescht sie. */
readonly notiz?: string | null;
};
function text(wert: string | null | undefined): string | null {
return wert?.trim() || null;
}
/**
* Die Notizpflicht, an einer Stelle.
*
* Geprueft wird der **Zustand danach**: wer einen Schritt mit vorhandener Notiz
* auf `NICHT_ZUTREFFEND` setzt, muss sie nicht noch einmal eintippen; wer die
* Notiz im selben Zug leert, faellt hierauf.
*/
function notizPflichtPruefen(zustand: SchrittZustand, notiz: string | null): void {
if (notiz !== null) return;
if (zustand === SchrittZustand.NICHT_ZUTREFFEND) {
throw new LaufFehler(
"Ein Schritt, der nicht zutrifft, braucht eine Begründung — sonst steht im Archiv ein " +
"übersprungener Schritt, und niemand weiß mehr, warum.",
);
}
if (zustand === SchrittZustand.FEHLGESCHLAGEN) {
throw new LaufFehler(
"Ein fehlgeschlagener Schritt braucht eine Notiz: was ist passiert und was ist zu tun?",
);
}
}
/**
* Prueft, dass dieser Schritt zu diesem Lauf gehoert.
*
* Die Schrittkennung kommt von aussen. Ohne diese Pruefung liesse sich ein
* Schritt einer fremden Version — und damit eines fremden Kunden — an diesen Lauf
* haengen; in der Abarbeitungsansicht tauchte er nie auf, im Archiv aber schon.
*/
async function schrittDesLaufsHolen(
tx: Prisma.TransactionClient,
vorlagenSchrittId: string,
vorlagenVersionId: string,
): Promise<{ readonly id: string; readonly titel: string; readonly pflicht: boolean }> {
const schritt = await tx.vorlagenSchritt.findUnique({
where: { id: vorlagenSchrittId },
select: { id: true, titel: true, pflicht: true, abschnitt: { select: { versionId: true } } },
});
if (!schritt || schritt.abschnitt.versionId !== vorlagenVersionId) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
return schritt;
}
/**
* Setzt ein Schrittergebnis.
*
* Nebenwirkung mit Absicht: ein `OFFEN`er Lauf wechselt dabei auf `IN_ARBEIT`.
* Wer einen Schritt eintraegt, arbeitet den Lauf ab — ein Lauf mit
* Schrittergebnissen, aber ohne `gestartetAm`, waere eine Zeitleiste, die luegt.
*
* Aendert die Eingabe nichts, wird auch nichts geschrieben: ein Doppelklick soll
* keine zwei Protokolleintraege ergeben.
*/
export async function schrittergebnisSetzen(
eingabe: SchrittergebnisEingabe,
benutzer: HandelnderBenutzer,
): Promise<Schrittergebnis> {
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schritt = await schrittDesLaufsHolen(tx, eingabe.vorlagenSchrittId, lauf.vorlagenVersionId);
const vorher = await tx.schrittergebnis.findUnique({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
});
const notiz = eingabe.notiz === undefined ? (vorher?.notiz ?? null) : text(eingabe.notiz);
notizPflichtPruefen(eingabe.zustand, notiz);
const wertGeaendert =
eingabe.wert !== undefined &&
JSON.stringify(eingabe.wert ?? null) !== JSON.stringify(vorher?.wert ?? null);
if (vorher && vorher.zustand === eingabe.zustand && vorher.notiz === notiz && !wertGeaendert) {
return vorher;
}
const wert: Prisma.InputJsonValue | typeof Prisma.DbNull | undefined =
eingabe.wert === undefined ? undefined : (eingabe.wert ?? Prisma.DbNull);
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
update: {
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
});
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: ZUSTAND_AKTION[eingabe.zustand],
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, wert: vorher.wert ?? null, notiz: vorher.notiz }
: Prisma.DbNull,
nachher: {
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: nachher.zustand,
wert: nachher.wert ?? null,
notiz: nachher.notiz,
},
},
});
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
return nachher;
});
}
export type AbschnittEingabe = {
readonly laufId: string;
readonly abschnittId: string;
/** Gilt fuer jeden Schritt des Abschnitts – einmal eingetippt, einmal gespeichert. */
readonly notiz: string;
};
export type AbschnittErgebnis = {
readonly gesetzt: number;
/** Schritte, die schon `ERLEDIGT` waren und deshalb geblieben sind. */
readonly uebersprungen: readonly string[];
};
/**
* Setzt einen ganzen Abschnitt auf `NICHT_ZUTREFFEND`.
*
* Der Fall aus dem Auftrag: im Onboarding gibt es zwei Konto-Abschnitte, und je
* nach Kunde trifft einer davon gar nicht zu. Achtzehn Haken einzeln mit
* derselben Begruendung zu versehen macht niemand — und wer es nicht macht, laesst
* sie offen.
*
* **Schon `ERLEDIGT`e Schritte bleiben `ERLEDIGT`.** Das ist die interessante
* Entscheidung, deshalb der Grund: „erledigt“ ist die Aussage, dass jemand etwas
* wirklich getan hat, mit Namen und Zeitstempel. „Nicht zutreffend“ ist die
* Aussage, dass es nichts zu tun gab. Die zweite ueber die erste zu schreiben
* waere eine Tatsache durch eine Behauptung zu ersetzen und wuerde das „wer wann“
* dieser Arbeit loeschen. Wer wirklich einen erledigten Schritt zurueckdrehen
* will, tut das einzeln und begruendet es dort. Die Zahl der uebersprungenen
* Schritte kommt zurueck, damit die Oberflaeche es sagen kann.
*
* `FEHLGESCHLAGEN` wird dagegen ueberschrieben: ein Schritt, der nicht zutrifft,
* kann nicht fehlgeschlagen sein — er haette gar nicht versucht werden muessen.
*/
export async function abschnittNichtZutreffend(
eingabe: AbschnittEingabe,
benutzer: HandelnderBenutzer,
): Promise<AbschnittErgebnis> {
const notiz = text(eingabe.notiz);
if (!notiz) {
throw new LaufFehler(
"Ein ganzer Abschnitt, der nicht zutrifft, braucht eine Begründung — sie steht danach an " +
"jedem Schritt des Abschnitts.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const abschnitt = await tx.vorlagenAbschnitt.findUnique({
where: { id: eingabe.abschnittId },
select: {
titel: true,
versionId: true,
schritte: { orderBy: { position: "asc" }, select: { id: true, titel: true, pflicht: true } },
},
});
if (!abschnitt || abschnitt.versionId !== lauf.vorlagenVersionId) {
throw new LaufFehler("Dieser Abschnitt gehört nicht zu diesem Lauf.");
}
const vorhandene = new Map(
(
await tx.schrittergebnis.findMany({
where: { laufId: lauf.id, vorlagenSchrittId: { in: abschnitt.schritte.map((s) => s.id) } },
})
).map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]),
);
const uebersprungen: string[] = [];
let gesetzt = 0;
for (const schritt of abschnitt.schritte) {
const vorher = vorhandene.get(schritt.id);
if (vorher?.zustand === SchrittZustand.ERLEDIGT) {
uebersprungen.push(schritt.titel);
continue;
}
if (vorher?.zustand === SchrittZustand.NICHT_ZUTREFFEND && vorher.notiz === notiz) {
// Schon so eingetragen, mit derselben Begruendung: nichts zu tun und kein
// zweiter Protokolleintrag.
continue;
}
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
update: {
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
});
gesetzt += 1;
// Je Schritt ein Eintrag: „jede Aenderung an einem Schrittergebnis steht im
// Protokoll“ gilt auch, wenn achtzehn davon aus einem Klick entstehen. Der
// Abschnitt steht dabei, damit die Herkunft erkennbar bleibt.
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: "Abschnitt als nicht zutreffend eingetragen",
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, notiz: vorher.notiz }
: { zustand: SchrittZustand.OFFEN, notiz: null },
nachher: {
abschnitt: abschnitt.titel,
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
},
},
});
}
if (gesetzt > 0) {
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
}
return { gesetzt, uebersprungen };
});
}