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
@@ -0,0 +1,269 @@
-- =============================================================================
-- Phase 4: Tabellen für das Terminal im Gerätehaus und den Offline-Betrieb.
--
-- Erzeugt mit `prisma migrate dev --create-only` und wie in den Phasen davor
-- um die DROP INDEX-Zeilen bereinigt: Prisma kennt die Trigram- und
-- Geoindizes nicht und würde sie sonst bei jeder Migration löschen wollen.
--
-- Mandantentrennung, die Sperre gegen doppelte Vorgänge und die Indizes für
-- den Terminalbetrieb stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "FahrtZweck" AS ENUM ('EINSATZ', 'UEBUNG', 'AUSBILDUNG', 'WERKSTATT', 'BESCHAFFUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "WaescheStatus" AS ENUM ('ABGEGEBEN', 'IN_REINIGUNG', 'ZURUECK', 'VERSCHOLLEN');
-- CreateEnum
CREATE TYPE "AnwesenheitAnlass" AS ENUM ('DIENST', 'UEBUNG', 'EINSATZ', 'ARBEITSDIENST', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "InventurStatus" AS ENUM ('LAEUFT', 'ABGESCHLOSSEN', 'ABGEBROCHEN');
-- CreateEnum
CREATE TYPE "KioskVorgangArt" AS ENUM ('MANGEL_MELDEN', 'FAHRT_BEGINNEN', 'FAHRT_BEENDEN', 'ANWESENHEIT_KOMMEN', 'ANWESENHEIT_GEHEN', 'WAESCHE_ABGEBEN', 'INVENTUR_SCANNEN', 'PRUEFUNG_ERFASSEN');
-- CreateEnum
CREATE TYPE "KonfliktGrund" AS ENUM ('ZIEL_FEHLT', 'ZIEL_GESPERRT', 'DOPPELT', 'VERALTET', 'SONSTIGES');
-- CreateTable
CREATE TABLE "fahrt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"fahrzeugId" UUID NOT NULL,
"fahrerId" UUID NOT NULL,
"zweck" "FahrtZweck" NOT NULL DEFAULT 'SONSTIGES',
"einsatzId" UUID,
"uebungId" UUID,
"abfahrtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"rueckkehrUm" TIMESTAMPTZ(3),
"kilometerAbfahrt" INTEGER NOT NULL,
"kilometerRueckkehr" INTEGER,
"ziel" VARCHAR(200),
"tankmenge" DECIMAL(8,2),
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "fahrt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "anwesenheit" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"anlass" "AnwesenheitAnlass" NOT NULL DEFAULT 'DIENST',
"angemeldetUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"abgemeldetUm" TIMESTAMPTZ(3),
"einsatzId" UUID,
"uebungId" UUID,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "anwesenheit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "waescheabgabe" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"geraetId" UUID,
"bezeichnung" VARCHAR(200) NOT NULL,
"menge" INTEGER NOT NULL DEFAULT 1,
"status" "WaescheStatus" NOT NULL DEFAULT 'ABGEGEBEN',
"grund" VARCHAR(200),
"abgegebenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zurueckAm" TIMESTAMPTZ(3),
"angenommenVonId" UUID,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "waescheabgabe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventurgang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"status" "InventurStatus" NOT NULL DEFAULT 'LAEUFT',
"standortId" UUID,
"fahrzeugId" UUID,
"begonnenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"abgeschlossenAm" TIMESTAMPTZ(3),
"begonnenVonId" UUID,
"bemerkung" VARCHAR(1000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "inventurgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventurposten" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"inventurId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"gescanntAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gefunden" BOOLEAN NOT NULL DEFAULT true,
"vorgefundenAn" VARCHAR(200),
"bemerkung" VARCHAR(500),
"erfasstVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "inventurposten_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "kiosk_vorgang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"art" "KioskVorgangArt" NOT NULL,
"personId" UUID,
"erfasstAm" TIMESTAMPTZ(3) NOT NULL,
"empfangenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ergebnisId" UUID,
"nutzlast" JSONB,
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
"meldung" VARCHAR(500),
CONSTRAINT "kiosk_vorgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sync_konflikt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorgangId" UUID NOT NULL,
"art" "KioskVorgangArt" NOT NULL,
"grund" "KonfliktGrund" NOT NULL DEFAULT 'SONSTIGES',
"beschreibung" VARCHAR(500) NOT NULL,
"nutzlast" JSONB,
"personId" UUID,
"entstandenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erledigtAm" TIMESTAMPTZ(3),
"erledigtVonId" UUID,
"erledigung" VARCHAR(500),
CONSTRAINT "sync_konflikt_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "fahrt_organisationId_fahrzeugId_abfahrtUm_idx" ON "fahrt"("organisationId", "fahrzeugId", "abfahrtUm" DESC);
-- CreateIndex
CREATE INDEX "fahrt_organisationId_fahrerId_idx" ON "fahrt"("organisationId", "fahrerId");
-- CreateIndex
CREATE INDEX "anwesenheit_organisationId_personId_angemeldetUm_idx" ON "anwesenheit"("organisationId", "personId", "angemeldetUm" DESC);
-- CreateIndex
CREATE INDEX "anwesenheit_organisationId_abgemeldetUm_idx" ON "anwesenheit"("organisationId", "abgemeldetUm");
-- CreateIndex
CREATE INDEX "waescheabgabe_organisationId_status_idx" ON "waescheabgabe"("organisationId", "status");
-- CreateIndex
CREATE INDEX "waescheabgabe_organisationId_personId_idx" ON "waescheabgabe"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "inventurgang_organisationId_status_idx" ON "inventurgang"("organisationId", "status");
-- CreateIndex
CREATE INDEX "inventurposten_organisationId_inventurId_idx" ON "inventurposten"("organisationId", "inventurId");
-- CreateIndex
CREATE UNIQUE INDEX "inventurposten_inventurId_geraetId_key" ON "inventurposten"("inventurId", "geraetId");
-- CreateIndex
CREATE INDEX "kiosk_vorgang_organisationId_empfangenAm_idx" ON "kiosk_vorgang"("organisationId", "empfangenAm" DESC);
-- CreateIndex
CREATE INDEX "kiosk_vorgang_organisationId_art_idx" ON "kiosk_vorgang"("organisationId", "art");
-- CreateIndex
CREATE INDEX "sync_konflikt_organisationId_erledigtAm_idx" ON "sync_konflikt"("organisationId", "erledigtAm");
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrerId_fkey" FOREIGN KEY ("fahrerId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_angenommenVonId_fkey" FOREIGN KEY ("angenommenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_begonnenVonId_fkey" FOREIGN KEY ("begonnenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_inventurId_fkey" FOREIGN KEY ("inventurId") REFERENCES "inventurgang"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_erledigtVonId_fkey" FOREIGN KEY ("erledigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,110 @@
-- =============================================================================
-- Phase 4: Mandantentrennung und Regeln für den Terminalbetrieb.
--
-- Drei Dinge:
--
-- 1. Row Level Security für die sieben neuen Tabellen.
--
-- 2. Regeln, die verhindern, dass ein offline erfasster Vorgang beim
-- Nachtragen Unsinn anrichtet: kein Kilometerstand, der rückwärts läuft,
-- keine Rückkehr vor der Abfahrt, keine Anwesenheit, die vor dem Kommen
-- endet.
--
-- 3. Indizes für die Abfragen, die das Terminal und der Hallenmonitor
-- dauernd stellen: wer ist gerade da, welches Fahrzeug ist unterwegs.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'fahrt',
'anwesenheit',
'waescheabgabe',
'inventurgang',
'inventurposten',
'kiosk_vorgang',
'sync_konflikt'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Was am Terminal nicht passieren darf
--
-- Diese Bedingungen fangen ab, was beim Eintippen auf einem Tablet mit
-- Handschuhen tatsächlich passiert: eine vertippte Ziffer im Kilometerstand,
-- ein Rückkehrzeitpunkt vor der Abfahrt. Im Gerätehaus schaut niemand ein
-- zweites Mal hin, und offline erfasste Vorgänge kommen unter Umständen erst
-- Stunden später an.
-- -----------------------------------------------------------------------------
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_kilometer_vorwaerts" CHECK (
"kilometerRueckkehr" IS NULL OR "kilometerRueckkehr" >= "kilometerAbfahrt"
);
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_rueckkehr_nach_abfahrt" CHECK (
"rueckkehrUm" IS NULL OR "rueckkehrUm" >= "abfahrtUm"
);
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_gehen_nach_kommen" CHECK (
"abgemeldetUm" IS NULL OR "abgemeldetUm" >= "angemeldetUm"
);
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_menge_positiv" CHECK (
"menge" > 0
);
-- -----------------------------------------------------------------------------
-- Indizes für den laufenden Betrieb
--
-- Der Hallenmonitor fragt im Sekundentakt dasselbe: wer ist da, welches
-- Fahrzeug ist draußen. Beides sind Teilmengen, die klein bleiben, während
-- die Tabellen über Jahre wachsen — also Teilindizes.
-- -----------------------------------------------------------------------------
CREATE INDEX "anwesenheit_anwesend" ON "anwesenheit" ("organisationId", "angemeldetUm" DESC)
WHERE "abgemeldetUm" IS NULL;
CREATE INDEX "fahrt_unterwegs" ON "fahrt" ("organisationId", "fahrzeugId", "abfahrtUm" DESC)
WHERE "rueckkehrUm" IS NULL;
CREATE INDEX "waesche_offen" ON "waescheabgabe" ("organisationId", "personId")
WHERE "status" <> 'ZURUECK';
CREATE INDEX "sync_konflikt_offen" ON "sync_konflikt" ("organisationId", "entstandenAm" DESC)
WHERE "erledigtAm" IS NULL;
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
-- Ein empfangener Terminalvorgang wird nie geaendert oder geloescht:
-- er ist der Beleg dafuer, dass etwas schon einmal angekommen ist.
-- Ohne diese Sperre koennte ein zweiter Sendeversuch den ersten
-- ueberschreiben und derselbe Vorgang doch zweimal wirken.
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
+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")
}
+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:");