Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten, und deshalb faellt der Handelnde immer aus der Empfaengerliste. Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war. In keiner Nachricht steht ein Schrittinhalt, und damit auch kein erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt wurden, so wie #012 es fuer die Druckansicht tat. Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880 Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn hinter einem Lastverteiler laufen zwei Instanzen. Eine stille Warteschlange ist schlimmer als keine: Verwaltung → Zustellungen zeigt mit Grund, was hinausging und was nicht. Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus. Closes #54 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1230 lines
46 KiB
TypeScript
1230 lines
46 KiB
TypeScript
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<Record<string, string | number | boolean>>;
|
||
|
||
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<Lauf> {
|
||
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<Lauf> {
|
||
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",
|
||
});
|
||
|
||
/**
|
||
* `<Vorlage> – <Hostname oder Mitarbeiter> – <Datum>`.
|
||
*
|
||
* 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<VersionFuerLauf> {
|
||
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<Bezuege> {
|
||
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<void> {
|
||
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<string, string | number | boolean> {
|
||
const bekannt = new Map(variablen.map((variable) => [variable.schluessel, variable]));
|
||
const sauber: Record<string, string | number | boolean> = {};
|
||
|
||
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<void> {
|
||
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<Lauf> {
|
||
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<string> {
|
||
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<LaufStatus> {
|
||
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<Lauf> {
|
||
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<readonly OffenerPflichtschritt[]> {
|
||
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<void> {
|
||
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<Lauf> {
|
||
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<Lauf> {
|
||
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<Lauf> {
|
||
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<Lauf> {
|
||
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<string, unknown>).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<VersionFuerLauf["variablen"]> {
|
||
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<Record<string, Prisma.InputJsonValue | null>> = {
|
||
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<string, Prisma.InputJsonValue | null> = {};
|
||
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<Lauf> {
|
||
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;
|
||
}
|