feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
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 was merged in pull request #27.
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
type Namenswerte,
|
||||
type Zielformat,
|
||||
} from "@/lib/namensregeln";
|
||||
import { PASSWORT_HINWEIS } from "@/lib/passwort";
|
||||
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
|
||||
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
|
||||
import {
|
||||
platzhalterAusserhalbAnfuehrungszeichen,
|
||||
@@ -71,7 +71,16 @@ export type FertigerBefehl = {
|
||||
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
|
||||
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
|
||||
*/
|
||||
export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl {
|
||||
export function befehlFuellen(
|
||||
schritt: BefehlsSchritt,
|
||||
werte: PlatzhalterWerte,
|
||||
/**
|
||||
* Das Passwort dieses Durchgangs. Ohne Angabe bekommt jeder Befehl sein
|
||||
* eigenes — richtig fuer einen einzelnen, falsch fuer einen Lauf, in dem ein
|
||||
* Schritt das Konto anlegt und der naechste das Passwort uebergibt.
|
||||
*/
|
||||
passwort?: string,
|
||||
): FertigerBefehl {
|
||||
const titel = platzhalterErsetzen(schritt.titel, werte).text;
|
||||
|
||||
/*
|
||||
@@ -97,10 +106,12 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
|
||||
}
|
||||
|
||||
try {
|
||||
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
|
||||
art: "befehl",
|
||||
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
|
||||
});
|
||||
const befehl = platzhalterErsetzen(
|
||||
schritt.befehl,
|
||||
werte,
|
||||
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, schritt.shell) },
|
||||
passwort,
|
||||
);
|
||||
return {
|
||||
titel,
|
||||
shell: schritt.shell,
|
||||
@@ -139,9 +150,10 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
|
||||
/**
|
||||
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
|
||||
*
|
||||
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
|
||||
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
|
||||
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
|
||||
* **Das Passwort darin entsteht genau jetzt.** Wer die Seite neu laedt, bekommt
|
||||
* ein anderes — das ist die ehrliche Folge der Regel „bei jeder Anzeige neu“ und
|
||||
* der Grund, warum der Hinweis daneben steht. Innerhalb eines Aufbaus ist es
|
||||
* ueberall dasselbe.
|
||||
*/
|
||||
/**
|
||||
* Ein Skript aus **bereits gefuellten** Schritten.
|
||||
@@ -184,6 +196,7 @@ export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
|
||||
export function skriptFuerAbschnitt(
|
||||
schritte: readonly BefehlsSchritt[],
|
||||
werte: PlatzhalterWerte,
|
||||
passwort?: string,
|
||||
): {
|
||||
readonly skript: string;
|
||||
readonly mitPasswort: boolean;
|
||||
@@ -191,7 +204,7 @@ export function skriptFuerAbschnitt(
|
||||
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
|
||||
readonly nichtMaskierbar: readonly string[];
|
||||
} {
|
||||
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
|
||||
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte, passwort)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +247,13 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
|
||||
if (!lauf) return [];
|
||||
|
||||
const werte = await platzhalterWerteDesLaufs(kontext);
|
||||
/*
|
||||
* **Ein** Passwort fuer den ganzen Lauf, nicht eines je Befehl: der Schritt
|
||||
* „Konto anlegen“ und der Schritt „Passwort uebergeben“ meinen dasselbe.
|
||||
* Erzeugt wird es hier und nirgends gespeichert; beim naechsten Abruf ist es
|
||||
* ein anderes.
|
||||
*/
|
||||
const passwort = passwortErzeugen();
|
||||
|
||||
return lauf.vorlagenVersion.abschnitte
|
||||
.map((abschnitt) => {
|
||||
@@ -244,7 +264,7 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
|
||||
}));
|
||||
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
|
||||
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
|
||||
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
|
||||
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte, passwort));
|
||||
const skript = skriptAusFertigen(gefuellt);
|
||||
return {
|
||||
abschnitt: abschnitt.titel,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Kommentar } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { HandelnderBenutzer } from "@/server/mandant";
|
||||
import { LaufFehler, laufZumSchreibenHolen } from "@/server/services/laeufe";
|
||||
|
||||
/**
|
||||
* Kommentare an Laeufen und Schrittergebnissen (#013).
|
||||
*
|
||||
* Der Ort fuer das, was in kein Feld passt: „Kunde war nicht erreichbar“,
|
||||
* „Lizenz kommt am Montag“, „Netzteil defekt, Ersatz bestellt“. Ein Kommentar
|
||||
* traegt seinen Urheber und seinen Zeitpunkt selbst, und er wird nie geaendert —
|
||||
* deshalb gibt es hier nur einen Weg hinein und keinen hinaus. Das ist auch der
|
||||
* Grund, warum ein Kommentar **keinen** Protokolleintrag bekommt: er ist schon
|
||||
* einer. Ein zweiter Eintrag, der dasselbe noch einmal sagt, wuerde das Protokoll
|
||||
* nur laenger machen.
|
||||
*
|
||||
* Der Schreibschutz gilt trotzdem: an einem archivierten Lauf kommt kein
|
||||
* Kommentar mehr dazu. Der Weg dorthin ist derselbe wie bei den
|
||||
* Schrittergebnissen — `laufZumSchreibenHolen`, und darin
|
||||
* `laufMussVeraenderbarSein`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Obergrenze je Kommentar.
|
||||
*
|
||||
* Ohne Grenze landet ein versehentlich eingefuegtes Ereignisprotokoll in der
|
||||
* Datenbank und in jeder Druckansicht. Viertausend Zeichen sind ungefaehr zwei
|
||||
* DIN-A4-Seiten — mehr als genug fuer eine Notiz, und die Meldung nennt die Zahl.
|
||||
*/
|
||||
export const KOMMENTAR_HOECHSTLAENGE = 4000;
|
||||
|
||||
export type NeuerKommentar = {
|
||||
readonly laufId: string;
|
||||
/** Leer: der Kommentar haengt am Lauf und nicht an einem einzelnen Schritt. */
|
||||
readonly schrittergebnisId?: string | null;
|
||||
readonly rumpf: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schreibt einen Kommentar.
|
||||
*
|
||||
* Ein Kommentar **startet den Lauf nicht**: eine Rueckfrage zu notieren ist keine
|
||||
* Abarbeitung, und ein zugewiesener Lauf, der von einer Notiz auf `IN_ARBEIT`
|
||||
* springt, saehe in der Liste aus wie begonnene Arbeit.
|
||||
*/
|
||||
export async function kommentarHinzufuegen(
|
||||
eingabe: NeuerKommentar,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Kommentar> {
|
||||
const rumpf = eingabe.rumpf.trim();
|
||||
if (!rumpf) throw new LaufFehler("Ein leerer Kommentar hilft niemandem.");
|
||||
if (rumpf.length > KOMMENTAR_HOECHSTLAENGE) {
|
||||
throw new LaufFehler(
|
||||
`Der Kommentar ist ${rumpf.length} Zeichen lang; erlaubt sind ${KOMMENTAR_HOECHSTLAENGE}. ` +
|
||||
"Längeres gehört in die Notiz am Schritt oder ins Ticket.",
|
||||
);
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
|
||||
|
||||
const schrittergebnisId = eingabe.schrittergebnisId?.trim() || null;
|
||||
if (schrittergebnisId) {
|
||||
// Die Kennung kommt von aussen. Ohne diese Pruefung hinge der Kommentar an
|
||||
// einem Schritt eines fremden Laufs – und damit moeglicherweise eines
|
||||
// fremden Kunden, dessen Schreibschutz nie geprueft wurde.
|
||||
const ergebnis = await tx.schrittergebnis.findUnique({
|
||||
where: { id: schrittergebnisId },
|
||||
select: { laufId: true },
|
||||
});
|
||||
if (!ergebnis || ergebnis.laufId !== lauf.id) {
|
||||
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
|
||||
}
|
||||
}
|
||||
|
||||
return tx.kommentar.create({
|
||||
data: { laufId: lauf.id, schrittergebnisId, benutzerId: benutzer.id, rumpf },
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user