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
+233 -1
View File
@@ -8,7 +8,10 @@
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
* Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt,
* sechs Übungen und die Beteiligung der Mannschaft.
* sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein
* Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
* beim ersten Start nicht leer ist.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -22,12 +25,14 @@
import "dotenv/config";
import {
Alarmierungsart,
AnwesenheitAnlass,
AusweisTokenTyp,
ChecklistenpunktTyp,
EinsatzFunktion,
EinsatzKategorie,
EinsatzStatus,
Dienstgrad,
FahrtZweck,
FahrzeugArt,
GeraeteKategorie,
GeraetStatus,
@@ -44,6 +49,7 @@ import {
SignaturRolle,
StandortArt,
UebungsArt,
WaescheStatus,
} from "@prisma/client";
import bcrypt from "bcryptjs";
@@ -1949,6 +1955,229 @@ async function uebungenAnlegen(organisationId: string) {
log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`);
}
// -----------------------------------------------------------------------------
// Phase 4 — was am Terminal zusammenkommt
// -----------------------------------------------------------------------------
/**
* Fahrtenbuch, Anwesenheit und Wäsche.
*
* Alles drei entsteht im Betrieb am Wandtablet. Der Seed legt genug davon an,
* dass die Terminalübersicht beim ersten Start etwas zeigt — darunter bewusst
* je ein offener Fall: eine Fahrt, die noch läuft, jemand, der noch im Haus
* ist, und Kleidung, die noch nicht zurück ist. Das sind die Zustände, an
* denen sich die Ansichten beweisen müssen.
*/
async function terminaldatenAnlegen(organisationId: string) {
const vorhanden = await prisma.fahrt.count({ where: { organisationId } });
if (vorhanden > 0) {
log(`Terminal: bereits ${vorhanden} Fahrten vorhanden`);
return;
}
const personen = await prisma.person.findMany({
where: { organisationId, aktiv: true, geloeschtAm: null },
select: { id: true },
orderBy: { mitgliedsnummer: "asc" },
});
const fahrzeuge = await prisma.fahrzeug.findMany({
where: { organisationId, geloeschtAm: null },
select: { id: true, kilometerstand: true },
orderBy: { funkrufname: "asc" },
});
if (personen.length === 0 || fahrzeuge.length === 0) return;
const jetzt = new Date();
function vorStunden(stunden: number): Date {
return new Date(jetzt.getTime() - stunden * 60 * 60 * 1000);
}
// --- Fahrtenbuch ----------------------------------------------------------
const FAHRTEN: Array<{
fahrzeug: number;
fahrer: number;
zweck: FahrtZweck;
ziel: string;
vorStunden: number;
dauer: number;
kilometer: number;
/** Offen heißt: das Fahrzeug ist noch unterwegs. */
offen?: boolean;
tankmenge?: number;
}> = [
{
fahrzeug: 0,
fahrer: 2,
zweck: FahrtZweck.WERKSTATT,
ziel: "Fahrzeugbau Huber, Landshut",
vorStunden: 26 * 24,
dauer: 5,
kilometer: 84,
tankmenge: 42.5,
},
{
fahrzeug: 1,
fahrer: 7,
zweck: FahrtZweck.UEBUNG,
ziel: "Übungsgelände Kiesgrube",
vorStunden: 8 * 24,
dauer: 3,
kilometer: 22,
},
{
fahrzeug: 2,
fahrer: 5,
zweck: FahrtZweck.SONSTIGES,
ziel: "Materialabholung Kreisbrandinspektion",
vorStunden: 3 * 24,
dauer: 2,
kilometer: 47,
},
{
fahrzeug: 0,
fahrer: 0,
zweck: FahrtZweck.AUSBILDUNG,
ziel: "Feuerwehrschule Geretsried",
vorStunden: 2,
dauer: 0,
kilometer: 0,
offen: true,
},
];
// Der Kilometerstand muss über die Fahrten hinweg vorwärts laufen — sonst
// schlägt die Prüfung in der Datenbank zu, und zwar zu Recht. Deshalb werden
// die erledigten Fahrten rückwärts vom heutigen Stand aus gerechnet.
const laufenderStand = new Map(
fahrzeuge.map((fahrzeug) => [fahrzeug.id, fahrzeug.kilometerstand]),
);
for (const vorlage of FAHRTEN) {
const fahrzeug = fahrzeuge[vorlage.fahrzeug % fahrzeuge.length]!;
const fahrer = personen[vorlage.fahrer % personen.length]!;
const abfahrt = vorStunden(vorlage.vorStunden);
const stand = laufenderStand.get(fahrzeug.id) ?? 0;
const abfahrtKm = Math.max(0, stand - vorlage.kilometer);
await prisma.fahrt.create({
data: {
organisationId,
fahrzeugId: fahrzeug.id,
fahrerId: fahrer.id,
zweck: vorlage.zweck,
ziel: vorlage.ziel,
abfahrtUm: abfahrt,
kilometerAbfahrt: vorlage.offen ? stand : abfahrtKm,
...(vorlage.offen
? {}
: {
rueckkehrUm: new Date(abfahrt.getTime() + vorlage.dauer * 60 * 60 * 1000),
kilometerRueckkehr: stand,
tankmenge: vorlage.tankmenge ?? null,
}),
},
});
if (!vorlage.offen) laufenderStand.set(fahrzeug.id, abfahrtKm);
}
// --- Anwesenheit ----------------------------------------------------------
const ANWESENHEITEN: Array<{
person: number;
anlass: AnwesenheitAnlass;
vorStunden: number;
/** null heißt: noch im Haus. */
dauer: number | null;
}> = [
{ person: 0, anlass: AnwesenheitAnlass.UEBUNG, vorStunden: 8 * 24, dauer: 3 },
{ person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 5 * 24, dauer: 4 },
{ person: 4, anlass: AnwesenheitAnlass.DIENST, vorStunden: 5 * 24, dauer: 2 },
{ person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 2, dauer: null },
];
for (const vorlage of ANWESENHEITEN) {
const person = personen[vorlage.person % personen.length]!;
const angemeldet = vorStunden(vorlage.vorStunden);
await prisma.anwesenheit.create({
data: {
organisationId,
personId: person.id,
anlass: vorlage.anlass,
angemeldetUm: angemeldet,
abgemeldetUm:
vorlage.dauer === null
? null
: new Date(angemeldet.getTime() + vorlage.dauer * 60 * 60 * 1000),
},
});
}
// --- Wäsche ---------------------------------------------------------------
const WAESCHE: Array<{
person: number;
bezeichnung: string;
menge: number;
grund: string;
status: WaescheStatus;
vorStunden: number;
}> = [
{
person: 3,
bezeichnung: "Einsatzjacke",
menge: 1,
grund: "Einsatzverschmutzung",
status: WaescheStatus.IN_REINIGUNG,
vorStunden: 4 * 24,
},
{
person: 6,
bezeichnung: "Einsatzhose",
menge: 1,
grund: "Kontamination",
status: WaescheStatus.ABGEGEBEN,
vorStunden: 20,
},
{
person: 1,
bezeichnung: "Flammschutzhaube",
menge: 2,
grund: "Routine",
status: WaescheStatus.ZURUECK,
vorStunden: 30 * 24,
},
];
for (const vorlage of WAESCHE) {
const person = personen[vorlage.person % personen.length]!;
const abgegeben = vorStunden(vorlage.vorStunden);
await prisma.waescheabgabe.create({
data: {
organisationId,
personId: person.id,
bezeichnung: vorlage.bezeichnung,
menge: vorlage.menge,
grund: vorlage.grund,
status: vorlage.status,
abgegebenAm: abgegeben,
zurueckAm:
vorlage.status === WaescheStatus.ZURUECK
? new Date(abgegeben.getTime() + 6 * 24 * 60 * 60 * 1000)
: null,
},
});
}
log(
`Terminal: ${FAHRTEN.length} Fahrten (eine läuft), ${ANWESENHEITEN.length} Anwesenheiten, ${WAESCHE.length} Wäscheabgaben`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -1980,6 +2209,9 @@ async function main(): Promise<void> {
await einsaetzeAnlegen(organisation.id, fahrzeuge);
await uebungenAnlegen(organisation.id);
// Phase 4 — Terminal im Gerätehaus
await terminaldatenAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");