Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan, die Lagekarte eines Einsatzes und die geplante Foerderstrecke. Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst (tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht erreichbaren Server. Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger Tabellendurchlauf. Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen — raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne Statusangabe steht "ungeprueft", nicht "einsatzbereit". 22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung. Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke nicht auf, sagt die Ausgabe das. Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben. Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung pruefen und nicht gegen die eigene Implementierung. Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es nur den Upload. 59 neue Tests (48 Unit, 11 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2814 lines
92 KiB
TypeScript
2814 lines
92 KiB
TypeScript
/**
|
|
* Seed — legt eine vollständige Beispielwehr an.
|
|
*
|
|
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 16 Personen mit Rollen und
|
|
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
|
|
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
|
|
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
|
|
* 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. 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. Ab Phase 5 dazu: G26-Untersuchungen und
|
|
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
|
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
|
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
|
* Feuerwehrplan.
|
|
*
|
|
* 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
|
|
* die Organisation ja erst an, deren Kontext RLS bräuchte.
|
|
*
|
|
* Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits
|
|
* vorhandenen Daten.
|
|
*
|
|
* npm run db:seed
|
|
*/
|
|
import "dotenv/config";
|
|
import {
|
|
Alarmierungsart,
|
|
AnwesenheitAnlass,
|
|
AusweisTokenTyp,
|
|
ChecklistenpunktTyp,
|
|
EinsatzFunktion,
|
|
EinsatzKategorie,
|
|
EinsatzStatus,
|
|
Dienstgrad,
|
|
DruckArt,
|
|
EreignisArt,
|
|
FahrtZweck,
|
|
FahrzeugArt,
|
|
G26Ergebnis,
|
|
GeraeteKategorie,
|
|
GeraetStatus,
|
|
LebensdauerBezug,
|
|
LebensdauerKategorie,
|
|
MangelPrioritaet,
|
|
MangelStatus,
|
|
PrismaClient,
|
|
PruefErgebnis,
|
|
PruefungArt,
|
|
PruefungStatus,
|
|
QualifikationsArt,
|
|
RolleSchluessel,
|
|
SignaturRolle,
|
|
HydrantStatus,
|
|
HydrantTyp,
|
|
ObjektKategorie,
|
|
StandortArt,
|
|
TruppStatus,
|
|
UebungsArt,
|
|
UebungsartAtemschutz,
|
|
WaescheStatus,
|
|
} from "@prisma/client";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const prisma = new PrismaClient({
|
|
datasources: {
|
|
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
|
|
},
|
|
});
|
|
|
|
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
|
|
const ORGANISATION_SLUG = "ff-musterhausen";
|
|
|
|
function log(nachricht: string): void {
|
|
console.info(`[seed] ${nachricht}`);
|
|
}
|
|
|
|
/** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */
|
|
function datum(jahr: number, monat: number, tag: number): Date {
|
|
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
|
|
}
|
|
|
|
/** Verschiebt ein Datum um Monate — für Prüffristen. */
|
|
function plusMonate(basis: Date, monate: number): Date {
|
|
const neu = new Date(basis);
|
|
neu.setUTCMonth(neu.getUTCMonth() + monate);
|
|
return neu;
|
|
}
|
|
|
|
/** Verschiebt ein Datum um Jahre — für Lebensdauern. */
|
|
function plusJahre(basis: Date, jahre: number): Date {
|
|
const neu = new Date(basis);
|
|
neu.setUTCFullYear(neu.getUTCFullYear() + jahre);
|
|
return neu;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Berechtigungskatalog
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über
|
|
* einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft.
|
|
*/
|
|
async function katalogAbgleichen(): Promise<Map<string, string>> {
|
|
const { RECHTE } = await import("../src/lib/authz");
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const [schluessel, angaben] of Object.entries(RECHTE)) {
|
|
const eintrag = await prisma.berechtigung.upsert({
|
|
where: { schluessel },
|
|
update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung },
|
|
create: {
|
|
schluessel,
|
|
bereich: angaben.bereich,
|
|
bezeichnung: angaben.bezeichnung,
|
|
},
|
|
});
|
|
karte.set(schluessel, eintrag.id);
|
|
}
|
|
|
|
log(`Berechtigungskatalog: ${karte.size} Einzelrechte`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Organisation und Rollen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function organisationAnlegen() {
|
|
const organisation = await prisma.organisation.upsert({
|
|
where: { slug: ORGANISATION_SLUG },
|
|
update: {},
|
|
create: {
|
|
slug: ORGANISATION_SLUG,
|
|
name: "Freiwillige Feuerwehr Musterhausen",
|
|
kurzname: "FF Musterhausen",
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
landkreis: "Freising",
|
|
bundesland: "Bayern",
|
|
telefon: "08161 123456",
|
|
email: "kommandant@ff-musterhausen.de",
|
|
ilsKennung: "FS-MUS-01",
|
|
funkPraefix: "Florian Musterhausen",
|
|
},
|
|
});
|
|
|
|
log(`Organisation: ${organisation.name} (${organisation.id})`);
|
|
return organisation;
|
|
}
|
|
|
|
async function rollenAnlegen(organisationId: string, katalog: Map<string, string>) {
|
|
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
|
|
await import("../src/lib/authz");
|
|
|
|
const rollenKarte = new Map<string, string>();
|
|
|
|
for (const schluessel of ROLLEN) {
|
|
const rolle = await prisma.rolle.upsert({
|
|
where: {
|
|
organisationId_schluessel: {
|
|
organisationId,
|
|
schluessel: schluessel as RolleSchluessel,
|
|
},
|
|
},
|
|
update: {
|
|
name: ROLLEN_BEZEICHNUNG[schluessel],
|
|
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
|
},
|
|
create: {
|
|
organisationId,
|
|
schluessel: schluessel as RolleSchluessel,
|
|
name: ROLLEN_BEZEICHNUNG[schluessel],
|
|
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
|
systemRolle: true,
|
|
},
|
|
});
|
|
rollenKarte.set(schluessel, rolle.id);
|
|
|
|
// Rechte der Rolle spiegeln — der Administrator bekommt alles.
|
|
const rechte =
|
|
schluessel === "SUPERADMIN"
|
|
? ALLE_RECHTE
|
|
: (ROLLEN_RECHTE[schluessel as Exclude<typeof schluessel, "SUPERADMIN">] ?? []);
|
|
|
|
for (const recht of rechte) {
|
|
const berechtigungId = katalog.get(recht);
|
|
if (!berechtigungId) continue;
|
|
|
|
await prisma.rolleBerechtigung.upsert({
|
|
where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } },
|
|
update: { gewaehrt: true },
|
|
create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`);
|
|
return rollenKarte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Personen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface PersonVorlage {
|
|
vorname: string;
|
|
nachname: string;
|
|
dienstgrad: Dienstgrad;
|
|
rollen: RolleSchluessel[];
|
|
eintritt: Date;
|
|
abteilung: string;
|
|
/** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */
|
|
pin?: string;
|
|
/** RFID-Kennung der Ausweiskarte. */
|
|
rfid?: string;
|
|
}
|
|
|
|
const PERSONEN: PersonVorlage[] = [
|
|
{
|
|
vorname: "Andreas",
|
|
nachname: "Huber",
|
|
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
|
rollen: [RolleSchluessel.KOMMANDANT],
|
|
eintritt: datum(1998, 4, 1),
|
|
abteilung: "Aktive",
|
|
pin: "471203",
|
|
rfid: "04A3B7C1D2",
|
|
},
|
|
{
|
|
vorname: "Bernhard",
|
|
nachname: "Maier",
|
|
dienstgrad: Dienstgrad.OBERBRANDMEISTER,
|
|
rollen: [RolleSchluessel.KOMMANDANT],
|
|
eintritt: datum(2001, 9, 15),
|
|
abteilung: "Aktive",
|
|
pin: "938157",
|
|
},
|
|
{
|
|
vorname: "Christian",
|
|
nachname: "Bauer",
|
|
dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER,
|
|
rollen: [RolleSchluessel.GERAETEWART],
|
|
eintritt: datum(2005, 3, 1),
|
|
abteilung: "Aktive",
|
|
pin: "620394",
|
|
rfid: "04B8C2D3E4",
|
|
},
|
|
{
|
|
vorname: "Daniela",
|
|
nachname: "Schmid",
|
|
dienstgrad: Dienstgrad.OBERLOESCHMEISTER,
|
|
rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART],
|
|
eintritt: datum(2008, 7, 1),
|
|
abteilung: "Aktive",
|
|
pin: "184926",
|
|
rfid: "04C9D3E4F5",
|
|
},
|
|
{
|
|
vorname: "Elisabeth",
|
|
nachname: "Wagner",
|
|
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
|
rollen: [RolleSchluessel.KASSIER],
|
|
eintritt: datum(2010, 1, 15),
|
|
abteilung: "Aktive",
|
|
pin: "573081",
|
|
},
|
|
{
|
|
vorname: "Franz",
|
|
nachname: "Fischer",
|
|
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
|
rollen: [RolleSchluessel.SCHRIFTFUEHRER],
|
|
eintritt: datum(2009, 5, 20),
|
|
abteilung: "Aktive",
|
|
pin: "306159",
|
|
},
|
|
{
|
|
vorname: "Gabriele",
|
|
nachname: "Weber",
|
|
dienstgrad: Dienstgrad.BRANDMEISTER,
|
|
rollen: [RolleSchluessel.AUSBILDER],
|
|
eintritt: datum(2004, 11, 1),
|
|
abteilung: "Aktive",
|
|
pin: "849265",
|
|
},
|
|
{
|
|
vorname: "Hans",
|
|
nachname: "Becker",
|
|
dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER],
|
|
eintritt: datum(2012, 2, 1),
|
|
abteilung: "Aktive",
|
|
pin: "715384",
|
|
},
|
|
{
|
|
vorname: "Ingrid",
|
|
nachname: "Hofmann",
|
|
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2015, 6, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Josef",
|
|
nachname: "Schneider",
|
|
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2016, 4, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Katharina",
|
|
nachname: "Koch",
|
|
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2019, 9, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Ludwig",
|
|
nachname: "Richter",
|
|
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2020, 3, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Martina",
|
|
nachname: "Klein",
|
|
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2024, 1, 15),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Nikolaus",
|
|
nachname: "Wolf",
|
|
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2025, 2, 1),
|
|
abteilung: "Jugendfeuerwehr",
|
|
},
|
|
{
|
|
vorname: "Otto",
|
|
nachname: "Neumann",
|
|
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(1975, 5, 1),
|
|
abteilung: "Passive",
|
|
},
|
|
];
|
|
|
|
async function personenAnlegen(
|
|
organisationId: string,
|
|
rollenKarte: Map<string, string>,
|
|
): Promise<void> {
|
|
let nummer = 1;
|
|
|
|
for (const vorlage of PERSONEN) {
|
|
const mitgliedsnummer = String(nummer).padStart(4, "0");
|
|
const email = `${vorlage.vorname}.${vorlage.nachname}`
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss") + "@ff-musterhausen.de";
|
|
|
|
const person = await prisma.person.upsert({
|
|
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
mitgliedsnummer,
|
|
vorname: vorlage.vorname,
|
|
nachname: vorlage.nachname,
|
|
dienstgrad: vorlage.dienstgrad,
|
|
email,
|
|
telefon: `08161 ${100000 + nummer * 7}`,
|
|
strasse: `Beispielweg ${nummer}`,
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
eintrittsdatum: vorlage.eintritt,
|
|
abteilung: vorlage.abteilung,
|
|
aktiv: vorlage.abteilung !== "Passive",
|
|
},
|
|
});
|
|
|
|
for (const schluessel of vorlage.rollen) {
|
|
const rolleId = rollenKarte.get(schluessel);
|
|
if (!rolleId) continue;
|
|
await prisma.personRolle.upsert({
|
|
where: { personId_rolleId: { personId: person.id, rolleId } },
|
|
update: {},
|
|
create: { organisationId, personId: person.id, rolleId },
|
|
});
|
|
}
|
|
|
|
// Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte.
|
|
if (vorlage.pin) {
|
|
const vorhanden = await prisma.ausweisToken.findFirst({
|
|
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN },
|
|
});
|
|
if (!vorhanden) {
|
|
await prisma.ausweisToken.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typ: AusweisTokenTyp.PIN,
|
|
tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN),
|
|
bezeichnung: "Terminal-PIN",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (vorlage.rfid) {
|
|
const vorhanden = await prisma.ausweisToken.findFirst({
|
|
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID },
|
|
});
|
|
if (!vorhanden) {
|
|
await prisma.ausweisToken.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typ: AusweisTokenTyp.RFID,
|
|
tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN),
|
|
kennzeichen: vorlage.rfid.slice(-4),
|
|
bezeichnung: "Ausweiskarte",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
nummer += 1;
|
|
}
|
|
|
|
log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`);
|
|
}
|
|
|
|
/**
|
|
* Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für
|
|
* den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor
|
|
* authentik eingerichtet ist.
|
|
*/
|
|
async function adminAnlegen(organisationId: string, rollenKarte: Map<string, string>) {
|
|
const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase();
|
|
const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
|
|
|
const admin = await prisma.person.upsert({
|
|
where: { organisationId_email: { organisationId, email } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
mitgliedsnummer: "0000",
|
|
vorname: "System",
|
|
nachname: "Administrator",
|
|
email,
|
|
dienstgrad: Dienstgrad.KEIN,
|
|
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
|
passwortGeaendertAm: new Date(),
|
|
abteilung: "Verwaltung",
|
|
},
|
|
});
|
|
|
|
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
|
|
if (rolleId) {
|
|
await prisma.personRolle.upsert({
|
|
where: { personId_rolleId: { personId: admin.id, rolleId } },
|
|
update: {},
|
|
create: { organisationId, personId: admin.id, rolleId },
|
|
});
|
|
}
|
|
|
|
log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`);
|
|
return admin;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Standorte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function standorteAnlegen(organisationId: string) {
|
|
const vorlagen = [
|
|
{
|
|
name: "Gerätehaus",
|
|
kuerzel: "GH",
|
|
art: StandortArt.GERAETEHAUS,
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
},
|
|
{
|
|
name: "Lager Bauhof",
|
|
kuerzel: "LAG",
|
|
art: StandortArt.LAGER,
|
|
strasse: "Industriestraße 4",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
bemerkung: "Reserve und Ersatzteile, Schlüssel beim Bauhof",
|
|
},
|
|
{
|
|
name: "Atemschutzwerkstatt",
|
|
kuerzel: "ASW",
|
|
art: StandortArt.WERKSTATT,
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
bemerkung: "Im Gerätehaus, Untergeschoss",
|
|
},
|
|
];
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of vorlagen) {
|
|
const standort = await prisma.standort.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: { organisationId, ...vorlage },
|
|
});
|
|
karte.set(vorlage.name, standort.id);
|
|
}
|
|
|
|
log(`Standorte: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Gerätetypen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface TypVorlage {
|
|
name: string;
|
|
kategorie: GeraeteKategorie;
|
|
hersteller: string;
|
|
pruefintervallMonate: number;
|
|
pruefvorschrift?: string;
|
|
lebensdauerKategorie?: LebensdauerKategorie;
|
|
lebensdauerJahre?: number;
|
|
lebensdauerBezug?: LebensdauerBezug;
|
|
}
|
|
|
|
/**
|
|
* Prüfintervalle und Lebensdauern nach den üblichen Vorgaben. Sie sind hier
|
|
* bewusst konkret hinterlegt — eine leere Beispielwehr zeigt nicht, worum es
|
|
* bei diesem Programm eigentlich geht.
|
|
*/
|
|
const GERAETETYPEN: TypVorlage[] = [
|
|
{
|
|
name: "Pressluftatmer PA 94",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
|
lebensdauerKategorie: LebensdauerKategorie.PRESSLUFTATMER,
|
|
lebensdauerJahre: 20,
|
|
},
|
|
{
|
|
name: "Atemschutzmaske FPS 7000",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 6,
|
|
pruefvorschrift: "DGUV Regel 112-190",
|
|
lebensdauerKategorie: LebensdauerKategorie.ATEMANSCHLUSS,
|
|
lebensdauerJahre: 10,
|
|
},
|
|
{
|
|
name: "Atemluftflasche 6,8 l / 300 bar",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Luxfer",
|
|
pruefintervallMonate: 60,
|
|
pruefvorschrift: "Betriebssicherheitsverordnung, wiederkehrende Prüfung",
|
|
lebensdauerKategorie: LebensdauerKategorie.DRUCKBEHAELTER,
|
|
lebensdauerJahre: 20,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Feuerwehrhelm F220",
|
|
kategorie: GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG,
|
|
hersteller: "Schuberth",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Information 205-014",
|
|
lebensdauerKategorie: LebensdauerKategorie.HELM,
|
|
lebensdauerJahre: 15,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Feuerwehrleine 30 m",
|
|
kategorie: GeraeteKategorie.RETTUNG,
|
|
hersteller: "Gustav Bauer",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14920, DGUV Grundsatz 305-002",
|
|
lebensdauerKategorie: LebensdauerKategorie.SEIL_GURT,
|
|
lebensdauerJahre: 8,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Steckleiterteil",
|
|
kategorie: GeraeteKategorie.RETTUNG,
|
|
hersteller: "Iturri",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14711, DGUV Information 208-016",
|
|
lebensdauerKategorie: LebensdauerKategorie.LEITER,
|
|
lebensdauerJahre: 25,
|
|
},
|
|
{
|
|
name: "Tragkraftspritze PFPN 10-1000",
|
|
kategorie: GeraeteKategorie.WASSERFOERDERUNG,
|
|
hersteller: "Rosenbauer",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14420",
|
|
},
|
|
{
|
|
name: "Hydraulischer Rettungssatz",
|
|
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
|
hersteller: "Weber Rescue",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Grundsatz 305-002",
|
|
},
|
|
{
|
|
name: "Motorkettensäge MS 261",
|
|
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
|
hersteller: "Stihl",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3, Betriebsanleitung",
|
|
},
|
|
{
|
|
name: "Stromerzeuger 8 kVA",
|
|
kategorie: GeraeteKategorie.ELEKTRO,
|
|
hersteller: "Endress",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3",
|
|
},
|
|
{
|
|
name: "Wärmebildkamera",
|
|
kategorie: GeraeteKategorie.MESSTECHNIK,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 12,
|
|
},
|
|
{
|
|
name: "Handsprechfunkgerät TPH900",
|
|
kategorie: GeraeteKategorie.FUNK,
|
|
hersteller: "Airbus",
|
|
pruefintervallMonate: 24,
|
|
},
|
|
{
|
|
name: "B-Druckschlauch 20 m",
|
|
kategorie: GeraeteKategorie.SCHLAUCHMATERIAL,
|
|
hersteller: "Ziegler",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14811",
|
|
lebensdauerKategorie: LebensdauerKategorie.SCHLAUCH,
|
|
lebensdauerJahre: 10,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Überdrucklüfter",
|
|
kategorie: GeraeteKategorie.BRANDBEKAEMPFUNG,
|
|
hersteller: "Leader",
|
|
pruefintervallMonate: 12,
|
|
},
|
|
];
|
|
|
|
async function geraetetypenAnlegen(organisationId: string) {
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of GERAETETYPEN) {
|
|
const typ = await prisma.geraetetyp.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
kategorie: vorlage.kategorie,
|
|
hersteller: vorlage.hersteller,
|
|
pruefintervallMonate: vorlage.pruefintervallMonate,
|
|
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
|
lebensdauerKategorie: vorlage.lebensdauerKategorie ?? LebensdauerKategorie.KEINE,
|
|
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
|
|
lebensdauerBezug: vorlage.lebensdauerBezug ?? LebensdauerBezug.INDIENSTSTELLUNG,
|
|
},
|
|
});
|
|
karte.set(vorlage.name, typ.id);
|
|
}
|
|
|
|
log(`Gerätetypen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Fahrzeuge samt Geräteräumen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const FAHRZEUGE = [
|
|
{
|
|
funkrufname: "Florian Musterhausen 40/1",
|
|
bezeichnung: "Löschgruppenfahrzeug",
|
|
dinTyp: "LF 20",
|
|
art: FahrzeugArt.LOESCHFAHRZEUG,
|
|
kennzeichen: "FS-MU 401",
|
|
hersteller: "MAN",
|
|
aufbauhersteller: "Rosenbauer",
|
|
fahrgestellnummer: "WMAN12ZZ8JY123456",
|
|
baujahr: 2018,
|
|
erstzulassung: datum(2018, 6, 14),
|
|
indienststellung: datum(2018, 7, 1),
|
|
sitzplaetze: 9,
|
|
tankinhalt: 2000,
|
|
gesamtmasse: 16_000,
|
|
tuevBis: datum(2027, 5, 31),
|
|
uvvBis: datum(2026, 11, 30),
|
|
},
|
|
{
|
|
funkrufname: "Florian Musterhausen 42/1",
|
|
bezeichnung: "Tragkraftspritzenfahrzeug Wasser",
|
|
dinTyp: "TSF-W",
|
|
art: FahrzeugArt.LOESCHFAHRZEUG,
|
|
kennzeichen: "FS-MU 421",
|
|
hersteller: "Mercedes-Benz",
|
|
aufbauhersteller: "Ziegler",
|
|
fahrgestellnummer: "WDB90661310123789",
|
|
baujahr: 2011,
|
|
erstzulassung: datum(2011, 9, 20),
|
|
indienststellung: datum(2011, 10, 15),
|
|
sitzplaetze: 6,
|
|
tankinhalt: 750,
|
|
gesamtmasse: 7500,
|
|
tuevBis: datum(2026, 11, 30),
|
|
uvvBis: datum(2026, 10, 15),
|
|
},
|
|
{
|
|
funkrufname: "Florian Musterhausen 11/1",
|
|
bezeichnung: "Mannschaftstransportwagen",
|
|
dinTyp: "MTW",
|
|
art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
|
|
kennzeichen: "FS-MU 111",
|
|
hersteller: "Volkswagen",
|
|
fahrgestellnummer: "WV2ZZZ7HZMH098765",
|
|
baujahr: 2021,
|
|
erstzulassung: datum(2021, 3, 8),
|
|
indienststellung: datum(2021, 3, 15),
|
|
sitzplaetze: 9,
|
|
gesamtmasse: 3500,
|
|
tuevBis: datum(2027, 3, 31),
|
|
},
|
|
];
|
|
|
|
/** Beladeordnung. Der MTW hat weniger Fächer als ein Löschfahrzeug. */
|
|
const RAEUME_GROSS = [
|
|
{ bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
|
|
{ bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
|
|
{ bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
|
|
{ bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
|
|
{ bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
|
|
{ bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
|
|
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
|
|
{ bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
|
|
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
|
|
];
|
|
|
|
const RAEUME_KLEIN = [
|
|
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 10 },
|
|
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 20 },
|
|
];
|
|
|
|
async function fahrzeugeAnlegen(organisationId: string, standorte: Map<string, string>) {
|
|
const fahrzeuge: Array<{ id: string; funkrufname: string; raeume: Map<string, string> }> = [];
|
|
|
|
for (const vorlage of FAHRZEUGE) {
|
|
const fahrzeug = await prisma.fahrzeug.upsert({
|
|
where: {
|
|
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
|
|
},
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
...vorlage,
|
|
standortId: standorte.get("Gerätehaus") ?? null,
|
|
},
|
|
});
|
|
|
|
const vorlagen =
|
|
vorlage.art === FahrzeugArt.MANNSCHAFTSTRANSPORT ? RAEUME_KLEIN : RAEUME_GROSS;
|
|
|
|
const raeume = new Map<string, string>();
|
|
for (const raumVorlage of vorlagen) {
|
|
const raum = await prisma.geraeteraum.upsert({
|
|
where: {
|
|
fahrzeugId_bezeichnung: {
|
|
fahrzeugId: fahrzeug.id,
|
|
bezeichnung: raumVorlage.bezeichnung,
|
|
},
|
|
},
|
|
update: {},
|
|
create: { organisationId, fahrzeugId: fahrzeug.id, ...raumVorlage },
|
|
});
|
|
raeume.set(raumVorlage.bezeichnung, raum.id);
|
|
}
|
|
|
|
fahrzeuge.push({ id: fahrzeug.id, funkrufname: fahrzeug.funkrufname, raeume });
|
|
}
|
|
|
|
const raeumeGesamt = fahrzeuge.reduce((summe, f) => summe + f.raeume.size, 0);
|
|
log(`Fahrzeuge: ${fahrzeuge.length}, Geräteräume: ${raeumeGesamt}`);
|
|
return fahrzeuge;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Geräte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface GeraetVorlage {
|
|
typ: string;
|
|
anzahl: number;
|
|
/** Index in FAHRZEUGE, oder null für einen Standort. */
|
|
aufFahrzeug: number | null;
|
|
raum?: string;
|
|
standort?: string;
|
|
lagerort?: string;
|
|
}
|
|
|
|
const GERAETE: GeraetVorlage[] = [
|
|
{ typ: "Pressluftatmer PA 94", anzahl: 4, aufFahrzeug: 0, raum: "MR" },
|
|
{ typ: "Atemschutzmaske FPS 7000", anzahl: 6, aufFahrzeug: null, standort: "Atemschutzwerkstatt", lagerort: "Schrank 1" },
|
|
{ typ: "Atemluftflasche 6,8 l / 300 bar", anzahl: 8, aufFahrzeug: 0, raum: "G4" },
|
|
{ typ: "Feuerwehrhelm F220", anzahl: 6, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Umkleide, Spind-Reihe A" },
|
|
{ typ: "Feuerwehrleine 30 m", anzahl: 3, aufFahrzeug: 0, raum: "G2" },
|
|
{ typ: "Steckleiterteil", anzahl: 4, aufFahrzeug: 0, raum: "Dach" },
|
|
{ typ: "Tragkraftspritze PFPN 10-1000", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
|
{ typ: "Hydraulischer Rettungssatz", anzahl: 1, aufFahrzeug: 0, raum: "G3" },
|
|
{ typ: "Motorkettensäge MS 261", anzahl: 2, aufFahrzeug: 0, raum: "G5" },
|
|
{ typ: "Stromerzeuger 8 kVA", anzahl: 1, aufFahrzeug: 0, raum: "G6" },
|
|
{ typ: "Wärmebildkamera", anzahl: 1, aufFahrzeug: 0, raum: "MR" },
|
|
{ typ: "Handsprechfunkgerät TPH900", anzahl: 3, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Funkraum, Ladeschale" },
|
|
{ typ: "B-Druckschlauch 20 m", anzahl: 8, aufFahrzeug: 0, raum: "G1" },
|
|
{ typ: "Überdrucklüfter", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
|
];
|
|
|
|
async function geraeteAnlegen(
|
|
organisationId: string,
|
|
typen: Map<string, string>,
|
|
fahrzeuge: Array<{ id: string; raeume: Map<string, string> }>,
|
|
standorte: Map<string, string>,
|
|
) {
|
|
const heute = new Date();
|
|
let laufend = 1;
|
|
let angelegt = 0;
|
|
|
|
for (const vorlage of GERAETE) {
|
|
const typId = typen.get(vorlage.typ);
|
|
const typVorlage = GERAETETYPEN.find((t) => t.name === vorlage.typ);
|
|
if (!typId || !typVorlage) continue;
|
|
|
|
for (let index = 0; index < vorlage.anzahl; index += 1) {
|
|
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
|
|
|
|
// Ein Teil der Geräte bekommt eine überschrittene Frist, damit die
|
|
// Statusanzeige nicht durchgehend grün ist und man sieht, wofür sie da
|
|
// ist.
|
|
const ueberfaellig = laufend % 4 === 0;
|
|
const indienststellung = datum(2015 + (laufend % 9), (laufend % 12) + 1, 15);
|
|
const herstellung = datum(indienststellung.getUTCFullYear() - 1, 6, 1);
|
|
|
|
const letztePruefung = ueberfaellig
|
|
? plusMonate(heute, -(typVorlage.pruefintervallMonate + 2))
|
|
: plusMonate(heute, -(laufend % typVorlage.pruefintervallMonate));
|
|
|
|
const naechstePruefung = plusMonate(letztePruefung, typVorlage.pruefintervallMonate);
|
|
|
|
const lebensdauerBis = typVorlage.lebensdauerJahre
|
|
? plusJahre(
|
|
typVorlage.lebensdauerBezug === LebensdauerBezug.HERSTELLUNG
|
|
? herstellung
|
|
: indienststellung,
|
|
typVorlage.lebensdauerJahre,
|
|
)
|
|
: null;
|
|
|
|
const status =
|
|
lebensdauerBis && lebensdauerBis < heute
|
|
? GeraetStatus.GESPERRT
|
|
: naechstePruefung < heute
|
|
? GeraetStatus.PRUEFUNG_FAELLIG
|
|
: laufend % 19 === 0
|
|
? GeraetStatus.IN_REPARATUR
|
|
: GeraetStatus.EINSATZBEREIT;
|
|
|
|
const fahrzeug = vorlage.aufFahrzeug !== null ? fahrzeuge[vorlage.aufFahrzeug] : null;
|
|
|
|
await prisma.geraet.upsert({
|
|
where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
inventarnummer,
|
|
bezeichnung: vorlage.typ,
|
|
geraetetypId: typId,
|
|
hersteller: typVorlage.hersteller,
|
|
seriennummer: `SN-${typVorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
|
|
herstellungsdatum: herstellung,
|
|
indienststellung,
|
|
anschaffungspreis: preisFuer(typVorlage.kategorie, laufend),
|
|
status,
|
|
letztePruefung,
|
|
naechstePruefung,
|
|
lebensdauerBis,
|
|
fahrzeugId: fahrzeug?.id ?? null,
|
|
geraeteraumId: fahrzeug && vorlage.raum ? (fahrzeug.raeume.get(vorlage.raum) ?? null) : null,
|
|
standortId: fahrzeug ? null : (standorte.get(vorlage.standort ?? "Gerätehaus") ?? null),
|
|
lagerort: vorlage.lagerort ?? null,
|
|
},
|
|
});
|
|
|
|
laufend += 1;
|
|
angelegt += 1;
|
|
}
|
|
}
|
|
|
|
log(`Geräte: ${angelegt}`);
|
|
}
|
|
|
|
/** Grobe Anschaffungspreise, damit Auswertungen später etwas zu rechnen haben. */
|
|
function preisFuer(kategorie: GeraeteKategorie, streuung: number): number {
|
|
const grundpreise: Partial<Record<GeraeteKategorie, number>> = {
|
|
[GeraeteKategorie.ATEMSCHUTZ]: 1800,
|
|
[GeraeteKategorie.RETTUNG]: 450,
|
|
[GeraeteKategorie.TECHNISCHE_HILFELEISTUNG]: 6500,
|
|
[GeraeteKategorie.ELEKTRO]: 3200,
|
|
[GeraeteKategorie.MESSTECHNIK]: 4800,
|
|
[GeraeteKategorie.FUNK]: 1400,
|
|
[GeraeteKategorie.SCHLAUCHMATERIAL]: 120,
|
|
[GeraeteKategorie.WASSERFOERDERUNG]: 9500,
|
|
[GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG]: 320,
|
|
[GeraeteKategorie.BRANDBEKAEMPFUNG]: 2100,
|
|
};
|
|
const grund = grundpreise[kategorie] ?? 250;
|
|
return Math.round((grund + (streuung % 7) * grund * 0.03) * 100) / 100;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Qualifikationen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface QualiTypVorlage {
|
|
name: string;
|
|
kuerzel: string;
|
|
art: QualifikationsArt;
|
|
gueltigkeitMonate?: number;
|
|
vorwarnzeitTage?: number;
|
|
einsatzrelevant?: boolean;
|
|
}
|
|
|
|
const QUALIFIKATIONSTYPEN: QualiTypVorlage[] = [
|
|
{ name: "Truppmann Teil 1 (MTA Basismodul)", kuerzel: "TM1", art: QualifikationsArt.LEHRGANG, einsatzrelevant: true },
|
|
{ name: "Truppmann Teil 2", kuerzel: "TM2", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Truppführer", kuerzel: "TF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Gruppenführer", kuerzel: "GF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Zugführer", kuerzel: "ZF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Maschinist für Löschfahrzeuge", kuerzel: "MA", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Sprechfunker", kuerzel: "SF", art: QualifikationsArt.LEHRGANG },
|
|
{
|
|
name: "Atemschutzgeräteträger",
|
|
kuerzel: "AGT",
|
|
art: QualifikationsArt.BEFAEHIGUNG,
|
|
gueltigkeitMonate: 12,
|
|
vorwarnzeitTage: 60,
|
|
einsatzrelevant: true,
|
|
},
|
|
{
|
|
name: "Arbeitsmedizinische Vorsorge G26.3",
|
|
kuerzel: "G26.3",
|
|
art: QualifikationsArt.UNTERSUCHUNG,
|
|
gueltigkeitMonate: 36,
|
|
vorwarnzeitTage: 120,
|
|
einsatzrelevant: true,
|
|
},
|
|
{
|
|
name: "Belastungsübung Atemschutz",
|
|
kuerzel: "BÜ",
|
|
art: QualifikationsArt.UNTERWEISUNG,
|
|
gueltigkeitMonate: 12,
|
|
vorwarnzeitTage: 60,
|
|
einsatzrelevant: true,
|
|
},
|
|
{ name: "Motorsägenführer", kuerzel: "MS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 60 },
|
|
{ name: "Führerschein C/CE", kuerzel: "C/CE", art: QualifikationsArt.FUEHRERSCHEIN, gueltigkeitMonate: 60, vorwarnzeitTage: 90 },
|
|
{ name: "Erste Hilfe", kuerzel: "EH", art: QualifikationsArt.UNTERWEISUNG, gueltigkeitMonate: 24 },
|
|
{ name: "Absturzsicherung", kuerzel: "AS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 12 },
|
|
];
|
|
|
|
async function qualifikationstypenAnlegen(organisationId: string) {
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of QUALIFIKATIONSTYPEN) {
|
|
const typ = await prisma.qualifikationstyp.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
kuerzel: vorlage.kuerzel,
|
|
art: vorlage.art,
|
|
gueltigkeitMonate: vorlage.gueltigkeitMonate ?? null,
|
|
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 90,
|
|
einsatzrelevant: vorlage.einsatzrelevant ?? false,
|
|
},
|
|
});
|
|
karte.set(vorlage.name, typ.id);
|
|
}
|
|
|
|
log(`Qualifikationstypen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
/**
|
|
* Verteilt Qualifikationen auf die Mannschaft.
|
|
*
|
|
* Ein Teil läuft absichtlich demnächst ab oder ist bereits abgelaufen — sonst
|
|
* bliebe die Ablaufwarnung in der Beispielwehr leer, und man sähe nicht, wofür
|
|
* sie da ist.
|
|
*/
|
|
async function qualifikationenAnlegen(organisationId: string, typen: Map<string, string>) {
|
|
const personen = await prisma.person.findMany({
|
|
where: { organisationId, geloeschtAm: null, abteilung: { not: "Verwaltung" } },
|
|
orderBy: { mitgliedsnummer: "asc" },
|
|
select: { id: true, mitgliedsnummer: true, eintrittsdatum: true },
|
|
});
|
|
|
|
const heute = new Date();
|
|
let angelegt = 0;
|
|
|
|
for (const [index, person] of personen.entries()) {
|
|
const eintritt = person.eintrittsdatum ?? datum(2015, 1, 1);
|
|
|
|
/** Grundausbildung hat jeder, weitere je nach Dienstalter. */
|
|
const zuweisungen: Array<{ typ: string; erworben: Date; gueltigBis?: Date | null }> = [
|
|
{ typ: "Truppmann Teil 1 (MTA Basismodul)", erworben: plusMonate(eintritt, 6) },
|
|
];
|
|
|
|
if (index < 12) {
|
|
zuweisungen.push({ typ: "Truppmann Teil 2", erworben: plusMonate(eintritt, 24) });
|
|
zuweisungen.push({ typ: "Sprechfunker", erworben: plusMonate(eintritt, 12) });
|
|
}
|
|
if (index < 8) {
|
|
zuweisungen.push({ typ: "Maschinist für Löschfahrzeuge", erworben: plusMonate(eintritt, 36) });
|
|
}
|
|
if (index < 6) {
|
|
zuweisungen.push({ typ: "Truppführer", erworben: plusMonate(eintritt, 48) });
|
|
}
|
|
if (index < 3) {
|
|
zuweisungen.push({ typ: "Gruppenführer", erworben: plusMonate(eintritt, 72) });
|
|
}
|
|
|
|
// Atemschutz: die Fristen werden gestreut, damit die Ampel etwas zeigt.
|
|
if (index < 9) {
|
|
const versatz = [-2, -1, 1, 2, 3, 5, 8, 10, 11][index] ?? 6;
|
|
zuweisungen.push({
|
|
typ: "Atemschutzgeräteträger",
|
|
erworben: plusMonate(heute, versatz - 12),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
zuweisungen.push({
|
|
typ: "Arbeitsmedizinische Vorsorge G26.3",
|
|
erworben: plusMonate(heute, versatz - 36),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
zuweisungen.push({
|
|
typ: "Belastungsübung Atemschutz",
|
|
erworben: plusMonate(heute, versatz - 12),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
}
|
|
|
|
if (index < 5) {
|
|
zuweisungen.push({
|
|
typ: "Führerschein C/CE",
|
|
erworben: plusMonate(eintritt, 12),
|
|
gueltigBis: plusMonate(heute, 6 + index * 4),
|
|
});
|
|
}
|
|
if (index < 4) {
|
|
zuweisungen.push({
|
|
typ: "Motorsägenführer",
|
|
erworben: plusMonate(heute, -30),
|
|
gueltigBis: plusMonate(heute, 30),
|
|
});
|
|
}
|
|
|
|
zuweisungen.push({
|
|
typ: "Erste Hilfe",
|
|
erworben: plusMonate(heute, -20 - index),
|
|
gueltigBis: plusMonate(heute, 4 - index),
|
|
});
|
|
|
|
for (const zuweisung of zuweisungen) {
|
|
const typId = typen.get(zuweisung.typ);
|
|
if (!typId) continue;
|
|
|
|
const vorhanden = await prisma.qualifikation.findFirst({
|
|
where: { organisationId, personId: person.id, typId },
|
|
select: { id: true },
|
|
});
|
|
if (vorhanden) continue;
|
|
|
|
await prisma.qualifikation.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typId,
|
|
erworbenAm: zuweisung.erworben,
|
|
gueltigBis: zuweisung.gueltigBis ?? null,
|
|
ausstellendeStelle:
|
|
zuweisung.typ.includes("G26") ? "Betriebsarzt Dr. Berger" : "Kreisbrandinspektion Freising",
|
|
},
|
|
});
|
|
angelegt += 1;
|
|
}
|
|
}
|
|
|
|
log(`Qualifikationen: ${angelegt}`);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
|
|
//
|
|
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
|
|
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
|
|
// heißt, zeigt nicht, worum es geht.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface PunktVorlage {
|
|
text: string;
|
|
typ: ChecklistenpunktTyp;
|
|
pflicht?: boolean;
|
|
sollwert?: string;
|
|
einheit?: string;
|
|
minWert?: number;
|
|
maxWert?: number;
|
|
hinweis?: string;
|
|
}
|
|
|
|
interface WartungsVorlage {
|
|
name: string;
|
|
geraetetyp: string;
|
|
intervallMonate: number;
|
|
vorwarnzeitTage?: number;
|
|
pruefvorschrift?: string;
|
|
standardDauerMinuten?: number;
|
|
punkte: PunktVorlage[];
|
|
}
|
|
|
|
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
|
|
{
|
|
name: "Pressluftatmer — Jahresprüfung",
|
|
geraetetyp: "Pressluftatmer PA 94",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
|
standardDauerMinuten: 25,
|
|
punkte: [
|
|
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 10 bar in einer Minute",
|
|
einheit: "bar",
|
|
minWert: 0,
|
|
maxWert: 10,
|
|
},
|
|
{
|
|
text: "Ansprechdruck der Warneinrichtung",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "50 bis 60 bar",
|
|
einheit: "bar",
|
|
minWert: 50,
|
|
maxWert: 60,
|
|
},
|
|
{
|
|
text: "Lungenautomat: Öffnungsdruck",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "bis 3,5 mbar",
|
|
einheit: "mbar",
|
|
maxWert: 3.5,
|
|
},
|
|
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Atemanschluss — Halbjahresprüfung",
|
|
geraetetyp: "Atemschutzmaske FPS 7000",
|
|
intervallMonate: 6,
|
|
pruefvorschrift: "DGUV Regel 112-190",
|
|
standardDauerMinuten: 15,
|
|
punkte: [
|
|
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Dichtprüfung: Druckabfall in einer Minute",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 0,5 mbar",
|
|
einheit: "mbar",
|
|
minWert: 0,
|
|
maxWert: 0.5,
|
|
},
|
|
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Feuerwehrleine — Jahresprüfung",
|
|
geraetetyp: "Feuerwehrleine 30 m",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
|
|
standardDauerMinuten: 10,
|
|
punkte: [
|
|
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
|
|
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Druckschlauch — Druckprüfung",
|
|
geraetetyp: "B-Druckschlauch 20 m",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DIN 14811",
|
|
standardDauerMinuten: 8,
|
|
punkte: [
|
|
{
|
|
text: "Prüfdruck halten",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "mindestens 8 bar über 5 Minuten",
|
|
einheit: "bar",
|
|
minWert: 8,
|
|
},
|
|
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
],
|
|
},
|
|
{
|
|
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
|
|
geraetetyp: "Stromerzeuger 8 kVA",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
|
|
standardDauerMinuten: 30,
|
|
punkte: [
|
|
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Schutzleiterwiderstand",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 0,3 Ohm",
|
|
einheit: "Ohm",
|
|
minWert: 0,
|
|
maxWert: 0.3,
|
|
},
|
|
{
|
|
text: "Isolationswiderstand",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "mindestens 1 Megaohm",
|
|
einheit: "MOhm",
|
|
minWert: 1,
|
|
},
|
|
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
],
|
|
},
|
|
];
|
|
|
|
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
|
|
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
|
|
|
|
for (const vorlage of WARTUNGSVORLAGEN) {
|
|
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
|
|
|
|
const angelegt = await prisma.wartungsvorlage.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
geraetetypId,
|
|
intervallMonate: vorlage.intervallMonate,
|
|
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
|
|
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
|
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
|
|
},
|
|
});
|
|
|
|
const punktIds: string[] = [];
|
|
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
|
|
|
|
if (vorhandene === 0) {
|
|
for (const [index, punkt] of vorlage.punkte.entries()) {
|
|
const erzeugt = await prisma.checklistenpunkt.create({
|
|
data: {
|
|
organisationId,
|
|
vorlageId: angelegt.id,
|
|
text: punkt.text,
|
|
typ: punkt.typ,
|
|
pflicht: punkt.pflicht ?? true,
|
|
sollwert: punkt.sollwert ?? null,
|
|
einheit: punkt.einheit ?? null,
|
|
minWert: punkt.minWert ?? null,
|
|
maxWert: punkt.maxWert ?? null,
|
|
hinweis: punkt.hinweis ?? null,
|
|
sortierung: (index + 1) * 10,
|
|
},
|
|
});
|
|
punktIds.push(erzeugt.id);
|
|
}
|
|
} else {
|
|
const bestand = await prisma.checklistenpunkt.findMany({
|
|
where: { vorlageId: angelegt.id },
|
|
orderBy: { sortierung: "asc" },
|
|
select: { id: true },
|
|
});
|
|
punktIds.push(...bestand.map((punkt) => punkt.id));
|
|
}
|
|
|
|
karte.set(vorlage.name, {
|
|
id: angelegt.id,
|
|
intervallMonate: vorlage.intervallMonate,
|
|
punkte: punktIds,
|
|
});
|
|
}
|
|
|
|
log(`Wartungsvorlagen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
/**
|
|
* Termine für alle Geräte der jeweiligen Bauart.
|
|
*
|
|
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
|
|
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
|
|
* Fristen-Dashboard hat.
|
|
*/
|
|
async function wartungstermineAnlegen(
|
|
organisationId: string,
|
|
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
|
) {
|
|
let angelegt = 0;
|
|
|
|
for (const vorlage of WARTUNGSVORLAGEN) {
|
|
const eintrag = vorlagen.get(vorlage.name);
|
|
if (!eintrag) continue;
|
|
|
|
const geraete = await prisma.geraet.findMany({
|
|
where: {
|
|
organisationId,
|
|
geloeschtAm: null,
|
|
geraetetyp: { name: vorlage.geraetetyp },
|
|
},
|
|
select: { id: true, letztePruefung: true, indienststellung: true },
|
|
});
|
|
|
|
for (const geraet of geraete) {
|
|
const basis = geraet.letztePruefung ?? geraet.indienststellung;
|
|
if (!basis) continue;
|
|
|
|
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
|
|
|
|
const ergebnis = await prisma.wartungstermin.createMany({
|
|
data: [
|
|
{
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
vorlageId: eintrag.id,
|
|
faelligAm,
|
|
automatischErzeugt: true,
|
|
},
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
angelegt += ergebnis.count;
|
|
}
|
|
}
|
|
|
|
log(`Wartungstermine: ${angelegt}`);
|
|
}
|
|
|
|
/**
|
|
* Ein abgeschlossenes Protokoll samt Mangel.
|
|
*
|
|
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
|
|
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
|
|
* Worker, sobald einer läuft.
|
|
*/
|
|
async function beispielPruefungAnlegen(
|
|
organisationId: string,
|
|
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
|
) {
|
|
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
|
|
if (!vorlage) return;
|
|
|
|
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log("Beispielprüfung: bereits vorhanden");
|
|
return;
|
|
}
|
|
|
|
const geraet = await prisma.geraet.findFirst({
|
|
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
|
|
select: { id: true, inventarnummer: true },
|
|
});
|
|
if (!geraet) return;
|
|
|
|
const pruefer = await prisma.person.findFirst({
|
|
where: {
|
|
organisationId,
|
|
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
|
|
},
|
|
select: { id: true, vorname: true, nachname: true },
|
|
});
|
|
|
|
const geprueftAm = plusMonate(new Date(), -2);
|
|
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
|
|
|
|
const punkte = await prisma.checklistenpunkt.findMany({
|
|
where: { vorlageId: vorlage.id },
|
|
orderBy: { sortierung: "asc" },
|
|
});
|
|
|
|
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
|
|
// Trigger aus Migration 20260903000300 lässt an einer bereits
|
|
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
|
|
// das ist auch für den Seed richtig so.
|
|
const pruefung = await prisma.pruefung.create({
|
|
data: {
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
vorlageId: vorlage.id,
|
|
art: PruefungArt.INTERN,
|
|
status: PruefungStatus.ENTWURF,
|
|
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
|
|
prueferId: pruefer?.id ?? null,
|
|
prueferName,
|
|
durchgefuehrtAm: geprueftAm,
|
|
begonnenUm: geprueftAm,
|
|
dauerMinuten: 12,
|
|
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
|
|
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
|
|
abgeschlossenAm: geprueftAm,
|
|
bemerkung:
|
|
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
|
|
ergebnisse: {
|
|
create: punkte.map((punkt, index) => ({
|
|
organisationId,
|
|
punktId: punkt.id,
|
|
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
|
|
text:
|
|
punkt.typ === ChecklistenpunktTyp.TEXT
|
|
? "Leine selbst ohne Befund, Beutel beschädigt."
|
|
: null,
|
|
inOrdnung: index !== 2,
|
|
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
|
|
})),
|
|
},
|
|
signaturen: {
|
|
create: [
|
|
{
|
|
organisationId,
|
|
rolle: SignaturRolle.PRUEFER,
|
|
personId: pruefer?.id ?? null,
|
|
name: prueferName,
|
|
unterzeichnetAm: geprueftAm,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
await prisma.pruefung.update({
|
|
where: { id: pruefung.id },
|
|
data: { status: PruefungStatus.ABGESCHLOSSEN },
|
|
});
|
|
|
|
await prisma.mangel.create({
|
|
data: {
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
pruefungId: pruefung.id,
|
|
melderId: pruefer?.id ?? null,
|
|
melderName: prueferName,
|
|
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
|
|
prioritaet: MangelPrioritaet.NIEDRIG,
|
|
status: MangelStatus.ZUGEWIESEN,
|
|
zugewiesenAnId: pruefer?.id ?? null,
|
|
},
|
|
});
|
|
|
|
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
|
|
}
|
|
|
|
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
|
|
async function rueckrufAnlegen(organisationId: string) {
|
|
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
|
|
if (vorhanden > 0) return;
|
|
|
|
const typ = await prisma.geraetetyp.findFirst({
|
|
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
|
|
select: { id: true },
|
|
});
|
|
|
|
await prisma.rueckruf.create({
|
|
data: {
|
|
organisationId,
|
|
hersteller: "Dräger",
|
|
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
|
|
referenz: "SI-2026-03",
|
|
beschreibung:
|
|
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
|
|
+ "Betroffen sind Masken mit Herstellung vor 2020.",
|
|
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
|
|
veroeffentlichtAm: plusMonate(new Date(), -1),
|
|
sperrtGeraete: false,
|
|
typen: typ
|
|
? {
|
|
create: [
|
|
{
|
|
organisationId,
|
|
geraetetypId: typ.id,
|
|
herstellungBis: datum(2019, 12, 31),
|
|
},
|
|
],
|
|
}
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
log("Rückruf: 1 Beispiel");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phase 3 — Einsätze und Übungen
|
|
//
|
|
// Ein Jahr im Leben einer Wehr: Brände, technische Hilfeleistungen, ein
|
|
// Fehlalarm einer Brandmeldeanlage und die Übungen dazwischen. Die Zahlen sind
|
|
// so gewählt, dass die Statistik etwas zeigt — auch eine Hilfsfrist, die
|
|
// gerade noch passt.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface EinsatzVorlage {
|
|
/** Tage vor heute. */
|
|
vorTagen: number;
|
|
uhrzeit: [number, number];
|
|
stichwort: string;
|
|
kategorie: EinsatzKategorie;
|
|
strasse: string;
|
|
hausnummer?: string;
|
|
objekt?: string;
|
|
/** Minuten nach der Alarmierung: ausgerückt, eingetroffen, Ende, eingerückt. */
|
|
ablauf: [number, number, number, number];
|
|
fahrzeuge: number[];
|
|
/** Wie viele Kräfte, und wer davon führt. */
|
|
mannschaft: number;
|
|
gerettet?: number;
|
|
verletzt?: number;
|
|
sachschaden?: number;
|
|
beschreibung: string;
|
|
bericht?: string;
|
|
meldungen?: Array<{ nachMinuten: number; text: string; wichtig?: boolean }>;
|
|
material?: Array<{ bezeichnung: string; menge: number; einheit: string }>;
|
|
status?: EinsatzStatus;
|
|
}
|
|
|
|
const EINSAETZE: EinsatzVorlage[] = [
|
|
{
|
|
vorTagen: 12,
|
|
uhrzeit: [3, 42],
|
|
stichwort: "B 3 Wohnhausbrand",
|
|
kategorie: EinsatzKategorie.BRAND_GROSS,
|
|
strasse: "Bahnhofstraße",
|
|
hausnummer: "14",
|
|
objekt: "Reihenhaus, zwei Vollgeschosse",
|
|
ablauf: [5, 9, 143, 168],
|
|
fahrzeuge: [0, 1],
|
|
mannschaft: 14,
|
|
gerettet: 2,
|
|
verletzt: 1,
|
|
sachschaden: 85000,
|
|
beschreibung:
|
|
"Bei Eintreffen Rauch aus dem Dachgeschoss, zwei Personen im Obergeschoss gemeldet.",
|
|
bericht:
|
|
"Menschenrettung über die Steckleiter durch den Angriffstrupp. Zwei Personen unverletzt gerettet, eine Person mit Rauchgasintoxikation an den Rettungsdienst übergeben.\n\nBrandbekämpfung mit einem C-Rohr unter Atemschutz vom Treppenraum aus. Nachlöscharbeiten und Kontrolle mit der Wärmebildkamera bis 06:05 Uhr. Einsatzstelle an die Polizei übergeben.",
|
|
meldungen: [
|
|
{ nachMinuten: 10, text: "Rauch aus Dachgeschoss, zwei Personen vermisst.", wichtig: true },
|
|
{ nachMinuten: 22, text: "Beide Personen gerettet, Menschenrettung abgeschlossen.", wichtig: true },
|
|
{ nachMinuten: 48, text: "Feuer unter Kontrolle." },
|
|
{ nachMinuten: 140, text: "Nachlöscharbeiten beendet, Kontrolle mit Wärmebildkamera." },
|
|
],
|
|
material: [
|
|
{ bezeichnung: "Atemluftflaschen 300 bar", menge: 6, einheit: "Stück" },
|
|
{ bezeichnung: "Löschwasser", menge: 4500, einheit: "Liter" },
|
|
],
|
|
},
|
|
{
|
|
vorTagen: 26,
|
|
uhrzeit: [14, 8],
|
|
stichwort: "THL 1 Ölspur",
|
|
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
|
strasse: "Hauptstraße",
|
|
ablauf: [6, 11, 55, 68],
|
|
fahrzeuge: [1],
|
|
mannschaft: 6,
|
|
beschreibung: "Ölspur über etwa 300 Meter, ausgehend von einem Traktor.",
|
|
bericht: "Ölspur mit Bindemittel abgestreut und aufgenommen. Straßenmeisterei verständigt.",
|
|
material: [{ bezeichnung: "Ölbindemittel", menge: 40, einheit: "kg" }],
|
|
},
|
|
{
|
|
vorTagen: 41,
|
|
uhrzeit: [19, 55],
|
|
stichwort: "THL 2 Verkehrsunfall",
|
|
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_MITTEL,
|
|
strasse: "Kreisstraße MÜ 12",
|
|
objekt: "zwischen Musterhausen und Nachbarhausen",
|
|
ablauf: [4, 12, 78, 96],
|
|
fahrzeuge: [0],
|
|
mannschaft: 9,
|
|
verletzt: 2,
|
|
sachschaden: 24000,
|
|
beschreibung: "Zwei Fahrzeuge, eine Person eingeklemmt.",
|
|
bericht:
|
|
"Person mit dem hydraulischen Rettungssatz befreit und an den Rettungsdienst übergeben. Brandschutz sichergestellt, Fahrzeuge gegen Wegrollen gesichert, Betriebsstoffe aufgenommen.",
|
|
meldungen: [
|
|
{ nachMinuten: 14, text: "Eine Person eingeklemmt, Rettungsdienst vor Ort.", wichtig: true },
|
|
{ nachMinuten: 39, text: "Person befreit und übergeben.", wichtig: true },
|
|
],
|
|
material: [{ bezeichnung: "Ölbindemittel", menge: 15, einheit: "kg" }],
|
|
},
|
|
{
|
|
vorTagen: 58,
|
|
uhrzeit: [9, 12],
|
|
stichwort: "BMA Auslösung",
|
|
kategorie: EinsatzKategorie.BRANDMELDEANLAGE,
|
|
strasse: "Schulstraße",
|
|
hausnummer: "3",
|
|
objekt: "Grundschule",
|
|
ablauf: [7, 13, 32, 44],
|
|
fahrzeuge: [0],
|
|
mannschaft: 8,
|
|
beschreibung: "Auslösung eines Rauchmelders im Werkraum.",
|
|
bericht: "Kein Schadenfeuer. Auslösung durch Staub bei Reinigungsarbeiten. Anlage zurückgestellt.",
|
|
},
|
|
{
|
|
vorTagen: 73,
|
|
uhrzeit: [16, 30],
|
|
stichwort: "B 1 Kleinbrand",
|
|
kategorie: EinsatzKategorie.BRAND_KLEIN,
|
|
strasse: "Am Anger",
|
|
hausnummer: "7",
|
|
ablauf: [5, 8, 41, 52],
|
|
fahrzeuge: [1],
|
|
mannschaft: 6,
|
|
sachschaden: 1200,
|
|
beschreibung: "Brennende Mülltonne an der Hauswand.",
|
|
bericht: "Ablöschen mit Schnellangriff, Kontrolle der Fassade mit der Wärmebildkamera.",
|
|
},
|
|
{
|
|
vorTagen: 95,
|
|
uhrzeit: [21, 47],
|
|
stichwort: "THL 1 Sturmschaden",
|
|
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
|
strasse: "Lindenweg",
|
|
ablauf: [8, 16, 62, 74],
|
|
fahrzeuge: [0],
|
|
mannschaft: 7,
|
|
beschreibung: "Baum auf Fahrbahn nach Gewitterfront.",
|
|
bericht: "Baum mit der Motorkettensäge zerlegt und beiseitegeräumt. Fahrbahn gereinigt.",
|
|
material: [{ bezeichnung: "Kettensägenbenzin", menge: 2, einheit: "Liter" }],
|
|
},
|
|
{
|
|
vorTagen: 118,
|
|
uhrzeit: [11, 20],
|
|
stichwort: "Sicherheitswache Pfarrfest",
|
|
kategorie: EinsatzKategorie.SICHERHEITSWACHE,
|
|
strasse: "Kirchplatz",
|
|
ablauf: [10, 20, 300, 310],
|
|
fahrzeuge: [2],
|
|
mannschaft: 4,
|
|
beschreibung: "Brandsicherheitswache beim Pfarrfest.",
|
|
bericht: "Ohne besondere Vorkommnisse.",
|
|
},
|
|
{
|
|
vorTagen: 140,
|
|
uhrzeit: [6, 5],
|
|
stichwort: "THL 1 Türöffnung",
|
|
kategorie: EinsatzKategorie.RETTUNGSDIENST,
|
|
strasse: "Rosenstraße",
|
|
hausnummer: "22",
|
|
ablauf: [6, 10, 28, 38],
|
|
fahrzeuge: [1],
|
|
mannschaft: 5,
|
|
gerettet: 1,
|
|
beschreibung: "Hilflose Person hinter verschlossener Tür, Rettungsdienst vor Ort.",
|
|
bericht: "Tür schonend geöffnet, Person an den Rettungsdienst übergeben.",
|
|
},
|
|
];
|
|
|
|
/** Funktionen einer Gruppe in der Reihenfolge, in der sie besetzt werden. */
|
|
const FUNKTIONSFOLGE: EinsatzFunktion[] = [
|
|
EinsatzFunktion.GRUPPENFUEHRER,
|
|
EinsatzFunktion.MASCHINIST,
|
|
EinsatzFunktion.MELDER,
|
|
EinsatzFunktion.TRUPPFUEHRER,
|
|
EinsatzFunktion.TRUPPMANN,
|
|
EinsatzFunktion.TRUPPFUEHRER,
|
|
EinsatzFunktion.TRUPPMANN,
|
|
EinsatzFunktion.TRUPPFUEHRER,
|
|
EinsatzFunktion.TRUPPMANN,
|
|
];
|
|
|
|
function minutenSpaeter(basis: Date, minuten: number): Date {
|
|
return new Date(basis.getTime() + minuten * 60_000);
|
|
}
|
|
|
|
async function einsaetzeAnlegen(
|
|
organisationId: string,
|
|
fahrzeuge: Array<{ id: string; funkrufname: string }>,
|
|
) {
|
|
const vorhanden = await prisma.einsatz.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log(`Einsätze: bereits ${vorhanden} 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 === 0 || fahrzeuge.length === 0) return;
|
|
|
|
let laufend = 0;
|
|
|
|
for (const vorlage of EINSAETZE) {
|
|
laufend += 1;
|
|
|
|
const alarmiert = new Date();
|
|
alarmiert.setUTCDate(alarmiert.getUTCDate() - vorlage.vorTagen);
|
|
alarmiert.setUTCHours(vorlage.uhrzeit[0], vorlage.uhrzeit[1], 0, 0);
|
|
|
|
const jahr = alarmiert.getUTCFullYear();
|
|
const [ausgerueckt, eingetroffen, ende, eingerueckt] = vorlage.ablauf;
|
|
|
|
const einsatz = await prisma.einsatz.create({
|
|
data: {
|
|
organisationId,
|
|
einsatznummer: `${jahr}-${String(laufend).padStart(4, "0")}`,
|
|
stichwort: vorlage.stichwort,
|
|
kategorie: vorlage.kategorie,
|
|
status: vorlage.status ?? EinsatzStatus.ABGESCHLOSSEN,
|
|
alarmierungsart: Alarmierungsart.MELDEEMPFAENGER,
|
|
strasse: vorlage.strasse,
|
|
hausnummer: vorlage.hausnummer ?? null,
|
|
plz: "84543",
|
|
ort: "Musterhausen",
|
|
objekt: vorlage.objekt ?? null,
|
|
alarmiertUm: alarmiert,
|
|
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt),
|
|
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen),
|
|
einsatzendeUm: minutenSpaeter(alarmiert, ende),
|
|
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
|
einsatzbereitUm: minutenSpaeter(alarmiert, eingerueckt + 30),
|
|
einsatzleiterId: personen[0]?.id ?? null,
|
|
beschreibung: vorlage.beschreibung,
|
|
bericht: vorlage.bericht ?? null,
|
|
geretteteePersonen: vorlage.gerettet ?? 0,
|
|
verletztePersonen: vorlage.verletzt ?? 0,
|
|
sachschaden: vorlage.sachschaden ?? null,
|
|
fahrzeuge: {
|
|
create: vorlage.fahrzeuge
|
|
.map((index) => fahrzeuge[index])
|
|
.filter((fahrzeug): fahrzeug is { id: string; funkrufname: string } => Boolean(fahrzeug))
|
|
.map((fahrzeug, position) => ({
|
|
organisationId,
|
|
fahrzeugId: fahrzeug.id,
|
|
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt + position),
|
|
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen + position),
|
|
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
|
kilometerAbfahrt: 12000 + laufend * 40,
|
|
kilometerRueckkehr: 12000 + laufend * 40 + 8 + position * 3,
|
|
wasserabgabe: vorlage.kategorie.startsWith("BRAND") ? 1500 : null,
|
|
})),
|
|
},
|
|
kraefte: {
|
|
create: Array.from({ length: Math.min(vorlage.mannschaft, personen.length) }).map(
|
|
(_, index) => ({
|
|
organisationId,
|
|
// Die Mannschaft rotiert, damit die Beteiligung je Person
|
|
// unterschiedlich ausfällt.
|
|
personId: personen[(index + laufend * 3) % personen.length]!.id,
|
|
funktion: FUNKTIONSFOLGE[index % FUNKTIONSFOLGE.length]!,
|
|
atemschutz: vorlage.kategorie.startsWith("BRAND") && index >= 3 && index <= 6,
|
|
vonUm: minutenSpaeter(alarmiert, ausgerueckt),
|
|
bisUm: minutenSpaeter(alarmiert, eingerueckt),
|
|
}),
|
|
),
|
|
},
|
|
lagemeldungen: {
|
|
create: (vorlage.meldungen ?? []).map((meldung) => ({
|
|
organisationId,
|
|
zeitpunkt: minutenSpaeter(alarmiert, meldung.nachMinuten),
|
|
text: meldung.text,
|
|
wichtig: meldung.wichtig ?? false,
|
|
verfasserId: personen[0]?.id ?? null,
|
|
verfasserName: personen[0]
|
|
? `${personen[0].vorname} ${personen[0].nachname}`
|
|
: "Einsatzleitung",
|
|
})),
|
|
},
|
|
material: {
|
|
create: (vorlage.material ?? []).map((posten) => ({
|
|
organisationId,
|
|
bezeichnung: posten.bezeichnung,
|
|
menge: posten.menge,
|
|
einheit: posten.einheit,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Der älteste Einsatz ist bereits freigegeben — damit die Ansicht eines
|
|
// festgeschriebenen Berichts nicht leer bleibt.
|
|
if (vorlage.vorTagen >= 140) {
|
|
await prisma.einsatz.update({
|
|
where: { id: einsatz.id },
|
|
data: {
|
|
status: EinsatzStatus.FREIGEGEBEN,
|
|
freigegebenAm: minutenSpaeter(alarmiert, 2880),
|
|
freigegebenVonId: personen[0]?.id ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
log(`Einsätze: ${EINSAETZE.length} mit Fahrzeugen, Mannschaft und Lagemeldungen`);
|
|
}
|
|
|
|
interface UebungVorlage {
|
|
vorTagen: number;
|
|
thema: string;
|
|
art: UebungsArt;
|
|
ort: string;
|
|
lernziele: string;
|
|
teilnehmer: number;
|
|
dauer: number;
|
|
}
|
|
|
|
const UEBUNGEN: UebungVorlage[] = [
|
|
{
|
|
vorTagen: 7,
|
|
thema: "Innenangriff mit Atemschutz",
|
|
art: UebungsArt.ATEMSCHUTZ,
|
|
ort: "Übungscontainer Nachbarhausen",
|
|
lernziele:
|
|
"Vorgehen im Trupp unter Nullsicht, Türöffnungsprozedur, Rückweg sichern, Kommunikation mit dem Gruppenführer.",
|
|
teilnehmer: 9,
|
|
dauer: 150,
|
|
},
|
|
{
|
|
vorTagen: 21,
|
|
thema: "Wasserförderung über lange Wegstrecke",
|
|
art: UebungsArt.PRAXIS,
|
|
ort: "Löschwasserentnahmestelle Mühlbach",
|
|
lernziele: "Aufbau einer B-Leitung über 600 m, Verstärkerpumpe setzen, Druckverluste erkennen.",
|
|
teilnehmer: 12,
|
|
dauer: 120,
|
|
},
|
|
{
|
|
vorTagen: 35,
|
|
thema: "Technische Hilfeleistung nach Verkehrsunfall",
|
|
art: UebungsArt.PRAXIS,
|
|
ort: "Übungsgelände am Bauhof",
|
|
lernziele:
|
|
"Fahrzeug sichern, Glasmanagement, patientengerechte Rettung, Zusammenarbeit mit dem Rettungsdienst.",
|
|
teilnehmer: 11,
|
|
dauer: 180,
|
|
},
|
|
{
|
|
vorTagen: 49,
|
|
thema: "Funkausbildung nach BOS-Richtlinie",
|
|
art: UebungsArt.FUNK,
|
|
ort: "Gerätehaus, Schulungsraum",
|
|
lernziele: "Funkdisziplin, Meldeschema, Umgang mit dem Handsprechfunkgerät.",
|
|
teilnehmer: 8,
|
|
dauer: 90,
|
|
},
|
|
{
|
|
vorTagen: 63,
|
|
thema: "Maschinistenausbildung Tragkraftspritze",
|
|
art: UebungsArt.MASCHINIST,
|
|
ort: "Gerätehaus",
|
|
lernziele: "Inbetriebnahme, Entlüften, Störungssuche, Wartung nach dem Einsatz.",
|
|
teilnehmer: 5,
|
|
dauer: 120,
|
|
},
|
|
{
|
|
vorTagen: 84,
|
|
thema: "Gemeinschaftsübung mit der FF Nachbarhausen",
|
|
art: UebungsArt.GEMEINSCHAFTSUEBUNG,
|
|
ort: "Sägewerk Nachbarhausen",
|
|
lernziele:
|
|
"Zusammenarbeit zweier Wehren, gemeinsame Einsatzleitung, Wasserversorgung aus offenem Gewässer.",
|
|
teilnehmer: 14,
|
|
dauer: 210,
|
|
},
|
|
];
|
|
|
|
async function uebungenAnlegen(organisationId: string) {
|
|
const vorhanden = await prisma.uebung.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log(`Übungen: bereits ${vorhanden} vorhanden`);
|
|
return;
|
|
}
|
|
|
|
const personen = await prisma.person.findMany({
|
|
where: { organisationId, aktiv: true, geloeschtAm: null },
|
|
select: { id: true },
|
|
orderBy: { mitgliedsnummer: "asc" },
|
|
});
|
|
|
|
if (personen.length === 0) return;
|
|
|
|
let laufend = 0;
|
|
|
|
for (const vorlage of UEBUNGEN) {
|
|
laufend += 1;
|
|
|
|
const datum = new Date();
|
|
datum.setUTCDate(datum.getUTCDate() - vorlage.vorTagen);
|
|
datum.setUTCHours(0, 0, 0, 0);
|
|
|
|
const beginn = new Date(datum);
|
|
beginn.setUTCHours(19, 0, 0, 0);
|
|
|
|
await prisma.uebung.create({
|
|
data: {
|
|
organisationId,
|
|
nummer: `${datum.getUTCFullYear()}-${String(laufend).padStart(4, "0")}`,
|
|
thema: vorlage.thema,
|
|
art: vorlage.art,
|
|
datum,
|
|
beginnUm: beginn,
|
|
endeUm: minutenSpaeter(beginn, vorlage.dauer),
|
|
ort: vorlage.ort,
|
|
leiterId: personen[0]?.id ?? null,
|
|
lernziele: vorlage.lernziele,
|
|
teilnahmen: {
|
|
create: Array.from({ length: Math.min(vorlage.teilnehmer, personen.length) }).map(
|
|
(_, index) => ({
|
|
organisationId,
|
|
personId: personen[(index + laufend * 2) % personen.length]!.id,
|
|
}),
|
|
),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
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`,
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 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`,
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phase 6 — Kartenmodule
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/** Der Ortskern von Musterhausen — frei erfunden, aber in Niederbayern plausibel. */
|
|
const ORTSMITTE = { breitengrad: 48.5, laengengrad: 12.15 };
|
|
|
|
/**
|
|
* Löschwasser und Objekte.
|
|
*
|
|
* Die Hydranten liegen in einem Raster um den Ortskern, mit den Zuständen, die
|
|
* eine Wehr tatsächlich vorfindet: die meisten in Ordnung, einige ungeprüft,
|
|
* zwei defekt. Dazu ein Weiher und eine Zisterne am Ortsrand — im Außenbereich
|
|
* gibt es keine Leitung.
|
|
*
|
|
* Die drei Objekte sind die, für die eine Wehr zuerst einen Plan schreibt:
|
|
* Schule, Pflegeheim, Aussiedlerhof.
|
|
*/
|
|
async function kartendatenAnlegen(organisationId: string) {
|
|
const vorhanden = await prisma.hydrant.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log(`Karte: bereits ${vorhanden} Löschwasserstellen vorhanden`);
|
|
return;
|
|
}
|
|
|
|
// --- Standorte auf die Karte holen ---------------------------------------
|
|
//
|
|
// Ohne Koordinaten am Gerätehaus wüsste die Karte nicht, wohin sie schauen
|
|
// soll. Die Standorte gibt es seit Phase 1, die Dezimalgrade seit Phase 6.
|
|
const standorte = await prisma.standort.findMany({
|
|
where: { organisationId, geloeschtAm: null },
|
|
select: { id: true },
|
|
orderBy: { erstelltAm: "asc" },
|
|
});
|
|
|
|
for (const [index, standort] of standorte.entries()) {
|
|
await prisma.standort.update({
|
|
where: { id: standort.id },
|
|
data: {
|
|
breitengrad: ORTSMITTE.breitengrad + index * 0.004,
|
|
laengengrad: ORTSMITTE.laengengrad + index * 0.006,
|
|
},
|
|
});
|
|
}
|
|
|
|
// --- Löschwasserstellen ---------------------------------------------------
|
|
const STRASSEN = [
|
|
"Hauptstraße",
|
|
"Bahnhofstraße",
|
|
"Schulstraße",
|
|
"Kirchplatz",
|
|
"Am Anger",
|
|
"Industriestraße",
|
|
];
|
|
|
|
const hydranten: Array<{
|
|
bezeichnung: string;
|
|
typ: HydrantTyp;
|
|
status: HydrantStatus;
|
|
nennweite: number | null;
|
|
durchfluss: number | null;
|
|
strasse: string;
|
|
breitengrad: number;
|
|
laengengrad: number;
|
|
letztePruefung: Date | null;
|
|
hinweis: string | null;
|
|
}> = [];
|
|
|
|
const heute = new Date();
|
|
|
|
function vorTagen(tage: number): Date {
|
|
const datum = new Date(heute);
|
|
datum.setUTCDate(datum.getUTCDate() - tage);
|
|
datum.setUTCHours(0, 0, 0, 0);
|
|
return datum;
|
|
}
|
|
|
|
for (let nummer = 1; nummer <= 20; nummer += 1) {
|
|
const strasse = STRASSEN[nummer % STRASSEN.length]!;
|
|
// Ein Raster von rund 120 Metern Kantenlänge um die Ortsmitte.
|
|
const reihe = Math.floor((nummer - 1) / 5);
|
|
const spalte = (nummer - 1) % 5;
|
|
|
|
const ungeprueft = nummer % 7 === 0;
|
|
const defekt = nummer === 9 || nummer === 17;
|
|
|
|
hydranten.push({
|
|
bezeichnung: `H-${String(nummer).padStart(4, "0")}`,
|
|
typ: nummer % 6 === 0 ? HydrantTyp.UEBERFLUR : HydrantTyp.UNTERFLUR,
|
|
status: defekt
|
|
? HydrantStatus.DEFEKT
|
|
: ungeprueft
|
|
? HydrantStatus.UNBEKANNT
|
|
: HydrantStatus.EINSATZBEREIT,
|
|
nennweite: nummer % 4 === 0 ? 150 : nummer % 3 === 0 ? 80 : 100,
|
|
durchfluss: nummer % 4 === 0 ? 1600 : nummer % 3 === 0 ? 400 : 800,
|
|
strasse,
|
|
breitengrad: ORTSMITTE.breitengrad + reihe * 0.0011 - 0.002,
|
|
laengengrad: ORTSMITTE.laengengrad + spalte * 0.0016 - 0.003,
|
|
letztePruefung: ungeprueft ? null : vorTagen(60 + nummer * 9),
|
|
hinweis: defekt
|
|
? "Deckel verzogen, Standrohr passt nicht. Stadtwerke gemeldet."
|
|
: nummer === 3
|
|
? "Im Winter oft zugeschoben — Schneepflug fährt hier durch."
|
|
: null,
|
|
});
|
|
}
|
|
|
|
// Im Außenbereich gibt es keine Leitung.
|
|
hydranten.push(
|
|
{
|
|
bezeichnung: "Weiher Kiesgrube",
|
|
typ: HydrantTyp.OFFENES_GEWAESSER,
|
|
status: HydrantStatus.EINSATZBEREIT,
|
|
nennweite: null,
|
|
durchfluss: 2400,
|
|
strasse: "Kiesgrubenweg",
|
|
breitengrad: ORTSMITTE.breitengrad - 0.008,
|
|
laengengrad: ORTSMITTE.laengengrad + 0.012,
|
|
letztePruefung: vorTagen(200),
|
|
hinweis: "Zufahrt für LF geeignet, im Winter vereist. Saughöhe rund 3 m.",
|
|
},
|
|
{
|
|
bezeichnung: "Zisterne Aussiedlerhof",
|
|
typ: HydrantTyp.ZISTERNE,
|
|
status: HydrantStatus.EINSATZBEREIT,
|
|
nennweite: null,
|
|
durchfluss: 800,
|
|
strasse: "Hofweg 2",
|
|
breitengrad: ORTSMITTE.breitengrad + 0.011,
|
|
laengengrad: ORTSMITTE.laengengrad + 0.018,
|
|
letztePruefung: vorTagen(120),
|
|
hinweis: "50 m³, Saugstutzen an der Nordseite der Scheune.",
|
|
},
|
|
{
|
|
bezeichnung: "Saugstelle Mühlbach",
|
|
typ: HydrantTyp.SAUGSTELLE,
|
|
status: HydrantStatus.EINGESCHRAENKT,
|
|
nennweite: null,
|
|
durchfluss: 600,
|
|
strasse: "Mühlweg",
|
|
breitengrad: ORTSMITTE.breitengrad - 0.004,
|
|
laengengrad: ORTSMITTE.laengengrad - 0.009,
|
|
letztePruefung: vorTagen(400),
|
|
hinweis: "Im Sommer zu wenig Wasser. Nur nach Regen brauchbar.",
|
|
},
|
|
{
|
|
bezeichnung: "H-0021",
|
|
typ: HydrantTyp.UEBERFLUR,
|
|
status: HydrantStatus.EINSATZBEREIT,
|
|
nennweite: 150,
|
|
durchfluss: 1600,
|
|
strasse: "Industriestraße",
|
|
breitengrad: ORTSMITTE.breitengrad + 0.006,
|
|
laengengrad: ORTSMITTE.laengengrad + 0.008,
|
|
letztePruefung: vorTagen(45),
|
|
hinweis: "Am Werkstor, ergiebigster Hydrant im Ort.",
|
|
},
|
|
);
|
|
|
|
for (const hydrant of hydranten) {
|
|
await prisma.hydrant.create({
|
|
data: {
|
|
organisationId,
|
|
...hydrant,
|
|
betreiber: "Stadtwerke Musterhausen",
|
|
quelle: "seed",
|
|
externeNummer: `SW-${hydrant.bezeichnung}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// --- Objekte mit Feuerwehrplan -------------------------------------------
|
|
const OBJEKTE: Array<{
|
|
name: string;
|
|
kategorie: ObjektKategorie;
|
|
strasse: string;
|
|
hausnummer: string;
|
|
breitengrad: number;
|
|
laengengrad: number;
|
|
personenzahl: number;
|
|
zufahrt: string;
|
|
gefahren: string;
|
|
schluesseldepot: string;
|
|
loeschwasser: string;
|
|
kontakte: Array<{ name: string; funktion: string; telefon: string; jederzeit: boolean }>;
|
|
}> = [
|
|
{
|
|
name: "Grundschule Musterhausen",
|
|
kategorie: ObjektKategorie.SCHULE,
|
|
strasse: "Schulstraße",
|
|
hausnummer: "4",
|
|
breitengrad: ORTSMITTE.breitengrad + 0.0015,
|
|
laengengrad: ORTSMITTE.laengengrad + 0.0022,
|
|
personenzahl: 180,
|
|
zufahrt:
|
|
"Aufstellfläche auf dem Pausenhof, Zufahrt über die Schulstraße. Poller mit Feuerwehrschlüssel, Durchfahrt 3,10 m.",
|
|
gefahren:
|
|
"Photovoltaik auf dem Süddach (Gleichstrom bis zum Wechselrichter im Keller). Chemieraum im 1. OG Nordseite.",
|
|
schluesseldepot: "FSD rechts neben dem Haupteingang, Schlüssel für alle Türen.",
|
|
loeschwasser: "Wandhydranten in jedem Geschoss, Steigleitung trocken im Treppenhaus B.",
|
|
kontakte: [
|
|
{ name: "Ursula Berger", funktion: "Schulleitung", telefon: "08721 4711", jederzeit: false },
|
|
{ name: "Karl Steininger", funktion: "Hausmeister", telefon: "0170 1234567", jederzeit: true },
|
|
],
|
|
},
|
|
{
|
|
name: "Seniorenheim St. Anna",
|
|
kategorie: ObjektKategorie.PFLEGEHEIM,
|
|
strasse: "Am Anger",
|
|
hausnummer: "11",
|
|
breitengrad: ORTSMITTE.breitengrad - 0.0018,
|
|
laengengrad: ORTSMITTE.laengengrad + 0.0009,
|
|
personenzahl: 84,
|
|
zufahrt:
|
|
"Anleiterbare Seite nur nach Norden. Aufstellfläche für DL im Innenhof, Einfahrt an der Ostseite.",
|
|
gefahren:
|
|
"62 Bewohner, davon 28 bettlägerig. Sauerstoffflaschen in den Zimmern 12, 14 und 31. Sammelplatz auf dem Parkplatz an der Ostseite.",
|
|
schluesseldepot: "FSD links vom Haupteingang, dazu Objektschlüssel in der Pforte.",
|
|
loeschwasser: "Wandhydranten je Geschoss, nächster Überflurhydrant 40 m westlich.",
|
|
kontakte: [
|
|
{ name: "Pflegedienstleitung", funktion: "Nachtdienst", telefon: "08721 5522", jederzeit: true },
|
|
{ name: "Hermann Falk", funktion: "Heimleitung", telefon: "0171 9876543", jederzeit: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Aussiedlerhof Huber",
|
|
kategorie: ObjektKategorie.LANDWIRTSCHAFT,
|
|
strasse: "Hofweg",
|
|
hausnummer: "2",
|
|
breitengrad: ORTSMITTE.breitengrad + 0.0108,
|
|
laengengrad: ORTSMITTE.laengengrad + 0.0178,
|
|
personenzahl: 6,
|
|
zufahrt:
|
|
"Feldweg ab der Kreisstraße, 600 m unbefestigt. Für TLF befahrbar, bei Nässe nur mit Allrad.",
|
|
gefahren:
|
|
"Silo mit Gärgasen, Biogasanlage 80 m nordöstlich. 60 Rinder im Stall, Notausgang Südseite. PV auf allen Dächern.",
|
|
schluesseldepot: "Kein FSD. Familie wohnt auf dem Hof, Schlüssel beim Betriebsleiter.",
|
|
loeschwasser: "Zisterne 50 m³ an der Scheune, sonst kein Löschwasser im Umkreis von 1,5 km.",
|
|
kontakte: [
|
|
{ name: "Josef Huber", funktion: "Betriebsleiter", telefon: "0175 3344556", jederzeit: true },
|
|
],
|
|
},
|
|
];
|
|
|
|
for (const vorlage of OBJEKTE) {
|
|
const { kontakte, ...felder } = vorlage;
|
|
|
|
await prisma.objekt.create({
|
|
data: {
|
|
organisationId,
|
|
...felder,
|
|
plz: "84359",
|
|
ort: "Musterhausen",
|
|
kontakte: {
|
|
create: kontakte.map((kontakt, index) => ({
|
|
organisationId,
|
|
...kontakt,
|
|
reihenfolge: index,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
log(
|
|
`Karte: ${hydranten.length} Löschwasserstellen, ${OBJEKTE.length} Objekte mit Ansprechpartnern, ${standorte.length} Standorte verortet`,
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Ablauf
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function main(): Promise<void> {
|
|
log("Starte ...");
|
|
|
|
const katalog = await katalogAbgleichen();
|
|
const organisation = await organisationAnlegen();
|
|
const rollen = await rollenAnlegen(organisation.id, katalog);
|
|
await adminAnlegen(organisation.id, rollen);
|
|
await personenAnlegen(organisation.id, rollen);
|
|
|
|
// Phase 1 — Stammdaten
|
|
const standorte = await standorteAnlegen(organisation.id);
|
|
const typen = await geraetetypenAnlegen(organisation.id);
|
|
const fahrzeuge = await fahrzeugeAnlegen(organisation.id, standorte);
|
|
await geraeteAnlegen(organisation.id, typen, fahrzeuge, standorte);
|
|
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
|
|
await qualifikationenAnlegen(organisation.id, qualiTypen);
|
|
|
|
// Phase 2 — Wartung und Prüfung
|
|
const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen);
|
|
await wartungstermineAnlegen(organisation.id, wartungsvorlagen);
|
|
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
|
|
await rueckrufAnlegen(organisation.id);
|
|
|
|
// Phase 3 — Einsätze und Übungen
|
|
await einsaetzeAnlegen(organisation.id, fahrzeuge);
|
|
await uebungenAnlegen(organisation.id);
|
|
|
|
// Phase 4 — Terminal im Gerätehaus
|
|
await terminaldatenAnlegen(organisation.id);
|
|
|
|
// Phase 5 — Atemschutz nach FwDV 7
|
|
await atemschutzAnlegen(organisation.id);
|
|
|
|
// Phase 6 — Kartenmodule
|
|
await kartendatenAnlegen(organisation.id);
|
|
|
|
log("Fertig.");
|
|
log("");
|
|
log("Anmeldung für den Erststart:");
|
|
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
|
|
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
|
|
log("");
|
|
log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203");
|
|
}
|
|
|
|
main()
|
|
.catch((fehler) => {
|
|
console.error("[seed] Abbruch:", fehler);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|