Atemschutzueberwachung nach FwDV 7 ergaenzen

Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht
steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt —
abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten.

Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein
Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus.
Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine
Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht
verlieren laesst. Server und Browser rechnen mit denselben Funktionen.

Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein
gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das
Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist
damit die vorsichtigere Annahme.

Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf
druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen
Eintrag richtiggestellt, nicht durch Aendern.

Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine
Richtung, und so bleibt das Deployment bei "next start".

Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette
weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille
von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag
"Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1.

68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 23:19:05 +02:00
co-authored by Claude Opus 5
parent b2b31ed00a
commit 4e2daaafd4
107 changed files with 7214 additions and 40 deletions
+305 -1
View File
@@ -11,7 +11,9 @@
* 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.
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
* Überwachung mit vollständigem Protokoll.
*
* 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
@@ -32,8 +34,11 @@ import {
EinsatzKategorie,
EinsatzStatus,
Dienstgrad,
DruckArt,
EreignisArt,
FahrtZweck,
FahrzeugArt,
G26Ergebnis,
GeraeteKategorie,
GeraetStatus,
LebensdauerBezug,
@@ -48,7 +53,9 @@ import {
RolleSchluessel,
SignaturRolle,
StandortArt,
TruppStatus,
UebungsArt,
UebungsartAtemschutz,
WaescheStatus,
} from "@prisma/client";
import bcrypt from "bcryptjs";
@@ -2178,6 +2185,300 @@ async function terminaldatenAnlegen(organisationId: string) {
);
}
// -----------------------------------------------------------------------------
// Phase 5 — Atemschutz nach FwDV 7
// -----------------------------------------------------------------------------
/**
* Atemschutz: Nachweise und eine abgeschlossene Überwachung.
*
* Die Nachweise sind bewusst nicht bei allen vollständig — genau so sieht es
* in einer Wehr aus. Einer hat eine abgelaufene G26, einem fehlt die
* Belastungsübung, einer ist bald fällig. Die Tauglichkeitsampel zeigt
* dadurch beim ersten Start alle drei Zustände, statt nur Grün.
*
* Dazu ein vollständig durchgespielter Einsatz: zwei Trupps, Eingangsdrücke,
* Zielmeldung, Zwischenmeldungen, Rückweg, Ausgangsdrücke — das ist die
* Vorlage, an der sich das Protokoll ansehen lässt, ohne selbst einen Einsatz
* zu fahren.
*/
async function atemschutzAnlegen(organisationId: string) {
const vorhanden = await prisma.atemschutzeinsatz.count({ where: { organisationId } });
if (vorhanden > 0) {
log(`Atemschutz: bereits ${vorhanden} Überwachungen vorhanden`);
return;
}
const personen = await prisma.person.findMany({
where: { organisationId, aktiv: true, geloeschtAm: null },
select: { id: true, vorname: true, nachname: true },
orderBy: { mitgliedsnummer: "asc" },
});
if (personen.length < 6) return;
const jetzt = new Date();
function vorTagen(tage: number): Date {
const datum = new Date(jetzt);
datum.setUTCDate(datum.getUTCDate() - tage);
datum.setUTCHours(0, 0, 0, 0);
return datum;
}
function inTagen(tage: number): Date {
return vorTagen(-tage);
}
// --- G26.3 ----------------------------------------------------------------
//
// Die ersten acht sind Atemschutzgeräteträger. Drei Zustände absichtlich:
// gültig, läuft bald ab, abgelaufen.
const G26: Array<{ person: number; untersuchtVorTagen: number; gueltigInTagen: number; ergebnis: G26Ergebnis }> = [
{ person: 0, untersuchtVorTagen: 400, gueltigInTagen: 690, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 2, untersuchtVorTagen: 200, gueltigInTagen: 890, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 3, untersuchtVorTagen: 120, gueltigInTagen: 975, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 5, untersuchtVorTagen: 90, gueltigInTagen: 1005, ergebnis: G26Ergebnis.TAUGLICH },
{ person: 7, untersuchtVorTagen: 30, gueltigInTagen: 1065, ergebnis: G26Ergebnis.TAUGLICH },
// Läuft in gut zwei Monaten ab — die Ampel steht auf Gelb.
{ person: 6, untersuchtVorTagen: 1030, gueltigInTagen: 65, ergebnis: G26Ergebnis.TAUGLICH },
// Abgelaufen: die Person ist nicht einsetzbar, und das soll man sehen.
{ person: 8, untersuchtVorTagen: 1200, gueltigInTagen: -105, ergebnis: G26Ergebnis.TAUGLICH },
// Befristet tauglich — zählt, aber der Arzt hat etwas dazugeschrieben.
{ person: 1, untersuchtVorTagen: 250, gueltigInTagen: 480, ergebnis: G26Ergebnis.BEFRISTET_TAUGLICH },
];
for (const eintrag of G26) {
const person = personen[eintrag.person % personen.length]!;
await prisma.g26Untersuchung.create({
data: {
organisationId,
personId: person.id,
untersuchtAm: vorTagen(eintrag.untersuchtVorTagen),
gueltigBis: inTagen(eintrag.gueltigInTagen),
ergebnis: eintrag.ergebnis,
arzt: "Betriebsarzt Dr. Wagner, Landshut",
bemerkung:
eintrag.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH
? "Nachuntersuchung des Blutdrucks in einem Jahr empfohlen."
: null,
},
});
}
// --- Belastungs- und Einsatzübungen ---------------------------------------
//
// Person 5 fehlt bewusst in der Belastungsübung: Tauglichkeit ist nicht
// allein eine Frage der G26.
const UEBUNGSTEILNEHMER = [0, 1, 2, 3, 6, 7];
await prisma.belastungsuebung.createMany({
data: UEBUNGSTEILNEHMER.map((nummer) => ({
organisationId,
personId: personen[nummer % personen.length]!.id,
datum: vorTagen(120),
art: UebungsartAtemschutz.UEBUNGSSTRECKE,
ort: "Atemschutzübungsstrecke Landshut",
dauerMinuten: 45,
})),
});
await prisma.belastungsuebung.createMany({
data: [0, 1, 2, 3, 5, 7].map((nummer) => ({
organisationId,
personId: personen[nummer % personen.length]!.id,
datum: vorTagen(60),
art: UebungsartAtemschutz.EINSATZUEBUNG,
ort: "Übungsobjekt ehemalige Schreinerei",
dauerMinuten: 90,
})),
});
// --- Eine abgeschlossene Überwachung --------------------------------------
const beginn = new Date(jetzt.getTime() - 26 * 24 * 60 * 60 * 1000);
beginn.setUTCHours(14, 12, 0, 0);
function nach(minuten: number): Date {
return new Date(beginn.getTime() + minuten * 60 * 1000);
}
const einsatz = await prisma.einsatz.findFirst({
where: { organisationId, geloeschtAm: null },
orderBy: { alarmiertUm: "desc" },
select: { id: true },
});
const ueberwacher = personen[1]!;
const ueberwachung = await prisma.atemschutzeinsatz.create({
data: {
organisationId,
bezeichnung: "Zimmerbrand Hauptstraße 12",
einsatzId: einsatz?.id ?? null,
ueberwacherId: ueberwacher.id,
beginnUm: beginn,
endeUm: nach(38),
abfrageIntervall: 600,
sicherheitszuschlag: 10,
warndruck: 55,
bemerkung: "Regulärer Verlauf, keine besonderen Vorkommnisse.",
},
});
/** Legt einen Trupp samt Messungen und Protokollzeilen an. */
async function truppAnlegen(angaben: {
funkrufname: string;
auftrag: string;
fuehrer: number;
mann: number;
eingang: [number, number];
ziel: [number, number] | null;
zwischen: Array<[number, number, number]>;
ausgang: [number, number];
einstiegMinute: number;
rueckwegMinute: number;
draussenMinute: number;
rueckzugsdruck: number;
}) {
const fuehrer = personen[angaben.fuehrer % personen.length]!;
const mann = personen[angaben.mann % personen.length]!;
const trupp = await prisma.atemschutztrupp.create({
data: {
organisationId,
atemschutzeinsatzId: ueberwachung.id,
funkrufname: angaben.funkrufname,
auftrag: angaben.auftrag,
status: TruppStatus.DRAUSSEN,
angelegtUm: nach(angaben.einstiegMinute - 3),
einstiegUm: nach(angaben.einstiegMinute),
rueckzugUm: nach(angaben.rueckwegMinute),
drausenUm: nach(angaben.draussenMinute),
rueckzugsdruck: angaben.rueckzugsdruck,
mitglieder: {
create: [
{ organisationId, personId: fuehrer.id, truppfuehrer: true },
{ organisationId, personId: mann.id, truppfuehrer: false },
],
},
},
});
const messungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }> = [
{ personId: fuehrer.id, druck: angaben.eingang[0], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) },
{ personId: mann.id, druck: angaben.eingang[1], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) },
];
if (angaben.ziel) {
messungen.push(
{ personId: fuehrer.id, druck: angaben.ziel[0], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) },
{ personId: mann.id, druck: angaben.ziel[1], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) },
);
}
for (const [minute, fuehrerDruck, mannDruck] of angaben.zwischen) {
messungen.push(
{ personId: fuehrer.id, druck: fuehrerDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) },
{ personId: mann.id, druck: mannDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) },
);
}
messungen.push(
{ personId: fuehrer.id, druck: angaben.ausgang[0], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) },
{ personId: mann.id, druck: angaben.ausgang[1], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) },
);
await prisma.druckmessung.createMany({
data: messungen.map((messung) => ({
organisationId,
truppId: trupp.id,
personId: messung.personId,
druck: messung.druck,
art: messung.art,
zeitpunkt: messung.zeitpunkt,
erfasstVonId: ueberwacher.id,
})),
});
const ereignisse: Array<{ art: EreignisArt; text: string; minute: number }> = [
{ art: EreignisArt.TRUPP_ANGELEGT, text: `Trupp ${angaben.funkrufname} angelegt, Rückzugsdruck ${angaben.rueckzugsdruck} bar.`, minute: angaben.einstiegMinute - 3 },
{ art: EreignisArt.EINSTIEG, text: `Trupp ${angaben.funkrufname} unter Atemschutz eingestiegen.`, minute: angaben.einstiegMinute },
{ art: EreignisArt.RUECKZUG_ANGETRETEN, text: `Trupp ${angaben.funkrufname} tritt den Rückweg an.`, minute: angaben.rueckwegMinute },
{ art: EreignisArt.HERAUSGEKOMMEN, text: `Trupp ${angaben.funkrufname} ist draußen.`, minute: angaben.draussenMinute },
];
await prisma.atemschutzEreignis.createMany({
data: ereignisse.map((ereignis) => ({
organisationId,
atemschutzeinsatzId: ueberwachung.id,
truppId: trupp.id,
art: ereignis.art,
text: ereignis.text,
zeitpunkt: nach(ereignis.minute),
personId: ueberwacher.id,
})),
});
}
await truppAnlegen({
funkrufname: "Angriffstrupp",
auftrag: "Menschenrettung 1. OG, Brandbekämpfung",
fuehrer: 0,
mann: 7,
eingang: [300, 290],
ziel: [255, 240],
zwischen: [
[16, 205, 185],
[26, 165, 140],
],
ausgang: [120, 95],
einstiegMinute: 6,
rueckwegMinute: 27,
draussenMinute: 32,
rueckzugsdruck: 165,
});
await truppAnlegen({
funkrufname: "Sicherheitstrupp",
auftrag: "Bereitstellung, auf Abruf",
fuehrer: 2,
mann: 3,
eingang: [295, 300],
ziel: null,
zwischen: [[22, 270, 275]],
ausgang: [250, 258],
einstiegMinute: 14,
rueckwegMinute: 28,
draussenMinute: 30,
rueckzugsdruck: 207,
});
await prisma.atemschutzEreignis.createMany({
data: [
{
organisationId,
atemschutzeinsatzId: ueberwachung.id,
art: EreignisArt.HINWEIS,
text: `Überwachung eröffnet durch ${ueberwacher.vorname} ${ueberwacher.nachname}.`,
zeitpunkt: beginn,
personId: ueberwacher.id,
},
{
organisationId,
atemschutzeinsatzId: ueberwachung.id,
art: EreignisArt.HINWEIS,
text: "Überwachung beendet. Alle Trupps zurück, keine besonderen Vorkommnisse.",
zeitpunkt: nach(38),
personId: ueberwacher.id,
},
],
});
log(
`Atemschutz: ${G26.length} G26-Untersuchungen, ${UEBUNGSTEILNEHMER.length + 6} Übungsnachweise, 1 abgeschlossene Überwachung mit 2 Trupps`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -2212,6 +2513,9 @@ async function main(): Promise<void> {
// Phase 4 — Terminal im Gerätehaus
await terminaldatenAnlegen(organisation.id);
// Phase 5 — Atemschutz nach FwDV 7
await atemschutzAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");