Terminal im Geraetehaus mit Offline-Betrieb ergaenzen

Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 18:35:48 +02:00
co-authored by Claude Opus 5
parent d8d692098d
commit 791523027f
126 changed files with 7787 additions and 40 deletions
+364 -7
View File
@@ -190,6 +190,15 @@ model Organisation {
uebungen Uebung[]
uebungsteilnahmen Uebungsteilnahme[]
// Phase 4 — Terminal im Gerätehaus
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
waescheabgaben Waescheabgabe[]
inventurgaenge Inventurgang[]
inventurposten Inventurposten[]
kioskvorgaenge KioskVorgang[]
syncKonflikte SyncKonflikt[]
@@index([aktiv])
@@map("organisation")
}
@@ -272,6 +281,17 @@ model Person {
geleiteteUebungen Uebung[] @relation("UebungLeitung")
uebungsteilnahmen Uebungsteilnahme[] @relation("UebungTeilnahme")
// Phase 4 — Terminal im Gerätehaus
fahrten Fahrt[] @relation("FahrtFahrer")
anwesenheiten Anwesenheit[] @relation("AnwesenheitPerson")
waescheabgaben Waescheabgabe[] @relation("WaescheTraeger")
angenommeneWaesche Waescheabgabe[] @relation("WaescheAnnahme")
geleiteteInventuren Inventurgang[] @relation("InventurLeitung")
inventurposten Inventurposten[] @relation("InventurErfasser")
kioskvorgaenge KioskVorgang[] @relation("KioskVorgangPerson")
verursachteKonflikte SyncKonflikt[] @relation("KonfliktVerursacher")
erledigteKonflikte SyncKonflikt[] @relation("KonfliktErledigung")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -616,9 +636,10 @@ model Standort {
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fahrzeuge Fahrzeug[]
geraete Geraet[]
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fahrzeuge Fahrzeug[]
geraete Geraet[]
inventurgaenge Inventurgang[]
@@unique([organisationId, name])
@@index([organisationId, aktiv])
@@ -676,6 +697,10 @@ model Fahrzeug {
einsaetze Einsatzfahrzeug[]
einsatzkraefte Einsatzkraft[]
// Phase 4
fahrten Fahrt[]
inventurgaenge Inventurgang[]
@@unique([organisationId, funkrufname])
@@unique([organisationId, kennzeichen])
@@index([organisationId, aktiv])
@@ -813,6 +838,10 @@ model Geraet {
rueckrufBetroffenheiten RueckrufBetroffenheit[]
materialverbrauch Materialverbrauch[]
// Phase 4
waescheabgaben Waescheabgabe[]
inventurposten Inventurposten[]
@@unique([organisationId, inventarnummer])
@@index([organisationId, status])
@@index([organisationId, naechstePruefung])
@@ -1620,6 +1649,8 @@ model Einsatz {
material Materialverbrauch[]
lagemeldungen Lagemeldung[]
portalzugaenge PortalZugang[]
fahrts Fahrt[]
anwesenheits Anwesenheit[]
@@unique([organisationId, einsatznummer])
@@index([organisationId, alarmiertUm(sort: Desc)])
@@ -1822,10 +1853,12 @@ model Uebung {
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
teilnahmen Uebungsteilnahme[]
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
leiter Person? @relation("UebungLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("UebungBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
teilnahmen Uebungsteilnahme[]
fahrten Fahrt[]
anwesenheiten Anwesenheit[]
@@unique([organisationId, nummer])
@@index([organisationId, datum(sort: Desc)])
@@ -1856,3 +1889,327 @@ model Uebungsteilnahme {
@@index([organisationId, personId])
@@map("uebungsteilnahme")
}
// =============================================================================
// Phase 4 — Terminal im Gerätehaus
//
// Was am Wandtablet in der Fahrzeughalle erfasst wird:
//
// Fahrt das Fahrtenbuch — wer ist wann womit wohin gefahren
// Anwesenheit ein- und auschecken zu Dienst, Übung oder Einsatz
// Waescheabgabe Schutzkleidung zur Reinigung geben und zurückbekommen
// Inventurgang Bestand am Regal scannen, Fehlendes finden
// KioskVorgang Idempotenz: was das Terminal offline erfasst hat
// SyncKonflikt was sich beim Nachtragen nicht auflösen ließ
//
// Die beiden letzten sind der Kern des Offline-Betriebs. Ein Tablet in einer
// Halle aus Stahlbeton hat regelmäßig kein Netz; erfasst wird trotzdem, und
// nachgetragen wird, sobald es wieder geht. Damit ein zweimal abgeschickter
// Vorgang nicht zweimal ankommt, trägt jeder eine im Browser erzeugte Kennung.
// =============================================================================
/// Wozu eine Fahrt unternommen wurde.
enum FahrtZweck {
EINSATZ
UEBUNG
AUSBILDUNG
WERKSTATT
BESCHAFFUNG
/// Probefahrt, Überführung, Fahrsicherheitstraining.
SONSTIGES
}
/// Weg der Schutzkleidung durch die Reinigung.
enum WaescheStatus {
ABGEGEBEN
IN_REINIGUNG
ZURUECK
/// Nicht wieder aufgetaucht — muss ersetzt werden.
VERSCHOLLEN
}
/// Wozu jemand am Terminal eincheckt.
enum AnwesenheitAnlass {
DIENST
UEBUNG
EINSATZ
ARBEITSDIENST
SONSTIGES
}
/// Stand eines Inventurgangs.
enum InventurStatus {
LAEUFT
ABGESCHLOSSEN
ABGEBROCHEN
}
/// Was ein offline erfasster Vorgang bewirken soll.
enum KioskVorgangArt {
MANGEL_MELDEN
FAHRT_BEGINNEN
FAHRT_BEENDEN
ANWESENHEIT_KOMMEN
ANWESENHEIT_GEHEN
WAESCHE_ABGEBEN
INVENTUR_SCANNEN
PRUEFUNG_ERFASSEN
}
/// Warum sich ein nachgetragener Vorgang nicht anwenden ließ.
enum KonfliktGrund {
/// Der Datensatz, auf den er sich bezieht, gibt es nicht mehr.
ZIEL_FEHLT
/// Der Datensatz wurde zwischenzeitlich festgeschrieben.
ZIEL_GESPERRT
/// Zwei Terminals haben dasselbe erfasst.
DOPPELT
/// Die Angaben passen nicht mehr zum Stand auf dem Server.
VERALTET
SONSTIGES
}
// -----------------------------------------------------------------------------
/// Eine Fahrt im Fahrtenbuch.
///
/// Nach § 6 StVO-Nachweispflicht und nach der Dienstanweisung der meisten
/// Wehren muss nachvollziehbar sein, wer wann mit welchem Fahrzeug unterwegs
/// war. Am Terminal ist das ein Vorgang von zwanzig Sekunden: Fahrzeug wählen,
/// Kilometerstand eintippen, Zweck antippen.
model Fahrt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fahrzeugId String @db.Uuid
fahrerId String @db.Uuid
zweck FahrtZweck @default(SONSTIGES)
/// Wenn die Fahrt zu einem Einsatz oder einer Übung gehört.
einsatzId String? @db.Uuid
uebungId String? @db.Uuid
abfahrtUm DateTime @default(now()) @db.Timestamptz(3)
rueckkehrUm DateTime? @db.Timestamptz(3)
kilometerAbfahrt Int
kilometerRueckkehr Int?
ziel String? @db.VarChar(200)
/// Getankte Menge in Litern, wenn unterwegs getankt wurde.
tankmenge Decimal? @db.Decimal(8, 2)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Restrict)
fahrer Person @relation("FahrtFahrer", fields: [fahrerId], references: [id], onDelete: Restrict)
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull)
@@index([organisationId, fahrzeugId, abfahrtUm(sort: Desc)])
@@index([organisationId, fahrerId])
@@map("fahrt")
}
/// Anwesenheit am Gerätehaus.
///
/// Wer eincheckt, ist da — für die Übungsteilnahme, für den Arbeitsdienst und
/// im Einsatzfall dafür, dass die Einsatzleitung weiß, wer im Haus ist.
model Anwesenheit {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
anlass AnwesenheitAnlass @default(DIENST)
angemeldetUm DateTime @default(now()) @db.Timestamptz(3)
abgemeldetUm DateTime? @db.Timestamptz(3)
einsatzId String? @db.Uuid
uebungId String? @db.Uuid
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("AnwesenheitPerson", fields: [personId], references: [id], onDelete: Cascade)
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
uebung Uebung? @relation(fields: [uebungId], references: [id], onDelete: SetNull)
@@index([organisationId, personId, angemeldetUm(sort: Desc)])
@@index([organisationId, abgemeldetUm])
@@map("anwesenheit")
}
/// Schutzkleidung in der Reinigung.
///
/// Wer seine Einsatzjacke abgibt, steht ohne da — deshalb hängt an der Abgabe
/// auch die Frage, ob Ersatz vorhanden ist. Erfasst wird am Terminal beim
/// Einwurf in den Wäschekorb.
model Waescheabgabe {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
/// Wenn das Kleidungsstück als Gerät erfasst ist.
geraetId String? @db.Uuid
bezeichnung String @db.VarChar(200)
menge Int @default(1)
status WaescheStatus @default(ABGEGEBEN)
/// Grund: Einsatzverschmutzung, Kontamination, Routine.
grund String? @db.VarChar(200)
abgegebenAm DateTime @default(now()) @db.Timestamptz(3)
zurueckAm DateTime? @db.Timestamptz(3)
/// Wer die Rückgabe bestätigt hat.
angenommenVonId String? @db.Uuid
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("WaescheTraeger", fields: [personId], references: [id], onDelete: Cascade)
geraet Geraet? @relation(fields: [geraetId], references: [id], onDelete: SetNull)
angenommenVon Person? @relation("WaescheAnnahme", fields: [angenommenVonId], references: [id], onDelete: SetNull)
@@index([organisationId, status])
@@index([organisationId, personId])
@@map("waescheabgabe")
}
/// Ein Inventurgang.
///
/// Der Gerätewart geht mit dem Tablet durch die Halle und scannt, was da ist.
/// Am Ende steht die Liste dessen, was fehlt — das ist der Zweck der Sache.
model Inventurgang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(160)
status InventurStatus @default(LAEUFT)
/// Worauf sich der Gang bezieht — ein Standort oder ein Fahrzeug.
standortId String? @db.Uuid
fahrzeugId String? @db.Uuid
begonnenAm DateTime @default(now()) @db.Timestamptz(3)
abgeschlossenAm DateTime? @db.Timestamptz(3)
begonnenVonId String? @db.Uuid
bemerkung String? @db.VarChar(1000)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
begonnenVon Person? @relation("InventurLeitung", fields: [begonnenVonId], references: [id], onDelete: SetNull)
posten Inventurposten[]
@@index([organisationId, status])
@@map("inventurgang")
}
/// Ein gescanntes Gerät innerhalb eines Inventurgangs.
model Inventurposten {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
inventurId String @db.Uuid
geraetId String @db.Uuid
gescanntAm DateTime @default(now()) @db.Timestamptz(3)
/// Am erwarteten Ort vorgefunden.
gefunden Boolean @default(true)
/// Wo es tatsächlich lag, wenn es nicht dort war, wo es sein sollte.
vorgefundenAn String? @db.VarChar(200)
bemerkung String? @db.VarChar(500)
erfasstVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
inventur Inventurgang @relation(fields: [inventurId], references: [id], onDelete: Cascade)
geraet Geraet @relation(fields: [geraetId], references: [id], onDelete: Cascade)
erfasstVon Person? @relation("InventurErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
@@unique([inventurId, geraetId])
@@index([organisationId, inventurId])
@@map("inventurposten")
}
/// Ein vom Terminal übermittelter Vorgang.
///
/// Der Schlüssel zum Offline-Betrieb: die Kennung erzeugt der Browser, bevor
/// er den Vorgang in seine Warteschlange legt. Kommt derselbe Vorgang nach
/// einem abgebrochenen Sendeversuch ein zweites Mal an, erkennt der Server ihn
/// an dieser Kennung wieder und antwortet mit dem Ergebnis von damals, statt
/// alles noch einmal anzulegen.
///
/// Deshalb ist die Kennung der Primärschlüssel und wird nicht vergeben,
/// sondern angenommen.
model KioskVorgang {
/// Vom Browser erzeugt (crypto.randomUUID) — nicht vom Server.
id String @id @db.Uuid
organisationId String @db.Uuid
art KioskVorgangArt
personId String? @db.Uuid
/// Wann der Vorgang am Terminal erfasst wurde — kann deutlich vor dem
/// Eingang liegen.
erfasstAm DateTime @db.Timestamptz(3)
empfangenAm DateTime @default(now()) @db.Timestamptz(3)
/// Was der Vorgang bewirkt hat: die Kennung des angelegten Datensatzes.
ergebnisId String? @db.Uuid
/// Die Nutzlast, wie sie das Terminal geschickt hat — für die Fehlersuche
/// und für den Fall, dass ein Vorgang von Hand nachgezogen werden muss.
nutzlast Json? @db.JsonB
erfolgreich Boolean @default(true)
meldung String? @db.VarChar(500)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person? @relation("KioskVorgangPerson", fields: [personId], references: [id], onDelete: SetNull)
@@index([organisationId, empfangenAm(sort: Desc)])
@@index([organisationId, art])
@@map("kiosk_vorgang")
}
/// Ein Vorgang, der sich beim Nachtragen nicht anwenden ließ.
///
/// Die Regel dahinter: bei Stammdaten gewinnt der Server, bei neuen
/// Datensätzen der Client. Was in keine der beiden Schubladen passt — ein
/// Mangel zu einem inzwischen ausgesonderten Gerät, eine Prüfung zu einem
/// festgeschriebenen Protokoll — landet hier und wartet auf einen Menschen.
model SyncKonflikt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Die Kennung des Vorgangs, der nicht durchging.
vorgangId String @db.Uuid
art KioskVorgangArt
grund KonfliktGrund @default(SONSTIGES)
beschreibung String @db.VarChar(500)
nutzlast Json? @db.JsonB
personId String? @db.Uuid
entstandenAm DateTime @default(now()) @db.Timestamptz(3)
erledigtAm DateTime? @db.Timestamptz(3)
erledigtVonId String? @db.Uuid
erledigung String? @db.VarChar(500)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person? @relation("KonfliktVerursacher", fields: [personId], references: [id], onDelete: SetNull)
erledigtVon Person? @relation("KonfliktErledigung", fields: [erledigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, erledigtAm])
@@map("sync_konflikt")
}