Files
Laufzettel/src/server/queries/druck.ts
T
Florian WildeandClaude Opus 5 75c335df23
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
#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>
2026-10-11 06:05:29 +02:00

337 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}