Files
FireAdministration/prisma/seed.ts
T
Florian WildeandClaude Opus 5 4165334c5a Phase 9: Kassenverwaltung
Kassenbuch, Mitgliedsbeitraege, Rechnungen an Dritte, Kassenpruefung und
die Auswertungen fuer die Jahreshauptversammlung.

Die drei Festlegungen aus Phase-9.md sitzen in der Datenbank, nicht im
Router: eine festgeschriebene Buchung ist unveraenderbar, ein geprueftes
Jahr nimmt kein Datum mehr an, und gerechnet wird mit Prisma.Decimal.
Korrigiert wird ueber eine Gegenbuchung mit Verweis - beide Buchungen
bleiben stehen, damit im Kassenbuch zu sehen ist, dass etwas berichtigt
wurde.

Neun Modelle, drei Trigger, zwoelf CHECKs, sechs Router, fuenf Ansichten.
Dazu der Bankimport fuer CAMT.053 und MT940 ohne Fremdbibliothek, der
Kategorievorschlag aus dem eigenen Buchungsbestand und die Bruecke von
Phase 8: aus einem gegengezeichneten Beleg wird eine Buchung, das Foto
haengt danach an ihr.

Nebenbei behoben: die Rechnungsnummer war ein Wettlauf, und eine
Organisation liess sich nicht mehr loeschen, sobald sie eine
festgeschriebene Buchung hatte - der Trigger kannte den
Aufbewahrungsschalter nicht.

562 Unit-Tests und 239 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:23:34 +02:00

3968 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. Ab Phase 7 dazu: der Jahresplan — Übungsabend als Serie,
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
* Ab Phase 9 dazu: das Kassenjahr — drei Konten, der Standardkontenrahmen,
* gut dreißig Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen,
* zwei Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres.
*
* 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,
BeitragStand,
BuchungsArt,
ChecklistenpunktTyp,
EinsatzFunktion,
EinsatzKategorie,
EinsatzStatus,
Dienstgrad,
KontoArt,
DruckArt,
EreignisArt,
FahrtZweck,
FahrzeugArt,
G26Ergebnis,
GeraeteKategorie,
GeraetStatus,
LebensdauerBezug,
LebensdauerKategorie,
MangelPrioritaet,
MangelStatus,
PrismaClient,
PruefErgebnis,
PruefungArt,
PruefungStatus,
QualifikationsArt,
RolleSchluessel,
RechnungsAnlass,
RechnungStand,
SignaturRolle,
HydrantStatus,
HydrantTyp,
ObjektKategorie,
Serienmuster,
StandortArt,
WikiKategorie,
Teilnahmestand,
TerminArt,
TruppStatus,
UebungsArt,
UebungsartAtemschutz,
WaescheStatus,
} from "@prisma/client";
import bcrypt from "bcryptjs";
import { serientermine } from "../src/lib/termin/serie";
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);
/**
* Welche Wehr angelegt wird.
*
* Vorgabe ist die Beispielwehr, die in den Bildschirmfotos und in der
* Anleitung steht. Über die Umgebung lässt sich eine zweite anlegen — genau
* dafür gibt es `npm run db:test-reset`:
*
* SEED_SLUG=ff-teststelle SEED_NAME="Freiwillige Feuerwehr Teststelle" \
* SEED_KURZNAME="FF Teststelle" SEED_ADMIN_EMAIL=e2e@teststelle.de \
* npm run db:seed
*
* Der Grund ist ein handfester: die E2E-Tests haben bis Phase 8 in die
* Beispielwehr geschrieben. Nach fünf Phasen standen dort 94 Atemschutz-
* Überwachungen statt einer, und bei jedem Testlauf änderten sich alle
* Bildschirmfotos. Seitdem laufen sie gegen eine eigene Wehr.
*/
const ORGANISATION_SLUG = process.env.SEED_SLUG ?? "ff-musterhausen";
const ORGANISATION_NAME = process.env.SEED_NAME ?? "Freiwillige Feuerwehr Musterhausen";
const ORGANISATION_KURZNAME = process.env.SEED_KURZNAME ?? "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: ORGANISATION_NAME,
kurzname: ORGANISATION_KURZNAME,
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`,
);
}
// -----------------------------------------------------------------------------
// Phase 7 — Benachrichtigungen und Termine
//
// Der Jahresplan einer Wehr, wie er wirklich aussieht: der Übungsabend jeden
// zweiten Dienstag, ein Lehrgang mit begrenzten Plätzen (samt Warteliste, weil
// die immer entsteht), der Arbeitsdienst im Frühjahr, die
// Jahreshauptversammlung — und ein Termin, der abgesagt wurde, damit man sieht,
// wie das aussieht.
//
// Bewusst KEINE Zustellkanäle im Seed: niemand wird ungefragt benachrichtigt,
// und das gilt auch für Beispieldaten. Wer Push ausprobieren will, richtet ihn
// unter /benachrichtigungen selbst ein — genau so, wie es später jedes
// Mitglied tut.
// -----------------------------------------------------------------------------
async function termineAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.termin.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Termine bereits vorhanden — übersprungen.");
return;
}
const start = new Date();
start.setUTCHours(0, 0, 0, 0);
/** Ein Zeitpunkt in n Tagen, zur angegebenen Stunde (Ortszeit-nah, UTC+1). */
function inTagen(tage: number, stunde = 19, minute = 0): Date {
const wann = new Date(start);
wann.setUTCDate(wann.getUTCDate() + tage);
wann.setUTCHours(stunde - 1, minute, 0, 0);
return wann;
}
const personen = await prisma.person.findMany({
where: { organisationId, aktiv: true, geloeschtAm: null },
orderBy: { mitgliedsnummer: "asc" },
select: { id: true },
});
const kommandant = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
},
select: { id: true },
});
// --- Der Übungsabend als Serie -------------------------------------------
//
// Alle zwei Wochen, ein halbes Jahr im Voraus. Ausgeschrieben als einzelne
// Termine, nicht als Regel — warum, steht in lib/termin/serie.ts.
const ersterAbend = await prisma.termin.create({
data: {
organisationId,
art: TerminArt.UEBUNG,
titel: "Übungsabend",
beschreibung:
"Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.",
ort: "Gerätehaus Musterhausen",
beginn: inTagen(4),
ende: inTagen(4, 21),
leiterId: kommandant?.id ?? null,
serienmuster: Serienmuster.ZWEIWOECHENTLICH,
},
});
await prisma.termin.update({
where: { id: ersterAbend.id },
data: { serieId: ersterAbend.id },
});
const weitereAbende = serientermine({
beginn: ersterAbend.beginn,
muster: Serienmuster.ZWEIWOECHENTLICH,
bis: inTagen(4 + 182),
}).slice(1);
await prisma.termin.createMany({
data: weitereAbende.map((zeitpunkt) => ({
organisationId,
art: TerminArt.UEBUNG,
titel: "Übungsabend",
beschreibung:
"Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.",
ort: "Gerätehaus Musterhausen",
beginn: zeitpunkt,
ende: new Date(zeitpunkt.getTime() + 2 * 3_600_000),
leiterId: kommandant?.id ?? null,
serieId: ersterAbend.id,
serienmuster: Serienmuster.ZWEIWOECHENTLICH,
})),
});
// --- Lehrgang mit Platzbegrenzung ----------------------------------------
//
// Acht Plätze, elf Anmeldungen. Die Warteliste ist der Regelfall, nicht der
// Sonderfall — deshalb steht sie schon im Seed.
const lehrgang = await prisma.termin.create({
data: {
organisationId,
art: TerminArt.LEHRGANG,
titel: "Maschinistenlehrgang Teil 1",
beschreibung:
"Pumpenkunde, Wasserförderung, Fahrzeugtechnik. Voraussetzung für die Ausbildung zum Maschinisten.",
ort: "Feuerwehrschule Landkreis",
beginn: inTagen(21, 8),
ende: inTagen(21, 17),
leiterId: kommandant?.id ?? null,
anmeldungNoetig: true,
teilnehmerLimit: 8,
anmeldeschluss: inTagen(14, 23, 59),
},
});
const lehrgangsplaetze = personen.slice(0, 11);
await prisma.terminanmeldung.createMany({
data: lehrgangsplaetze.map((person, stelle) => ({
organisationId,
terminId: lehrgang.id,
personId: person.id,
// Die ersten acht haben einen Platz, der Rest steht in der Reihenfolge
// der Anmeldung auf der Warteliste.
stand: stelle < 8 ? Teilnahmestand.ZUGESAGT : Teilnahmestand.WARTELISTE,
gemeldetAm: new Date(Date.now() - (11 - stelle) * 3_600_000),
})),
});
// --- Arbeitsdienst --------------------------------------------------------
const arbeitsdienst = await prisma.termin.create({
data: {
organisationId,
art: TerminArt.ARBEITSDIENST,
titel: "Frühjahrsputz am Gerätehaus",
beschreibung:
"Fahrzeughalle, Schlauchturm und Hof. Arbeitskleidung, kein Atemschutz nötig. Brotzeit ist gestellt.",
ort: "Gerätehaus Musterhausen",
beginn: inTagen(11, 9),
ende: inTagen(11, 13),
anmeldungNoetig: true,
},
});
// Gemischte Rückmeldungen — mit Gründen, wie sie wirklich kommen.
const GRUENDE = ["Schicht", "Urlaub", "Kind krank", "auswärts"];
await prisma.terminanmeldung.createMany({
data: personen.slice(0, 12).map((person, stelle) => {
const abgesagtVon = stelle % 4 === 3;
return {
organisationId,
terminId: arbeitsdienst.id,
personId: person.id,
stand: abgesagtVon ? Teilnahmestand.ABGESAGT : Teilnahmestand.ZUGESAGT,
grund: abgesagtVon ? (GRUENDE[stelle % GRUENDE.length] ?? null) : null,
};
}),
});
// --- Jahreshauptversammlung ----------------------------------------------
await prisma.termin.create({
data: {
organisationId,
art: TerminArt.SITZUNG,
titel: "Jahreshauptversammlung",
beschreibung:
"Berichte des Kommandanten, des Kassiers und des Jugendwarts. Ehrungen, Neuwahl der Kassenprüfer.",
ort: "Gasthaus zur Post, Nebenzimmer",
beginn: inTagen(45, 19, 30),
ende: inTagen(45, 22),
leiterId: kommandant?.id ?? null,
},
});
// --- Ein abgesagter Termin ------------------------------------------------
//
// Damit gleich sichtbar ist, wie eine Absage aussieht: durchgestrichen im
// Kalender, mit Grund — nicht verschwunden.
const abgesagt = await prisma.termin.create({
data: {
organisationId,
art: TerminArt.VERANSTALTUNG,
titel: "Waldfest der Feuerwehr",
beschreibung: "Aufbau ab 14 Uhr, Ausschank ab 17 Uhr.",
ort: "Festplatz am Mühlweg",
beginn: inTagen(30, 14),
ende: inTagen(30, 23),
abgesagtAm: new Date(),
absageGrund: "Dauerregen angekündigt, Festplatz nicht befahrbar. Neuer Termin folgt.",
},
});
await prisma.terminanmeldung.createMany({
data: personen.slice(0, 6).map((person) => ({
organisationId,
terminId: abgesagt.id,
personId: person.id,
stand: Teilnahmestand.ZUGESAGT,
})),
});
// --- Ein vergangener Termin mit abgehakter Anwesenheit --------------------
//
// Der Nachweis, um den es am Ende geht: wer war da. Mit zweien, die zugesagt
// hatten und nicht kamen — das ist der Fall, für den die Trennung von Zusage
// und Anwesenheit überhaupt existiert.
const vergangen = await prisma.termin.create({
data: {
organisationId,
art: TerminArt.UEBUNG,
titel: "Übungsabend Löschangriff",
beschreibung: "Wasserentnahme aus offenem Gewässer, Löschangriff nach FwDV 3.",
ort: "Mühlweg, Saugstelle",
beginn: inTagen(-10),
ende: inTagen(-10, 21),
leiterId: kommandant?.id ?? null,
},
});
await prisma.terminanmeldung.createMany({
data: personen.slice(0, 14).map((person, stelle) => ({
organisationId,
terminId: vergangen.id,
personId: person.id,
stand: Teilnahmestand.ZUGESAGT,
anwesend: stelle !== 5 && stelle !== 9,
})),
});
const gesamt = await prisma.termin.count({ where: { organisationId } });
log(
`Termine: ${gesamt} insgesamt (Übungsserie mit ${weitereAbende.length + 1} Abenden, ` +
`Lehrgang mit 8 Plätzen und ${lehrgangsplaetze.length - 8} auf der Warteliste, ` +
`ein abgesagter Termin, eine abgehakte Anwesenheitsliste)`,
);
}
// -----------------------------------------------------------------------------
// Phase 8 — Wissen
//
// Vier Seiten, wie eine Wehr sie wirklich hätte: eine Zusammenfassung der
// Prüffristen, ein Merkblatt zum Atemschutz, eine Anleitung zur Schlauchpflege
// und die Alarmierungsordnung.
//
// Der Inhalt ist bewusst **eigener Text** und keine abgeschriebene Vorschrift.
// Eine Dienstvorschrift ist urheberrechtlich geschützt; was hier steht, ist
// eine Zusammenfassung mit Fundstellenangabe — genau so, wie eine Wehr ihre
// eigenen Merkblätter schreibt.
//
// Ohne Sprachmodell bleiben die Seiten uneingebettet. Das ist kein Mangel: sie
// sind über die Wortsuche auffindbar, und die Seitenansicht sagt, dass der
// Suchstand fehlt.
// -----------------------------------------------------------------------------
async function wikiAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.wikiseite.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Wiki bereits vorhanden — übersprungen.");
return;
}
const autor = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
},
select: { id: true },
});
const SEITEN: Array<{
slug: string;
titel: string;
art: WikiKategorie;
quelle: string;
schlagworte: string[];
inhalt: string;
}> = [
{
slug: "pruefristen-im-ueberblick",
titel: "Prüffristen im Überblick",
art: WikiKategorie.GERAETE,
quelle: "Zusammenfassung nach DGUV Vorschrift 49 und Herstellerangaben",
schlagworte: ["prüffrist", "dguv", "gerätewart", "wartung"],
inhalt: [
"Diese Seite fasst zusammen, was bei uns in welchem Abstand geprüft wird.",
"Verbindlich sind die Vorschrift und die Herstellerangabe — im Zweifel gilt",
"die kürzere Frist.",
"",
"## Jährlich",
"",
"- Pressluftatmer: Sicht- und Dichtprüfung",
"- Atemschutzmasken: Sichtprüfung, Dichtprüfung",
"- Tragbare Leitern: Sichtprüfung nach DGUV",
"- Rettungsgeräte und Sprungpolster",
"- Handscheinwerfer und Handfunkgeräte: Funktionsprüfung",
"",
"## Alle zwei Jahre",
"",
"- Feuerwehrschläuche: Druckprüfung",
"- Kupplungen und Armaturen: Dichtheit",
"",
"## Alle drei Jahre",
"",
"- Feuerlöscher: Wartung durch einen Sachkundigen",
"",
"## Alle sechs Jahre",
"",
"- Pressluftatmer: Grundüberholung durch die Werkstatt",
"- Atemluftflaschen: wiederkehrende Prüfung nach Druckgeräterichtlinie",
"",
"## Was passiert, wenn eine Frist abläuft",
"",
"Das Gerät wechselt in FLORIAN von selbst auf **Prüfung fällig**. Benutzen",
"darf man es weiter — es ist nicht gesperrt. Gesperrt wird nur von Hand und",
"immer mit Begründung.",
"",
"Wer eine Frist nicht halten kann, meldet das dem Gerätewart. Ein",
"stillschweigend abgelaufener Nachweis ist im Schadensfall das Problem des",
"Kommandanten, nicht des Geräts.",
].join("\n"),
},
{
slug: "atemschutz-merkblatt",
titel: "Atemschutz — was vor jedem Einsatz gilt",
art: WikiKategorie.DIENSTVORSCHRIFT,
quelle: "Zusammenfassung nach FwDV 7, Ausgabe 2002 mit Änderung 2005",
schlagworte: ["atemschutz", "fwdv 7", "überwachung", "rückzugsdruck"],
inhalt: [
"## Wer darf unter Atemschutz",
"",
"Drei Nachweise müssen gleichzeitig gültig sein:",
"",
"1. **G 26.3** — arbeitsmedizinische Vorsorge, in der Regel drei Jahre gültig,",
" ab 50 Jahren jährlich.",
"2. **Belastungsübung** — jährlich, in einer Atemschutzübungsanlage.",
"3. **Einsatz oder Übung unter Atemschutz** — jährlich.",
"",
"Fehlt einer davon, ist der Einsatz unter Atemschutz nicht zulässig. FLORIAN",
"zeigt das an, sperrt aber nicht: die Entscheidung trifft der",
"Atemschutzgerätewart, nicht die Software.",
"",
"## Rückzugsdruck",
"",
"Der Rückzugsdruck ist der Druck, bei dem der Trupp den Rückweg antreten",
"muss. Er wird aus dem **doppelten Anmarschverbrauch** plus einem",
"Sicherheitszuschlag gerechnet:",
"",
"```",
"Rückzugsdruck = Eingangsdruck - 2 x (Eingangsdruck - Druck am Ziel) + Zuschlag",
"```",
"",
"Ist der Anmarsch noch nicht gemessen, gilt die Zwei-Drittel-Regel: bei zwei",
"Dritteln des Eingangsdrucks wird zurückgegangen.",
"",
"## Überwachung",
"",
"Dokumentiert werden: Truppzusammensetzung, Einsatzbeginn, Druckwerte in",
"Abständen von höchstens zehn Minuten, Rückzugsdruck und Ende.",
"",
"> Die analoge Überwachung bleibt führend, solange die Wehr die digitale",
"> nicht ausdrücklich freigegeben hat. Das ist keine Formalie — eine",
"> Software, die im Einsatz ausfällt, darf keine Lücke im Nachweis",
"> hinterlassen.",
].join("\n"),
},
{
slug: "schlaeuche-pflegen",
titel: "Schläuche pflegen und prüfen",
art: WikiKategorie.GERAETE,
quelle: "Merkblatt der Wehr, beschlossen in der Kommandantensitzung",
schlagworte: ["schlauch", "pflege", "druckprüfung", "ablegereife"],
inhalt: [
"## Nach jedem Einsatz",
"",
"1. Schläuche vom Fahrzeug nehmen und durch neue ersetzen — nie einen",
" benutzten Schlauch wieder aufs Fahrzeug legen.",
"2. Grob reinigen, bei Verunreinigung mit Öl oder Chemikalien gesondert",
" behandeln und dem Gerätewart melden.",
"3. Trocknen, bevor sie gerollt werden. Ein feucht gerollter Schlauch",
" schimmelt von innen.",
"",
"## Ablegereife",
"",
"Ein Schlauch wird ausgesondert, wenn:",
"",
"- das Gewebe durchgescheuert oder eingerissen ist,",
"- die Einbindung sich gelöst hat,",
"- er die Druckprüfung nicht besteht,",
"- die Innenbeschichtung sich ablöst.",
"",
"Ein ausgesonderter Schlauch wird gekennzeichnet und **sofort** aus dem",
"Fahrzeug genommen. Ein abgelegter Schlauch, der noch im Regal liegt, kommt",
"irgendwann wieder aufs Fahrzeug.",
"",
"## Druckprüfung",
"",
"Alle zwei Jahre. Prüfdruck nach Herstellerangabe, in der Regel:",
"",
"| Größe | Prüfdruck |",
"|---|---|",
"| C 42 / C 52 | 12 bar |",
"| B 75 | 12 bar |",
"| A 110 | 8 bar |",
"",
"Das Ergebnis wird in FLORIAN am Schlauch eingetragen, nicht auf einem",
"Zettel im Schlauchturm.",
].join("\n"),
},
{
slug: "alarmierungsordnung",
titel: "Alarmierungsordnung",
art: WikiKategorie.ORGANISATION,
quelle: "Beschluss der Kommandantschaft",
schlagworte: ["alarm", "ausrücken", "einsatzleitung"],
inhalt: [
"## Wer rückt aus",
"",
"Alarmiert wird über Meldeempfänger und Sirene. Wer alarmiert wird, kommt",
"zum Gerätehaus — die Entscheidung, welches Fahrzeug besetzt wird, trifft",
"der Einsatzleiter vor Ort.",
"",
"Die Mindestbesetzung ist die **Staffel** (1/5). Kommt sie in fünf Minuten",
"nicht zusammen, wird mit dem nachrückenden Personal aufgefüllt und",
"ausgerückt.",
"",
"## Einsatzleitung",
"",
"Bis zum Eintreffen des Kommandanten führt der ranghöchste Anwesende. Die",
"Übergabe wird ausdrücklich ausgesprochen und in der Lagemeldung vermerkt —",
"ein stillschweigender Wechsel der Einsatzleitung ist im Nachhinein nicht",
"rekonstruierbar.",
"",
"## Was im Bericht stehen muss",
"",
"- Lage beim Eintreffen",
"- Getroffene Maßnahmen, in der Reihenfolge",
"- Übergabe: an wen, wann",
"",
"Nicht in den Bericht gehören Vermutungen über die Brandursache und",
"Bewertungen des Verhaltens Beteiligter. Beides ist Sache der Polizei.",
].join("\n"),
},
];
for (const seite of SEITEN) {
await prisma.wikiseite.create({
data: {
organisationId,
...seite,
veroeffentlicht: true,
autorId: autor?.id ?? null,
// Bewusst kein eingebettetAm: ohne Sprachmodell gibt es keine
// Einbettungen. Die Seitenansicht sagt das, und die Wortsuche
// funktioniert trotzdem.
},
});
}
log(
`Wissen: ${SEITEN.length} Seiten angelegt (noch nicht eingebettet — dafür braucht es ein Sprachmodell)`,
);
}
// -----------------------------------------------------------------------------
// Phase 9 — Kassenverwaltung
//
// Ein Kassenjahr, wie es eine Wehr wirklich hat: drei Konten, der
// Standardkontenrahmen, gut dreißig Buchungen über zwei Jahre, die
// Mitgliedsbeiträge, zwei Rechnungen und die abgeschlossene Kassenprüfung
// des Vorjahres.
//
// Die Reihenfolge ist hier nicht beliebig, und das ist kein Zufall des
// Skripts, sondern die Absicherung selbst: sobald der Abschluss für das
// Vorjahr festgeschrieben ist, lehnt ein Trigger jede weitere Buchung mit
// einem Datum in diesem Zeitraum ab. Erst die Buchungen, dann der Abschluss —
// wer es umdreht, bekommt vom Seed dieselbe Fehlermeldung wie der Kassier,
// der drei Wochen nach der Prüfung noch eine Quittung findet.
// -----------------------------------------------------------------------------
async function kassendatenAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.kassenkonto.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Kassendaten bereits vorhanden — übersprungen.");
return;
}
const { STANDARDKATEGORIEN } = await import("../src/lib/kasse/vorschlag");
const jahr = new Date().getUTCFullYear();
const vorjahr = jahr - 1;
// --- Konten ---------------------------------------------------------------
const bar = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Barkasse",
art: KontoArt.BAR,
anfangsbestand: "250.00",
bemerkung: "Kassette im Kommandantenzimmer. Wechselgeld für Feste.",
},
});
const giro = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Girokonto Raiffeisenbank",
art: KontoArt.BANK,
iban: "DE02701500000000594937",
bic: "SSKMDEMM",
anfangsbestand: "4800.00",
},
});
const ruecklage = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Rücklage Fahrzeugbeschaffung",
art: KontoArt.RUECKLAGE,
iban: "DE02120300000000202051",
anfangsbestand: "12000.00",
bemerkung: "Angespart für den Ersatz des MTW. Wird nicht laufend bewegt.",
},
});
// --- Kontenrahmen ---------------------------------------------------------
//
// Zwei Durchgänge: erst die Oberkategorien, dann die Unterkategorien mit
// ihrem Verweis. Anders ginge es nicht, der Baum verweist auf sich selbst.
const kategorien = new Map<string, string>();
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
const angelegt = await prisma.buchungskategorie.create({
data: { organisationId, name: eintrag.name, art: eintrag.art },
});
kategorien.set(eintrag.name, angelegt.id);
}
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
const angelegt = await prisma.buchungskategorie.create({
data: {
organisationId,
name: eintrag.name,
art: eintrag.art,
oberkategorieId: kategorien.get(eintrag.oberkategorie!) ?? null,
},
});
kategorien.set(eintrag.name, angelegt.id);
}
const kategorie = (name: string): string => {
const id = kategorien.get(name);
if (!id) throw new Error(`Kategorie fehlt im Standardkontenrahmen: ${name}`);
return id;
};
const kassier = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KASSIER } } },
},
select: { id: true },
});
// --- Buchungen ------------------------------------------------------------
//
// Das Vorjahr vollständig und festgeschrieben, das laufende Jahr bis in den
// Sommer und noch offen. So zeigt die Ansicht beide Zustände: das
// geschlossene Buch und das, in dem noch gearbeitet wird.
type Zeile = {
konto: string;
monat: number;
tag: number;
art: BuchungsArt;
betrag: string;
kategorie: string;
zweck: string;
gegenueber?: string;
beleg?: string;
};
const vorjahrszeilen: Zeile[] = [
{
konto: giro.id,
monat: 1,
tag: 15,
art: BuchungsArt.EINNAHME,
betrag: "6500.00",
kategorie: "Zuschüsse",
zweck: "Gemeindezuschuss laufender Betrieb",
gegenueber: "Gemeinde Musterhausen",
beleg: "Z-2024-01",
},
{
konto: giro.id,
monat: 2,
tag: 3,
art: BuchungsArt.AUSGABE,
betrag: "1284.50",
kategorie: "Versicherungen",
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
gegenueber: "Bayerische Versicherungskammer",
},
{
konto: giro.id,
monat: 3,
tag: 12,
art: BuchungsArt.AUSGABE,
betrag: "412.90",
kategorie: "Kraftstoff",
zweck: "Diesel LF 20, Sammelrechnung Februar",
gegenueber: "Tankstelle Musterhausen",
},
{
konto: giro.id,
monat: 4,
tag: 8,
art: BuchungsArt.AUSGABE,
betrag: "2340.00",
kategorie: "Atemschutz",
zweck: "Zwei Pressluftatmer, Ersatzbeschaffung",
gegenueber: "Dräger Safety",
beleg: "RE-2024-114",
},
{
konto: giro.id,
monat: 5,
tag: 22,
art: BuchungsArt.AUSGABE,
betrag: "868.00",
kategorie: "Prüfungen",
zweck: "Wiederkehrende Prüfung Drehleiter",
gegenueber: "TÜV Süd",
},
{
konto: bar.id,
monat: 6,
tag: 15,
art: BuchungsArt.EINNAHME,
betrag: "3860.00",
kategorie: "Veranstaltungen",
zweck: "Maifest, Getränke- und Speisenverkauf",
},
{
konto: bar.id,
monat: 6,
tag: 15,
art: BuchungsArt.AUSGABE,
betrag: "1420.60",
kategorie: "Verpflegung",
zweck: "Maifest, Einkauf Getränke und Fleisch",
gegenueber: "Getränke Hofmann",
},
{
konto: giro.id,
monat: 7,
tag: 2,
art: BuchungsArt.AUSGABE,
betrag: "1150.00",
kategorie: "Schutzkleidung",
zweck: "Vier Einsatzjacken, Ersatz nach Verschleiß",
gegenueber: "Feuerwehrbedarf Barth",
},
{
konto: giro.id,
monat: 9,
tag: 18,
art: BuchungsArt.AUSGABE,
betrag: "624.00",
kategorie: "Ausbildung",
zweck: "Lehrgangsgebühren Maschinistenlehrgang",
gegenueber: "Landkreis Musterkreis",
},
{
konto: giro.id,
monat: 10,
tag: 30,
art: BuchungsArt.AUSGABE,
betrag: "398.20",
kategorie: "Wasser und Strom",
zweck: "Abschlagszahlung Strom Gerätehaus",
gegenueber: "Stadtwerke Musterhausen",
},
{
konto: giro.id,
monat: 11,
tag: 14,
art: BuchungsArt.EINNAHME,
betrag: "780.00",
kategorie: "Spenden",
zweck: "Spenden aus der Haussammlung",
},
{
konto: giro.id,
monat: 12,
tag: 20,
art: BuchungsArt.AUSGABE,
betrag: "36.00",
kategorie: "Bankgebühren",
zweck: "Kontoführung viertes Quartal",
},
];
const laufendeZeilen: Zeile[] = [
{
konto: giro.id,
monat: 1,
tag: 20,
art: BuchungsArt.EINNAHME,
betrag: "6500.00",
kategorie: "Zuschüsse",
zweck: "Gemeindezuschuss laufender Betrieb",
gegenueber: "Gemeinde Musterhausen",
},
{
konto: giro.id,
monat: 2,
tag: 5,
art: BuchungsArt.AUSGABE,
betrag: "1341.80",
kategorie: "Versicherungen",
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
gegenueber: "Bayerische Versicherungskammer",
},
{
konto: giro.id,
monat: 2,
tag: 28,
art: BuchungsArt.AUSGABE,
betrag: "487.40",
kategorie: "Kraftstoff",
zweck: "Diesel LF 20 und MTW, Sammelrechnung Februar",
gegenueber: "Tankstelle Musterhausen",
},
{
konto: giro.id,
monat: 3,
tag: 14,
art: BuchungsArt.AUSGABE,
betrag: "1890.00",
kategorie: "Reparatur",
zweck: "Kupplung MTW, Werkstattrechnung",
gegenueber: "Werkstatt Müller GmbH",
beleg: "RE-4471",
},
{
konto: giro.id,
monat: 4,
tag: 3,
art: BuchungsArt.AUSGABE,
betrag: "212.00",
kategorie: "Verwaltung",
zweck: "Büromaterial und Druckerpatronen",
gegenueber: "Schreibwaren Ludwig",
},
{
konto: bar.id,
monat: 5,
tag: 1,
art: BuchungsArt.EINNAHME,
betrag: "4120.00",
kategorie: "Veranstaltungen",
zweck: "Maifest, Getränke- und Speisenverkauf",
},
{
konto: bar.id,
monat: 5,
tag: 1,
art: BuchungsArt.AUSGABE,
betrag: "1588.30",
kategorie: "Verpflegung",
zweck: "Maifest, Einkauf Getränke und Fleisch",
gegenueber: "Getränke Hofmann",
},
{
konto: bar.id,
monat: 5,
tag: 6,
art: BuchungsArt.AUSGABE,
betrag: "2500.00",
kategorie: "Verwaltung",
zweck: "Einzahlung Barkasse auf das Girokonto nach dem Maifest",
},
{
konto: giro.id,
monat: 5,
tag: 6,
art: BuchungsArt.EINNAHME,
betrag: "2500.00",
kategorie: "Veranstaltungen",
zweck: "Einzahlung aus der Barkasse nach dem Maifest",
},
{
konto: giro.id,
monat: 6,
tag: 11,
art: BuchungsArt.AUSGABE,
betrag: "742.00",
kategorie: "Prüfungen",
zweck: "Schlauchprüfung, 40 Stück B und C",
gegenueber: "Schlauchpflege Nordbayern",
},
{
konto: giro.id,
monat: 6,
tag: 25,
art: BuchungsArt.AUSGABE,
betrag: "36.00",
kategorie: "Bankgebühren",
zweck: "Kontoführung zweites Quartal",
},
{
konto: ruecklage.id,
monat: 7,
tag: 1,
art: BuchungsArt.EINNAHME,
betrag: "3000.00",
kategorie: "Zuschüsse",
zweck: "Jährliche Zuführung zur Fahrzeugrücklage",
gegenueber: "Gemeinde Musterhausen",
},
];
async function buchungAnlegen(zeile: Zeile, festgeschrieben: boolean, buchungsjahr: number) {
return prisma.buchung.create({
data: {
organisationId,
kontoId: zeile.konto,
kategorieId: kategorie(zeile.kategorie),
datum: datum(buchungsjahr, zeile.monat, zeile.tag),
art: zeile.art,
betrag: zeile.betrag,
verwendungszweck: zeile.zweck,
lieferant: zeile.gegenueber ?? null,
belegnummer: zeile.beleg ?? null,
erfasstVonId: kassier?.id ?? null,
festgeschriebenAm: festgeschrieben ? datum(vorjahr + 1, 2, 10) : null,
},
});
}
for (const zeile of vorjahrszeilen) {
await buchungAnlegen(zeile, true, vorjahr);
}
for (const zeile of laufendeZeilen) {
await buchungAnlegen(zeile, false, jahr);
}
// --- Beiträge -------------------------------------------------------------
const satzAktive = await prisma.beitragssatz.create({
data: {
organisationId,
bezeichnung: "Aktive und Passive",
betrag: "25.00",
abteilung: null,
abJahr: vorjahr,
},
});
const satzJugend = await prisma.beitragssatz.create({
data: {
organisationId,
bezeichnung: "Jugendfeuerwehr",
betrag: "10.00",
abteilung: "Jugendfeuerwehr",
abJahr: vorjahr,
},
});
const mitglieder = await prisma.person.findMany({
where: { organisationId, aktiv: true, austrittsdatum: null },
orderBy: { mitgliedsnummer: "asc" },
select: { id: true, abteilung: true },
});
const beitragsKategorie = kategorie("Mitgliedsbeiträge");
// Die meisten haben bezahlt, zwei stehen offen, einer ist gemahnt, einem
// wurde erlassen. Das ist die Verteilung, die eine Wehr im Herbst wirklich
// vor sich hat — und der Grund, warum es die Rückstandsliste gibt.
for (const [stelle, person] of mitglieder.entries()) {
const jugend = person.abteilung === "Jugendfeuerwehr";
const satz = jugend ? satzJugend : satzAktive;
const betrag = jugend ? "10.00" : "25.00";
let buchungId: string | null = null;
let stand: BeitragStand = BeitragStand.BEZAHLT;
let bezahltAm: Date | null = datum(jahr, 2, 12 + (stelle % 10));
let mahnstufe = 0;
let gemahntAm: Date | null = null;
let erlassGrund: string | null = null;
if (stelle % 7 === 3) {
stand = BeitragStand.OFFEN;
bezahltAm = null;
} else if (stelle % 11 === 5) {
stand = BeitragStand.GEMAHNT;
bezahltAm = null;
mahnstufe = 1;
gemahntAm = datum(jahr, 6, 30);
} else if (stelle % 13 === 9) {
stand = BeitragStand.ERLASSEN;
bezahltAm = null;
erlassGrund = "Längere Erkrankung, Beschluss der Kommandantschaft vom 12.03.";
}
if (stand === BeitragStand.BEZAHLT && bezahltAm) {
const buchung = await prisma.buchung.create({
data: {
organisationId,
kontoId: giro.id,
kategorieId: beitragsKategorie,
datum: bezahltAm,
art: BuchungsArt.EINNAHME,
betrag,
verwendungszweck: `Mitgliedsbeitrag ${jahr}`,
personId: person.id,
erfasstVonId: kassier?.id ?? null,
},
});
buchungId = buchung.id;
}
await prisma.beitrag.create({
data: {
organisationId,
personId: person.id,
jahr,
satzId: satz.id,
betrag,
stand,
buchungId,
bezahltAm,
mahnstufe,
gemahntAm,
erlassGrund,
},
});
}
// --- Rechnungen -----------------------------------------------------------
const kostenersatz = kategorie("Kostenersatz");
const fehlalarmBuchung = await prisma.buchung.create({
data: {
organisationId,
kontoId: giro.id,
kategorieId: kostenersatz,
datum: datum(jahr, 4, 28),
art: BuchungsArt.EINNAHME,
betrag: "612.00",
verwendungszweck: `Zahlungseingang Rechnung ${jahr}-0001`,
lieferant: "Möbelhaus Sonnleitner GmbH",
erfasstVonId: kassier?.id ?? null,
},
});
await prisma.rechnung.create({
data: {
organisationId,
nummer: `${jahr}-0001`,
anlass: RechnungsAnlass.FEHLALARM,
stand: RechnungStand.BEZAHLT,
empfaenger: "Möbelhaus Sonnleitner GmbH",
strasse: "Industriestraße 14",
plz: "91315",
ort: "Musterhausen",
datum: datum(jahr, 3, 28),
faelligAm: datum(jahr, 4, 25),
betrag: "612.00",
bemerkung:
"Fehlalarm der Brandmeldeanlage am 26.03., verursacht durch Staub bei Umbauarbeiten. " +
"Kostenersatz nach Art. 28 Abs. 2 Nr. 4 BayFwG.",
buchungId: fehlalarmBuchung.id,
bezahltAm: datum(jahr, 4, 28),
erstelltVonId: kassier?.id ?? null,
positionen: {
create: [
{
organisationId,
reihenfolge: 0,
bezeichnung: "Einsatz LF 20, angefangene Stunde",
menge: "1",
einheit: "Std.",
einzelpreis: "396.00",
gesamtpreis: "396.00",
},
{
organisationId,
reihenfolge: 1,
bezeichnung: "Einsatzkräfte, je angefangene Stunde",
menge: "9",
einheit: "Pers.",
einzelpreis: "24.00",
gesamtpreis: "216.00",
},
],
},
},
});
await prisma.rechnung.create({
data: {
organisationId,
nummer: `${jahr}-0002`,
anlass: RechnungsAnlass.VERMIETUNG,
stand: RechnungStand.VERSENDET,
empfaenger: "Trachtenverein Almrausch Musterhausen e. V.",
empfaengerZusatz: "z. Hd. Frau Vorstand Berger",
strasse: "Dorfplatz 2",
plz: "91315",
ort: "Musterhausen",
datum: datum(jahr, 6, 20),
faelligAm: datum(jahr, 7, 18),
betrag: "180.00",
bemerkung: "Überlassung des Schulungsraums für die Jahresversammlung am 14.06.",
erstelltVonId: kassier?.id ?? null,
positionen: {
create: [
{
organisationId,
reihenfolge: 0,
bezeichnung: "Nutzung Schulungsraum, Tagespauschale",
menge: "1",
einheit: "Tag",
einzelpreis: "150.00",
gesamtpreis: "150.00",
},
{
organisationId,
reihenfolge: 1,
bezeichnung: "Reinigung",
menge: "1",
einheit: "pauschal",
einzelpreis: "30.00",
gesamtpreis: "30.00",
},
],
},
},
});
// --- Kassenprüfung des Vorjahres -----------------------------------------
//
// Zuletzt, und das ist der Punkt: ab hier ist der Zeitraum zu. Jede weitere
// Buchung mit einem Datum aus dem Vorjahr wird von der Datenbank abgelehnt.
const pruefer = await prisma.person.findMany({
where: { organisationId, aktiv: true, austrittsdatum: null },
orderBy: { mitgliedsnummer: "desc" },
take: 2,
select: { id: true },
});
// Die Zahlen sind nicht erfunden, sondern die Summe der Buchungen oben:
// Barkasse 250,00 + 3.860,00 − 1.420,60, Giro 4.800,00 + 7.280,00 − 7.113,60,
// Rücklage unberührt. Ein Seed mit erfundenen Beständen wäre genau der
// Fall, den die Kassenprüfung finden soll.
const vorjahrsposten = [
{ konto: bar.id, buch: "2689.40", gezaehlt: "2686.20" },
{ konto: giro.id, buch: "4966.40", gezaehlt: "4966.40" },
{ konto: ruecklage.id, buch: "12000.00", gezaehlt: "12000.00" },
];
const abschluss = await prisma.kassenabschluss.create({
data: {
organisationId,
vonAm: datum(vorjahr, 1, 1),
bisAm: datum(vorjahr, 12, 31),
bestandLautBuch: "19655.80",
gezaehlterBestand: "19652.60",
differenz: "-3.20",
erlaeuterung:
"In der Barkasse fehlen 3,20 €. Zurückzuführen auf das Maifest: das Wechselgeld " +
"wurde am Abend gezählt, ein Beleg über den Nachkauf von Eis fehlt. Die Prüfer " +
"halten den Betrag für erklärbar und schlagen Entlastung vor.",
prueferId: pruefer[0]?.id ?? null,
zweiterPrueferId: pruefer[1]?.id ?? null,
geprueftAm: datum(vorjahr + 1, 2, 8),
festgeschriebenAm: datum(vorjahr + 1, 2, 10),
posten: {
create: vorjahrsposten.map((posten) => ({
organisationId,
kontoId: posten.konto,
bestandLautBuch: posten.buch,
gezaehlterBestand: posten.gezaehlt,
differenz: (Number(posten.gezaehlt) - Number(posten.buch)).toFixed(2),
})),
},
},
});
log(
`Kasse: 3 Konten, ${STANDARDKATEGORIEN.length} Kategorien, ` +
`${vorjahrszeilen.length + laufendeZeilen.length} Buchungen, ` +
`${mitglieder.length} Beiträge, 2 Rechnungen und die Kassenprüfung ${vorjahr} ` +
`(festgeschrieben — der Zeitraum ist zu, Abschluss ${abschluss.id.slice(0, 8)})`,
);
}
// -----------------------------------------------------------------------------
// 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);
// Phase 7 — Benachrichtigungen und Termine
await termineAnlegen(organisation.id);
// Phase 8 — Lokale KI
await wikiAnlegen(organisation.id);
// Phase 9 — Kassenverwaltung
await kassendatenAnlegen(organisation.id);
log("Fertig.");
log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
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();
});