Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der Kennung, nie aus dem Dateinamen vom Browser. Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes selbst: der Route Handler ruft rechtErforderlich() selbst, weil die Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber eine Server Action statt ueber einen zweiten Endpunkt; der Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt. In der Datenbank halten zwei CHECK-Bedingungen und zwei zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht an einem Schritt eines anderen Laufs oder bei einem fremden Kunden haengen. docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach der alten Anleitung haette ab hier still Daten verloren. Sicherung und Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich. Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder "datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei" waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts laedt bewusst ueber den zweiten Knopf hoch. Closes #52 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/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<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);
|
||
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,
|
||
};
|
||
}
|