import { Prisma, type Lauf } from "@/generated/prisma/client"; import { LaufStatus, Meldeanlass, SchrittZustand, VariablenTyp } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { geltendeAbschnitteErmitteln, nurGeltende } from "@/lib/geltende-abschnitte"; import { laufMeldungEinreihen } from "@/server/services/benachrichtigungen"; import type { LaufAnlass } from "@/lib/meldungen"; import { darf } from "@/server/auth/rollen"; import { MandantFehler, kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer, } from "@/server/mandant"; import { VorlagenFehler, veroeffentlichteVersionPruefen } from "@/server/services/vorlagen"; /** * Laeufe: anlegen, starten, abschliessen, archivieren (#013). * * Das Herz der Anwendung. Vier Regeln tragen es: * * 1. **Ein archivierter Lauf ist schreibgeschuetzt.** Die Sperre sitzt in genau * einer Funktion, `laufMussVeraenderbarSein`, die jede Aenderung an * Kopfdaten, `Schrittergebnis` und `Kommentar` durchlaeuft. Zurueckholen * darf nur `ADMIN`. * 2. **Ein Lauf haengt fuer immer an der Version, mit der er gestartet wurde.** * Er verweist nur auf eine veroeffentlichte Version; ein spaeteres * Veroeffentlichen aendert ihn nicht. `laufKopfdatenAendern` weist einen * Versionswechsel deshalb ausdruecklich ab. * 3. **`OFFEN` ist der Zuweisungs-Mechanismus.** Ein Lauf darf angelegt und * zugewiesen werden, ohne ihn zu starten. `gestartetAm` und `IN_ARBEIT` * entstehen beim ersten Oeffnen zur Bearbeitung oder beim ersten * Schrittergebnis — nicht beim Ansehen. * 4. **Die Mandantengrenze gilt an jeder Kennung von aussen.** Gerät, Person * und Abteilung muessen zum Kunden des Laufs gehoeren. Die Datenbank * erzwingt das ueber zusammengesetzte Fremdschluessel; hier steht es fuer die * verstaendliche Meldung. * * Jede schreibende Funktion sperrt die Laufzeile (`SELECT … FOR UPDATE`) und * arbeitet in **einer** Transaktion, die den Protokolleintrag einschliesst. Das * ist die Antwort auf zwei Techniker am selben Lauf, auf zweimal Abschliessen und * auf Starten waehrend des Abschliessens: die Schreibzugriffe auf einen Lauf * reihen sich hintereinander auf. */ export class LaufFehler extends Error { constructor(nachricht: string) { super(nachricht); this.name = "LaufFehler"; } } /** * Werte der Vorlagenvariablen. * * Zahlen und Wahrheitswerte sind erlaubt, weil `VariablenTyp.ZAHL` und die * Formulare sie so liefern; `platzhalterWerteDesLaufs` liest genau diese drei * Arten wieder heraus (siehe src/server/services/platzhalter.ts). */ export type LaufVariablen = Readonly>; export type NeuerLauf = { readonly vorlagenVersionId: string; /** Leer: der Titelvorschlag wird eingesetzt. */ readonly titel?: string | null; /** Person aus dem Verzeichnis – schliesst `mitarbeiterName` aus. */ readonly mitarbeiterId?: string | null; /** Freitext, wenn die Person noch nicht im Verzeichnis steht. */ readonly mitarbeiterName?: string | null; readonly geraetId?: string | null; readonly abteilungId?: string | null; readonly ticket?: string | null; readonly variablen?: LaufVariablen; readonly zugewiesenAnId?: string | null; readonly faelligAm?: Date | null; /** Nur im Modus DIENSTLEISTUNG beachtet; im Modus INTERN verworfen (#008). */ readonly kundeId?: string | null; }; function text(wert: string | null | undefined): string | null { return wert?.trim() || null; } // ---- Die eine Stelle, an der das Archiv schliesst -------------------------- /** * Wirft, wenn an diesem Lauf nichts mehr geaendert werden darf. * * Gerufen vor **jeder** Aenderung an Kopfdaten, `Schrittergebnis` und * `Kommentar` — auch aus `schrittergebnisse.ts` und `kommentare.ts`, die genau * dafuer `laufZumSchreibenHolen` benutzen. * * **Ein `ABGESCHLOSSEN`er Lauf bleibt aenderbar, ein `ARCHIVIERT`er nicht.** Die * Entscheidung ist nicht offensichtlich, deshalb der Grund: „abgeschlossen“ ist * ein Arbeitszustand — fertig fuer jetzt. Eine nachgetragene Notiz, ein * Kommentar mit dem, was beim Nachfassen herauskam, eine korrigierte * Seriennummer: das alles passiert nach dem Abschluss, und zwar oft. Wuerde * schon der Abschluss sperren, braeuchte jede Kleinigkeit einen Administrator, * und die Technikerin wuerde Laeufe schlicht nicht mehr abschliessen — das * Archiv waere danach nicht genauer, sondern leerer. Unveraenderlich wird ein * Lauf durch das **Archivieren**, eine ausdrueckliche zweite Handlung, die nur * `ADMIN` wieder aufhebt (Recht `archivVerwalten`). * * Damit „abgeschlossen“ trotzdem nicht luegt, nimmt * `abschlussPruefenNachAenderung` den Abschluss zurueck, sobald eine Aenderung * einen Pflichtschritt wieder offen laesst. */ export function laufMussVeraenderbarSein(lauf: { readonly status: LaufStatus; readonly titel: string; }): void { if (lauf.status === LaufStatus.ARCHIVIERT) { throw new LaufFehler( `„${lauf.titel}“ ist archiviert und damit schreibgeschützt. ` + "Ein Administrator kann den Lauf zurückholen, dann lässt er sich wieder ändern.", ); } } /** * Sperrt die Laufzeile und gibt sie zurueck. * * `FOR UPDATE` haelt die Zeile bis zum Ende der Transaktion. Ohne diese Sperre * lief folgendes durch: Abschliessen zaehlt die offenen Pflichtschritte, findet * keine, und waehrend es schreibt, setzt der zweite Techniker einen Schritt * zurueck. Danach stuende im Archiv ein abgeschlossener Lauf mit einem offenen * Pflichtschritt. */ async function laufSperren(tx: Prisma.TransactionClient, laufId: string): Promise { const treffer = await tx.$queryRaw<{ id: string }[]>` SELECT id FROM "Lauf" WHERE id = ${laufId} FOR UPDATE `; if (!treffer[0]) throw new LaufFehler("Diesen Lauf gibt es nicht."); return tx.lauf.findUniqueOrThrow({ where: { id: laufId } }); } /** * Sperren, Mandantengrenze pruefen, Schreibschutz pruefen – in dieser Reihenfolge. * * Der eine Einstieg fuer jede Aenderung an einem bestehenden Lauf. `laufId` kommt * von aussen, deshalb steht `kundeZugriffPruefen` hier und nicht im Aufrufer: * eine Kennung allein darf keinen fremden Mandanten oeffnen. */ export async function laufZumSchreibenHolen( tx: Prisma.TransactionClient, laufId: string, benutzer: HandelnderBenutzer, ): Promise { const lauf = await laufSperren(tx, laufId); await kundeZugriffPruefen(lauf.kundeId, benutzer); laufMussVeraenderbarSein(lauf); return lauf; } // ---- Titelvorschlag -------------------------------------------------------- const TITEL_DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", }); /** * ` – – `. * * Eigene Funktion, damit der Start-Dialog den Vorschlag zeigen kann, **bevor** * der Lauf existiert. Fehlt ein Teil, faellt er weg statt als leere Stelle zu * erscheinen: „Patchday – 25.09.2026“ ist ein Titel, „Patchday – – 25.09.2026“ * nicht. */ export function titelVorschlag(teile: { readonly vorlage: string; readonly geraet?: string | null; readonly person?: string | null; readonly datum?: Date; }): string { // Der Hostname zuerst: er steht auf dem Gerät, vor dem der Techniker sitzt. const bezug = text(teile.geraet) ?? text(teile.person); return [text(teile.vorlage), bezug, TITEL_DATUM.format(teile.datum ?? new Date())] .filter((stueck) => stueck !== null) .join(" – "); } // ---- Bezuege eines Laufs: Version, Geraet, Person, Abteilung --------------- type VersionFuerLauf = { readonly id: string; readonly vorlagenName: string; readonly variablen: readonly { readonly schluessel: string; readonly beschriftung: string; readonly typ: VariablenTyp; readonly optionen: readonly string[]; readonly pflicht: boolean; }[]; readonly schrittIds: readonly string[]; /** Fuer die Auswahl nach Abteilung beim Start (#031). */ readonly abschnitte: readonly { readonly id: string; readonly titel: string; readonly nurFuerAbteilungen: readonly string[]; readonly schrittIds: readonly string[]; }[]; }; /** * Die Version, an der der Lauf haengen soll. * * `veroeffentlichteVersionPruefen` steht in vorlagen.ts – die Regel „ein Lauf * haengt nur an einer veroeffentlichten Version“ gehoert dorthin, wo die * Versionierung wohnt. Ihr `VorlagenFehler` wird hier zu einem `LaufFehler`, * damit der Aufrufer nur eine Fehlerklasse kennen muss. */ async function versionFuerLaufHolen(versionId: string, kundeId: string): Promise { try { await veroeffentlichteVersionPruefen(versionId); } catch (fehler) { if (fehler instanceof VorlagenFehler) throw new LaufFehler(fehler.message); throw fehler; } const version = await prisma.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId }, select: { id: true, vorlage: { select: { name: true, kundeId: true } }, variablen: { orderBy: { position: "asc" }, select: { schluessel: true, beschriftung: true, typ: true, optionen: true, pflicht: true }, }, abschnitte: { orderBy: { position: "asc" }, select: { id: true, titel: true, nurFuerAbteilungen: true, schritte: { orderBy: { position: "asc" }, select: { id: true } }, }, }, }, }); // Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden. // Die Datenbank fasst diesen Fall nicht – Vorlage und Lauf haengen nur ueber // die Version zusammen –, also ist das hier die einzige Sicherung. if (version.vorlage.kundeId !== null && version.vorlage.kundeId !== kundeId) { throw new LaufFehler( `„${version.vorlage.name}“ ist die Vorlage eines anderen Kunden und lässt sich hier nicht starten.`, ); } return { id: version.id, vorlagenName: version.vorlage.name, variablen: version.variablen, schrittIds: version.abschnitte.flatMap((abschnitt) => abschnitt.schritte.map((s) => s.id)), abschnitte: version.abschnitte.map((abschnitt) => ({ id: abschnitt.id, titel: abschnitt.titel, nurFuerAbteilungen: abschnitt.nurFuerAbteilungen, schrittIds: abschnitt.schritte.map((schritt) => schritt.id), })), }; } type Bezuege = { readonly mitarbeiterId: string | null; readonly mitarbeiterName: string | null; readonly geraetId: string | null; readonly abteilungId: string | null; /** Name der gewaehlten Abteilung – fuer die Abschnittsauswahl (#031). */ readonly abteilungsName: string | null; /** Nur fuer den Titelvorschlag. */ readonly geraetName: string | null; readonly personName: string | null; }; /** * Prueft Person, Gerät und Abteilung gegen den Kunden des Laufs. * * Ein **deaktivierter** Mitarbeiter wird bewusst nicht abgewiesen: das * Offboarding ist genau der Lauf, den man fuer eine Person startet, die gerade * geht. Ein **archiviertes** Geraet und eine archivierte Abteilung schon — die * sind aus dem Bestand genommen und sollen keine neue Arbeit mehr aufnehmen. */ async function bezuegePruefen( eingabe: { readonly mitarbeiterId?: string | null; readonly mitarbeiterName?: string | null; readonly geraetId?: string | null; readonly abteilungId?: string | null; }, kundeId: string, ): Promise { const mitarbeiterId = text(eingabe.mitarbeiterId); const mitarbeiterName = text(eingabe.mitarbeiterName); if (mitarbeiterId && mitarbeiterName) { throw new LaufFehler( "Entweder eine Person aus dem Verzeichnis oder ein Freitextname — beides zugleich " + "schließt die Datenbank aus. Der Freitext ist für Personen gedacht, die es im " + "Verzeichnis noch nicht gibt.", ); } let personName: string | null = mitarbeiterName; if (mitarbeiterId) { const person = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId }, select: { kundeId: true, anzeigename: true }, }); if (!person) throw new LaufFehler("Diese Person gibt es nicht."); if (person.kundeId !== kundeId) { throw new LaufFehler(`„${person.anzeigename}“ gehört zu einem anderen Kunden.`); } personName = person.anzeigename; } const geraetId = text(eingabe.geraetId); let geraetName: string | null = null; if (geraetId) { const geraet = await prisma.geraet.findUnique({ where: { id: geraetId }, select: { kundeId: true, archiviert: true, hostname: true, bezeichnung: true, tagCode: true }, }); if (!geraet) throw new LaufFehler("Dieses Gerät gibt es nicht."); const name = geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode; if (geraet.kundeId !== kundeId) { throw new LaufFehler(`„${name}“ gehört zu einem anderen Kunden.`); } if (geraet.archiviert) { throw new LaufFehler(`„${name}“ ist archiviert. Erst wieder freigeben, dann einen Lauf starten.`); } geraetName = geraet.hostname ?? geraet.bezeichnung; } const abteilungId = text(eingabe.abteilungId); let abteilungsName: string | null = null; if (abteilungId) { const abteilung = await prisma.abteilung.findUnique({ where: { id: abteilungId }, select: { kundeId: true, archiviert: true, name: true }, }); if (!abteilung) throw new LaufFehler("Diese Abteilung gibt es nicht."); if (abteilung.kundeId !== kundeId) { throw new LaufFehler(`Die Abteilung „${abteilung.name}“ gehört zu einem anderen Kunden.`); } if (abteilung.archiviert) { throw new LaufFehler(`Die Abteilung „${abteilung.name}“ ist archiviert.`); } abteilungsName = abteilung.name; } return { mitarbeiterId, mitarbeiterName, geraetId, abteilungId, abteilungsName, geraetName, personName, }; } /** * Prueft, wem der Lauf zugewiesen werden darf. * * Zwei Bedingungen, und beide sind Erfahrung: ein deaktiviertes Konto und eine * Rolle ohne `laeufeBearbeiten` ergeben eine Zuweisung, die niemand abarbeiten * kann. Im Modus DIENSTLEISTUNG kommt die Kundensicht dazu — ein Techniker, der * diesen Kunden nicht sieht, findet den Lauf in keiner Liste. */ async function zuweisungPruefen(zugewiesenAnId: string, kundeId: string): Promise { const ziel = await prisma.benutzer.findUnique({ where: { id: zugewiesenAnId }, select: { id: true, name: true, rolle: true, aktiv: true }, }); if (!ziel) throw new LaufFehler("Diesen Benutzer gibt es nicht."); if (!ziel.aktiv) { throw new LaufFehler(`Das Konto von ${ziel.name} ist deaktiviert und kann keinen Lauf übernehmen.`); } if (!darf(ziel.rolle, "laeufeBearbeiten")) { throw new LaufFehler(`${ziel.name} darf Läufe nicht bearbeiten und kann keinen übernehmen.`); } try { await kundeZugriffPruefen(kundeId, { id: ziel.id, rolle: ziel.rolle }); } catch (fehler) { // Die Meldung aus mandant.ts spricht den Handelnden an („dir“) – hier geht es // um einen Dritten. if (fehler instanceof MandantFehler) { throw new LaufFehler(`${ziel.name} sieht diesen Kunden nicht und könnte den Lauf nicht öffnen.`); } throw fehler; } } /** * Prueft die Variablenwerte gegen die Variablen der Version. * * Ein **unbekannter** Schluessel wird abgewiesen und nicht still verworfen: er * ist entweder ein Tippfehler in der Vorlage oder ein Rest aus einem aelteren * Dialog, und beides faellt sonst erst auf, wenn ein PowerShell-Befehl den Wert * nicht enthaelt, den jemand eingetippt hat. */ function variablenPruefen( variablen: VersionFuerLauf["variablen"], werte: LaufVariablen, ): Record { const bekannt = new Map(variablen.map((variable) => [variable.schluessel, variable])); const sauber: Record = {}; for (const [schluessel, roh] of Object.entries(werte)) { const variable = bekannt.get(schluessel); if (!variable) { throw new LaufFehler(`Die Variable „${schluessel}“ gibt es in dieser Version der Vorlage nicht.`); } const wert = typeof roh === "string" ? roh.trim() : roh; if (wert === "") continue; if (variable.typ === VariablenTyp.ZAHL && Number.isNaN(Number(wert))) { throw new LaufFehler(`„${variable.beschriftung}“ erwartet eine Zahl, nicht „${String(wert)}“.`); } if ( variable.typ === VariablenTyp.AUSWAHL && variable.optionen.length > 0 && !variable.optionen.includes(String(wert)) ) { throw new LaufFehler( `„${String(wert)}“ ist keine Auswahl für „${variable.beschriftung}“. ` + `Möglich sind: ${variable.optionen.join(", ")}.`, ); } sauber[schluessel] = wert; } const fehlend = variablen .filter((variable) => variable.pflicht && sauber[variable.schluessel] === undefined) .map((variable) => `„${variable.beschriftung}“`); if (fehlend.length > 0) { throw new LaufFehler(`Diese Angaben fehlen: ${fehlend.join(", ")}.`); } return sauber; } // ---- Anlegen --------------------------------------------------------------- /** * Legt einen Lauf an – als `OFFEN`, ohne `gestartetAm`. * * Das ist der Zuweisungs-Mechanismus aus dem Auftrag: ein Lauf darf jemandem * zugewiesen werden, ohne ihn zu starten, und dafuer braucht es keine zweite * Tabelle. * * **Die `Schrittergebnis`-Zeilen entstehen hier mit**, alle auf `OFFEN`. Zwei * Gruende: der Fortschritt („3 von 17“) ist damit ab der ersten Sekunde richtig, * ohne die Schritte der Version zweimal zu zaehlen, und jedes spaetere Setzen * eines Schritts ist ein reines Update auf eine vorhandene Zeile — zwei * Techniker, die gleichzeitig denselben Schritt anfassen, koennen sich so nicht * in den eindeutigen Index `(laufId, vorlagenSchrittId)` laufen. */ /** * Eine Meldung einreihen, ohne den Vorgang daran haengen zu lassen (#034). * * **Nach** der Transaktion und mit abgefangenem Fehler. Zwei Gruende: der Dienst * liest den Lauf selbst, und innerhalb der Transaktion saehe er die noch nicht * festgeschriebene Zeile nicht. Und ein Abhaken darf nicht daran scheitern, dass * eine Benachrichtigung nicht eingereiht werden konnte — gesendet wird sie * ohnehin erst spaeter vom Zeitplan. */ async function meldenOhneBruch( anlass: LaufAnlass, laufId: string, handelnderId: string | null, ): Promise { try { await laufMeldungEinreihen(anlass, laufId, handelnderId); } catch (fehler) { console.error( `Benachrichtigung ${anlass} fuer Lauf ${laufId} konnte nicht eingereiht werden:`, fehler instanceof Error ? fehler.message : fehler, ); } } export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutzer): Promise { const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer); const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId); const bezuege = await bezuegePruefen(eingabe, kundeId); const variablen = variablenPruefen(version.variablen, eingabe.variablen ?? {}); const zugewiesenAnId = text(eingabe.zugewiesenAnId); if (zugewiesenAnId) await zuweisungPruefen(zugewiesenAnId, kundeId); const titel = text(eingabe.titel) ?? titelVorschlag({ vorlage: version.vorlagenName, geraet: bezuege.geraetName, person: bezuege.personName, }); /* * Die Abschnittsauswahl **jetzt** festlegen und am Lauf festhalten (#031). * Danach aendert sie sich nie wieder: ein spaeterer Abteilungswechsel darf * keinen Schritt entfernen, an dem schon eine Notiz haengt, und ein * archivierter Lauf muss genau das zeigen, was damals abgearbeitet wurde. */ const geltende = geltendeAbschnitteErmitteln(version.abschnitte, bezuege.abteilungsName); if (version.abschnitte.length > 0 && geltende.length === 0) { throw new LaufFehler( bezuege.abteilungsName === null ? `Von „${version.vorlagenName}“ gilt ohne Abteilung kein Abschnitt — jeder ist auf ` + "bestimmte Abteilungen eingeschränkt. Wähle eine Abteilung." : `Von „${version.vorlagenName}“ gilt für die Abteilung „${bezuege.abteilungsName}“ kein ` + "Abschnitt. Es gäbe nichts abzuarbeiten.", ); } const geltendeSchrittIds = new Set( version.abschnitte .filter((abschnitt) => geltende.includes(abschnitt.id)) .flatMap((abschnitt) => abschnitt.schrittIds), ); const angelegt = await prisma.$transaction(async (tx) => { const lauf = await tx.lauf.create({ data: { vorlagenVersionId: version.id, titel, kundeId, mitarbeiterId: bezuege.mitarbeiterId, mitarbeiterName: bezuege.mitarbeiterName, geraetId: bezuege.geraetId, abteilungId: bezuege.abteilungId, ticket: text(eingabe.ticket), variablen: variablen as Prisma.InputJsonObject, status: LaufStatus.OFFEN, zugewiesenAnId, faelligAm: eingabe.faelligAm ?? null, erstelltVonId: benutzer.id, geltendeAbschnitte: [...geltende], }, }); // Nur fuer die geltenden Abschnitte. Ein Schrittergebnis zu einem // ausgefilterten Schritt waere eine Zeile, die niemand je sieht — und sie // zaehlte in jeder Statistik mit. const anzulegen = version.schrittIds.filter((id) => geltendeSchrittIds.has(id)); if (anzulegen.length > 0) { await tx.schrittergebnis.createMany({ data: anzulegen.map((vorlagenSchrittId) => ({ laufId: lauf.id, vorlagenSchrittId, zustand: SchrittZustand.OFFEN, })), }); } await tx.protokoll.create({ data: { laufId: lauf.id, entitaet: "Lauf", entitaetId: lauf.id, aktion: "Lauf angelegt", benutzerId: benutzer.id, nachher: { titel, vorlage: version.vorlagenName, vorlagenVersionId: version.id, status: LaufStatus.OFFEN, anzahlSchritte: version.schrittIds.length, zugewiesenAnId, }, }, }); return lauf; }); // Zugewiesen heisst: jemand soll es erfahren. if (zugewiesenAnId) { await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, angelegt.id, benutzer.id); } return angelegt; } /** * Der Titelvorschlag fuer eine Eingabe, die noch kein Lauf ist. * * **Die Oberflaeche ruft das heute nicht.** Der Start-Dialog laesst den Titel * leer und ueberlaesst ihn dem Anlegen; ein Vorschlag im Browser waere eine * zweite Wahrheit. Die Funktion bleibt, weil die Geraeteseite (#014) den Titel * vor dem Anlegen zeigen soll — dann kennt sie Kennungen, keine Namen, und das * Nachschlagen gehoert hierher und nicht dorthin. Wer sie bis dahin sucht, * findet sie nur in `tests/laeufe.test.ts`. */ export async function titelVorschlagErmitteln( eingabe: Pick< NeuerLauf, "vorlagenVersionId" | "mitarbeiterId" | "mitarbeiterName" | "geraetId" | "kundeId" >, benutzer: HandelnderBenutzer, ): Promise { const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer); const version = await versionFuerLaufHolen(eingabe.vorlagenVersionId, kundeId); const bezuege = await bezuegePruefen(eingabe, kundeId); return titelVorschlag({ vorlage: version.vorlagenName, geraet: bezuege.geraetName, person: bezuege.personName, }); } // ---- Starten --------------------------------------------------------------- /** * Der Uebergang `OFFEN` → `IN_ARBEIT`, an genau einer Stelle. * * Erwartet eine **gesperrte** Laufzeile; ohne Sperre koennten zwei Aufrufe zwei * Protokolleintraege „Lauf gestartet“ schreiben. `benutzerId` darf leer sein: ab * Phase 4 startet ein Systemlauf ohne angemeldeten Menschen (siehe * `Lauf.erstelltVonId` im Schema). */ export async function laufStartenFallsOffen( tx: Prisma.TransactionClient, lauf: { readonly id: string; readonly status: LaufStatus; readonly gestartetAm: Date | null }, benutzerId: string | null, ): Promise { if (lauf.status !== LaufStatus.OFFEN) return lauf.status; const gestartetAm = lauf.gestartetAm ?? new Date(); await tx.lauf.update({ where: { id: lauf.id }, data: { status: LaufStatus.IN_ARBEIT, gestartetAm }, }); await tx.protokoll.create({ data: { laufId: lauf.id, entitaet: "Lauf", entitaetId: lauf.id, aktion: "Lauf gestartet", benutzerId, vorher: { status: LaufStatus.OFFEN }, nachher: { status: LaufStatus.IN_ARBEIT, gestartetAm: gestartetAm.toISOString() }, }, }); return LaufStatus.IN_ARBEIT; } /** * „Oeffnen zur Bearbeitung“ – idempotent. * * Aus `OFFEN` wird `IN_ARBEIT` mit `gestartetAm`; jeder andere Zustand bleibt, * wie er ist. Insbesondere wirft diese Funktion **nicht** bei einem * abgeschlossenen oder archivierten Lauf: sie aendert daran nichts, und die * Oberflaeche ruft sie beim Aufschlagen der Abarbeitungsansicht. Wuerde sie * werfen, liesse sich ein archivierter Lauf nicht mehr ansehen — und ein * abgeschlossener wuerde beim zweiten Blick zurueck auf `IN_ARBEIT` fallen. * * Das reine **Ansehen** ruft sie nicht; dafuer gibt es die Lesemodelle. */ export async function laufZurBearbeitungOeffnen(laufId: string, benutzer: HandelnderBenutzer): Promise { return prisma.$transaction(async (tx) => { const lauf = await laufSperren(tx, laufId); await kundeZugriffPruefen(lauf.kundeId, benutzer); if (lauf.status !== LaufStatus.OFFEN) return lauf; await laufStartenFallsOffen(tx, lauf, benutzer.id); return tx.lauf.findUniqueOrThrow({ where: { id: laufId } }); }); } // ---- Pflichtschritte und Abschluss ---------------------------------------- /** Erledigt heisst: vom Tisch. `FEHLGESCHLAGEN` ist es ausdruecklich nicht. */ function istVomTisch(zustand: SchrittZustand): boolean { return zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND; } export type OffenerPflichtschritt = { /** `Abschnitt · Schritt` – so steht er auch in der Abarbeitungsansicht. */ readonly titel: string; readonly zustand: SchrittZustand; }; /** * Die Pflichtschritte, die einem Abschluss noch im Weg stehen. * * Gelesen wird ueber `Lauf.vorlagenVersionId` – die Version, mit der der Lauf * gestartet wurde. Ein spaeter veroeffentlichter Pflichtschritt taucht hier * nicht auf, und genau das ist die Zusage aus #009. */ export async function offenePflichtschritte( tx: Prisma.TransactionClient, laufId: string, ): Promise { const lauf = await tx.lauf.findUniqueOrThrow({ where: { id: laufId }, select: { geltendeAbschnitte: true, vorlagenVersion: { select: { abschnitte: { orderBy: { position: "asc" }, select: { id: true, titel: true, schritte: { where: { pflicht: true }, orderBy: { position: "asc" }, select: { id: true, titel: true }, }, }, }, }, }, ergebnisse: { select: { vorlagenSchrittId: true, zustand: true } }, }, }); const zustaende = new Map(lauf.ergebnisse.map((e) => [e.vorlagenSchrittId, e.zustand])); const offen: OffenerPflichtschritt[] = []; /* * Nur die geltenden Abschnitte (#031). Ein ausgefilterter Pflichtschritt darf * den Abschluss nicht blockieren — sonst waere er unerreichbar: abhaken laesst * sich nur, was in der Abarbeitung steht. */ for (const abschnitt of nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte)) { for (const schritt of abschnitt.schritte) { // Ohne Ergebniszeile gilt OFFEN – ein Schritt ohne Eintrag ist nicht erledigt. const zustand = zustaende.get(schritt.id) ?? SchrittZustand.OFFEN; if (!istVomTisch(zustand)) { offen.push({ titel: `${abschnitt.titel} · ${schritt.titel}`, zustand }); } } } return offen; } /** Hoechstens so viele Schritte stehen in der Meldung; der Rest wird gezaehlt. */ const MELDUNG_HOECHSTENS = 5; function offeneMeldung(offen: readonly OffenerPflichtschritt[]): string { const genannt = offen.slice(0, MELDUNG_HOECHSTENS).map((schritt) => `„${schritt.titel}“`); const rest = offen.length - genannt.length; const aufzaehlung = genannt.join(", ") + (rest > 0 ? ` und ${rest} weitere` : ""); const fehlgeschlagen = offen.some((schritt) => schritt.zustand === SchrittZustand.FEHLGESCHLAGEN) ? " Ein fehlgeschlagener Schritt zählt nicht als erledigt: entweder beheben oder mit " + "Begründung als nicht zutreffend eintragen." : ""; return `Der Lauf lässt sich noch nicht abschließen. Offen sind: ${aufzaehlung}.` + fehlgeschlagen; } /** * Nimmt den Abschluss zurueck, wenn eine Aenderung einen Pflichtschritt wieder * offen laesst. * * Gerufen nach jeder Aenderung an einem Schrittergebnis. Ohne das stuende ein * `ABGESCHLOSSEN`er Lauf mit einem offenen Pflichtschritt da – und der Abschluss * behauptete etwas, das nicht mehr stimmt. Erwartet eine gesperrte Laufzeile. */ export async function abschlussPruefenNachAenderung( tx: Prisma.TransactionClient, lauf: { readonly id: string; readonly status: LaufStatus }, benutzerId: string | null, ): Promise { if (lauf.status !== LaufStatus.ABGESCHLOSSEN) return; const offen = await offenePflichtschritte(tx, lauf.id); if (offen.length === 0) return; await tx.lauf.update({ where: { id: lauf.id }, data: { status: LaufStatus.IN_ARBEIT, abgeschlossenAm: null, abgeschlossenVonId: null }, }); await tx.protokoll.create({ data: { laufId: lauf.id, entitaet: "Lauf", entitaetId: lauf.id, aktion: "Abschluss zurückgenommen", benutzerId, vorher: { status: LaufStatus.ABGESCHLOSSEN }, nachher: { status: LaufStatus.IN_ARBEIT, offen: offen.slice(0, MELDUNG_HOECHSTENS).map((schritt) => schritt.titel), }, }, }); } /** * Schliesst einen Lauf ab. * * Nur, wenn jeder Pflichtschritt `ERLEDIGT` oder `NICHT_ZUTREFFEND` ist. Die * Meldung nennt die Schritte, die fehlen – „es fehlen noch Schritte“ schickt * jemanden durch siebzehn Abschnitte auf die Suche. * * Zweimal Abschliessen ist kein Fehler: der zweite Aufruf findet den Lauf schon * abgeschlossen und gibt ihn unveraendert zurueck, ohne zweiten * Protokolleintrag. Ein Doppelklick soll keine Fehlermeldung ergeben. */ export async function laufAbschliessen(laufId: string, benutzer: HandelnderBenutzer): Promise { const abgeschlossen = await prisma.$transaction(async (tx) => { const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer); if (lauf.status === LaufStatus.ABGESCHLOSSEN) return { lauf, warSchonFertig: true }; const offen = await offenePflichtschritte(tx, laufId); if (offen.length > 0) throw new LaufFehler(offeneMeldung(offen)); const jetzt = new Date(); const nachher = await tx.lauf.update({ where: { id: laufId }, data: { status: LaufStatus.ABGESCHLOSSEN, abgeschlossenAm: jetzt, abgeschlossenVonId: benutzer.id, // Ein Lauf ohne Pflichtschritte laesst sich direkt aus OFFEN // abschliessen. `gestartetAm` muss dann trotzdem stehen: die Bedingung // Lauf_begonnen_mit_zeitpunkt verlangt es, und ein abgeschlossener Lauf // ohne Startzeitpunkt saehe im Archiv wie ein Fehler aus. gestartetAm: lauf.gestartetAm ?? jetzt, }, }); await tx.protokoll.create({ data: { laufId: laufId, entitaet: "Lauf", entitaetId: laufId, aktion: "Lauf abgeschlossen", benutzerId: benutzer.id, vorher: { status: lauf.status }, nachher: { status: LaufStatus.ABGESCHLOSSEN, abgeschlossenAm: jetzt.toISOString() }, }, }); return { lauf: nachher, warSchonFertig: false }; }); /* * Nur bei einem echten Abschluss melden. Ein zweiter Aufruf gibt den Lauf * unveraendert zurueck — eine zweite Mail darueber waere falsch. */ if (!abgeschlossen.warSchonFertig) { await meldenOhneBruch(Meldeanlass.LAUF_ABGESCHLOSSEN, laufId, benutzer.id); } return abgeschlossen.lauf; } /** * Archiviert einen abgeschlossenen Lauf. * * Getrennt vom Abschliessen, weil es etwas anderes bedeutet: der Abschluss sagt * „fertig“, das Archivieren sagt „hier aendert sich nichts mehr“. Deshalb geht * es nur aus `ABGESCHLOSSEN` – ein laufender Lauf wuerde mitten in der Arbeit * einfrieren. * * Diese Funktion geht nicht durch `laufZumSchreibenHolen`: sie aendert keine * Kopfdaten, sie setzt den Schreibschutz selbst. Ein zweiter Aufruf gibt den * Lauf unveraendert zurueck. */ export async function laufArchivieren(laufId: string, benutzer: HandelnderBenutzer): Promise { return prisma.$transaction(async (tx) => { const lauf = await laufSperren(tx, laufId); await kundeZugriffPruefen(lauf.kundeId, benutzer); if (lauf.status === LaufStatus.ARCHIVIERT) return lauf; if (lauf.status !== LaufStatus.ABGESCHLOSSEN) { throw new LaufFehler( `„${lauf.titel}“ ist noch nicht abgeschlossen. Erst abschließen, dann archivieren.`, ); } const jetzt = new Date(); const nachher = await tx.lauf.update({ where: { id: laufId }, data: { status: LaufStatus.ARCHIVIERT, archiviertAm: jetzt, archiviertVonId: benutzer.id }, }); await tx.protokoll.create({ data: { laufId: laufId, entitaet: "Lauf", entitaetId: laufId, aktion: "Lauf archiviert", benutzerId: benutzer.id, vorher: { status: LaufStatus.ABGESCHLOSSEN }, nachher: { status: LaufStatus.ARCHIVIERT, archiviertAm: jetzt.toISOString() }, }, }); return nachher; }); } /** * Holt einen archivierten Lauf auf `ABGESCHLOSSEN` zurueck – nur `ADMIN`. * * Das Recht `archivVerwalten` wird **hier** geprueft und nicht erst in der Server * Action: an dieser Zusage haengt der Wert des ganzen Archivs, und ein zweiter * Aufrufer (Skript, Phase-4-Connector) kaeme an einer Pruefung in der Action * vorbei. */ export async function laufZurueckholen(laufId: string, benutzer: HandelnderBenutzer): Promise { if (!darf(benutzer.rolle, "archivVerwalten")) { throw new LaufFehler( "Nur ein Administrator holt einen Lauf aus dem Archiv zurück. Das Archiv ist die Zusage, " + "dass ein abgeschlossener Lauf sich nicht mehr ändert — und diese Zusage darf nur eine " + "Stelle aufheben.", ); } return prisma.$transaction(async (tx) => { const lauf = await laufSperren(tx, laufId); await kundeZugriffPruefen(lauf.kundeId, benutzer); if (lauf.status !== LaufStatus.ARCHIVIERT) { throw new LaufFehler(`„${lauf.titel}“ ist nicht archiviert.`); } const nachher = await tx.lauf.update({ where: { id: laufId }, data: { status: LaufStatus.ABGESCHLOSSEN, archiviertAm: null, archiviertVonId: null }, }); await tx.protokoll.create({ data: { laufId: laufId, entitaet: "Lauf", entitaetId: laufId, aktion: "Lauf aus dem Archiv zurückgeholt", benutzerId: benutzer.id, vorher: { status: LaufStatus.ARCHIVIERT, archiviertAm: lauf.archiviertAm?.toISOString() ?? null }, nachher: { status: LaufStatus.ABGESCHLOSSEN }, }, }); return nachher; }); } // ---- Kopfdaten ------------------------------------------------------------- export type LaufKopfdaten = { readonly titel?: string; readonly ticket?: string | null; readonly zugewiesenAnId?: string | null; readonly faelligAm?: Date | null; readonly geraetId?: string | null; readonly abteilungId?: string | null; /** Nur solange keine Person aus dem Verzeichnis am Lauf haengt. */ readonly mitarbeiterName?: string | null; readonly variablen?: LaufVariablen; /** * Nimmt eine mitgeschickte Version nur an, um sie abzuweisen, wenn sie * abweicht. Ohne dieses Feld liefe der Versuch ins Leere und saehe fuer den * Aufrufer wie ein Erfolg aus. */ readonly vorlagenVersionId?: string; }; /** * Aendert die Kopfdaten eines Laufs. * * Nicht dabei: die Version und der Kunde. **Die Version bleibt fuer immer die, * mit der der Lauf gestartet wurde** – sonst zeigte ein Lauf im Archiv Schritte, * die damals niemand abgearbeitet hat. Den Kunden zu wechseln wuerde Gerät, * Person und Abteilung des Laufs mandantenfremd machen; die zusammengesetzten * Fremdschluessel liessen es ohnehin nicht zu. * * Die Person aus dem Verzeichnis wird ueber `laufMitAdKontoVerknuepfen` gesetzt. */ export async function laufKopfdatenAendern( laufId: string, aenderung: LaufKopfdaten, benutzer: HandelnderBenutzer, ): Promise { const geaendertesErgebnis = await prisma.$transaction(async (tx) => { const vorher = await laufZumSchreibenHolen(tx, laufId, benutzer); if ( aenderung.vorlagenVersionId !== undefined && aenderung.vorlagenVersionId !== vorher.vorlagenVersionId ) { throw new LaufFehler( `„${vorher.titel}“ hängt an der Version, mit der er gestartet wurde, und bleibt dort. ` + "Für eine neuere Version der Vorlage einen neuen Lauf starten.", ); } const daten: Prisma.LaufUpdateInput = {}; const geaendert: string[] = []; if (aenderung.titel !== undefined) { const titel = text(aenderung.titel); if (!titel) throw new LaufFehler("Ein Lauf braucht einen Titel."); if (titel !== vorher.titel) { daten.titel = titel; geaendert.push("Titel"); } } if (aenderung.ticket !== undefined) { const ticket = text(aenderung.ticket); if (ticket !== vorher.ticket) { daten.ticket = ticket; geaendert.push("Ticket"); } } if (aenderung.faelligAm !== undefined) { const neu = aenderung.faelligAm?.getTime() ?? null; if (neu !== (vorher.faelligAm?.getTime() ?? null)) { daten.faelligAm = aenderung.faelligAm; geaendert.push("Fälligkeit"); } } if (aenderung.zugewiesenAnId !== undefined) { const ziel = text(aenderung.zugewiesenAnId); if (ziel !== vorher.zugewiesenAnId) { if (ziel) await zuweisungPruefen(ziel, vorher.kundeId); daten.zugewiesenAn = ziel ? { connect: { id: ziel } } : { disconnect: true }; geaendert.push("Zuweisung"); } } if (aenderung.mitarbeiterName !== undefined) { const name = text(aenderung.mitarbeiterName); if (name !== vorher.mitarbeiterName) { if (vorher.mitarbeiterId !== null && name !== null) { throw new LaufFehler( "An diesem Lauf hängt eine Person aus dem Verzeichnis. Ein Freitextname daneben " + "wäre widersprüchlich — die Datenbank schließt beides zugleich aus.", ); } daten.mitarbeiterName = name; geaendert.push("Name der Person"); } } // Gerät und Abteilung gehen durch dieselbe Pruefung wie beim Anlegen. if (aenderung.geraetId !== undefined || aenderung.abteilungId !== undefined) { const bezuege = await bezuegePruefen( { geraetId: aenderung.geraetId === undefined ? vorher.geraetId : aenderung.geraetId, abteilungId: aenderung.abteilungId === undefined ? vorher.abteilungId : aenderung.abteilungId, }, vorher.kundeId, ); if (aenderung.geraetId !== undefined && bezuege.geraetId !== vorher.geraetId) { daten.geraet = bezuege.geraetId ? { connect: { id: bezuege.geraetId } } : { disconnect: true }; geaendert.push("Gerät"); } if (aenderung.abteilungId !== undefined && bezuege.abteilungId !== vorher.abteilungId) { daten.abteilung = bezuege.abteilungId ? { connect: { id: bezuege.abteilungId } } : { disconnect: true }; geaendert.push("Abteilung"); } } if (aenderung.variablen !== undefined) { const version = await versionVariablenLesen(tx, vorher.vorlagenVersionId); const werte = variablenPruefen(version, aenderung.variablen); if (flachVerglichen(werte) !== flachVerglichen(vorher.variablen)) { daten.variablen = werte as Prisma.InputJsonObject; geaendert.push("Variablen"); } } if (geaendert.length === 0) return { lauf: vorher, neuZugewiesenAn: null }; const nachher = await tx.lauf.update({ where: { id: laufId }, data: daten }); await tx.protokoll.create({ data: { laufId: laufId, entitaet: "Lauf", entitaetId: laufId, aktion: `${geaendert.join(" und ")} geändert`, benutzerId: benutzer.id, // Nur die betroffenen Felder – ein Protokoll, das jedes Mal alle Felder // wiederholt, liest niemand mehr. vorher: kopfAuszug(vorher, geaendert), nachher: kopfAuszug(nachher, geaendert), }, }); return { lauf: nachher, // Nur eine **neue** Zuweisung an eine Person meldet; ein Entzug nicht. neuZugewiesenAn: geaendert.includes("Zuweisung") ? nachher.zugewiesenAnId : null, }; }); if (geaendertesErgebnis.neuZugewiesenAn) { await meldenOhneBruch(Meldeanlass.LAUF_ZUGEWIESEN, laufId, benutzer.id); } return geaendertesErgebnis.lauf; } /** * Ein flaches Json-Objekt in eine vergleichbare Form. * * Verglichen wird ohne die Schluesselreihenfolge: schickt das Formular dieselben * Werte in anderer Folge, ist das keine Aenderung. Sonst stuende im Protokoll * „Variablen geändert“, obwohl sich nichts geaendert hat — und dem Protokoll * waere danach nicht mehr zu glauben. */ function flachVerglichen(wert: unknown): string { if (wert === null || typeof wert !== "object" || Array.isArray(wert)) { return JSON.stringify(wert ?? null); } const paare = Object.entries(wert as Record).sort(([links], [rechts]) => links.localeCompare(rechts), ); return JSON.stringify(paare); } /** Die Variablen der Version, an der dieser Lauf haengt. */ async function versionVariablenLesen( tx: Prisma.TransactionClient, versionId: string, ): Promise { return tx.vorlagenVariable.findMany({ where: { versionId }, orderBy: { position: "asc" }, select: { schluessel: true, beschriftung: true, typ: true, optionen: true, pflicht: true }, }); } /** Die Felder zu den genannten Beschriftungen – fuer `Protokoll.vorher`/`nachher`. */ function kopfAuszug(lauf: Lauf, geaendert: readonly string[]): Prisma.InputJsonValue { const abbildung: Readonly> = { Titel: lauf.titel, Ticket: lauf.ticket, Fälligkeit: lauf.faelligAm?.toISOString() ?? null, Zuweisung: lauf.zugewiesenAnId, "Name der Person": lauf.mitarbeiterName, Gerät: lauf.geraetId, Abteilung: lauf.abteilungId, Variablen: lauf.variablen as Prisma.InputJsonValue, }; const auszug: Record = {}; for (const beschriftung of geaendert) { if (beschriftung in abbildung) auszug[beschriftung] = abbildung[beschriftung] ?? null; } return auszug as Prisma.InputJsonValue; } // ---- Mit AD-Konto verknuepfen --------------------------------------------- export type AdKontoZiel = { readonly mitarbeiterId?: string | null; /** Alternative zur Kennung: der UPN, wie er im Verzeichnis steht. */ readonly upn?: string | null; }; /** * Verbindet einen Lauf mit Freitextnamen nachtraeglich mit einem `Mitarbeiter`. * * Der Fall aus dem Onboarding: der Lauf startet, bevor das Konto existiert, und * traegt den Namen als Freitext. Ist das Konto angelegt und abgeglichen, wird der * Lauf daran gehaengt — ab dann liefern `{{upn}}`, `{{sam}}`, `{{mail}}` und * `{{ou}}` echte Werte. * * `mitarbeiterName` wird dabei geleert, weil die Bedingung * `Lauf_mitarbeiter_eindeutig` beides zugleich ausschliesst. **Der urspruengliche * Freitextname bleibt im `Protokoll`** – er ist der Beleg dafuer, wen dieser Lauf * gemeint hat, als es das Konto noch nicht gab. */ export async function laufMitAdKontoVerknuepfen( laufId: string, ziel: AdKontoZiel, benutzer: HandelnderBenutzer, ): Promise { return prisma.$transaction(async (tx) => { const lauf = await laufZumSchreibenHolen(tx, laufId, benutzer); if (lauf.mitarbeiterId !== null) { throw new LaufFehler("An diesem Lauf hängt schon eine Person aus dem Verzeichnis."); } const person = await adKontoAufloesen(ziel, lauf.kundeId); const nachher = await tx.lauf.update({ where: { id: laufId }, data: { mitarbeiterId: person.id, mitarbeiterName: null }, }); await tx.protokoll.create({ data: { laufId: laufId, entitaet: "Lauf", entitaetId: laufId, aktion: "Mit AD-Konto verknüpft", benutzerId: benutzer.id, vorher: { mitarbeiterName: lauf.mitarbeiterName }, nachher: { mitarbeiterId: person.id, anzeigename: person.anzeigename, upn: person.upn }, }, }); return nachher; }); } /** Loest Kennung oder UPN zu einer Person **dieses** Kunden auf. */ async function adKontoAufloesen( ziel: AdKontoZiel, kundeId: string, ): Promise<{ readonly id: string; readonly anzeigename: string; readonly upn: string | null }> { const mitarbeiterId = text(ziel.mitarbeiterId); if (mitarbeiterId) { const person = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId }, select: { id: true, kundeId: true, anzeigename: true, upn: true }, }); if (!person) throw new LaufFehler("Diese Person gibt es nicht."); if (person.kundeId !== kundeId) { throw new LaufFehler(`„${person.anzeigename}“ gehört zu einem anderen Kunden.`); } return person; } const upn = text(ziel.upn); if (!upn) throw new LaufFehler("Ohne Konto oder UPN gibt es nichts zu verknüpfen."); // Der UPN ist im Schema nicht eindeutig – zwei Verzeichnisquellen koennen // denselben tragen. Zu raten, welche gemeint ist, waere die falsche Person im // Lauf; deshalb wird gefragt. const treffer = await prisma.mitarbeiter.findMany({ where: { kundeId, upn: { equals: upn, mode: "insensitive" } }, select: { id: true, anzeigename: true, upn: true }, take: 2, }); const erster = treffer[0]; if (!erster) { throw new LaufFehler(`Zu „${upn}“ steht in diesem Verzeichnis kein Konto.`); } if (treffer.length > 1) { throw new LaufFehler(`„${upn}“ gibt es mehr als einmal. Bitte das Konto in der Liste auswählen.`); } return erster; }