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:
co-authored by
Claude Opus 5
parent
b2b31ed00a
commit
4e2daaafd4
@@ -0,0 +1,263 @@
|
||||
import { DruckArt, TruppStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Die Rechenregeln der Atemschutzüberwachung.
|
||||
*
|
||||
* Diese Datei hat bewusst keinen Datenbankzugriff und kein `server-only`: sie
|
||||
* wird auf dem Server für die verbindlichen Werte gebraucht und im Browser für
|
||||
* die laufende Anzeige zwischen zwei Aktualisierungen. Beide Seiten rechnen
|
||||
* mit demselben Code — sonst zeigte das Tablet etwas anderes an als das
|
||||
* Protokoll später ausweist.
|
||||
*
|
||||
* **Die Zeit gehört dem Server.** Nirgends wird eine Restzeit heruntergezählt.
|
||||
* Jede Angabe entsteht aus zwei Zeitstempeln, und den späteren liefert der
|
||||
* Aufrufer. Damit übersteht jede Anzeige einen Neustart, einen zweiten
|
||||
* Bildschirm und einen Verbindungsabbruch — es gibt schlicht nichts, das
|
||||
* verloren gehen könnte.
|
||||
*
|
||||
* **Und über allem:** was hier gerechnet wird, ist eine Hilfe, keine
|
||||
* Überwachung. Die analoge Überwachung bleibt führend.
|
||||
*/
|
||||
|
||||
/** Ein Pressluftatmer hält 300 bar; darüber liegt ein Ablesefehler. */
|
||||
export const DRUCK_HOECHSTWERT = 400;
|
||||
|
||||
/**
|
||||
* Die Regel für den Rückzugsdruck: **doppelter Anmarschverbrauch**.
|
||||
*
|
||||
* Was der Weg hinein an Luft gekostet hat, kostet der Weg zurück noch einmal.
|
||||
* Dazu kommt der Druck, bei dem die Warneinrichtung anspricht, und ein
|
||||
* Sicherheitszuschlag, den der Kommandant festlegt:
|
||||
*
|
||||
* Rückzug = 2 × (Eingangsdruck − Druck am Ziel) + Warndruck + Zuschlag
|
||||
*
|
||||
* Solange der Trupp das Einsatzziel noch nicht gemeldet hat, gibt es keinen
|
||||
* Anmarschverbrauch — dann greift ersatzweise die Zwei-Drittel-Regel: Rückzug,
|
||||
* sobald ein Drittel verbraucht ist. Sie liegt in aller Regel höher als die
|
||||
* spätere Rechnung und ist damit die vorsichtigere Annahme.
|
||||
*/
|
||||
export function rueckzugsdruck(angaben: {
|
||||
eingangsdruck: number;
|
||||
/** Druck beim Erreichen des Einsatzziels, falls schon gemeldet. */
|
||||
zieldruck?: number | null;
|
||||
warndruck: number;
|
||||
sicherheitszuschlag: number;
|
||||
}): { druck: number; regel: "anmarschverbrauch" | "zweidrittel" } {
|
||||
const { eingangsdruck, zieldruck, warndruck, sicherheitszuschlag } = angaben;
|
||||
|
||||
if (typeof zieldruck === "number" && zieldruck > 0 && zieldruck <= eingangsdruck) {
|
||||
const anmarsch = eingangsdruck - zieldruck;
|
||||
return {
|
||||
druck: Math.min(eingangsdruck, 2 * anmarsch + warndruck + sicherheitszuschlag),
|
||||
regel: "anmarschverbrauch",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
druck: Math.round((eingangsdruck * 2) / 3) + sicherheitszuschlag,
|
||||
regel: "zweidrittel",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Der niedrigste Druck im Trupp.
|
||||
*
|
||||
* Ein Trupp geht gemeinsam zurück, und zwar wenn der **erste** den
|
||||
* Rückzugsdruck erreicht. Deshalb zählt für jede Bewertung der kleinste Wert,
|
||||
* nie der Durchschnitt.
|
||||
*/
|
||||
export function niedrigsterDruck(
|
||||
messungen: Array<{ personId: string; druck: number; zeitpunkt: Date }>,
|
||||
): { personId: string; druck: number; zeitpunkt: Date } | null {
|
||||
const letzteJePerson = new Map<string, { personId: string; druck: number; zeitpunkt: Date }>();
|
||||
|
||||
for (const messung of messungen) {
|
||||
const bisher = letzteJePerson.get(messung.personId);
|
||||
if (!bisher || messung.zeitpunkt.getTime() > bisher.zeitpunkt.getTime()) {
|
||||
letzteJePerson.set(messung.personId, messung);
|
||||
}
|
||||
}
|
||||
|
||||
let niedrigste: { personId: string; druck: number; zeitpunkt: Date } | null = null;
|
||||
for (const messung of letzteJePerson.values()) {
|
||||
if (!niedrigste || messung.druck < niedrigste.druck) niedrigste = messung;
|
||||
}
|
||||
|
||||
return niedrigste;
|
||||
}
|
||||
|
||||
/** Wie ein Trupp gerade dasteht. Farbe **und** Text — Farbe allein reicht nicht. */
|
||||
export type Ampel = "gruen" | "gelb" | "rot";
|
||||
|
||||
export const AMPEL_TEXT: Record<Ampel, string> = {
|
||||
gruen: "im Rahmen",
|
||||
gelb: "Rückzugsdruck bald erreicht",
|
||||
rot: "Rückzugsdruck erreicht — Trupp zurückziehen",
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Ampel.
|
||||
*
|
||||
* Gelb ab zehn Prozent über dem Rückzugsdruck: der Trupp soll den Rückweg
|
||||
* antreten, bevor der Wert erreicht ist, nicht wenn er erreicht ist.
|
||||
*/
|
||||
export function ampel(angaben: {
|
||||
aktuellerDruck: number | null;
|
||||
rueckzugsdruck: number | null;
|
||||
status: TruppStatus;
|
||||
}): Ampel {
|
||||
const { aktuellerDruck, rueckzugsdruck: grenze, status } = angaben;
|
||||
|
||||
// Wer draußen ist, ist draußen.
|
||||
if (status === TruppStatus.DRAUSSEN) return "gruen";
|
||||
if (aktuellerDruck === null || grenze === null) return "gruen";
|
||||
|
||||
if (aktuellerDruck <= grenze) return "rot";
|
||||
if (aktuellerDruck <= grenze * 1.1) return "gelb";
|
||||
return "gruen";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange ein Trupp schon unter Atemschutz ist, in Sekunden.
|
||||
*
|
||||
* Aus zwei Zeitstempeln, nicht aus einem laufenden Zähler. `jetzt` gibt der
|
||||
* Aufrufer mit — im Test ein fester Zeitpunkt, im Betrieb die Uhr.
|
||||
*/
|
||||
export function einsatzdauer(
|
||||
trupp: { einstiegUm: Date | null; drausenUm: Date | null },
|
||||
jetzt: Date,
|
||||
): number | null {
|
||||
if (!trupp.einstiegUm) return null;
|
||||
const ende = trupp.drausenUm ?? jetzt;
|
||||
return Math.max(0, Math.floor((ende.getTime() - trupp.einstiegUm.getTime()) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wann die nächste Druckabfrage fällig ist.
|
||||
*
|
||||
* Gerechnet ab der letzten Meldung, nicht ab dem Einstieg — sonst käme die
|
||||
* Erinnerung nach einer verspäteten Meldung sofort wieder.
|
||||
*/
|
||||
export function naechsteAbfrage(angaben: {
|
||||
einstiegUm: Date | null;
|
||||
letzteMessungUm: Date | null;
|
||||
intervallSekunden: number;
|
||||
}): Date | null {
|
||||
const basis = angaben.letzteMessungUm ?? angaben.einstiegUm;
|
||||
if (!basis) return null;
|
||||
return new Date(basis.getTime() + angaben.intervallSekunden * 1000);
|
||||
}
|
||||
|
||||
/** Ist die Druckabfrage überfällig? */
|
||||
export function abfrageUeberfaellig(angaben: {
|
||||
einstiegUm: Date | null;
|
||||
letzteMessungUm: Date | null;
|
||||
intervallSekunden: number;
|
||||
status: TruppStatus;
|
||||
jetzt: Date;
|
||||
}): boolean {
|
||||
if (angaben.status !== TruppStatus.IM_EINSATZ && angaben.status !== TruppStatus.RUECKWEG) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const faellig = naechsteAbfrage(angaben);
|
||||
return faellig !== null && angaben.jetzt.getTime() >= faellig.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Luftverbrauch je Minute, aus den bisherigen Messungen.
|
||||
*
|
||||
* Nur eine Schätzung, und sie steht auch so in der Oberfläche: sie taugt für
|
||||
* einen Anhaltspunkt, nicht für eine Entscheidung. Unter zwei Messungen oder
|
||||
* unter einer Minute Abstand wird gar nichts gerechnet — eine Hochrechnung aus
|
||||
* zwei Werten im Sekundenabstand wäre eine erfundene Zahl.
|
||||
*/
|
||||
export function verbrauchProMinute(
|
||||
messungen: Array<{ druck: number; zeitpunkt: Date }>,
|
||||
): number | null {
|
||||
if (messungen.length < 2) return null;
|
||||
|
||||
const sortiert = [...messungen].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
|
||||
const erste = sortiert[0]!;
|
||||
const letzte = sortiert[sortiert.length - 1]!;
|
||||
|
||||
const minuten = (letzte.zeitpunkt.getTime() - erste.zeitpunkt.getTime()) / 60_000;
|
||||
if (minuten < 1) return null;
|
||||
|
||||
const verbraucht = erste.druck - letzte.druck;
|
||||
if (verbraucht <= 0) return null;
|
||||
|
||||
return Math.round((verbraucht / minuten) * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geschätzte Restzeit bis zum Rückzugsdruck, in Minuten.
|
||||
*
|
||||
* Ausdrücklich eine Schätzung: der Verbrauch steigt unter Belastung, und
|
||||
* niemand soll sich darauf verlassen. Deshalb gibt die Funktion null zurück,
|
||||
* sobald die Grundlage dünn wird — lieber keine Zahl als eine falsche.
|
||||
*/
|
||||
export function restzeitMinuten(angaben: {
|
||||
aktuellerDruck: number | null;
|
||||
rueckzugsdruck: number | null;
|
||||
verbrauchProMinute: number | null;
|
||||
}): number | null {
|
||||
const { aktuellerDruck, rueckzugsdruck: grenze, verbrauchProMinute: rate } = angaben;
|
||||
|
||||
if (aktuellerDruck === null || grenze === null || rate === null || rate <= 0) return null;
|
||||
if (aktuellerDruck <= grenze) return 0;
|
||||
|
||||
return Math.floor((aktuellerDruck - grenze) / rate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der nächste erlaubte Statusschritt.
|
||||
*
|
||||
* Ein Trupp geht immer denselben Weg: bereit, im Einsatz, Rückweg, draußen.
|
||||
* Zurück geht es nicht — ein Trupp, der schon draußen war, ist ein neuer
|
||||
* Trupp. Das hält das Protokoll gerade.
|
||||
*/
|
||||
export const STATUSFOLGE: Record<TruppStatus, TruppStatus[]> = {
|
||||
[TruppStatus.BEREIT]: [TruppStatus.IM_EINSATZ],
|
||||
[TruppStatus.IM_EINSATZ]: [TruppStatus.RUECKWEG, TruppStatus.DRAUSSEN],
|
||||
[TruppStatus.RUECKWEG]: [TruppStatus.DRAUSSEN],
|
||||
[TruppStatus.DRAUSSEN]: [],
|
||||
};
|
||||
|
||||
export function statuswechselErlaubt(von: TruppStatus, nach: TruppStatus): boolean {
|
||||
return STATUSFOLGE[von].includes(nach);
|
||||
}
|
||||
|
||||
export const STATUS_TEXT: Record<TruppStatus, string> = {
|
||||
BEREIT: "bereit",
|
||||
IM_EINSATZ: "im Einsatz",
|
||||
RUECKWEG: "auf dem Rückweg",
|
||||
DRAUSSEN: "draußen",
|
||||
};
|
||||
|
||||
export const DRUCKART_TEXT: Record<DruckArt, string> = {
|
||||
EINGANG: "Eingangsdruck",
|
||||
ZIEL: "am Einsatzziel",
|
||||
ZWISCHEN: "Zwischenmeldung",
|
||||
AUSGANG: "Ausgangsdruck",
|
||||
};
|
||||
|
||||
/**
|
||||
* Eine Sekundenzahl als `mm:ss` beziehungsweise `h:mm:ss`.
|
||||
*
|
||||
* Eigene Funktion statt der allgemeinen Formatierung: hier zählt jede Sekunde,
|
||||
* und die Anzeige darf nicht runden.
|
||||
*/
|
||||
export function alsUhr(sekunden: number | null): string {
|
||||
if (sekunden === null) return "—";
|
||||
|
||||
const stunden = Math.floor(sekunden / 3600);
|
||||
const minuten = Math.floor((sekunden % 3600) / 60);
|
||||
const rest = sekunden % 60;
|
||||
|
||||
const zweistellig = (wert: number) => String(wert).padStart(2, "0");
|
||||
|
||||
return stunden > 0
|
||||
? `${stunden}:${zweistellig(minuten)}:${zweistellig(rest)}`
|
||||
: `${zweistellig(minuten)}:${zweistellig(rest)}`;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import "server-only";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { env } from "@/lib/env";
|
||||
import { protokollPdf, type ProtokollDaten, type ProtokollZeile } from "@/lib/atemschutz/protokoll";
|
||||
import { DRUCKART_TEXT } from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Von der Überwachung zum abgelegten Protokoll.
|
||||
*
|
||||
* Dreigeteilt wie beim Prüfprotokoll und beim Einsatzbericht — lesen, rendern,
|
||||
* schreiben. Dazwischen liegt der Aufruf an Gotenberg, und der darf dauern;
|
||||
* eine offene Transaktion darf das nicht.
|
||||
*
|
||||
* Zwei Besonderheiten gegenüber den anderen Erzeugern:
|
||||
*
|
||||
* * **Erst nach dem Ende.** Ein Protokoll einer laufenden Überwachung wäre
|
||||
* unvollständig und würde als vollständig aussehen. Das ist genau der
|
||||
* Fehler, den ein Nachweis nicht machen darf.
|
||||
* * **Der Verlauf entsteht aus zwei Quellen.** Ereignisse und Druckmessungen
|
||||
* werden getrennt gespeichert, gehören im Protokoll aber in eine einzige
|
||||
* Reihenfolge — sonst müsste der Leser zwei Spalten gleichzeitig verfolgen.
|
||||
*
|
||||
* Wiederholbar: gibt es das Protokoll schon, passiert nichts.
|
||||
*/
|
||||
export async function atemschutzprotokollErzeugen(
|
||||
organisationId: string,
|
||||
ueberwachungId: string,
|
||||
): Promise<{ dateiId: string; neu: boolean } | null> {
|
||||
// ---------------------------------------------------------------- lesen
|
||||
const geladen = await fuerOrganisation(organisationId, async (db) => {
|
||||
const ueberwachung = await db.atemschutzeinsatz.findUnique({
|
||||
where: { id: ueberwachungId },
|
||||
include: {
|
||||
organisation: { select: { name: true, slug: true, ort: true } },
|
||||
ueberwacher: { select: { vorname: true, nachname: true } },
|
||||
einsatz: { select: { einsatznummer: true, stichwort: true } },
|
||||
uebung: { select: { nummer: true, thema: true } },
|
||||
trupps: {
|
||||
orderBy: { angelegtUm: "asc" },
|
||||
include: {
|
||||
mitglieder: {
|
||||
include: {
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
geraet: { select: { inventarnummer: true } },
|
||||
},
|
||||
},
|
||||
druckmessungen: {
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
include: { person: { select: { id: true, vorname: true, nachname: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
ereignisse: {
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
include: {
|
||||
trupp: { select: { funkrufname: true } },
|
||||
person: { select: { vorname: true, nachname: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ueberwachung) return null;
|
||||
// Ein Protokoll gibt es erst, wenn nichts mehr dazukommt.
|
||||
if (!ueberwachung.endeUm) return null;
|
||||
|
||||
return ueberwachung;
|
||||
});
|
||||
|
||||
if (!geladen) return null;
|
||||
if (geladen.protokollDateiId) return { dateiId: geladen.protokollDateiId, neu: false };
|
||||
|
||||
// --------------------------------------------------------------- rendern
|
||||
const name = (person: { vorname: string; nachname: string }) =>
|
||||
`${person.nachname}, ${person.vorname}`;
|
||||
|
||||
/** Drücke einer Art als „Huber 300, Maier 290" — kurz genug für die Spalte. */
|
||||
const drueckeAlsText = (
|
||||
messungen: Array<{ art: string; druck: number; person: { vorname: string; nachname: string } }>,
|
||||
art: string,
|
||||
) =>
|
||||
messungen
|
||||
.filter((messung) => messung.art === art)
|
||||
.map((messung) => `${messung.person.nachname} ${messung.druck}`)
|
||||
.join(", ");
|
||||
|
||||
const verlauf: ProtokollZeile[] = [
|
||||
...geladen.ereignisse.map((ereignis) => ({
|
||||
zeitpunkt: ereignis.zeitpunkt,
|
||||
trupp: ereignis.trupp?.funkrufname ?? null,
|
||||
was: ereignis.text,
|
||||
wer: ereignis.person ? name(ereignis.person) : null,
|
||||
})),
|
||||
...geladen.trupps.flatMap((trupp) =>
|
||||
trupp.druckmessungen.map((messung) => ({
|
||||
zeitpunkt: messung.zeitpunkt,
|
||||
trupp: trupp.funkrufname,
|
||||
was: `${DRUCKART_TEXT[messung.art]}: ${name(messung.person)} ${messung.druck} bar`,
|
||||
wer: null,
|
||||
})),
|
||||
),
|
||||
].sort((a, b) => a.zeitpunkt.getTime() - b.zeitpunkt.getTime());
|
||||
|
||||
const freigabe = env.ATEMSCHUTZ_FREIGABE?.trim();
|
||||
|
||||
const daten: ProtokollDaten = {
|
||||
organisation: { name: geladen.organisation.name, ort: geladen.organisation.ort },
|
||||
ueberwachung: {
|
||||
bezeichnung: geladen.bezeichnung,
|
||||
ueberwacher: name(geladen.ueberwacher),
|
||||
beginnUm: geladen.beginnUm,
|
||||
endeUm: geladen.endeUm,
|
||||
abfrageIntervall: geladen.abfrageIntervall,
|
||||
warndruck: geladen.warndruck,
|
||||
sicherheitszuschlag: geladen.sicherheitszuschlag,
|
||||
einsatz: geladen.einsatz
|
||||
? `Einsatz ${geladen.einsatz.einsatznummer} — ${geladen.einsatz.stichwort}`
|
||||
: null,
|
||||
uebung: geladen.uebung ? `Übung ${geladen.uebung.nummer} — ${geladen.uebung.thema}` : null,
|
||||
bemerkung: geladen.bemerkung,
|
||||
},
|
||||
trupps: geladen.trupps.map((trupp) => ({
|
||||
funkrufname: trupp.funkrufname,
|
||||
auftrag: trupp.auftrag,
|
||||
status: trupp.status,
|
||||
mitglieder: trupp.mitglieder.map((mitglied) => ({
|
||||
name: name(mitglied.person),
|
||||
truppfuehrer: mitglied.truppfuehrer,
|
||||
geraet: mitglied.geraet?.inventarnummer ?? null,
|
||||
})),
|
||||
einstiegUm: trupp.einstiegUm,
|
||||
rueckzugUm: trupp.rueckzugUm,
|
||||
drausenUm: trupp.drausenUm,
|
||||
rueckzugsdruck: trupp.rueckzugsdruck,
|
||||
maydayUm: trupp.maydayUm,
|
||||
maydayAufgehobenUm: trupp.maydayAufgehobenUm,
|
||||
eingangsdruecke: drueckeAlsText(trupp.druckmessungen, "EINGANG"),
|
||||
ausgangsdruecke: drueckeAlsText(trupp.druckmessungen, "AUSGANG"),
|
||||
})),
|
||||
verlauf,
|
||||
// Nur wenn der Hinweis tatsächlich abgeschaltet ist, war die digitale
|
||||
// Überwachung führend — sonst steht der ergänzende Charakter im Protokoll.
|
||||
freigabe: !env.ATEMSCHUTZ_HINWEIS && freigabe ? freigabe : null,
|
||||
erzeugtAm: new Date(),
|
||||
};
|
||||
|
||||
const pdf = await protokollPdf(daten);
|
||||
|
||||
const dateiname = `Atemschutzprotokoll-${geladen.beginnUm.toISOString().slice(0, 10)}-${geladen.id.slice(0, 8)}.pdf`;
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: geladen.organisation.slug,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: geladen.id,
|
||||
dateiname,
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
|
||||
atemschutz: geladen.id,
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const stand = await db.atemschutzeinsatz.findUnique({
|
||||
where: { id: geladen.id },
|
||||
select: { protokollDateiId: true },
|
||||
});
|
||||
|
||||
// Zwei gleichzeitige Anfragen: die zweite nimmt, was die erste abgelegt hat.
|
||||
if (stand?.protokollDateiId) return { dateiId: stand.protokollDateiId, neu: false };
|
||||
|
||||
const datei = await db.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname,
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: geladen.id,
|
||||
kategorie: "atemschutzprotokoll",
|
||||
hochgeladenVonId: geladen.ueberwacherId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.atemschutzeinsatz.update({
|
||||
where: { id: geladen.id },
|
||||
data: { protokollDateiId: datei.id },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Atemschutzeinsatz",
|
||||
entitaetId: geladen.id,
|
||||
beschreibung: `Atemschutzprotokoll erzeugt, ${verlauf.length} Einträge (${Math.round(
|
||||
abgelegt.groesseBytes / 1024,
|
||||
)} kB)`,
|
||||
akteurTyp: AkteurTyp.SYSTEM,
|
||||
akteurName: "FLORIAN",
|
||||
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
|
||||
});
|
||||
|
||||
return { dateiId: datei.id, neu: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import "server-only";
|
||||
import { htmlZuPdf, html } from "@/lib/pdf";
|
||||
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
|
||||
import { DRUCKART_TEXT, STATUS_TEXT, alsUhr } from "@/lib/atemschutz/berechnung";
|
||||
|
||||
/**
|
||||
* Das Atemschutzprotokoll als PDF.
|
||||
*
|
||||
* Es ist der Nachweis, den FwDV 7 verlangt — und im Schadensfall das Papier,
|
||||
* nach dem die Unfallkasse fragt. Daraus folgt der Aufbau:
|
||||
*
|
||||
* Kopf Wehr, Bezeichnung, Überwacher, Beginn und Ende
|
||||
* Trupps wer, wann eingestiegen, wann draußen, mit welchen Drücken
|
||||
* Verlauf **jede** Zeile in zeitlicher Reihenfolge, Ereignisse und
|
||||
* Druckmeldungen gemeinsam — das ist der Kern
|
||||
* Fuß wann erstellt, und der Hinweis, dass die analoge Überwachung
|
||||
* führend war
|
||||
*
|
||||
* Der letzte Satz steht dort nicht aus Bescheidenheit, sondern weil er
|
||||
* stimmen muss: solange die Wehr die digitale Überwachung nicht freigegeben
|
||||
* hat, ist dieses Blatt eine Mitschrift und keine Überwachungsdokumentation.
|
||||
* Wer das später liest, soll wissen, was er in der Hand hält.
|
||||
*/
|
||||
|
||||
export interface ProtokollTrupp {
|
||||
funkrufname: string;
|
||||
auftrag: string | null;
|
||||
status: string;
|
||||
mitglieder: Array<{ name: string; truppfuehrer: boolean; geraet: string | null }>;
|
||||
einstiegUm: Date | null;
|
||||
rueckzugUm: Date | null;
|
||||
drausenUm: Date | null;
|
||||
rueckzugsdruck: number | null;
|
||||
maydayUm: Date | null;
|
||||
maydayAufgehobenUm: Date | null;
|
||||
eingangsdruecke: string;
|
||||
ausgangsdruecke: string;
|
||||
}
|
||||
|
||||
export interface ProtokollZeile {
|
||||
zeitpunkt: Date;
|
||||
trupp: string | null;
|
||||
was: string;
|
||||
wer: string | null;
|
||||
}
|
||||
|
||||
export interface ProtokollDaten {
|
||||
organisation: { name: string; ort?: string | null };
|
||||
ueberwachung: {
|
||||
bezeichnung: string;
|
||||
ueberwacher: string;
|
||||
beginnUm: Date;
|
||||
endeUm: Date | null;
|
||||
abfrageIntervall: number;
|
||||
warndruck: number;
|
||||
sicherheitszuschlag: number;
|
||||
einsatz?: string | null;
|
||||
uebung?: string | null;
|
||||
bemerkung?: string | null;
|
||||
};
|
||||
trupps: ProtokollTrupp[];
|
||||
verlauf: ProtokollZeile[];
|
||||
/** Steht im Fuß: ob die Wehr die digitale Überwachung freigegeben hat. */
|
||||
freigabe: string | null;
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
const STIL = `
|
||||
@page { size: A4; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: #111;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
h1 { font-size: 15pt; margin: 0 0 1mm; }
|
||||
h2 { font-size: 11pt; margin: 6mm 0 2mm; border-bottom: 0.4mm solid #111; padding-bottom: 1mm; }
|
||||
|
||||
.kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 6mm; }
|
||||
.wehr { text-align: right; font-size: 8.5pt; color: #444; }
|
||||
|
||||
.hinweis {
|
||||
margin: 3mm 0 0;
|
||||
border: 0.5mm solid #b91c1c;
|
||||
background: #fdecec;
|
||||
padding: 3mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 2mm; }
|
||||
th, td { border: 0.2mm solid #bbb; padding: 1.2mm 1.6mm; text-align: left; vertical-align: top; }
|
||||
th { background: #eee; font-size: 8.5pt; }
|
||||
td.zahl, th.zahl { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
td.zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
|
||||
tr.mayday td { background: #fdecec; font-weight: bold; }
|
||||
|
||||
.fuss { margin-top: 6mm; font-size: 8pt; color: #444; border-top: 0.2mm solid #bbb; padding-top: 2mm; }
|
||||
`;
|
||||
|
||||
function protokollHtml(daten: ProtokollDaten): string {
|
||||
const { ueberwachung, organisation } = daten;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><title>Atemschutzprotokoll</title><style>${STIL}</style></head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Atemschutzüberwachung</h1>
|
||||
<div>${html(ueberwachung.bezeichnung)}</div>
|
||||
</div>
|
||||
<div class="wehr">
|
||||
<div><strong>${html(organisation.name)}</strong></div>
|
||||
${organisation.ort ? `<div>${html(organisation.ort)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hinweis">
|
||||
${
|
||||
daten.freigabe
|
||||
? `Digitale Atemschutzüberwachung freigegeben — ${html(daten.freigabe)}.`
|
||||
: "<strong>Ergänzendes System.</strong> Während dieser Überwachung war die analoge Atemschutzüberwachung führend. Dieses Blatt ist die Mitschrift des ergänzenden Systems."
|
||||
}
|
||||
</div>
|
||||
|
||||
<h2>Rahmen</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width:28%">Überwacher</th><td>${html(ueberwachung.ueberwacher)}</td>
|
||||
<th style="width:20%">Beginn</th><td class="zeit">${html(alsDatumZeit(ueberwachung.beginnUm))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Ende</th>
|
||||
<td class="zeit">${ueberwachung.endeUm ? html(alsDatumZeit(ueberwachung.endeUm)) : "— (nicht beendet)"}</td>
|
||||
<th>Abfrageintervall</th><td>${Math.round(ueberwachung.abfrageIntervall / 60)} Minuten</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Rückzugsregel</th>
|
||||
<td>doppelter Anmarschverbrauch + ${ueberwachung.warndruck} bar Warndruck + ${ueberwachung.sicherheitszuschlag} bar Zuschlag</td>
|
||||
<th>Zuordnung</th>
|
||||
<td>${html(ueberwachung.einsatz ?? ueberwachung.uebung ?? "keine")}</td>
|
||||
</tr>
|
||||
${
|
||||
ueberwachung.bemerkung
|
||||
? `<tr><th>Bemerkung</th><td colspan="3">${html(ueberwachung.bemerkung)}</td></tr>`
|
||||
: ""
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Trupps</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Trupp</th>
|
||||
<th>Besetzung</th>
|
||||
<th class="zeit">Einstieg</th>
|
||||
<th class="zeit">Rückweg</th>
|
||||
<th class="zeit">draußen</th>
|
||||
<th class="zahl">Dauer</th>
|
||||
<th class="zahl">Rückzug bei</th>
|
||||
<th>Drücke ein/aus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${daten.trupps
|
||||
.map((trupp) => {
|
||||
const dauer =
|
||||
trupp.einstiegUm && trupp.drausenUm
|
||||
? alsUhr(
|
||||
Math.floor((trupp.drausenUm.getTime() - trupp.einstiegUm.getTime()) / 1000),
|
||||
)
|
||||
: "—";
|
||||
|
||||
return `
|
||||
<tr${trupp.maydayUm ? ' class="mayday"' : ""}>
|
||||
<td>
|
||||
<strong>${html(trupp.funkrufname)}</strong>
|
||||
${trupp.auftrag ? `<div>${html(trupp.auftrag)}</div>` : ""}
|
||||
${trupp.maydayUm ? `<div>MAYDAY ${html(alsUhrzeit(trupp.maydayUm))}${trupp.maydayAufgehobenUm ? ` bis ${html(alsUhrzeit(trupp.maydayAufgehobenUm))}` : ""}</div>` : ""}
|
||||
</td>
|
||||
<td>
|
||||
${trupp.mitglieder
|
||||
.map(
|
||||
(mitglied) =>
|
||||
`${html(mitglied.name)}${mitglied.truppfuehrer ? " (TF)" : ""}${mitglied.geraet ? ` — ${html(mitglied.geraet)}` : ""}`,
|
||||
)
|
||||
.join("<br>")}
|
||||
</td>
|
||||
<td class="zeit">${trupp.einstiegUm ? html(alsUhrzeit(trupp.einstiegUm)) : "—"}</td>
|
||||
<td class="zeit">${trupp.rueckzugUm ? html(alsUhrzeit(trupp.rueckzugUm)) : "—"}</td>
|
||||
<td class="zeit">${trupp.drausenUm ? html(alsUhrzeit(trupp.drausenUm)) : "—"}</td>
|
||||
<td class="zahl">${html(dauer)}</td>
|
||||
<td class="zahl">${trupp.rueckzugsdruck ?? "—"} bar</td>
|
||||
<td>${html(trupp.eingangsdruecke)} / ${html(trupp.ausgangsdruecke || "—")}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Verlauf</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="zeit" style="width:18mm">Uhrzeit</th>
|
||||
<th style="width:30mm">Trupp</th>
|
||||
<th>Vorgang</th>
|
||||
<th style="width:35mm">erfasst von</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${daten.verlauf
|
||||
.map(
|
||||
(zeile) => `
|
||||
<tr${zeile.was.startsWith("MAYDAY") ? ' class="mayday"' : ""}>
|
||||
<td class="zeit">${html(alsUhrzeit(zeile.zeitpunkt))}</td>
|
||||
<td>${html(zeile.trupp ?? "—")}</td>
|
||||
<td>${html(zeile.was)}</td>
|
||||
<td>${html(zeile.wer ?? "—")}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="fuss">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Das Protokoll umfasst
|
||||
${daten.verlauf.length} Einträge und ist nachträglich nicht veränderbar — Druckmessungen und
|
||||
Ereignisse lassen sich in der Datenbank weder ändern noch löschen.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt das Protokoll als PDF. */
|
||||
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(protokollHtml(daten), {
|
||||
randOben: 0.6,
|
||||
randUnten: 0.5,
|
||||
randLinks: 0.5,
|
||||
randRechts: 0.5,
|
||||
hintergrundDrucken: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Klartext für eine Druckmessung im Verlauf. */
|
||||
export function druckZeile(art: string, druck: number, person: string): string {
|
||||
return `${DRUCKART_TEXT[art as keyof typeof DRUCKART_TEXT] ?? art}: ${person} ${druck} bar`;
|
||||
}
|
||||
|
||||
/** Klartext für einen Status im Trupp-Block. */
|
||||
export function statusText(status: string): string {
|
||||
return STATUS_TEXT[status as keyof typeof STATUS_TEXT] ?? status;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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",
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import "server-only";
|
||||
import type { MandantenClient } from "@/lib/db";
|
||||
import { tauglichkeit, type Tauglichkeit } from "@/lib/atemschutz/tauglichkeit";
|
||||
|
||||
/**
|
||||
* Wer ist heute einsetzbar?
|
||||
*
|
||||
* Die Frage stellt sich an zwei Stellen — auf der Startseite als Zahl und im
|
||||
* Atemschutzmodul als Liste. Damit beide dasselbe sagen, steht die Beschaffung
|
||||
* hier und nicht zweimal nebeneinander.
|
||||
*
|
||||
* Die Bewertung selbst liegt in lib/atemschutz/tauglichkeit.ts und ist dort
|
||||
* ohne Datenbank getestet. Diese Datei holt nur zusammen, was sie braucht.
|
||||
*/
|
||||
|
||||
/** Woran ein Atemschutzgeräteträger in den Qualifikationen erkannt wird. */
|
||||
const AGT_MERKMALE = ["atemschutz", "agt", "atemschutzgeräteträger", "atemschutzgeraetetraeger"];
|
||||
|
||||
export function istAgtTyp(name: string): boolean {
|
||||
const klein = name.toLowerCase();
|
||||
return AGT_MERKMALE.some((merkmal) => klein.includes(merkmal));
|
||||
}
|
||||
|
||||
export interface TraegerBewertung extends Tauglichkeit {
|
||||
id: string;
|
||||
vorname: string;
|
||||
nachname: string;
|
||||
dienstgrad: string | null;
|
||||
einsaetzeImJahr: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet die ganze Mannschaft.
|
||||
*
|
||||
* Eine Abfrage für alle statt einer je Person: bei fünfzig Mitgliedern wären
|
||||
* das sonst fünfzig Rundreisen zur Datenbank für eine Ansicht, die im
|
||||
* Gerätehaus schnell dastehen soll.
|
||||
*/
|
||||
export async function traegerBewerten(
|
||||
db: MandantenClient,
|
||||
jetzt = new Date(),
|
||||
): Promise<TraegerBewertung[]> {
|
||||
const jahresbeginn = new Date(Date.UTC(jetzt.getUTCFullYear(), 0, 1));
|
||||
|
||||
const personen = await db.person.findMany({
|
||||
where: { aktiv: true, geloeschtAm: null },
|
||||
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
dienstgrad: true,
|
||||
qualifikationen: {
|
||||
where: { geloeschtAm: null },
|
||||
select: { gueltigBis: true, typ: { select: { name: true } } },
|
||||
},
|
||||
g26Untersuchungen: {
|
||||
where: { geloeschtAm: null },
|
||||
select: { gueltigBis: true, ergebnis: true },
|
||||
},
|
||||
belastungsuebungen: {
|
||||
where: { geloeschtAm: null },
|
||||
select: { datum: true, art: true },
|
||||
},
|
||||
truppeinsaetze: {
|
||||
where: { trupp: { einstiegUm: { gte: jahresbeginn } } },
|
||||
select: { trupp: { select: { einstiegUm: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return personen.map((person) => ({
|
||||
id: person.id,
|
||||
vorname: person.vorname,
|
||||
nachname: person.nachname,
|
||||
dienstgrad: person.dienstgrad,
|
||||
einsaetzeImJahr: person.truppeinsaetze.length,
|
||||
...tauglichkeit({
|
||||
untersuchungen: person.g26Untersuchungen,
|
||||
uebungen: person.belastungsuebungen,
|
||||
einsaetzeUnterPa: person.truppeinsaetze
|
||||
.map((eintrag) => eintrag.trupp.einstiegUm)
|
||||
.filter((datum): datum is Date => datum !== null),
|
||||
istAtemschutzgeraetetraeger: person.qualifikationen.some(
|
||||
(qualifikation) =>
|
||||
istAgtTyp(qualifikation.typ.name) &&
|
||||
(!qualifikation.gueltigBis || qualifikation.gueltigBis >= jetzt),
|
||||
),
|
||||
jetzt,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Nur die Zahlen — für die Kachel auf der Startseite. */
|
||||
export async function verfuegbarkeit(
|
||||
db: MandantenClient,
|
||||
jetzt = new Date(),
|
||||
): Promise<{ einsetzbar: number; laeuftAb: number; nichtEinsetzbar: number }> {
|
||||
const alle = await traegerBewerten(db, jetzt);
|
||||
|
||||
return {
|
||||
einsetzbar: alle.filter((eintrag) => eintrag.stufe !== "nicht_tauglich").length,
|
||||
laeuftAb: alle.filter((eintrag) => eintrag.stufe === "laeuft_ab").length,
|
||||
nichtEinsetzbar: alle.filter((eintrag) => eintrag.stufe === "nicht_tauglich").length,
|
||||
};
|
||||
}
|
||||
@@ -120,6 +120,28 @@ const schema = z.object({
|
||||
/// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird.
|
||||
WETTER_ZWISCHENSPEICHER: zahl(300),
|
||||
|
||||
// Atemschutz nach FwDV 7 (Phase 5)
|
||||
/**
|
||||
* Der Sicherheitshinweis über jeder Ansicht der Atemschutzüberwachung.
|
||||
*
|
||||
* Er ist standardmäßig an und **gehört an**: FLORIAN ist ein ergänzendes
|
||||
* System, die analoge Überwachung bleibt führend. Wer ihn abschaltet,
|
||||
* erklärt damit, dass die Wehr die digitale Überwachung freigegeben hat —
|
||||
* das ist eine Entscheidung des Kommandanten, keine des Administrators.
|
||||
* Deshalb verlangt das Abschalten zusätzlich einen Verweis auf die
|
||||
* schriftliche Freigabe (ATEMSCHUTZ_FREIGABE), der in der Oberfläche und im
|
||||
* Protokoll steht.
|
||||
*/
|
||||
ATEMSCHUTZ_HINWEIS: boolAusText(true),
|
||||
/// Aktenzeichen oder Fundstelle der Freigabe durch den Kommandanten.
|
||||
ATEMSCHUTZ_FREIGABE: z.string().optional(),
|
||||
/// Vorgabe für den Abstand der Druckabfrage, in Sekunden.
|
||||
ATEMSCHUTZ_ABFRAGE_INTERVALL: zahl(600),
|
||||
/// Ansprechdruck der Warneinrichtung in bar, Vorgabe für neue Überwachungen.
|
||||
ATEMSCHUTZ_WARNDRUCK: zahl(55),
|
||||
/// Sicherheitszuschlag auf den Rückzugsdruck in bar.
|
||||
ATEMSCHUTZ_ZUSCHLAG: zahl(10),
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
|
||||
@@ -28,6 +28,8 @@ export const AUFTRAG = {
|
||||
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
|
||||
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
|
||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
|
||||
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
|
||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user