Files
FireAdministration/src/lib/atemschutz/tauglichkeit.ts
T
Florian WildeandClaude Opus 5 4e2daaafd4 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>
2026-09-03 23:19:05 +02:00

183 lines
6.2 KiB
TypeScript

import { G26Ergebnis, UebungsartAtemschutz } from "@prisma/client";
/**
* Wer darf unter Atemschutz?
*
* FwDV 7 verlangt dreierlei, und alle drei müssen gleichzeitig gelten:
*
* 1. **Gültige G26.3.** Die Untersuchung beim Arbeitsmediziner. Ohne sie
* geht gar nichts, auch nicht „einmalig ausnahmsweise".
* 2. **Belastungsübung im laufenden Jahr.** Übungsstrecke oder eine
* gleichwertige Belastung.
* 3. **Einsatzübung unter Atemschutz im laufenden Jahr.** Ein Einsatz unter
* PA zählt dafür ebenso — wer im Ernstfall drin war, hat mehr geleistet
* als in jeder Übung.
*
* Dazu die Qualifikation als Atemschutzgeräteträger, die aus Phase 1 kommt.
*
* Diese Datei rechnet nur. Sie entscheidet nicht, ob jemand eingesetzt wird —
* das tut der Einheitsführer, und das Ergebnis hier ist der Hinweis darauf,
* was ihm dabei bekannt sein sollte.
*/
/** Wie lange vor Ablauf gewarnt wird. Drei Monate reichen für einen Arzttermin. */
export const VORWARNUNG_TAGE = 90;
export type Tauglichkeitsstufe = "tauglich" | "laeuft_ab" | "nicht_tauglich";
export const STUFE_TEXT: Record<Tauglichkeitsstufe, string> = {
tauglich: "einsetzbar",
laeuft_ab: "einsetzbar, läuft bald ab",
nicht_tauglich: "nicht einsetzbar",
};
export interface Tauglichkeit {
stufe: Tauglichkeitsstufe;
/** Klartext, was fehlt — die Farbe allein sagt niemandem, was zu tun ist. */
gruende: string[];
g26GueltigBis: Date | null;
belastungsuebungAm: Date | null;
einsatzuebungAm: Date | null;
}
function tageBis(ziel: Date, jetzt: Date): number {
return Math.floor((ziel.getTime() - jetzt.getTime()) / 86_400_000);
}
/** Fällt ein Datum in dasselbe Kalenderjahr wie der Stichtag? */
function imSelbenJahr(datum: Date, jetzt: Date): boolean {
return datum.getUTCFullYear() === jetzt.getUTCFullYear();
}
/**
* Bewertet eine Person.
*
* Bewusst ohne Datenbankzugriff: die Aufrufer holen die Daten so, wie es an
* ihrer Stelle günstig ist — einzeln für eine Person, gebündelt für die
* Übersicht der ganzen Mannschaft.
*/
export function tauglichkeit(angaben: {
/** Untersuchungen der Person, beliebige Reihenfolge. */
untersuchungen: Array<{ gueltigBis: Date; ergebnis: G26Ergebnis }>;
/** Übungen und Unterweisungen, beliebige Reihenfolge. */
uebungen: Array<{ datum: Date; art: UebungsartAtemschutz }>;
/** Einsätze unter Atemschutz im laufenden Jahr — sie ersetzen die Einsatzübung. */
einsaetzeUnterPa: Date[];
/** Ist die Person überhaupt als Atemschutzgeräteträger qualifiziert? */
istAtemschutzgeraetetraeger: boolean;
jetzt: Date;
}): Tauglichkeit {
const { untersuchungen, uebungen, einsaetzeUnterPa, jetzt } = angaben;
const gruende: string[] = [];
// --- G26.3 ---------------------------------------------------------------
const brauchbar = untersuchungen.filter(
(untersuchung) =>
untersuchung.ergebnis === G26Ergebnis.TAUGLICH ||
untersuchung.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH,
);
const neueste = brauchbar.reduce<Date | null>(
(bisher, untersuchung) =>
!bisher || untersuchung.gueltigBis.getTime() > bisher.getTime()
? untersuchung.gueltigBis
: bisher,
null,
);
const abgelehnt = untersuchungen.some(
(untersuchung) => untersuchung.ergebnis === G26Ergebnis.NICHT_TAUGLICH,
);
let g26Laeuft = false;
if (!neueste) {
gruende.push(
abgelehnt ? "G26.3 ergab „nicht tauglich“" : "Keine G26.3-Untersuchung hinterlegt",
);
} else if (tageBis(neueste, jetzt) < 0) {
gruende.push("G26.3 ist abgelaufen");
} else if (tageBis(neueste, jetzt) <= VORWARNUNG_TAGE) {
g26Laeuft = true;
}
// --- Belastungsübung -----------------------------------------------------
const belastung = uebungen
.filter(
(uebung) =>
uebung.art === UebungsartAtemschutz.UEBUNGSSTRECKE && imSelbenJahr(uebung.datum, jetzt),
)
.sort((a, b) => b.datum.getTime() - a.datum.getTime())[0];
if (!belastung) gruende.push("Belastungsübung in diesem Jahr fehlt");
// --- Einsatzübung oder Einsatz unter PA ----------------------------------
const einsatzuebung = uebungen
.filter(
(uebung) =>
uebung.art === UebungsartAtemschutz.EINSATZUEBUNG && imSelbenJahr(uebung.datum, jetzt),
)
.sort((a, b) => b.datum.getTime() - a.datum.getTime())[0];
const echterEinsatz = einsaetzeUnterPa
.filter((datum) => imSelbenJahr(datum, jetzt))
.sort((a, b) => b.getTime() - a.getTime())[0];
if (!einsatzuebung && !echterEinsatz) {
gruende.push("Einsatzübung unter Atemschutz in diesem Jahr fehlt");
}
// --- Qualifikation -------------------------------------------------------
if (!angaben.istAtemschutzgeraetetraeger) {
gruende.push("Keine Qualifikation als Atemschutzgeräteträger");
}
const stufe: Tauglichkeitsstufe =
gruende.length > 0 ? "nicht_tauglich" : g26Laeuft ? "laeuft_ab" : "tauglich";
if (g26Laeuft && neueste) {
gruende.push(`G26.3 läuft in ${tageBis(neueste, jetzt)} Tagen ab`);
}
return {
stufe,
gruende,
g26GueltigBis: neueste,
belastungsuebungAm: belastung?.datum ?? null,
einsatzuebungAm: einsatzuebung?.datum ?? echterEinsatz ?? null,
};
}
/**
* Frist einer G26.3.
*
* Die Regel richtet sich nach dem Alter: bis 50 Jahre drei Jahre, danach ein
* Jahr. Der Arzt kann kürzer befristen — deshalb ist das hier nur der
* Vorschlag für das Eingabefeld, überschreibbar wie das Ablaufdatum einer
* Qualifikation aus Phase 1.
*/
export function g26FristVorschlag(geburtsdatum: Date | null, untersuchtAm: Date): Date {
const vorschlag = new Date(untersuchtAm);
const alter = geburtsdatum
? Math.floor((untersuchtAm.getTime() - geburtsdatum.getTime()) / (365.25 * 86_400_000))
: 0;
vorschlag.setUTCFullYear(vorschlag.getUTCFullYear() + (alter >= 50 ? 1 : 3));
return vorschlag;
}
export const G26_ERGEBNIS_TEXT: Record<G26Ergebnis, string> = {
TAUGLICH: "tauglich",
BEFRISTET_TAUGLICH: "befristet tauglich",
NACHUNTERSUCHUNG: "Nachuntersuchung nötig",
NICHT_TAUGLICH: "nicht tauglich",
};
export const UEBUNGSART_TEXT: Record<UebungsartAtemschutz, string> = {
UEBUNGSSTRECKE: "Belastungsübung (Übungsstrecke)",
EINSATZUEBUNG: "Einsatzübung unter Atemschutz",
UNTERWEISUNG: "Unterweisung",
};