feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt aenderbar, archiviert nicht. Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen und Einfuegen aus. Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
eec5de404a
commit
ed6e6b81eb
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Beispiel-Laeufe fuer die Screenshots (#013).
|
||||
*
|
||||
* Eine Abarbeitungsansicht ohne Lauf zeigt nichts, und die echten Beispieldaten
|
||||
* kommen erst mit #017. Bis dahin legt dieses Skript zwei Laeufe an: einen
|
||||
* begonnenen mit Fortschritt, Notiz und Kommentar, und einen vorbereiteten im
|
||||
* Zustand `OFFEN` — der Fall, den der Auftrag ausdruecklich verlangt (ein
|
||||
* Kollege richtet ein, der Techniker scannt und arbeitet ab).
|
||||
*
|
||||
* Es geht durch die echten Dienste. Waeren die Regeln anders als hier
|
||||
* angenommen, schlaegt es fehl, statt eine Lage herzustellen, die es in der
|
||||
* Anwendung nie gibt.
|
||||
*
|
||||
* Idempotent. Mit #017 wird das Skript geloescht.
|
||||
*
|
||||
* pnpm beispiel:lauf
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { SchrittZustand } from "../src/generated/prisma/enums.js";
|
||||
import { prisma } from "../src/lib/db.js";
|
||||
import { kommentarHinzufuegen } from "../src/server/services/kommentare.js";
|
||||
import { laufAnlegen, laufZurBearbeitungOeffnen } from "../src/server/services/laeufe.js";
|
||||
import { schrittergebnisSetzen } from "../src/server/services/schrittergebnisse.js";
|
||||
|
||||
const VORLAGE = "PC-Neuinstallation (Beispiel)";
|
||||
|
||||
const admin = await prisma.benutzer.findFirst({
|
||||
where: { rolle: "ADMIN" },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
select: { id: true, rolle: true },
|
||||
});
|
||||
if (!admin) {
|
||||
throw new Error("Es gibt keinen Administrator. Erst pnpm db:seed laufen lassen.");
|
||||
}
|
||||
const handelnder = { id: admin.id, rolle: admin.rolle };
|
||||
|
||||
const version = await prisma.vorlagenVersion.findFirst({
|
||||
where: { status: "VEROEFFENTLICHT", vorlage: { name: VORLAGE } },
|
||||
orderBy: { version: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!version) {
|
||||
throw new Error(`Es gibt keine veröffentlichte Version von „${VORLAGE}“. Erst pnpm beispiel:vorlage.`);
|
||||
}
|
||||
|
||||
const geraet = await prisma.geraet.findFirst({ where: { hostname: "NB-001" }, select: { id: true } });
|
||||
const person = await prisma.mitarbeiter.findFirst({ where: { sam: "mhuber" }, select: { id: true } });
|
||||
if (!geraet || !person) {
|
||||
throw new Error("NB-001 oder mhuber fehlen. Erst pnpm beispiel:stammdaten laufen lassen.");
|
||||
}
|
||||
const abteilung = await prisma.abteilung.findFirst({
|
||||
where: { name: "Buchhaltung" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
/** Die Schritte des ersten Abschnitts, in ihrer Reihenfolge. */
|
||||
const abschnitt = await prisma.vorlagenAbschnitt.findFirstOrThrow({
|
||||
where: { versionId: version.id },
|
||||
orderBy: { position: "asc" },
|
||||
select: { schritte: { orderBy: { position: "asc" }, select: { id: true, titel: true } } },
|
||||
});
|
||||
|
||||
const TITEL_BEGONNEN = "PC-Neuinstallation · NB-001 · Maria Huber";
|
||||
const TITEL_OFFEN = "PC-Neuinstallation · PC-001 · vorbereitet";
|
||||
|
||||
const schonDa = await prisma.lauf.findFirst({ where: { titel: TITEL_BEGONNEN }, select: { id: true } });
|
||||
if (schonDa) {
|
||||
console.log("Die Beispiel-Läufe gibt es schon – nichts zu tun.");
|
||||
} else {
|
||||
const begonnen = await laufAnlegen(
|
||||
{
|
||||
vorlagenVersionId: version.id,
|
||||
titel: TITEL_BEGONNEN,
|
||||
geraetId: geraet.id,
|
||||
mitarbeiterId: person.id,
|
||||
abteilungId: abteilung?.id ?? null,
|
||||
ticket: "TIK-4711",
|
||||
variablen: { os_version: "Windows 11 Pro 24H2", join_typ: "Active Directory", arbeitsspeicher: 16 },
|
||||
zugewiesenAnId: admin.id,
|
||||
},
|
||||
handelnder,
|
||||
);
|
||||
await laufZurBearbeitungOeffnen(begonnen.id, handelnder);
|
||||
|
||||
// Zwei erledigte Schritte und einer, der nicht zutrifft: so zeigt der
|
||||
// Fortschrittsbalken einen echten Stand und die Begruendung steht am Schritt.
|
||||
const [erster, zweiter, dritter] = abschnitt.schritte;
|
||||
for (const schritt of [erster, zweiter]) {
|
||||
if (!schritt) continue;
|
||||
await schrittergebnisSetzen(
|
||||
{ laufId: begonnen.id, vorlagenSchrittId: schritt.id, zustand: SchrittZustand.ERLEDIGT },
|
||||
handelnder,
|
||||
);
|
||||
}
|
||||
if (dritter) {
|
||||
await schrittergebnisSetzen(
|
||||
{
|
||||
laufId: begonnen.id,
|
||||
vorlagenSchrittId: dritter.id,
|
||||
zustand: SchrittZustand.NICHT_ZUTREFFEND,
|
||||
notiz: "Gerät ist neu ausgepackt – es gibt noch keine Altdaten.",
|
||||
},
|
||||
handelnder,
|
||||
);
|
||||
}
|
||||
await kommentarHinzufuegen(
|
||||
{
|
||||
laufId: begonnen.id,
|
||||
rumpf: "Rückfrage an die Buchhaltung: Steuer-Software wird erst nächste Woche gebraucht.",
|
||||
},
|
||||
handelnder,
|
||||
);
|
||||
|
||||
const pc001 = await prisma.geraet.findFirst({ where: { hostname: "PC-001" }, select: { id: true } });
|
||||
const morgen = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await laufAnlegen(
|
||||
{
|
||||
vorlagenVersionId: version.id,
|
||||
titel: TITEL_OFFEN,
|
||||
geraetId: pc001?.id ?? null,
|
||||
variablen: { os_version: "Windows 11 Pro 24H2", join_typ: "Active Directory" },
|
||||
ticket: "TIK-4712",
|
||||
zugewiesenAnId: admin.id,
|
||||
faelligAm: morgen,
|
||||
},
|
||||
handelnder,
|
||||
);
|
||||
console.log("Zwei Beispiel-Läufe angelegt: einer in Arbeit, einer vorbereitet.");
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
+42
-2
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { chromium, type Browser, type Cookie, type Page } from "@playwright/test";
|
||||
import { chromium, type Browser, type Cookie, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||||
|
||||
@@ -42,6 +42,12 @@ type Seite = {
|
||||
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
|
||||
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
|
||||
readonly vorbereiten?: (tab: Page) => Promise<void>;
|
||||
/**
|
||||
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. Fuer Bilder, die
|
||||
* ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild
|
||||
* wie nebenan, nur groesser.
|
||||
*/
|
||||
readonly ausschnitt?: (tab: Page) => Locator;
|
||||
};
|
||||
|
||||
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||||
@@ -124,6 +130,36 @@ const SEITEN: readonly Seite[] = [
|
||||
},
|
||||
},
|
||||
{ name: "geraete-liste", pfad: "/geraete" },
|
||||
{ name: "laeufe-liste", pfad: "/laeufe" },
|
||||
{ name: "lauf-starten", pfad: "/laeufe/starten" },
|
||||
{
|
||||
name: "lauf-abarbeiten",
|
||||
pfad: "/laeufe",
|
||||
vorbereiten: async (tab) => {
|
||||
await tab
|
||||
.getByRole("link", { name: /NB-001/ })
|
||||
.first()
|
||||
.click();
|
||||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
|
||||
// stuende er sonst weit unterhalb des Bildrands.
|
||||
name: "lauf-befehlsschritt",
|
||||
pfad: "/laeufe",
|
||||
vorbereiten: async (tab) => {
|
||||
await tab
|
||||
.getByRole("link", { name: /NB-001/ })
|
||||
.first()
|
||||
.click();
|
||||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||||
const schritt = tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" });
|
||||
await schritt.scrollIntoViewIfNeeded();
|
||||
await tab.waitForTimeout(150);
|
||||
},
|
||||
ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }),
|
||||
},
|
||||
{ name: "abteilungen", pfad: "/admin/abteilungen" },
|
||||
];
|
||||
|
||||
@@ -226,7 +262,11 @@ try {
|
||||
await tab.waitForLoadState("domcontentloaded");
|
||||
|
||||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||||
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
||||
if (seite.ausschnitt) {
|
||||
await seite.ausschnitt(tab).screenshot({ path: datei });
|
||||
} else {
|
||||
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
||||
}
|
||||
|
||||
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
|
||||
if (ansicht.name === "handy") {
|
||||
|
||||
Reference in New Issue
Block a user