#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen. Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in CLAUDE.md. Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt. tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen (Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch die Gegenrichtung. #037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf, dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird. Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen. Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen Durchgang haette ich zwei rote Ablaeufe gemergt. 1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur noch #024, und das verlangt einen echten Proxmox-Host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||
import { prisma } from "@/lib/db";
|
||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||
import { anhaengeDesLaufs } from "@/server/queries/anhaenge";
|
||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
||
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
|
||
|
||
/**
|
||
* Das Lesemodell der Druckansicht (#015).
|
||
*
|
||
* **Warum das kein zweites `laufZumAbarbeiten` ist** — drei Unterschiede, und
|
||
* jeder einzelne waere Grund genug:
|
||
*
|
||
* 1. **Hier entsteht kein Passwort.** `laufZumAbarbeiten` ruft
|
||
* `passwortErzeugen()` und fuellt die Befehle mit `{ art: "befehl" }`. Diese
|
||
* Abfrage loest die Vorlagentexte — Schritt-Titel und Befehl — mit
|
||
* `{ art: "druck" }` auf; dort steht statt des Passworts `PASSWORT_IM_DRUCK`.
|
||
* Ein Schalter an der bestehenden Funktion haette den Zweig, der **nie** ein
|
||
* Passwort erzeugen darf, mitten in die Funktion gelegt, die eines erzeugt —
|
||
* ein Ausdruck liegt herum und wird abgeheftet, und
|
||
* `{{generiertes_passwort}}` ist nirgends gespeichert, also auf dem Papier
|
||
* ohnehin nicht nachvollziehbar.
|
||
* 2. **Vom Mitarbeiter werden nur drei Felder geholt:** Anzeigename, UPN und
|
||
* Abteilung. Nicht „alles lesen und beim Anzeigen weglassen“ — was nicht
|
||
* geladen wird, kann auch kein spaeteres Feld in die Vorlage rutschen lassen.
|
||
* 3. **Der Ausdruck ist ein Beleg, keine Arbeitsflaeche.** Er traegt keine
|
||
* Skripte, keine Ergebnis-Kennungen zum Schreiben, keine Hilfetexte und keine
|
||
* Doku-Links — dafuer Zeitpunkte und Namen, die `LaufZeile` nicht fuehrt:
|
||
* begonnen am, abgeschlossen von wem, archiviert am und von wem.
|
||
*
|
||
* Gelesen wird wie ueberall ueber `Lauf.vorlagenVersionId`, also die Version, mit
|
||
* der der Lauf gestartet wurde (#009). Die Mandantensicht wendet diese Datei
|
||
* selbst an: ein Lauf eines fremden Kunden ist `undefined` — „gibt es nicht“ und
|
||
* „darfst du nicht“ sind dieselbe Antwort.
|
||
*/
|
||
|
||
export type DruckPerson = {
|
||
readonly name: string;
|
||
readonly upn: string | null;
|
||
readonly abteilung: string | null;
|
||
/**
|
||
* Der Name steht nur als Freitext im Lauf; im Verzeichnis gab es die Person
|
||
* beim Start noch nicht. Dann gibt es weder UPN noch Abteilung, und der
|
||
* Ausdruck sagt das, statt ein leeres Feld zu zeigen.
|
||
*/
|
||
readonly nurFreitext: boolean;
|
||
};
|
||
|
||
export type DruckKopf = {
|
||
readonly id: string;
|
||
readonly titel: string;
|
||
readonly status: LaufStatus;
|
||
readonly vorlagenName: string;
|
||
readonly version: number;
|
||
/** Hostname, sonst Bezeichnung, sonst der Tag-Code – wie in den Listen. */
|
||
readonly geraet: string | null;
|
||
readonly tagCode: string | null;
|
||
readonly person: DruckPerson | null;
|
||
/** Die beim Start gewaehlte Abteilung – nicht die des Verzeichniseintrags. */
|
||
readonly abteilungImLauf: string | null;
|
||
readonly ticket: string | null;
|
||
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN gibt es keine Kundenebene (#008). */
|
||
readonly kundeName: string | null;
|
||
readonly faelligAm: Date | null;
|
||
readonly erstelltAm: Date;
|
||
readonly erstelltVon: string | null;
|
||
readonly gestartetAm: Date | null;
|
||
readonly zugewiesenAn: string | null;
|
||
readonly abgeschlossenAm: Date | null;
|
||
readonly abgeschlossenVon: string | null;
|
||
readonly archiviertAm: Date | null;
|
||
readonly archiviertVon: string | null;
|
||
};
|
||
|
||
export type DruckSchritt = {
|
||
readonly nummer: number;
|
||
readonly titel: string;
|
||
readonly pflicht: boolean;
|
||
readonly typ: SchrittTyp;
|
||
readonly zustand: SchrittZustand;
|
||
/** Der erfasste Wert als Text, oder `null`, wenn keiner erfasst wurde. */
|
||
readonly wert: string | null;
|
||
readonly notiz: string | null;
|
||
/** Nur bei `BEFEHL`: der Befehlstext mit eingesetzten Werten, ohne Passwort. */
|
||
readonly befehl: string | null;
|
||
readonly bearbeitetVon: string | null;
|
||
readonly bearbeitetAm: Date | null;
|
||
readonly kommentare: readonly KommentarZeile[];
|
||
/** Anhaenge an diesem Schritt (#032). Bilder als Miniatur, PDF als Zeile. */
|
||
readonly anhaenge: readonly {
|
||
readonly id: string;
|
||
readonly dateiname: string;
|
||
readonly medientyp: string;
|
||
readonly groesse: number;
|
||
}[];
|
||
};
|
||
|
||
export type DruckAbschnitt = {
|
||
readonly id: string;
|
||
readonly titel: string;
|
||
/** Zweistellig aus der Position, nie gespeichert (Gestaltungsregel). */
|
||
readonly nummer: string;
|
||
readonly schritte: readonly DruckSchritt[];
|
||
};
|
||
|
||
export type LaufZumDrucken = {
|
||
readonly kopf: DruckKopf;
|
||
readonly abschnitte: readonly DruckAbschnitt[];
|
||
/** Die Kommentare am Lauf selbst; die an einem Schritt stehen beim Schritt. */
|
||
readonly kommentare: readonly KommentarZeile[];
|
||
readonly anzahlSchritte: number;
|
||
/** Erledigt oder nicht zutreffend – beides ist vom Tisch. */
|
||
readonly anzahlVomTisch: number;
|
||
};
|
||
|
||
/**
|
||
* Der erfasste Wert als eine Zeile Text.
|
||
*
|
||
* `wert` ist Json, weil ein Schritt vom Typ `AKTION` (Phase 4) dort auch ein
|
||
* Protokoll oder erzeugte Kennungen ablegen soll (CLAUDE.md, „Erweiterbarkeit“).
|
||
* Deshalb faellt hier nichts weg, was kein String ist: ein Objekt wird als JSON
|
||
* gedruckt. Lesbar genug fuer Papier — und in jedem Fall besser als das
|
||
* `[object Object]`, das ein `String(wert)` daraus machte.
|
||
*/
|
||
function wertAlsText(wert: unknown): string | null {
|
||
if (wert === null || wert === undefined) return null;
|
||
if (typeof wert === "string") return wert.trim() === "" ? null : wert;
|
||
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
|
||
return JSON.stringify(wert);
|
||
}
|
||
|
||
function befehlAusKonfig(konfig: unknown): string {
|
||
if (typeof konfig !== "object" || konfig === null) return "";
|
||
const befehl = (konfig as Record<string, unknown>)["befehl"];
|
||
return typeof befehl === "string" ? befehl : "";
|
||
}
|
||
|
||
/**
|
||
* Der ganze Lauf als Beleg. `undefined` heisst „gibt es nicht oder darfst du
|
||
* nicht sehen“.
|
||
*/
|
||
export async function laufZumDrucken(
|
||
laufId: string,
|
||
benutzer: HandelnderBenutzer,
|
||
): Promise<LaufZumDrucken | undefined> {
|
||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||
const mandant = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
|
||
|
||
const lauf = await prisma.lauf.findFirst({
|
||
where: { AND: [{ id: laufId }, ...mandant] },
|
||
select: {
|
||
id: true,
|
||
titel: true,
|
||
status: true,
|
||
ticket: true,
|
||
faelligAm: true,
|
||
erstelltAm: true,
|
||
gestartetAm: true,
|
||
abgeschlossenAm: true,
|
||
archiviertAm: true,
|
||
mitarbeiterName: true,
|
||
kunde: { select: { name: true } },
|
||
// Genau drei Felder. Kein Geburtsdatum, kein `dn`, kein `sam`, keine
|
||
// Kennung aus dem Verzeichnis: was nicht geladen wird, steht auch auf
|
||
// keinem Blatt, das irgendwo liegen bleibt.
|
||
mitarbeiter: { select: { anzeigename: true, upn: true, abteilung: true } },
|
||
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
|
||
abteilung: { select: { name: true } },
|
||
erstelltVon: { select: { name: true } },
|
||
zugewiesenAn: { select: { name: true } },
|
||
abgeschlossenVon: { select: { name: true } },
|
||
archiviertVon: { select: { name: true } },
|
||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||
geltendeAbschnitte: true,
|
||
vorlagenVersion: {
|
||
select: {
|
||
version: true,
|
||
vorlage: { select: { name: true } },
|
||
abschnitte: {
|
||
orderBy: { position: "asc" },
|
||
select: {
|
||
id: true,
|
||
titel: true,
|
||
schritte: {
|
||
orderBy: { position: "asc" },
|
||
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
ergebnisse: {
|
||
select: {
|
||
id: true,
|
||
vorlagenSchrittId: true,
|
||
zustand: true,
|
||
wert: true,
|
||
notiz: true,
|
||
aktualisiertAm: true,
|
||
aktualisiertVon: { select: { name: true } },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
if (!lauf) return undefined;
|
||
|
||
const kundenEbene = await kundenEbeneSichtbar();
|
||
/*
|
||
* Die Platzhalterwerte **ohne** Technikernamen und mit dem Anlegezeitpunkt als
|
||
* „heute“:
|
||
*
|
||
* - `{{techniker}}` bliebe sonst der Name dessen, der gerade druckt — und das
|
||
* ist nicht unbedingt der, der gearbeitet hat. Wer welchen Schritt erledigt
|
||
* hat, steht am Schritt und muss nicht in einem Titel behauptet werden.
|
||
* - `{{datum}}` waere sonst der Tag des Ausdrucks. Ein Lauf von vorletztem
|
||
* Jahr trueg dann im Schritt-Titel das heutige Datum, und zwei Ausdrucke
|
||
* desselben Laufs waeren verschieden.
|
||
*/
|
||
const werte = await platzhalterWerteDesLaufs({ laufId, technikerName: null, jetzt: lauf.erstelltAm });
|
||
|
||
// Die Mandantensicht steckt schon in `kommentareDesLaufs`; ein zweites
|
||
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
|
||
// kann.
|
||
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
||
// Nur Metadaten: der Ausdruck verweist auf die Adresse, er traegt die Datei
|
||
// nicht in sich (#032).
|
||
const alleAnhaenge = await anhaengeDesLaufs(laufId, benutzer);
|
||
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
|
||
|
||
let nummer = 0;
|
||
let anzahlSchritte = 0;
|
||
let anzahlVomTisch = 0;
|
||
|
||
// Dieselbe Auswahl wie in der Abarbeitung – nicht mehr und nicht weniger (#031).
|
||
const geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
|
||
const abschnitte = geltende.map((abschnitt, index) => {
|
||
const schritte = abschnitt.schritte.map((schritt) => {
|
||
nummer += 1;
|
||
anzahlSchritte += 1;
|
||
const ergebnis = ergebnisse.get(schritt.id);
|
||
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
|
||
if (zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND) {
|
||
anzahlVomTisch += 1;
|
||
}
|
||
|
||
const roherBefehl = schritt.typ === SchrittTyp.BEFEHL ? befehlAusKonfig(schritt.konfig) : "";
|
||
|
||
return {
|
||
nummer,
|
||
titel: platzhalterErsetzen(schritt.titel, werte, { art: "druck" }).text,
|
||
pflicht: schritt.pflicht,
|
||
typ: schritt.typ,
|
||
zustand,
|
||
wert: wertAlsText(ergebnis?.wert),
|
||
notiz: ergebnis?.notiz ?? null,
|
||
/*
|
||
* Der Befehl **unmaskiert**: `{ art: "druck" }` maskiert nicht, und das
|
||
* ist hier richtig. Der Beleg haelt fest, was zu tun war; Maskierzeichen
|
||
* waeren auf Papier Rauschen, und kopieren kann man von dort ohnehin
|
||
* nichts. Wer den Befehl **ausfuehren** will, nimmt die Laufseite — dort
|
||
* ist er fuer die gewaehlte Shell maskiert (#012). Das Passwort steht
|
||
* hier als `PASSWORT_IM_DRUCK`; erzeugt wird keines.
|
||
*/
|
||
befehl: roherBefehl === "" ? null : platzhalterErsetzen(roherBefehl, werte, { art: "druck" }).text,
|
||
/*
|
||
* „Wer wann“ nur, wenn der Schritt nicht mehr offen ist: die
|
||
* Ergebniszeilen entstehen beim Anlegen des Laufs, `aktualisiertAm`
|
||
* traegt also auch an einem unberuehrten Schritt einen Zeitstempel, der
|
||
* nichts sagt. Am Zustand und nicht am Urheber, weil `aktualisiertVonId`
|
||
* leer bleiben darf — dieselbe Regel wie in `laufZumAbarbeiten` (#013).
|
||
*/
|
||
bearbeitetVon:
|
||
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
|
||
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
|
||
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
|
||
anhaenge: alleAnhaenge
|
||
.filter((anhang) => anhang.schrittergebnisId === ergebnis?.id)
|
||
.map((anhang) => ({
|
||
id: anhang.id,
|
||
dateiname: anhang.dateiname,
|
||
medientyp: anhang.medientyp,
|
||
groesse: anhang.groesse,
|
||
})),
|
||
};
|
||
});
|
||
|
||
return {
|
||
id: abschnitt.id,
|
||
titel: abschnitt.titel,
|
||
nummer: String(index + 1).padStart(2, "0"),
|
||
schritte,
|
||
};
|
||
});
|
||
|
||
const person: DruckPerson | null = lauf.mitarbeiter
|
||
? {
|
||
name: lauf.mitarbeiter.anzeigename,
|
||
upn: lauf.mitarbeiter.upn,
|
||
abteilung: lauf.mitarbeiter.abteilung,
|
||
nurFreitext: false,
|
||
}
|
||
: lauf.mitarbeiterName
|
||
? { name: lauf.mitarbeiterName, upn: null, abteilung: null, nurFreitext: true }
|
||
: null;
|
||
|
||
return {
|
||
kopf: {
|
||
id: lauf.id,
|
||
titel: lauf.titel,
|
||
status: lauf.status,
|
||
vorlagenName: lauf.vorlagenVersion.vorlage.name,
|
||
version: lauf.vorlagenVersion.version,
|
||
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
|
||
tagCode: lauf.geraet?.tagCode ?? null,
|
||
person,
|
||
abteilungImLauf: lauf.abteilung?.name ?? null,
|
||
ticket: lauf.ticket,
|
||
kundeName: kundenEbene ? lauf.kunde.name : null,
|
||
faelligAm: lauf.faelligAm,
|
||
erstelltAm: lauf.erstelltAm,
|
||
erstelltVon: lauf.erstelltVon?.name ?? null,
|
||
gestartetAm: lauf.gestartetAm,
|
||
zugewiesenAn: lauf.zugewiesenAn?.name ?? null,
|
||
abgeschlossenAm: lauf.abgeschlossenAm,
|
||
abgeschlossenVon: lauf.abgeschlossenVon?.name ?? null,
|
||
archiviertAm: lauf.archiviertAm,
|
||
archiviertVon: lauf.archiviertVon?.name ?? null,
|
||
},
|
||
abschnitte,
|
||
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === null),
|
||
anzahlSchritte,
|
||
anzahlVomTisch,
|
||
};
|
||
}
|