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:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
@@ -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" } });
});
});
});
+210
View File
@@ -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();
});
});
+150
View File
@@ -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();
});
});
+140
View File
@@ -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();
});
});