import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { nurGeltende } from "@/lib/geltende-abschnitte"; import { anhaengeDesLaufs } from "@/server/services/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)["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 { 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); 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, }; }