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> = { 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 { 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 { 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 }; }); }