Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -0,0 +1,225 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E-Tests für Phase 2.
|
||||
*
|
||||
* Geprüft wird der Weg, den der Gerätewart wirklich geht: morgens ins
|
||||
* Fristen-Dashboard schauen, eine Vorlage nachsehen, eine Prüfung durchführen
|
||||
* und am Ende ein Protokoll in der Hand haben.
|
||||
*
|
||||
* Hinweis: Der Wizard-Test legt echte Prüfprotokolle an. Die lassen sich
|
||||
* absichtlich nicht wieder löschen — genau das ist der Sinn der Sache. Für die
|
||||
* Beispielwehr ist das unschädlich; vor einer Vorführung hilft ein frischer
|
||||
* Seed.
|
||||
*
|
||||
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
||||
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
async function anmelden(page: Page): Promise<void> {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await page.waitForURL("/");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page);
|
||||
});
|
||||
|
||||
test.describe("Fristen-Dashboard", () => {
|
||||
test("zeigt die vier Zeitfenster und die offenen Termine", async ({ page }) => {
|
||||
await page.goto("/pruefungen");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Prüfungen und Fristen" })).toBeVisible();
|
||||
|
||||
// Die vier Töpfe sind gleichzeitig Filter.
|
||||
for (const bezeichnung of ["überfällig", "diese Woche", "diesen Monat", "nächste 90 Tage"]) {
|
||||
await expect(page.getByRole("button", { name: new RegExp(bezeichnung) })).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
|
||||
// Aus dem Seed entstehen Termine für die Geräte mit Prüfvorgabe.
|
||||
await expect(page.getByRole("cell", { name: /INV-\d{4}/ }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("filtert die Terminliste über ein Zeitfenster", async ({ page }) => {
|
||||
await page.goto("/pruefungen");
|
||||
await expect(page.getByRole("heading", { name: "Offene Termine" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /überfällig/ }).first().click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Filter aufheben" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("bündelt die Termine nach Fahrzeug und lässt umschalten", async ({ page }) => {
|
||||
await page.goto("/pruefungen");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Verteilung" })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Bündelung wählen").click();
|
||||
await page.getByRole("option", { name: "nach Gerätetyp" }).click();
|
||||
|
||||
// Nach dem Umschalten stehen Gerätetypen in der Verteilungstabelle. Der
|
||||
// Name steht dort allein in der Zelle — in der Terminliste darunter immer
|
||||
// zusammen mit der Inventarnummer, deshalb "exact".
|
||||
await expect(
|
||||
page.getByRole("cell", { name: "Feuerwehrleine 30 m", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Wartungsvorlagen", () => {
|
||||
test("zeigt die Vorlagen mit Intervall und Prüfpunkten", async ({ page }) => {
|
||||
await page.goto("/pruefungen/vorlagen");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Wartungsvorlagen" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "DGUV Vorschrift 3, DIN VDE 0701-0702" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("öffnet die Checkliste einer Vorlage", async ({ page }) => {
|
||||
await page.goto("/pruefungen/vorlagen");
|
||||
await page.getByRole("link", { name: "Pressluftatmer — Jahresprüfung" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
|
||||
await expect(page.getByText("Ansprechdruck der Warneinrichtung")).toBeVisible();
|
||||
// Der zulässige Bereich steht am Punkt.
|
||||
await expect(page.getByText(/zulässig 50 bis 60 bar/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Nacheinander statt parallel: der Wizard setzt einen vorhandenen Entwurf fort,
|
||||
* statt einen zweiten anzulegen. Zwei gleichzeitig laufende Tests würden sich
|
||||
* denselben Entwurf teilen — und der zweite liefe in die Sperre für
|
||||
* abgeschlossene Protokolle.
|
||||
*
|
||||
* Aus demselben Grund bekommt jedes Testprojekt ein eigenes Gerät: Desktop und
|
||||
* Tablet laufen parallel, aber unter demselben Benutzer.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
/** Welches der Leinen-Geräte dieses Projekt prüft. */
|
||||
function geraetIndex(): number {
|
||||
return test.info().project.name === "tablet" ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Von der Geräteliste in den Wizard — der Weg des Gerätewarts. */
|
||||
async function pruefungStarten(page: Page): Promise<void> {
|
||||
await page.goto("/geraete");
|
||||
await page.getByLabel("Geräte durchsuchen").fill("Feuerwehrleine");
|
||||
await page.getByRole("link", { name: /INV-\d{4}/ }).nth(geraetIndex()).click();
|
||||
await page.getByRole("link", { name: "Prüfung durchführen" }).click();
|
||||
}
|
||||
|
||||
test.describe("Prüfwizard", () => {
|
||||
test("führt von der Checkliste bis zum unterschriebenen Protokoll", async ({ page }) => {
|
||||
await pruefungStarten(page);
|
||||
await page.waitForURL(/\/pruefungen\/neu/);
|
||||
|
||||
// Die Checkliste der Leinenprüfung hat vier Punkte: drei Ja/Nein und einen
|
||||
// Freitext. Vor jedem Schritt wird auf die Schrittanzeige gewartet — sonst
|
||||
// klickt der Test schneller, als die Oberfläche umschaltet.
|
||||
for (const nummer of [1, 2, 3, 4]) {
|
||||
await expect(page.getByText(new RegExp(`Schritt ${nummer} von 4`))).toBeVisible();
|
||||
|
||||
// "in Ordnung" exakt: sonst trifft der Name auch "nicht in Ordnung".
|
||||
const inOrdnung = page.getByRole("button", { name: "in Ordnung", exact: true });
|
||||
if ((await inOrdnung.count()) > 0) await inOrdnung.click();
|
||||
|
||||
const befund = page.getByLabel("Befund");
|
||||
if ((await befund.count()) > 0) {
|
||||
await befund.fill("Leine vollständig durch die Hand gezogen, ohne Befund.");
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Weiter" }).click();
|
||||
}
|
||||
|
||||
// Abschluss: Ergebnis ist vorbelegt, Unterschrift fehlt noch.
|
||||
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
|
||||
|
||||
// Unterschrift zeichnen — mit der Maus, wie mit dem Finger auf dem Tablet.
|
||||
// Erst ins Bild holen: die Mauskoordinaten beziehen sich auf das sichtbare
|
||||
// Fenster, nicht auf das Dokument. Liegt das Feld unterhalb, zeichnet der
|
||||
// Test sonst ins Leere.
|
||||
const feld = page.getByLabel(/Unterschriftenfeld/);
|
||||
await feld.scrollIntoViewIfNeeded();
|
||||
const rahmen = await feld.boundingBox();
|
||||
expect(rahmen).not.toBeNull();
|
||||
|
||||
await page.mouse.move(rahmen!.x + 30, rahmen!.y + rahmen!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(rahmen!.x + 90, rahmen!.y + 20, { steps: 8 });
|
||||
await page.mouse.move(rahmen!.x + 150, rahmen!.y + rahmen!.height - 20, { steps: 8 });
|
||||
await page.mouse.move(rahmen!.x + 210, rahmen!.y + 30, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Sobald etwas auf dem Feld steht, lässt es sich löschen und übernehmen.
|
||||
await expect(page.getByRole("button", { name: "Löschen" })).toBeEnabled();
|
||||
|
||||
await page.getByRole("button", { name: "Übernehmen" }).click();
|
||||
await expect(page.getByRole("button", { name: "übernommen" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Prüfung abschließen" }).click();
|
||||
|
||||
// Danach steht das Protokoll da — mit Nummer und Ergebnis.
|
||||
await page.waitForURL(/\/pruefungen\/[0-9a-f-]{36}$/);
|
||||
await expect(page.getByRole("heading", { name: /Prüfprotokoll \d{4}-\d{4}/ })).toBeVisible();
|
||||
// Genau der Kleinschreibung wegen "exact": darüber steht auch das Feld
|
||||
// "Abgeschlossen" mit dem Zeitpunkt.
|
||||
await expect(page.getByText("abgeschlossen", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Checkliste" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("verlangt eine Unterschrift, bevor abgeschlossen werden kann", async ({ page }) => {
|
||||
await pruefungStarten(page);
|
||||
|
||||
// Erst warten, bis der Wizard steht — er legt die Prüfung beim Öffnen an.
|
||||
await expect(page.getByText(/Schritt 1 von \d/)).toBeVisible();
|
||||
|
||||
// Dann bis zum Abschluss durchklicken, ohne etwas zu beantworten.
|
||||
for (let schritt = 0; schritt < 8; schritt += 1) {
|
||||
const weiter = page.getByRole("button", { name: "Weiter" });
|
||||
if (!(await weiter.isVisible())) break;
|
||||
await weiter.click();
|
||||
}
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Ergebnis" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Prüfung abschließen" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mängel und Rückrufe", () => {
|
||||
test("zeigt den Mangel aus der Beispielprüfung", async ({ page }) => {
|
||||
await page.goto("/maengel");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Mängel" })).toBeVisible();
|
||||
await expect(page.getByText(/Leinenbeutel an einer Naht aufgescheuert/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt den Rückruf und seine betroffenen Geräte", async ({ page }) => {
|
||||
await page.goto("/rueckrufe");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Rückrufe" })).toBeVisible();
|
||||
await page.getByRole("link", { name: /Sicherheitshinweis Ausatemventil/ }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Betroffene Geräte" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Mögliche Treffer im Bestand" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Protokolle", () => {
|
||||
test("listet abgeschlossene Prüfungen mit Ergebnis", async ({ page }) => {
|
||||
await page.goto("/pruefungen/verlauf");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Prüfprotokolle" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /\d{4}-\d{4}/ }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Die Unveränderbarkeit abgeschlossener Prüfprotokolle — geprüft dort, wo sie
|
||||
* durchgesetzt wird: in der Datenbank.
|
||||
*
|
||||
* Ein Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse. Wäre es
|
||||
* nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
|
||||
* deshalb in einem Trigger (Migration 20260903000300) und nicht nur im
|
||||
* Anwendungscode — ein vergessener Zustandscheck im Router würde sonst reichen,
|
||||
* um sie auszuhebeln.
|
||||
*
|
||||
* Der Test braucht eine laufende Datenbank. Ist keine erreichbar, überspringt
|
||||
* er sich selbst, damit "npm run test" auch ohne Docker durchläuft.
|
||||
*/
|
||||
|
||||
const APP_URL = process.env.DATABASE_URL ?? "";
|
||||
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
|
||||
|
||||
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
|
||||
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
||||
|
||||
const SLUG = "test-pruefprotokoll-wehr";
|
||||
|
||||
let erreichbar = false;
|
||||
let organisationId = "";
|
||||
let geraetId = "";
|
||||
let vorlageId = "";
|
||||
let punktId = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await system.$queryRaw`SELECT 1`;
|
||||
erreichbar = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisation = await system.organisation.upsert({
|
||||
where: { slug: SLUG },
|
||||
update: {},
|
||||
create: { slug: SLUG, name: "Prüfprotokoll-Testwehr", kurzname: "PPT" },
|
||||
});
|
||||
organisationId = organisation.id;
|
||||
|
||||
const standort = await system.standort.upsert({
|
||||
where: { organisationId_name: { organisationId, name: "Gerätehaus" } },
|
||||
update: {},
|
||||
create: { organisationId, name: "Gerätehaus", art: "GERAETEHAUS" },
|
||||
});
|
||||
|
||||
const geraet = await system.geraet.upsert({
|
||||
where: { organisationId_inventarnummer: { organisationId, inventarnummer: "PPT-0001" } },
|
||||
update: {},
|
||||
create: {
|
||||
organisationId,
|
||||
inventarnummer: "PPT-0001",
|
||||
bezeichnung: "Prüfling",
|
||||
standortId: standort.id,
|
||||
},
|
||||
});
|
||||
geraetId = geraet.id;
|
||||
|
||||
const vorlage = await system.wartungsvorlage.upsert({
|
||||
where: { organisationId_name: { organisationId, name: "Testvorlage" } },
|
||||
update: {},
|
||||
create: { organisationId, name: "Testvorlage", intervallMonate: 12 },
|
||||
});
|
||||
vorlageId = vorlage.id;
|
||||
|
||||
const punkt = await system.checklistenpunkt.create({
|
||||
data: { organisationId, vorlageId, text: "Sichtprüfung", typ: "JA_NEIN" },
|
||||
});
|
||||
punktId = punkt.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
// Der Schalter ist zweimal nötig: für die Audit-Einträge und für die
|
||||
// abgeschlossenen Prüfungen, die derselbe Aufbewahrungslauf abräumt.
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||
});
|
||||
}
|
||||
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
||||
});
|
||||
|
||||
/** Legt eine abgeschlossene Prüfung an — so wie es der Wizard tut. */
|
||||
async function abgeschlossenePruefung(nummer: string) {
|
||||
const pruefung = await system.pruefung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
geraetId,
|
||||
vorlageId,
|
||||
durchgefuehrtAm: new Date("2026-06-01T00:00:00.000Z"),
|
||||
prueferName: "Testprüfer",
|
||||
status: "ENTWURF",
|
||||
ergebnisse: { create: [{ organisationId, punktId, jaNein: true, inOrdnung: true }] },
|
||||
signaturen: { create: [{ organisationId, name: "Testprüfer" }] },
|
||||
},
|
||||
});
|
||||
|
||||
return system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: {
|
||||
status: "ABGESCHLOSSEN",
|
||||
ergebnis: "BESTANDEN",
|
||||
protokollNummer: nummer,
|
||||
abgeschlossenAm: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Abgeschlossene Prüfprotokolle", () => {
|
||||
it("lässt Entwürfe uneingeschränkt ändern", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const entwurf = await system.pruefung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
geraetId,
|
||||
vorlageId,
|
||||
durchgefuehrtAm: new Date(),
|
||||
status: "ENTWURF",
|
||||
},
|
||||
});
|
||||
|
||||
const geaendert = await system.pruefung.update({
|
||||
where: { id: entwurf.id },
|
||||
data: { bemerkung: "noch in Arbeit" },
|
||||
});
|
||||
|
||||
expect(geaendert.bemerkung).toBe("noch in Arbeit");
|
||||
|
||||
await system.pruefung.delete({ where: { id: entwurf.id } });
|
||||
});
|
||||
|
||||
it("weist jede inhaltliche Änderung am abgeschlossenen Protokoll ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const pruefung = await abgeschlossenePruefung("T-0001");
|
||||
|
||||
await expect(
|
||||
system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { ergebnis: "NICHT_BESTANDEN" },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
|
||||
await expect(
|
||||
system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { bemerkung: "nachträglich ergänzt" },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
|
||||
await expect(
|
||||
system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { durchgefuehrtAm: new Date("2020-01-01T00:00:00.000Z") },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("lässt das Protokoll-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const pruefung = await abgeschlossenePruefung("T-0002");
|
||||
|
||||
const datei = await system.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: "test",
|
||||
objektSchluessel: `test/${pruefung.id}.pdf`,
|
||||
dateiname: "protokoll.pdf",
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(1024),
|
||||
},
|
||||
});
|
||||
|
||||
// Das ist der Weg des Hintergrundprozesses — er muss offen bleiben.
|
||||
const mitPdf = await system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { protokollDateiId: datei.id },
|
||||
});
|
||||
expect(mitPdf.protokollDateiId).toBe(datei.id);
|
||||
|
||||
// Ein zweites Mal aber nicht: sonst ließe sich das Protokoll austauschen.
|
||||
const zweite = await system.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: "test",
|
||||
objektSchluessel: `test/${pruefung.id}-zwei.pdf`,
|
||||
dateiname: "anderes.pdf",
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(2048),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { protokollDateiId: zweite.id },
|
||||
}),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("erlaubt genau einen Zustandswechsel: abgeschlossen wird ersetzt", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const pruefung = await abgeschlossenePruefung("T-0003");
|
||||
|
||||
const ersetzt = await system.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { status: "ERSETZT" },
|
||||
});
|
||||
expect(ersetzt.status).toBe("ERSETZT");
|
||||
|
||||
// Zurück in den Entwurf geht es nicht.
|
||||
await expect(
|
||||
system.pruefung.update({ where: { id: pruefung.id }, data: { status: "ENTWURF" } }),
|
||||
).rejects.toThrow(/unveraenderlich/i);
|
||||
});
|
||||
|
||||
it("sperrt auch den Umweg über die Einzelergebnisse", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const pruefung = await abgeschlossenePruefung("T-0004");
|
||||
const ergebnis = await system.pruefergebnis.findFirstOrThrow({
|
||||
where: { pruefungId: pruefung.id },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.pruefergebnis.update({
|
||||
where: { id: ergebnis.id },
|
||||
data: { jaNein: false, inOrdnung: false },
|
||||
}),
|
||||
).rejects.toThrow(/abgeschlossen/i);
|
||||
|
||||
await expect(
|
||||
system.pruefergebnis.delete({ where: { id: ergebnis.id } }),
|
||||
).rejects.toThrow(/abgeschlossen/i);
|
||||
|
||||
await expect(
|
||||
system.pruefergebnis.create({
|
||||
data: { organisationId, pruefungId: pruefung.id, punktId, jaNein: false },
|
||||
}),
|
||||
).rejects.toThrow(/abgeschlossen/i);
|
||||
});
|
||||
|
||||
it("sperrt nachträgliche Unterschriften", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const pruefung = await abgeschlossenePruefung("T-0005");
|
||||
|
||||
await expect(
|
||||
system.signatur.create({
|
||||
data: { organisationId, pruefungId: pruefung.id, name: "Nachzügler" },
|
||||
}),
|
||||
).rejects.toThrow(/abgeschlossen/i);
|
||||
});
|
||||
|
||||
it("verhindert das Löschen abgeschlossener Protokolle", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const pruefung = await abgeschlossenePruefung("T-0006");
|
||||
|
||||
await expect(system.pruefung.delete({ where: { id: pruefung.id } })).rejects.toThrow(
|
||||
/nicht geloescht/i,
|
||||
);
|
||||
|
||||
// Nur der ausdrückliche Aufbewahrungslauf darf es.
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.pruefung.delete({ where: { id: pruefung.id } });
|
||||
});
|
||||
|
||||
expect(await system.pruefung.findUnique({ where: { id: pruefung.id } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Wartungstabellen", () => {
|
||||
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const gefunden = await app.$transaction(async (tx) => ({
|
||||
vorlagen: await tx.wartungsvorlage.findMany(),
|
||||
termine: await tx.wartungstermin.findMany(),
|
||||
pruefungen: await tx.pruefung.findMany(),
|
||||
maengel: await tx.mangel.findMany(),
|
||||
rueckrufe: await tx.rueckruf.findMany(),
|
||||
}));
|
||||
|
||||
expect(gefunden.vorlagen).toHaveLength(0);
|
||||
expect(gefunden.termine).toHaveLength(0);
|
||||
expect(gefunden.pruefungen).toHaveLength(0);
|
||||
expect(gefunden.maengel).toHaveLength(0);
|
||||
expect(gefunden.rueckrufe).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("zeigt im Kontext der Wehr nur ihre eigenen Vorlagen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorlagen = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.wartungsvorlage.findMany();
|
||||
});
|
||||
|
||||
expect(vorlagen.length).toBeGreaterThan(0);
|
||||
expect(vorlagen.every((vorlage) => vorlage.organisationId === organisationId)).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt keine Vorlage für eine fremde Wehr anlegen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const fremde = await system.organisation.upsert({
|
||||
where: { slug: "test-pruefprotokoll-fremd" },
|
||||
update: {},
|
||||
create: { slug: "test-pruefprotokoll-fremd", name: "Fremde Wehr", kurzname: "FW" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.wartungsvorlage.create({
|
||||
data: { organisationId: fremde.id, name: "Schmuggelvorlage", intervallMonate: 12 },
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.organisation.deleteMany({ where: { slug: "test-pruefprotokoll-fremd" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
beanstandetePunkte,
|
||||
beantwortet,
|
||||
dauerInMinuten,
|
||||
ergebnisVorschlag,
|
||||
geraetStatusNachPruefung,
|
||||
messwertInOrdnung,
|
||||
offenePflichtpunkte,
|
||||
punktInOrdnung,
|
||||
type Antwort,
|
||||
type Punkt,
|
||||
} from "@/lib/wartung/bewertung";
|
||||
|
||||
/**
|
||||
* Bewertung einer Prüfung.
|
||||
*
|
||||
* Der Kern ist eine einzige fachliche Regel: ein durchgefallener Pflichtpunkt
|
||||
* bedeutet "nicht bestanden", ein durchgefallener Kann-Punkt "bestanden mit
|
||||
* Mängeln". Daran hängt, ob ein Gerät im Einsatz sein darf — deshalb steht das
|
||||
* hier so ausführlich unter Test.
|
||||
*/
|
||||
|
||||
const SICHTPRUEFUNG: Punkt = {
|
||||
id: "p1",
|
||||
text: "Trageeinrichtung ohne Beschädigung",
|
||||
typ: "JA_NEIN",
|
||||
pflicht: true,
|
||||
};
|
||||
|
||||
const WARNDRUCK: Punkt = {
|
||||
id: "p2",
|
||||
text: "Ansprechdruck der Warneinrichtung",
|
||||
typ: "MESSWERT",
|
||||
pflicht: true,
|
||||
minWert: 50,
|
||||
maxWert: 60,
|
||||
};
|
||||
|
||||
const BEUTEL: Punkt = {
|
||||
id: "p3",
|
||||
text: "Leinenbeutel unbeschädigt",
|
||||
typ: "JA_NEIN",
|
||||
pflicht: false,
|
||||
};
|
||||
|
||||
const BEFUND: Punkt = {
|
||||
id: "p4",
|
||||
text: "Auffälligkeiten",
|
||||
typ: "TEXT",
|
||||
pflicht: false,
|
||||
};
|
||||
|
||||
const FOTO: Punkt = { id: "p5", text: "Foto nach der Reinigung", typ: "FOTO", pflicht: false };
|
||||
|
||||
function antwort(punktId: string, teil: Partial<Antwort> = {}): Antwort {
|
||||
return { punktId, ...teil };
|
||||
}
|
||||
|
||||
describe("messwertInOrdnung", () => {
|
||||
it("prüft gegen beide Grenzen", () => {
|
||||
expect(messwertInOrdnung(55, 50, 60)).toBe(true);
|
||||
expect(messwertInOrdnung(50, 50, 60)).toBe(true);
|
||||
expect(messwertInOrdnung(60, 50, 60)).toBe(true);
|
||||
expect(messwertInOrdnung(49.9, 50, 60)).toBe(false);
|
||||
expect(messwertInOrdnung(61, 50, 60)).toBe(false);
|
||||
});
|
||||
|
||||
it("behandelt eine fehlende Grenze als offen", () => {
|
||||
expect(messwertInOrdnung(300, 270, null)).toBe(true);
|
||||
expect(messwertInOrdnung(0.2, null, 0.5)).toBe(true);
|
||||
expect(messwertInOrdnung(0.8, null, 0.5)).toBe(false);
|
||||
});
|
||||
|
||||
it("wertet einen fehlenden Wert nicht als in Ordnung", () => {
|
||||
expect(messwertInOrdnung(null, 50, 60)).toBe(false);
|
||||
expect(messwertInOrdnung(undefined)).toBe(false);
|
||||
expect(messwertInOrdnung(Number.NaN, 0, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("beantwortet", () => {
|
||||
it("erkennt jede Antwortart", () => {
|
||||
expect(beantwortet(SICHTPRUEFUNG, antwort("p1", { jaNein: false }))).toBe(true);
|
||||
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: 0 }))).toBe(true);
|
||||
expect(beantwortet(BEFUND, antwort("p4", { text: "ohne Befund" }))).toBe(true);
|
||||
expect(beantwortet(FOTO, antwort("p5", { fotoDateiId: "abc" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("erkennt fehlende Antworten", () => {
|
||||
expect(beantwortet(SICHTPRUEFUNG, undefined)).toBe(false);
|
||||
expect(beantwortet(SICHTPRUEFUNG, antwort("p1"))).toBe(false);
|
||||
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: null }))).toBe(false);
|
||||
expect(beantwortet(BEFUND, antwort("p4", { text: " " }))).toBe(false);
|
||||
});
|
||||
|
||||
it("wertet die Null als Antwort — sie ist ein Messwert wie jeder andere", () => {
|
||||
expect(beantwortet(WARNDRUCK, antwort("p2", { zahlwert: 0 }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("punktInOrdnung", () => {
|
||||
it("folgt bei Ja/Nein der Antwort", () => {
|
||||
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: true }))).toBe(true);
|
||||
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: false }))).toBe(false);
|
||||
});
|
||||
|
||||
it("rechnet bei Messwerten den Bereich aus", () => {
|
||||
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 55 }))).toBe(true);
|
||||
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 45 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("lässt die ausdrückliche Angabe des Prüfers gewinnen", () => {
|
||||
// Wert in der Toleranz, der Prüfer beanstandet trotzdem.
|
||||
expect(punktInOrdnung(WARNDRUCK, antwort("p2", { zahlwert: 55, inOrdnung: false }))).toBe(
|
||||
false,
|
||||
);
|
||||
// Und umgekehrt.
|
||||
expect(punktInOrdnung(SICHTPRUEFUNG, antwort("p1", { jaNein: false, inOrdnung: true }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offenePflichtpunkte", () => {
|
||||
it("nennt nur unbeantwortete Pflichtpunkte", () => {
|
||||
const offen = offenePflichtpunkte(
|
||||
[SICHTPRUEFUNG, WARNDRUCK, BEUTEL],
|
||||
[antwort("p1", { jaNein: true })],
|
||||
);
|
||||
|
||||
expect(offen.map((punkt) => punkt.id)).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("ist leer, wenn alle Pflichtpunkte beantwortet sind", () => {
|
||||
const offen = offenePflichtpunkte(
|
||||
[SICHTPRUEFUNG, WARNDRUCK, BEUTEL],
|
||||
[antwort("p1", { jaNein: true }), antwort("p2", { zahlwert: 55 })],
|
||||
);
|
||||
|
||||
expect(offen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ergebnisVorschlag", () => {
|
||||
const punkte = [SICHTPRUEFUNG, WARNDRUCK, BEUTEL, BEFUND];
|
||||
|
||||
it("bestanden, wenn nichts beanstandet ist", () => {
|
||||
const antworten = [
|
||||
antwort("p1", { jaNein: true }),
|
||||
antwort("p2", { zahlwert: 55 }),
|
||||
antwort("p3", { jaNein: true }),
|
||||
];
|
||||
|
||||
expect(ergebnisVorschlag(punkte, antworten)).toBe("BESTANDEN");
|
||||
});
|
||||
|
||||
it("mit Mängeln, wenn nur ein Kann-Punkt durchfällt", () => {
|
||||
const antworten = [
|
||||
antwort("p1", { jaNein: true }),
|
||||
antwort("p2", { zahlwert: 55 }),
|
||||
antwort("p3", { jaNein: false }),
|
||||
];
|
||||
|
||||
expect(ergebnisVorschlag(punkte, antworten)).toBe("BESTANDEN_MIT_MAENGELN");
|
||||
expect(beanstandetePunkte(punkte, antworten).map((punkt) => punkt.id)).toEqual(["p3"]);
|
||||
});
|
||||
|
||||
it("nicht bestanden, sobald ein Pflichtpunkt durchfällt", () => {
|
||||
const antworten = [
|
||||
antwort("p1", { jaNein: true }),
|
||||
// Messwert außerhalb der Toleranz — Pflichtpunkt.
|
||||
antwort("p2", { zahlwert: 42 }),
|
||||
antwort("p3", { jaNein: false }),
|
||||
];
|
||||
|
||||
expect(ergebnisVorschlag(punkte, antworten)).toBe("NICHT_BESTANDEN");
|
||||
});
|
||||
|
||||
it("wertet unbeantwortete Punkte nicht als Beanstandung", () => {
|
||||
// Sonst wäre jede halb ausgefüllte Prüfung sofort "nicht bestanden".
|
||||
expect(ergebnisVorschlag(punkte, [antwort("p1", { jaNein: true })])).toBe("BESTANDEN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geraetStatusNachPruefung", () => {
|
||||
it("sperrt nur bei nicht bestanden", () => {
|
||||
expect(geraetStatusNachPruefung("NICHT_BESTANDEN")).toBe("GESPERRT");
|
||||
expect(geraetStatusNachPruefung("BESTANDEN_MIT_MAENGELN")).toBe("EINSATZBEREIT");
|
||||
expect(geraetStatusNachPruefung("BESTANDEN")).toBe("EINSATZBEREIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dauerInMinuten", () => {
|
||||
it("rundet auf volle Minuten", () => {
|
||||
const beginn = new Date("2026-06-15T09:00:00.000Z");
|
||||
const ende = new Date("2026-06-15T09:24:40.000Z");
|
||||
expect(dauerInMinuten(beginn, ende)).toBe(25);
|
||||
});
|
||||
|
||||
it("gibt ohne Beginn nichts zurück", () => {
|
||||
expect(dauerInMinuten(null, new Date())).toBeNull();
|
||||
});
|
||||
|
||||
it("liefert keine negative Dauer, wenn die Uhr gesprungen ist", () => {
|
||||
const beginn = new Date("2026-06-15T10:00:00.000Z");
|
||||
const ende = new Date("2026-06-15T09:00:00.000Z");
|
||||
expect(dauerInMinuten(beginn, ende)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
erinnerungsstufe,
|
||||
fensterGrenzen,
|
||||
innerhalbPlanungshorizont,
|
||||
naechsteFaelligkeit,
|
||||
protokollNummer,
|
||||
protokollNummerLesen,
|
||||
terminFenster,
|
||||
} from "@/lib/wartung/fristen";
|
||||
|
||||
/**
|
||||
* Fristenlogik der Wartung.
|
||||
*
|
||||
* Alle Tests rechnen mit einem festen "heute", nicht mit der Systemuhr. Sonst
|
||||
* wären sie am 31. Dezember rot und am 1. Januar wieder grün — und niemand
|
||||
* wüsste, warum.
|
||||
*/
|
||||
|
||||
/** Ein Kalendertag als UTC-Mitternacht, so wie er aus einer DATE-Spalte kommt. */
|
||||
function tag(jahr: number, monat: number, tagImMonat: number): Date {
|
||||
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
|
||||
}
|
||||
|
||||
/** Mittags, damit die Zeitzonenumrechnung nicht zufällig aufgeht. */
|
||||
const HEUTE = new Date("2026-06-15T12:00:00.000Z");
|
||||
|
||||
describe("terminFenster", () => {
|
||||
it("erkennt Überfälligkeit ab dem Vortag", () => {
|
||||
expect(terminFenster(tag(2026, 6, 14), HEUTE)).toBe("ueberfaellig");
|
||||
expect(terminFenster(tag(2026, 1, 1), HEUTE)).toBe("ueberfaellig");
|
||||
});
|
||||
|
||||
it("zählt den heutigen Tag zur laufenden Woche, nicht zur Überfälligkeit", () => {
|
||||
expect(terminFenster(tag(2026, 6, 15), HEUTE)).toBe("diese_woche");
|
||||
});
|
||||
|
||||
it("staffelt Woche, Monat und Quartal", () => {
|
||||
expect(terminFenster(tag(2026, 6, 22), HEUTE)).toBe("diese_woche");
|
||||
expect(terminFenster(tag(2026, 6, 23), HEUTE)).toBe("dieser_monat");
|
||||
expect(terminFenster(tag(2026, 7, 15), HEUTE)).toBe("dieser_monat");
|
||||
expect(terminFenster(tag(2026, 7, 16), HEUTE)).toBe("naechste_90_tage");
|
||||
expect(terminFenster(tag(2026, 9, 13), HEUTE)).toBe("naechste_90_tage");
|
||||
expect(terminFenster(tag(2026, 9, 14), HEUTE)).toBe("spaeter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fensterGrenzen", () => {
|
||||
it("liefert die Grenzen als UTC-Kalendertage", () => {
|
||||
const grenzen = fensterGrenzen(HEUTE);
|
||||
|
||||
expect(grenzen.heute.toISOString()).toBe("2026-06-15T00:00:00.000Z");
|
||||
expect(grenzen.inSiebenTagen.toISOString()).toBe("2026-06-22T00:00:00.000Z");
|
||||
expect(grenzen.inDreissigTagen.toISOString()).toBe("2026-07-15T00:00:00.000Z");
|
||||
expect(grenzen.inNeunzigTagen.toISOString()).toBe("2026-09-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rechnet auch kurz nach Mitternacht Berliner Zeit den richtigen Tag", () => {
|
||||
// 00:30 Uhr in Berlin ist nach UTC noch der Vortag.
|
||||
const kurzNachMitternacht = new Date("2026-06-15T22:30:00.000Z");
|
||||
expect(fensterGrenzen(kurzNachMitternacht).heute.toISOString()).toBe(
|
||||
"2026-06-16T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("naechsteFaelligkeit", () => {
|
||||
it("rechnet ab der letzten Prüfung", () => {
|
||||
const faellig = naechsteFaelligkeit({
|
||||
letztePruefung: tag(2026, 3, 10),
|
||||
intervallMonate: 12,
|
||||
});
|
||||
expect(faellig?.toISOString()).toBe("2027-03-10T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("nimmt ersatzweise die Indienststellung", () => {
|
||||
const faellig = naechsteFaelligkeit({
|
||||
indienststellung: tag(2024, 5, 1),
|
||||
intervallMonate: 6,
|
||||
});
|
||||
expect(faellig?.toISOString()).toBe("2024-11-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("klemmt überzählige Tage auf das Monatsende", () => {
|
||||
const faellig = naechsteFaelligkeit({
|
||||
letztePruefung: tag(2026, 1, 31),
|
||||
intervallMonate: 1,
|
||||
});
|
||||
expect(faellig?.toISOString()).toBe("2026-02-28T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("gibt ohne Anhaltspunkt oder Intervall nichts zurück", () => {
|
||||
expect(naechsteFaelligkeit({ intervallMonate: 12 })).toBeNull();
|
||||
expect(
|
||||
naechsteFaelligkeit({ letztePruefung: tag(2026, 1, 1), intervallMonate: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("erinnerungsstufe", () => {
|
||||
it("meldet Überfälligkeit", () => {
|
||||
expect(erinnerungsstufe(tag(2026, 6, 14), 30, HEUTE)).toBe("UEBERFAELLIG");
|
||||
});
|
||||
|
||||
it("meldet den Fälligkeitstag selbst", () => {
|
||||
expect(erinnerungsstufe(tag(2026, 6, 15), 30, HEUTE)).toBe("T_0");
|
||||
});
|
||||
|
||||
it("staffelt die Vorwarnungen", () => {
|
||||
expect(erinnerungsstufe(tag(2026, 6, 20), 30, HEUTE)).toBe("T_7");
|
||||
expect(erinnerungsstufe(tag(2026, 7, 5), 30, HEUTE)).toBe("T_30");
|
||||
expect(erinnerungsstufe(tag(2026, 8, 1), 30, HEUTE)).toBeNull();
|
||||
});
|
||||
|
||||
it("richtet die erste Stufe nach der Vorwarnzeit der Vorlage", () => {
|
||||
// 60 Tage Vorwarnzeit: ein Termin in 45 Tagen wird schon erinnert.
|
||||
expect(erinnerungsstufe(tag(2026, 7, 30), 60, HEUTE)).toBe("T_30");
|
||||
// Mit der Standardvorwarnzeit dagegen noch nicht.
|
||||
expect(erinnerungsstufe(tag(2026, 7, 30), 30, HEUTE)).toBeNull();
|
||||
});
|
||||
|
||||
it("erinnert auch bei spät angelegten Terminen — kleiner-gleich statt genau", () => {
|
||||
// Der Tag T-30 ist längst vorbei, die erste Erinnerung darf trotzdem raus.
|
||||
expect(erinnerungsstufe(tag(2026, 6, 30), 30, HEUTE)).toBe("T_30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("innerhalbPlanungshorizont", () => {
|
||||
it("plant nur, was in den Horizont fällt", () => {
|
||||
expect(innerhalbPlanungshorizont(tag(2026, 9, 1), 180, HEUTE)).toBe(true);
|
||||
expect(innerhalbPlanungshorizont(tag(2027, 6, 1), 180, HEUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it("bezieht Vergangenes immer ein — Überfälliges muss geplant bleiben", () => {
|
||||
expect(innerhalbPlanungshorizont(tag(2025, 1, 1), 180, HEUTE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Protokollnummern", () => {
|
||||
it("füllt die laufende Nummer auf vier Stellen", () => {
|
||||
expect(protokollNummer(2026, 1)).toBe("2026-0001");
|
||||
expect(protokollNummer(2026, 137)).toBe("2026-0137");
|
||||
});
|
||||
|
||||
it("liest die Nummer wieder auseinander", () => {
|
||||
expect(protokollNummerLesen("2026-0042")).toEqual({ jahr: 2026, laufend: 42 });
|
||||
expect(protokollNummerLesen("kaputt")).toBeNull();
|
||||
expect(protokollNummerLesen(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fruehesteFaelligkeit,
|
||||
zuPlanendeTermine,
|
||||
type PlanungsGeraet,
|
||||
} from "@/lib/wartung/planung";
|
||||
|
||||
/**
|
||||
* Terminplanung des nächtlichen Laufs.
|
||||
*
|
||||
* Der Lauf entscheidet, was im Fristen-Dashboard steht. Ein Fehler hier fällt
|
||||
* niemandem auf — bis eine Prüfung fehlt, die hätte stattfinden müssen.
|
||||
*/
|
||||
|
||||
const HEUTE = new Date("2026-06-15T02:15:00.000Z");
|
||||
|
||||
function tag(jahr: number, monat: number, tagImMonat: number): Date {
|
||||
return new Date(Date.UTC(jahr, monat - 1, tagImMonat));
|
||||
}
|
||||
|
||||
const VORLAGE = { id: "v1", intervallMonate: 12 };
|
||||
|
||||
function geraet(teil: Partial<PlanungsGeraet> = {}): PlanungsGeraet {
|
||||
return {
|
||||
id: "g1",
|
||||
inventarnummer: "INV-0001",
|
||||
status: "EINSATZBEREIT",
|
||||
hatOffenenTermin: false,
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zuPlanendeTermine", () => {
|
||||
it("rechnet ab der letzten Prüfung nach dieser Vorlage", () => {
|
||||
const geplant = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ letztePruefungZurVorlage: tag(2025, 8, 1) })],
|
||||
{ horizontTage: 180, heute: HEUTE },
|
||||
);
|
||||
|
||||
expect(geplant).toHaveLength(1);
|
||||
expect(geplant[0]!.faelligAm.toISOString()).toBe("2026-08-01T00:00:00.000Z");
|
||||
expect(geplant[0]!.ohneBasisdatum).toBe(false);
|
||||
});
|
||||
|
||||
it("nimmt ersatzweise das Prüfdatum am Gerät, dann die Indienststellung", () => {
|
||||
const ausGeraet = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ letztePruefung: tag(2025, 7, 1), indienststellung: tag(2020, 1, 1) })],
|
||||
{ horizontTage: 180, heute: HEUTE },
|
||||
);
|
||||
expect(ausGeraet[0]!.faelligAm.toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
||||
|
||||
const ausIndienststellung = zuPlanendeTermine(
|
||||
{ id: "v1", intervallMonate: 6 },
|
||||
[geraet({ indienststellung: tag(2026, 2, 1) })],
|
||||
{ horizontTage: 180, heute: HEUTE },
|
||||
);
|
||||
expect(ausIndienststellung[0]!.faelligAm.toISOString()).toBe("2026-08-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("setzt Geräte ohne jedes Datum sofort fällig", () => {
|
||||
const geplant = zuPlanendeTermine(VORLAGE, [geraet()], {
|
||||
horizontTage: 180,
|
||||
heute: HEUTE,
|
||||
});
|
||||
|
||||
expect(geplant).toHaveLength(1);
|
||||
expect(geplant[0]!.ohneBasisdatum).toBe(true);
|
||||
expect(geplant[0]!.faelligAm.toISOString()).toBe("2026-06-15T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("plant nichts für ausgesonderte Geräte", () => {
|
||||
const geplant = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ status: "AUSGESONDERT", letztePruefungZurVorlage: tag(2025, 8, 1) })],
|
||||
{ horizontTage: 180, heute: HEUTE },
|
||||
);
|
||||
|
||||
expect(geplant).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("plant gesperrte Geräte weiter — die sollen ja wieder in Dienst", () => {
|
||||
const geplant = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ status: "GESPERRT", letztePruefungZurVorlage: tag(2025, 8, 1) })],
|
||||
{ horizontTage: 180, heute: HEUTE },
|
||||
);
|
||||
|
||||
expect(geplant).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("legt keinen zweiten Termin an, solange einer offen ist", () => {
|
||||
const geplant = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ hatOffenenTermin: true, letztePruefungZurVorlage: tag(2025, 8, 1) })],
|
||||
{ horizontTage: 180, heute: HEUTE },
|
||||
);
|
||||
|
||||
expect(geplant).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("plant nur innerhalb des Horizonts", () => {
|
||||
const geplant = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ letztePruefungZurVorlage: tag(2026, 5, 1) })],
|
||||
// Fällig am 01.05.2027 — mit 30 Tagen Horizont noch nicht zu planen.
|
||||
{ horizontTage: 30, heute: HEUTE },
|
||||
);
|
||||
|
||||
expect(geplant).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("plant Überfälliges immer, egal wie lange es her ist", () => {
|
||||
const geplant = zuPlanendeTermine(
|
||||
VORLAGE,
|
||||
[geraet({ letztePruefungZurVorlage: tag(2019, 1, 1) })],
|
||||
{ horizontTage: 30, heute: HEUTE },
|
||||
);
|
||||
|
||||
expect(geplant).toHaveLength(1);
|
||||
expect(geplant[0]!.faelligAm.toISOString()).toBe("2020-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fruehesteFaelligkeit", () => {
|
||||
it("nimmt den dringendsten von mehreren Terminen", () => {
|
||||
const frueheste = fruehesteFaelligkeit([
|
||||
{ faelligAm: tag(2026, 9, 1) },
|
||||
{ faelligAm: tag(2026, 7, 1) },
|
||||
{ faelligAm: tag(2027, 1, 1) },
|
||||
]);
|
||||
|
||||
expect(frueheste?.toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("gibt ohne Termine nichts zurück", () => {
|
||||
expect(fruehesteFaelligkeit([])).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user