Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde, Statistik und Uebungen mit Anwesenheitsliste. Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung, zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich erreichbaren Endpunkte in FLORIAN. Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt das durch. Koordinaten liegen doppelt — als Dezimalgrad und als PostGIS-Punkt, zusammengehalten von einem Trigger. 57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2000 lines
65 KiB
TypeScript
2000 lines
65 KiB
TypeScript
/**
|
|
* Seed — legt eine vollständige Beispielwehr an.
|
|
*
|
|
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 16 Personen mit Rollen und
|
|
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
|
|
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
|
|
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
|
|
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
|
|
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
|
|
* Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt,
|
|
* sechs Übungen und die Beteiligung der Mannschaft.
|
|
*
|
|
* 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,
|
|
AusweisTokenTyp,
|
|
ChecklistenpunktTyp,
|
|
EinsatzFunktion,
|
|
EinsatzKategorie,
|
|
EinsatzStatus,
|
|
Dienstgrad,
|
|
FahrzeugArt,
|
|
GeraeteKategorie,
|
|
GeraetStatus,
|
|
LebensdauerBezug,
|
|
LebensdauerKategorie,
|
|
MangelPrioritaet,
|
|
MangelStatus,
|
|
PrismaClient,
|
|
PruefErgebnis,
|
|
PruefungArt,
|
|
PruefungStatus,
|
|
QualifikationsArt,
|
|
RolleSchluessel,
|
|
SignaturRolle,
|
|
StandortArt,
|
|
UebungsArt,
|
|
} from "@prisma/client";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const prisma = new PrismaClient({
|
|
datasources: {
|
|
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
|
|
},
|
|
});
|
|
|
|
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
|
|
const ORGANISATION_SLUG = "ff-musterhausen";
|
|
|
|
function log(nachricht: string): void {
|
|
console.info(`[seed] ${nachricht}`);
|
|
}
|
|
|
|
/** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */
|
|
function datum(jahr: number, monat: number, tag: number): Date {
|
|
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
|
|
}
|
|
|
|
/** Verschiebt ein Datum um Monate — für Prüffristen. */
|
|
function plusMonate(basis: Date, monate: number): Date {
|
|
const neu = new Date(basis);
|
|
neu.setUTCMonth(neu.getUTCMonth() + monate);
|
|
return neu;
|
|
}
|
|
|
|
/** Verschiebt ein Datum um Jahre — für Lebensdauern. */
|
|
function plusJahre(basis: Date, jahre: number): Date {
|
|
const neu = new Date(basis);
|
|
neu.setUTCFullYear(neu.getUTCFullYear() + jahre);
|
|
return neu;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Berechtigungskatalog
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über
|
|
* einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft.
|
|
*/
|
|
async function katalogAbgleichen(): Promise<Map<string, string>> {
|
|
const { RECHTE } = await import("../src/lib/authz");
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const [schluessel, angaben] of Object.entries(RECHTE)) {
|
|
const eintrag = await prisma.berechtigung.upsert({
|
|
where: { schluessel },
|
|
update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung },
|
|
create: {
|
|
schluessel,
|
|
bereich: angaben.bereich,
|
|
bezeichnung: angaben.bezeichnung,
|
|
},
|
|
});
|
|
karte.set(schluessel, eintrag.id);
|
|
}
|
|
|
|
log(`Berechtigungskatalog: ${karte.size} Einzelrechte`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Organisation und Rollen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function organisationAnlegen() {
|
|
const organisation = await prisma.organisation.upsert({
|
|
where: { slug: ORGANISATION_SLUG },
|
|
update: {},
|
|
create: {
|
|
slug: ORGANISATION_SLUG,
|
|
name: "Freiwillige Feuerwehr Musterhausen",
|
|
kurzname: "FF Musterhausen",
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
landkreis: "Freising",
|
|
bundesland: "Bayern",
|
|
telefon: "08161 123456",
|
|
email: "kommandant@ff-musterhausen.de",
|
|
ilsKennung: "FS-MUS-01",
|
|
funkPraefix: "Florian Musterhausen",
|
|
},
|
|
});
|
|
|
|
log(`Organisation: ${organisation.name} (${organisation.id})`);
|
|
return organisation;
|
|
}
|
|
|
|
async function rollenAnlegen(organisationId: string, katalog: Map<string, string>) {
|
|
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
|
|
await import("../src/lib/authz");
|
|
|
|
const rollenKarte = new Map<string, string>();
|
|
|
|
for (const schluessel of ROLLEN) {
|
|
const rolle = await prisma.rolle.upsert({
|
|
where: {
|
|
organisationId_schluessel: {
|
|
organisationId,
|
|
schluessel: schluessel as RolleSchluessel,
|
|
},
|
|
},
|
|
update: {
|
|
name: ROLLEN_BEZEICHNUNG[schluessel],
|
|
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
|
},
|
|
create: {
|
|
organisationId,
|
|
schluessel: schluessel as RolleSchluessel,
|
|
name: ROLLEN_BEZEICHNUNG[schluessel],
|
|
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
|
systemRolle: true,
|
|
},
|
|
});
|
|
rollenKarte.set(schluessel, rolle.id);
|
|
|
|
// Rechte der Rolle spiegeln — der Administrator bekommt alles.
|
|
const rechte =
|
|
schluessel === "SUPERADMIN"
|
|
? ALLE_RECHTE
|
|
: (ROLLEN_RECHTE[schluessel as Exclude<typeof schluessel, "SUPERADMIN">] ?? []);
|
|
|
|
for (const recht of rechte) {
|
|
const berechtigungId = katalog.get(recht);
|
|
if (!berechtigungId) continue;
|
|
|
|
await prisma.rolleBerechtigung.upsert({
|
|
where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } },
|
|
update: { gewaehrt: true },
|
|
create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`);
|
|
return rollenKarte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Personen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface PersonVorlage {
|
|
vorname: string;
|
|
nachname: string;
|
|
dienstgrad: Dienstgrad;
|
|
rollen: RolleSchluessel[];
|
|
eintritt: Date;
|
|
abteilung: string;
|
|
/** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */
|
|
pin?: string;
|
|
/** RFID-Kennung der Ausweiskarte. */
|
|
rfid?: string;
|
|
}
|
|
|
|
const PERSONEN: PersonVorlage[] = [
|
|
{
|
|
vorname: "Andreas",
|
|
nachname: "Huber",
|
|
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
|
rollen: [RolleSchluessel.KOMMANDANT],
|
|
eintritt: datum(1998, 4, 1),
|
|
abteilung: "Aktive",
|
|
pin: "471203",
|
|
rfid: "04A3B7C1D2",
|
|
},
|
|
{
|
|
vorname: "Bernhard",
|
|
nachname: "Maier",
|
|
dienstgrad: Dienstgrad.OBERBRANDMEISTER,
|
|
rollen: [RolleSchluessel.KOMMANDANT],
|
|
eintritt: datum(2001, 9, 15),
|
|
abteilung: "Aktive",
|
|
pin: "938157",
|
|
},
|
|
{
|
|
vorname: "Christian",
|
|
nachname: "Bauer",
|
|
dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER,
|
|
rollen: [RolleSchluessel.GERAETEWART],
|
|
eintritt: datum(2005, 3, 1),
|
|
abteilung: "Aktive",
|
|
pin: "620394",
|
|
rfid: "04B8C2D3E4",
|
|
},
|
|
{
|
|
vorname: "Daniela",
|
|
nachname: "Schmid",
|
|
dienstgrad: Dienstgrad.OBERLOESCHMEISTER,
|
|
rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART],
|
|
eintritt: datum(2008, 7, 1),
|
|
abteilung: "Aktive",
|
|
pin: "184926",
|
|
rfid: "04C9D3E4F5",
|
|
},
|
|
{
|
|
vorname: "Elisabeth",
|
|
nachname: "Wagner",
|
|
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
|
rollen: [RolleSchluessel.KASSIER],
|
|
eintritt: datum(2010, 1, 15),
|
|
abteilung: "Aktive",
|
|
pin: "573081",
|
|
},
|
|
{
|
|
vorname: "Franz",
|
|
nachname: "Fischer",
|
|
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
|
rollen: [RolleSchluessel.SCHRIFTFUEHRER],
|
|
eintritt: datum(2009, 5, 20),
|
|
abteilung: "Aktive",
|
|
pin: "306159",
|
|
},
|
|
{
|
|
vorname: "Gabriele",
|
|
nachname: "Weber",
|
|
dienstgrad: Dienstgrad.BRANDMEISTER,
|
|
rollen: [RolleSchluessel.AUSBILDER],
|
|
eintritt: datum(2004, 11, 1),
|
|
abteilung: "Aktive",
|
|
pin: "849265",
|
|
},
|
|
{
|
|
vorname: "Hans",
|
|
nachname: "Becker",
|
|
dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER],
|
|
eintritt: datum(2012, 2, 1),
|
|
abteilung: "Aktive",
|
|
pin: "715384",
|
|
},
|
|
{
|
|
vorname: "Ingrid",
|
|
nachname: "Hofmann",
|
|
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2015, 6, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Josef",
|
|
nachname: "Schneider",
|
|
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2016, 4, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Katharina",
|
|
nachname: "Koch",
|
|
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2019, 9, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Ludwig",
|
|
nachname: "Richter",
|
|
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2020, 3, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Martina",
|
|
nachname: "Klein",
|
|
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2024, 1, 15),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Nikolaus",
|
|
nachname: "Wolf",
|
|
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2025, 2, 1),
|
|
abteilung: "Jugendfeuerwehr",
|
|
},
|
|
{
|
|
vorname: "Otto",
|
|
nachname: "Neumann",
|
|
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(1975, 5, 1),
|
|
abteilung: "Passive",
|
|
},
|
|
];
|
|
|
|
async function personenAnlegen(
|
|
organisationId: string,
|
|
rollenKarte: Map<string, string>,
|
|
): Promise<void> {
|
|
let nummer = 1;
|
|
|
|
for (const vorlage of PERSONEN) {
|
|
const mitgliedsnummer = String(nummer).padStart(4, "0");
|
|
const email = `${vorlage.vorname}.${vorlage.nachname}`
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss") + "@ff-musterhausen.de";
|
|
|
|
const person = await prisma.person.upsert({
|
|
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
mitgliedsnummer,
|
|
vorname: vorlage.vorname,
|
|
nachname: vorlage.nachname,
|
|
dienstgrad: vorlage.dienstgrad,
|
|
email,
|
|
telefon: `08161 ${100000 + nummer * 7}`,
|
|
strasse: `Beispielweg ${nummer}`,
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
eintrittsdatum: vorlage.eintritt,
|
|
abteilung: vorlage.abteilung,
|
|
aktiv: vorlage.abteilung !== "Passive",
|
|
},
|
|
});
|
|
|
|
for (const schluessel of vorlage.rollen) {
|
|
const rolleId = rollenKarte.get(schluessel);
|
|
if (!rolleId) continue;
|
|
await prisma.personRolle.upsert({
|
|
where: { personId_rolleId: { personId: person.id, rolleId } },
|
|
update: {},
|
|
create: { organisationId, personId: person.id, rolleId },
|
|
});
|
|
}
|
|
|
|
// Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte.
|
|
if (vorlage.pin) {
|
|
const vorhanden = await prisma.ausweisToken.findFirst({
|
|
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN },
|
|
});
|
|
if (!vorhanden) {
|
|
await prisma.ausweisToken.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typ: AusweisTokenTyp.PIN,
|
|
tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN),
|
|
bezeichnung: "Terminal-PIN",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (vorlage.rfid) {
|
|
const vorhanden = await prisma.ausweisToken.findFirst({
|
|
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID },
|
|
});
|
|
if (!vorhanden) {
|
|
await prisma.ausweisToken.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typ: AusweisTokenTyp.RFID,
|
|
tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN),
|
|
kennzeichen: vorlage.rfid.slice(-4),
|
|
bezeichnung: "Ausweiskarte",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
nummer += 1;
|
|
}
|
|
|
|
log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`);
|
|
}
|
|
|
|
/**
|
|
* Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für
|
|
* den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor
|
|
* authentik eingerichtet ist.
|
|
*/
|
|
async function adminAnlegen(organisationId: string, rollenKarte: Map<string, string>) {
|
|
const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase();
|
|
const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
|
|
|
const admin = await prisma.person.upsert({
|
|
where: { organisationId_email: { organisationId, email } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
mitgliedsnummer: "0000",
|
|
vorname: "System",
|
|
nachname: "Administrator",
|
|
email,
|
|
dienstgrad: Dienstgrad.KEIN,
|
|
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
|
passwortGeaendertAm: new Date(),
|
|
abteilung: "Verwaltung",
|
|
},
|
|
});
|
|
|
|
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
|
|
if (rolleId) {
|
|
await prisma.personRolle.upsert({
|
|
where: { personId_rolleId: { personId: admin.id, rolleId } },
|
|
update: {},
|
|
create: { organisationId, personId: admin.id, rolleId },
|
|
});
|
|
}
|
|
|
|
log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`);
|
|
return admin;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Standorte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function standorteAnlegen(organisationId: string) {
|
|
const vorlagen = [
|
|
{
|
|
name: "Gerätehaus",
|
|
kuerzel: "GH",
|
|
art: StandortArt.GERAETEHAUS,
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
},
|
|
{
|
|
name: "Lager Bauhof",
|
|
kuerzel: "LAG",
|
|
art: StandortArt.LAGER,
|
|
strasse: "Industriestraße 4",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
bemerkung: "Reserve und Ersatzteile, Schlüssel beim Bauhof",
|
|
},
|
|
{
|
|
name: "Atemschutzwerkstatt",
|
|
kuerzel: "ASW",
|
|
art: StandortArt.WERKSTATT,
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
bemerkung: "Im Gerätehaus, Untergeschoss",
|
|
},
|
|
];
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of vorlagen) {
|
|
const standort = await prisma.standort.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: { organisationId, ...vorlage },
|
|
});
|
|
karte.set(vorlage.name, standort.id);
|
|
}
|
|
|
|
log(`Standorte: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Gerätetypen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface TypVorlage {
|
|
name: string;
|
|
kategorie: GeraeteKategorie;
|
|
hersteller: string;
|
|
pruefintervallMonate: number;
|
|
pruefvorschrift?: string;
|
|
lebensdauerKategorie?: LebensdauerKategorie;
|
|
lebensdauerJahre?: number;
|
|
lebensdauerBezug?: LebensdauerBezug;
|
|
}
|
|
|
|
/**
|
|
* Prüfintervalle und Lebensdauern nach den üblichen Vorgaben. Sie sind hier
|
|
* bewusst konkret hinterlegt — eine leere Beispielwehr zeigt nicht, worum es
|
|
* bei diesem Programm eigentlich geht.
|
|
*/
|
|
const GERAETETYPEN: TypVorlage[] = [
|
|
{
|
|
name: "Pressluftatmer PA 94",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
|
lebensdauerKategorie: LebensdauerKategorie.PRESSLUFTATMER,
|
|
lebensdauerJahre: 20,
|
|
},
|
|
{
|
|
name: "Atemschutzmaske FPS 7000",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 6,
|
|
pruefvorschrift: "DGUV Regel 112-190",
|
|
lebensdauerKategorie: LebensdauerKategorie.ATEMANSCHLUSS,
|
|
lebensdauerJahre: 10,
|
|
},
|
|
{
|
|
name: "Atemluftflasche 6,8 l / 300 bar",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Luxfer",
|
|
pruefintervallMonate: 60,
|
|
pruefvorschrift: "Betriebssicherheitsverordnung, wiederkehrende Prüfung",
|
|
lebensdauerKategorie: LebensdauerKategorie.DRUCKBEHAELTER,
|
|
lebensdauerJahre: 20,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Feuerwehrhelm F220",
|
|
kategorie: GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG,
|
|
hersteller: "Schuberth",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Information 205-014",
|
|
lebensdauerKategorie: LebensdauerKategorie.HELM,
|
|
lebensdauerJahre: 15,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Feuerwehrleine 30 m",
|
|
kategorie: GeraeteKategorie.RETTUNG,
|
|
hersteller: "Gustav Bauer",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14920, DGUV Grundsatz 305-002",
|
|
lebensdauerKategorie: LebensdauerKategorie.SEIL_GURT,
|
|
lebensdauerJahre: 8,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Steckleiterteil",
|
|
kategorie: GeraeteKategorie.RETTUNG,
|
|
hersteller: "Iturri",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14711, DGUV Information 208-016",
|
|
lebensdauerKategorie: LebensdauerKategorie.LEITER,
|
|
lebensdauerJahre: 25,
|
|
},
|
|
{
|
|
name: "Tragkraftspritze PFPN 10-1000",
|
|
kategorie: GeraeteKategorie.WASSERFOERDERUNG,
|
|
hersteller: "Rosenbauer",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14420",
|
|
},
|
|
{
|
|
name: "Hydraulischer Rettungssatz",
|
|
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
|
hersteller: "Weber Rescue",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Grundsatz 305-002",
|
|
},
|
|
{
|
|
name: "Motorkettensäge MS 261",
|
|
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
|
hersteller: "Stihl",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3, Betriebsanleitung",
|
|
},
|
|
{
|
|
name: "Stromerzeuger 8 kVA",
|
|
kategorie: GeraeteKategorie.ELEKTRO,
|
|
hersteller: "Endress",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3",
|
|
},
|
|
{
|
|
name: "Wärmebildkamera",
|
|
kategorie: GeraeteKategorie.MESSTECHNIK,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 12,
|
|
},
|
|
{
|
|
name: "Handsprechfunkgerät TPH900",
|
|
kategorie: GeraeteKategorie.FUNK,
|
|
hersteller: "Airbus",
|
|
pruefintervallMonate: 24,
|
|
},
|
|
{
|
|
name: "B-Druckschlauch 20 m",
|
|
kategorie: GeraeteKategorie.SCHLAUCHMATERIAL,
|
|
hersteller: "Ziegler",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14811",
|
|
lebensdauerKategorie: LebensdauerKategorie.SCHLAUCH,
|
|
lebensdauerJahre: 10,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Überdrucklüfter",
|
|
kategorie: GeraeteKategorie.BRANDBEKAEMPFUNG,
|
|
hersteller: "Leader",
|
|
pruefintervallMonate: 12,
|
|
},
|
|
];
|
|
|
|
async function geraetetypenAnlegen(organisationId: string) {
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of GERAETETYPEN) {
|
|
const typ = await prisma.geraetetyp.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
kategorie: vorlage.kategorie,
|
|
hersteller: vorlage.hersteller,
|
|
pruefintervallMonate: vorlage.pruefintervallMonate,
|
|
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
|
lebensdauerKategorie: vorlage.lebensdauerKategorie ?? LebensdauerKategorie.KEINE,
|
|
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
|
|
lebensdauerBezug: vorlage.lebensdauerBezug ?? LebensdauerBezug.INDIENSTSTELLUNG,
|
|
},
|
|
});
|
|
karte.set(vorlage.name, typ.id);
|
|
}
|
|
|
|
log(`Gerätetypen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Fahrzeuge samt Geräteräumen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const FAHRZEUGE = [
|
|
{
|
|
funkrufname: "Florian Musterhausen 40/1",
|
|
bezeichnung: "Löschgruppenfahrzeug",
|
|
dinTyp: "LF 20",
|
|
art: FahrzeugArt.LOESCHFAHRZEUG,
|
|
kennzeichen: "FS-MU 401",
|
|
hersteller: "MAN",
|
|
aufbauhersteller: "Rosenbauer",
|
|
fahrgestellnummer: "WMAN12ZZ8JY123456",
|
|
baujahr: 2018,
|
|
erstzulassung: datum(2018, 6, 14),
|
|
indienststellung: datum(2018, 7, 1),
|
|
sitzplaetze: 9,
|
|
tankinhalt: 2000,
|
|
gesamtmasse: 16_000,
|
|
tuevBis: datum(2027, 5, 31),
|
|
uvvBis: datum(2026, 11, 30),
|
|
},
|
|
{
|
|
funkrufname: "Florian Musterhausen 42/1",
|
|
bezeichnung: "Tragkraftspritzenfahrzeug Wasser",
|
|
dinTyp: "TSF-W",
|
|
art: FahrzeugArt.LOESCHFAHRZEUG,
|
|
kennzeichen: "FS-MU 421",
|
|
hersteller: "Mercedes-Benz",
|
|
aufbauhersteller: "Ziegler",
|
|
fahrgestellnummer: "WDB90661310123789",
|
|
baujahr: 2011,
|
|
erstzulassung: datum(2011, 9, 20),
|
|
indienststellung: datum(2011, 10, 15),
|
|
sitzplaetze: 6,
|
|
tankinhalt: 750,
|
|
gesamtmasse: 7500,
|
|
tuevBis: datum(2026, 11, 30),
|
|
uvvBis: datum(2026, 10, 15),
|
|
},
|
|
{
|
|
funkrufname: "Florian Musterhausen 11/1",
|
|
bezeichnung: "Mannschaftstransportwagen",
|
|
dinTyp: "MTW",
|
|
art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
|
|
kennzeichen: "FS-MU 111",
|
|
hersteller: "Volkswagen",
|
|
fahrgestellnummer: "WV2ZZZ7HZMH098765",
|
|
baujahr: 2021,
|
|
erstzulassung: datum(2021, 3, 8),
|
|
indienststellung: datum(2021, 3, 15),
|
|
sitzplaetze: 9,
|
|
gesamtmasse: 3500,
|
|
tuevBis: datum(2027, 3, 31),
|
|
},
|
|
];
|
|
|
|
/** Beladeordnung. Der MTW hat weniger Fächer als ein Löschfahrzeug. */
|
|
const RAEUME_GROSS = [
|
|
{ bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
|
|
{ bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
|
|
{ bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
|
|
{ bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
|
|
{ bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
|
|
{ bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
|
|
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
|
|
{ bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
|
|
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
|
|
];
|
|
|
|
const RAEUME_KLEIN = [
|
|
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 10 },
|
|
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 20 },
|
|
];
|
|
|
|
async function fahrzeugeAnlegen(organisationId: string, standorte: Map<string, string>) {
|
|
const fahrzeuge: Array<{ id: string; funkrufname: string; raeume: Map<string, string> }> = [];
|
|
|
|
for (const vorlage of FAHRZEUGE) {
|
|
const fahrzeug = await prisma.fahrzeug.upsert({
|
|
where: {
|
|
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
|
|
},
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
...vorlage,
|
|
standortId: standorte.get("Gerätehaus") ?? null,
|
|
},
|
|
});
|
|
|
|
const vorlagen =
|
|
vorlage.art === FahrzeugArt.MANNSCHAFTSTRANSPORT ? RAEUME_KLEIN : RAEUME_GROSS;
|
|
|
|
const raeume = new Map<string, string>();
|
|
for (const raumVorlage of vorlagen) {
|
|
const raum = await prisma.geraeteraum.upsert({
|
|
where: {
|
|
fahrzeugId_bezeichnung: {
|
|
fahrzeugId: fahrzeug.id,
|
|
bezeichnung: raumVorlage.bezeichnung,
|
|
},
|
|
},
|
|
update: {},
|
|
create: { organisationId, fahrzeugId: fahrzeug.id, ...raumVorlage },
|
|
});
|
|
raeume.set(raumVorlage.bezeichnung, raum.id);
|
|
}
|
|
|
|
fahrzeuge.push({ id: fahrzeug.id, funkrufname: fahrzeug.funkrufname, raeume });
|
|
}
|
|
|
|
const raeumeGesamt = fahrzeuge.reduce((summe, f) => summe + f.raeume.size, 0);
|
|
log(`Fahrzeuge: ${fahrzeuge.length}, Geräteräume: ${raeumeGesamt}`);
|
|
return fahrzeuge;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Geräte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface GeraetVorlage {
|
|
typ: string;
|
|
anzahl: number;
|
|
/** Index in FAHRZEUGE, oder null für einen Standort. */
|
|
aufFahrzeug: number | null;
|
|
raum?: string;
|
|
standort?: string;
|
|
lagerort?: string;
|
|
}
|
|
|
|
const GERAETE: GeraetVorlage[] = [
|
|
{ typ: "Pressluftatmer PA 94", anzahl: 4, aufFahrzeug: 0, raum: "MR" },
|
|
{ typ: "Atemschutzmaske FPS 7000", anzahl: 6, aufFahrzeug: null, standort: "Atemschutzwerkstatt", lagerort: "Schrank 1" },
|
|
{ typ: "Atemluftflasche 6,8 l / 300 bar", anzahl: 8, aufFahrzeug: 0, raum: "G4" },
|
|
{ typ: "Feuerwehrhelm F220", anzahl: 6, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Umkleide, Spind-Reihe A" },
|
|
{ typ: "Feuerwehrleine 30 m", anzahl: 3, aufFahrzeug: 0, raum: "G2" },
|
|
{ typ: "Steckleiterteil", anzahl: 4, aufFahrzeug: 0, raum: "Dach" },
|
|
{ typ: "Tragkraftspritze PFPN 10-1000", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
|
{ typ: "Hydraulischer Rettungssatz", anzahl: 1, aufFahrzeug: 0, raum: "G3" },
|
|
{ typ: "Motorkettensäge MS 261", anzahl: 2, aufFahrzeug: 0, raum: "G5" },
|
|
{ typ: "Stromerzeuger 8 kVA", anzahl: 1, aufFahrzeug: 0, raum: "G6" },
|
|
{ typ: "Wärmebildkamera", anzahl: 1, aufFahrzeug: 0, raum: "MR" },
|
|
{ typ: "Handsprechfunkgerät TPH900", anzahl: 3, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Funkraum, Ladeschale" },
|
|
{ typ: "B-Druckschlauch 20 m", anzahl: 8, aufFahrzeug: 0, raum: "G1" },
|
|
{ typ: "Überdrucklüfter", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
|
];
|
|
|
|
async function geraeteAnlegen(
|
|
organisationId: string,
|
|
typen: Map<string, string>,
|
|
fahrzeuge: Array<{ id: string; raeume: Map<string, string> }>,
|
|
standorte: Map<string, string>,
|
|
) {
|
|
const heute = new Date();
|
|
let laufend = 1;
|
|
let angelegt = 0;
|
|
|
|
for (const vorlage of GERAETE) {
|
|
const typId = typen.get(vorlage.typ);
|
|
const typVorlage = GERAETETYPEN.find((t) => t.name === vorlage.typ);
|
|
if (!typId || !typVorlage) continue;
|
|
|
|
for (let index = 0; index < vorlage.anzahl; index += 1) {
|
|
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
|
|
|
|
// Ein Teil der Geräte bekommt eine überschrittene Frist, damit die
|
|
// Statusanzeige nicht durchgehend grün ist und man sieht, wofür sie da
|
|
// ist.
|
|
const ueberfaellig = laufend % 4 === 0;
|
|
const indienststellung = datum(2015 + (laufend % 9), (laufend % 12) + 1, 15);
|
|
const herstellung = datum(indienststellung.getUTCFullYear() - 1, 6, 1);
|
|
|
|
const letztePruefung = ueberfaellig
|
|
? plusMonate(heute, -(typVorlage.pruefintervallMonate + 2))
|
|
: plusMonate(heute, -(laufend % typVorlage.pruefintervallMonate));
|
|
|
|
const naechstePruefung = plusMonate(letztePruefung, typVorlage.pruefintervallMonate);
|
|
|
|
const lebensdauerBis = typVorlage.lebensdauerJahre
|
|
? plusJahre(
|
|
typVorlage.lebensdauerBezug === LebensdauerBezug.HERSTELLUNG
|
|
? herstellung
|
|
: indienststellung,
|
|
typVorlage.lebensdauerJahre,
|
|
)
|
|
: null;
|
|
|
|
const status =
|
|
lebensdauerBis && lebensdauerBis < heute
|
|
? GeraetStatus.GESPERRT
|
|
: naechstePruefung < heute
|
|
? GeraetStatus.PRUEFUNG_FAELLIG
|
|
: laufend % 19 === 0
|
|
? GeraetStatus.IN_REPARATUR
|
|
: GeraetStatus.EINSATZBEREIT;
|
|
|
|
const fahrzeug = vorlage.aufFahrzeug !== null ? fahrzeuge[vorlage.aufFahrzeug] : null;
|
|
|
|
await prisma.geraet.upsert({
|
|
where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
inventarnummer,
|
|
bezeichnung: vorlage.typ,
|
|
geraetetypId: typId,
|
|
hersteller: typVorlage.hersteller,
|
|
seriennummer: `SN-${typVorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
|
|
herstellungsdatum: herstellung,
|
|
indienststellung,
|
|
anschaffungspreis: preisFuer(typVorlage.kategorie, laufend),
|
|
status,
|
|
letztePruefung,
|
|
naechstePruefung,
|
|
lebensdauerBis,
|
|
fahrzeugId: fahrzeug?.id ?? null,
|
|
geraeteraumId: fahrzeug && vorlage.raum ? (fahrzeug.raeume.get(vorlage.raum) ?? null) : null,
|
|
standortId: fahrzeug ? null : (standorte.get(vorlage.standort ?? "Gerätehaus") ?? null),
|
|
lagerort: vorlage.lagerort ?? null,
|
|
},
|
|
});
|
|
|
|
laufend += 1;
|
|
angelegt += 1;
|
|
}
|
|
}
|
|
|
|
log(`Geräte: ${angelegt}`);
|
|
}
|
|
|
|
/** Grobe Anschaffungspreise, damit Auswertungen später etwas zu rechnen haben. */
|
|
function preisFuer(kategorie: GeraeteKategorie, streuung: number): number {
|
|
const grundpreise: Partial<Record<GeraeteKategorie, number>> = {
|
|
[GeraeteKategorie.ATEMSCHUTZ]: 1800,
|
|
[GeraeteKategorie.RETTUNG]: 450,
|
|
[GeraeteKategorie.TECHNISCHE_HILFELEISTUNG]: 6500,
|
|
[GeraeteKategorie.ELEKTRO]: 3200,
|
|
[GeraeteKategorie.MESSTECHNIK]: 4800,
|
|
[GeraeteKategorie.FUNK]: 1400,
|
|
[GeraeteKategorie.SCHLAUCHMATERIAL]: 120,
|
|
[GeraeteKategorie.WASSERFOERDERUNG]: 9500,
|
|
[GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG]: 320,
|
|
[GeraeteKategorie.BRANDBEKAEMPFUNG]: 2100,
|
|
};
|
|
const grund = grundpreise[kategorie] ?? 250;
|
|
return Math.round((grund + (streuung % 7) * grund * 0.03) * 100) / 100;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Qualifikationen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface QualiTypVorlage {
|
|
name: string;
|
|
kuerzel: string;
|
|
art: QualifikationsArt;
|
|
gueltigkeitMonate?: number;
|
|
vorwarnzeitTage?: number;
|
|
einsatzrelevant?: boolean;
|
|
}
|
|
|
|
const QUALIFIKATIONSTYPEN: QualiTypVorlage[] = [
|
|
{ name: "Truppmann Teil 1 (MTA Basismodul)", kuerzel: "TM1", art: QualifikationsArt.LEHRGANG, einsatzrelevant: true },
|
|
{ name: "Truppmann Teil 2", kuerzel: "TM2", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Truppführer", kuerzel: "TF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Gruppenführer", kuerzel: "GF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Zugführer", kuerzel: "ZF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Maschinist für Löschfahrzeuge", kuerzel: "MA", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Sprechfunker", kuerzel: "SF", art: QualifikationsArt.LEHRGANG },
|
|
{
|
|
name: "Atemschutzgeräteträger",
|
|
kuerzel: "AGT",
|
|
art: QualifikationsArt.BEFAEHIGUNG,
|
|
gueltigkeitMonate: 12,
|
|
vorwarnzeitTage: 60,
|
|
einsatzrelevant: true,
|
|
},
|
|
{
|
|
name: "Arbeitsmedizinische Vorsorge G26.3",
|
|
kuerzel: "G26.3",
|
|
art: QualifikationsArt.UNTERSUCHUNG,
|
|
gueltigkeitMonate: 36,
|
|
vorwarnzeitTage: 120,
|
|
einsatzrelevant: true,
|
|
},
|
|
{
|
|
name: "Belastungsübung Atemschutz",
|
|
kuerzel: "BÜ",
|
|
art: QualifikationsArt.UNTERWEISUNG,
|
|
gueltigkeitMonate: 12,
|
|
vorwarnzeitTage: 60,
|
|
einsatzrelevant: true,
|
|
},
|
|
{ name: "Motorsägenführer", kuerzel: "MS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 60 },
|
|
{ name: "Führerschein C/CE", kuerzel: "C/CE", art: QualifikationsArt.FUEHRERSCHEIN, gueltigkeitMonate: 60, vorwarnzeitTage: 90 },
|
|
{ name: "Erste Hilfe", kuerzel: "EH", art: QualifikationsArt.UNTERWEISUNG, gueltigkeitMonate: 24 },
|
|
{ name: "Absturzsicherung", kuerzel: "AS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 12 },
|
|
];
|
|
|
|
async function qualifikationstypenAnlegen(organisationId: string) {
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of QUALIFIKATIONSTYPEN) {
|
|
const typ = await prisma.qualifikationstyp.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
kuerzel: vorlage.kuerzel,
|
|
art: vorlage.art,
|
|
gueltigkeitMonate: vorlage.gueltigkeitMonate ?? null,
|
|
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 90,
|
|
einsatzrelevant: vorlage.einsatzrelevant ?? false,
|
|
},
|
|
});
|
|
karte.set(vorlage.name, typ.id);
|
|
}
|
|
|
|
log(`Qualifikationstypen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
/**
|
|
* Verteilt Qualifikationen auf die Mannschaft.
|
|
*
|
|
* Ein Teil läuft absichtlich demnächst ab oder ist bereits abgelaufen — sonst
|
|
* bliebe die Ablaufwarnung in der Beispielwehr leer, und man sähe nicht, wofür
|
|
* sie da ist.
|
|
*/
|
|
async function qualifikationenAnlegen(organisationId: string, typen: Map<string, string>) {
|
|
const personen = await prisma.person.findMany({
|
|
where: { organisationId, geloeschtAm: null, abteilung: { not: "Verwaltung" } },
|
|
orderBy: { mitgliedsnummer: "asc" },
|
|
select: { id: true, mitgliedsnummer: true, eintrittsdatum: true },
|
|
});
|
|
|
|
const heute = new Date();
|
|
let angelegt = 0;
|
|
|
|
for (const [index, person] of personen.entries()) {
|
|
const eintritt = person.eintrittsdatum ?? datum(2015, 1, 1);
|
|
|
|
/** Grundausbildung hat jeder, weitere je nach Dienstalter. */
|
|
const zuweisungen: Array<{ typ: string; erworben: Date; gueltigBis?: Date | null }> = [
|
|
{ typ: "Truppmann Teil 1 (MTA Basismodul)", erworben: plusMonate(eintritt, 6) },
|
|
];
|
|
|
|
if (index < 12) {
|
|
zuweisungen.push({ typ: "Truppmann Teil 2", erworben: plusMonate(eintritt, 24) });
|
|
zuweisungen.push({ typ: "Sprechfunker", erworben: plusMonate(eintritt, 12) });
|
|
}
|
|
if (index < 8) {
|
|
zuweisungen.push({ typ: "Maschinist für Löschfahrzeuge", erworben: plusMonate(eintritt, 36) });
|
|
}
|
|
if (index < 6) {
|
|
zuweisungen.push({ typ: "Truppführer", erworben: plusMonate(eintritt, 48) });
|
|
}
|
|
if (index < 3) {
|
|
zuweisungen.push({ typ: "Gruppenführer", erworben: plusMonate(eintritt, 72) });
|
|
}
|
|
|
|
// Atemschutz: die Fristen werden gestreut, damit die Ampel etwas zeigt.
|
|
if (index < 9) {
|
|
const versatz = [-2, -1, 1, 2, 3, 5, 8, 10, 11][index] ?? 6;
|
|
zuweisungen.push({
|
|
typ: "Atemschutzgeräteträger",
|
|
erworben: plusMonate(heute, versatz - 12),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
zuweisungen.push({
|
|
typ: "Arbeitsmedizinische Vorsorge G26.3",
|
|
erworben: plusMonate(heute, versatz - 36),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
zuweisungen.push({
|
|
typ: "Belastungsübung Atemschutz",
|
|
erworben: plusMonate(heute, versatz - 12),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
}
|
|
|
|
if (index < 5) {
|
|
zuweisungen.push({
|
|
typ: "Führerschein C/CE",
|
|
erworben: plusMonate(eintritt, 12),
|
|
gueltigBis: plusMonate(heute, 6 + index * 4),
|
|
});
|
|
}
|
|
if (index < 4) {
|
|
zuweisungen.push({
|
|
typ: "Motorsägenführer",
|
|
erworben: plusMonate(heute, -30),
|
|
gueltigBis: plusMonate(heute, 30),
|
|
});
|
|
}
|
|
|
|
zuweisungen.push({
|
|
typ: "Erste Hilfe",
|
|
erworben: plusMonate(heute, -20 - index),
|
|
gueltigBis: plusMonate(heute, 4 - index),
|
|
});
|
|
|
|
for (const zuweisung of zuweisungen) {
|
|
const typId = typen.get(zuweisung.typ);
|
|
if (!typId) continue;
|
|
|
|
const vorhanden = await prisma.qualifikation.findFirst({
|
|
where: { organisationId, personId: person.id, typId },
|
|
select: { id: true },
|
|
});
|
|
if (vorhanden) continue;
|
|
|
|
await prisma.qualifikation.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typId,
|
|
erworbenAm: zuweisung.erworben,
|
|
gueltigBis: zuweisung.gueltigBis ?? null,
|
|
ausstellendeStelle:
|
|
zuweisung.typ.includes("G26") ? "Betriebsarzt Dr. Berger" : "Kreisbrandinspektion Freising",
|
|
},
|
|
});
|
|
angelegt += 1;
|
|
}
|
|
}
|
|
|
|
log(`Qualifikationen: ${angelegt}`);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
|
|
//
|
|
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
|
|
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
|
|
// heißt, zeigt nicht, worum es geht.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface PunktVorlage {
|
|
text: string;
|
|
typ: ChecklistenpunktTyp;
|
|
pflicht?: boolean;
|
|
sollwert?: string;
|
|
einheit?: string;
|
|
minWert?: number;
|
|
maxWert?: number;
|
|
hinweis?: string;
|
|
}
|
|
|
|
interface WartungsVorlage {
|
|
name: string;
|
|
geraetetyp: string;
|
|
intervallMonate: number;
|
|
vorwarnzeitTage?: number;
|
|
pruefvorschrift?: string;
|
|
standardDauerMinuten?: number;
|
|
punkte: PunktVorlage[];
|
|
}
|
|
|
|
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
|
|
{
|
|
name: "Pressluftatmer — Jahresprüfung",
|
|
geraetetyp: "Pressluftatmer PA 94",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
|
standardDauerMinuten: 25,
|
|
punkte: [
|
|
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 10 bar in einer Minute",
|
|
einheit: "bar",
|
|
minWert: 0,
|
|
maxWert: 10,
|
|
},
|
|
{
|
|
text: "Ansprechdruck der Warneinrichtung",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "50 bis 60 bar",
|
|
einheit: "bar",
|
|
minWert: 50,
|
|
maxWert: 60,
|
|
},
|
|
{
|
|
text: "Lungenautomat: Öffnungsdruck",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "bis 3,5 mbar",
|
|
einheit: "mbar",
|
|
maxWert: 3.5,
|
|
},
|
|
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Atemanschluss — Halbjahresprüfung",
|
|
geraetetyp: "Atemschutzmaske FPS 7000",
|
|
intervallMonate: 6,
|
|
pruefvorschrift: "DGUV Regel 112-190",
|
|
standardDauerMinuten: 15,
|
|
punkte: [
|
|
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Dichtprüfung: Druckabfall in einer Minute",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 0,5 mbar",
|
|
einheit: "mbar",
|
|
minWert: 0,
|
|
maxWert: 0.5,
|
|
},
|
|
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Feuerwehrleine — Jahresprüfung",
|
|
geraetetyp: "Feuerwehrleine 30 m",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
|
|
standardDauerMinuten: 10,
|
|
punkte: [
|
|
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
|
|
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Druckschlauch — Druckprüfung",
|
|
geraetetyp: "B-Druckschlauch 20 m",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DIN 14811",
|
|
standardDauerMinuten: 8,
|
|
punkte: [
|
|
{
|
|
text: "Prüfdruck halten",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "mindestens 8 bar über 5 Minuten",
|
|
einheit: "bar",
|
|
minWert: 8,
|
|
},
|
|
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
],
|
|
},
|
|
{
|
|
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
|
|
geraetetyp: "Stromerzeuger 8 kVA",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
|
|
standardDauerMinuten: 30,
|
|
punkte: [
|
|
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Schutzleiterwiderstand",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 0,3 Ohm",
|
|
einheit: "Ohm",
|
|
minWert: 0,
|
|
maxWert: 0.3,
|
|
},
|
|
{
|
|
text: "Isolationswiderstand",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "mindestens 1 Megaohm",
|
|
einheit: "MOhm",
|
|
minWert: 1,
|
|
},
|
|
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
],
|
|
},
|
|
];
|
|
|
|
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
|
|
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
|
|
|
|
for (const vorlage of WARTUNGSVORLAGEN) {
|
|
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
|
|
|
|
const angelegt = await prisma.wartungsvorlage.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
geraetetypId,
|
|
intervallMonate: vorlage.intervallMonate,
|
|
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
|
|
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
|
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
|
|
},
|
|
});
|
|
|
|
const punktIds: string[] = [];
|
|
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
|
|
|
|
if (vorhandene === 0) {
|
|
for (const [index, punkt] of vorlage.punkte.entries()) {
|
|
const erzeugt = await prisma.checklistenpunkt.create({
|
|
data: {
|
|
organisationId,
|
|
vorlageId: angelegt.id,
|
|
text: punkt.text,
|
|
typ: punkt.typ,
|
|
pflicht: punkt.pflicht ?? true,
|
|
sollwert: punkt.sollwert ?? null,
|
|
einheit: punkt.einheit ?? null,
|
|
minWert: punkt.minWert ?? null,
|
|
maxWert: punkt.maxWert ?? null,
|
|
hinweis: punkt.hinweis ?? null,
|
|
sortierung: (index + 1) * 10,
|
|
},
|
|
});
|
|
punktIds.push(erzeugt.id);
|
|
}
|
|
} else {
|
|
const bestand = await prisma.checklistenpunkt.findMany({
|
|
where: { vorlageId: angelegt.id },
|
|
orderBy: { sortierung: "asc" },
|
|
select: { id: true },
|
|
});
|
|
punktIds.push(...bestand.map((punkt) => punkt.id));
|
|
}
|
|
|
|
karte.set(vorlage.name, {
|
|
id: angelegt.id,
|
|
intervallMonate: vorlage.intervallMonate,
|
|
punkte: punktIds,
|
|
});
|
|
}
|
|
|
|
log(`Wartungsvorlagen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
/**
|
|
* Termine für alle Geräte der jeweiligen Bauart.
|
|
*
|
|
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
|
|
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
|
|
* Fristen-Dashboard hat.
|
|
*/
|
|
async function wartungstermineAnlegen(
|
|
organisationId: string,
|
|
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
|
) {
|
|
let angelegt = 0;
|
|
|
|
for (const vorlage of WARTUNGSVORLAGEN) {
|
|
const eintrag = vorlagen.get(vorlage.name);
|
|
if (!eintrag) continue;
|
|
|
|
const geraete = await prisma.geraet.findMany({
|
|
where: {
|
|
organisationId,
|
|
geloeschtAm: null,
|
|
geraetetyp: { name: vorlage.geraetetyp },
|
|
},
|
|
select: { id: true, letztePruefung: true, indienststellung: true },
|
|
});
|
|
|
|
for (const geraet of geraete) {
|
|
const basis = geraet.letztePruefung ?? geraet.indienststellung;
|
|
if (!basis) continue;
|
|
|
|
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
|
|
|
|
const ergebnis = await prisma.wartungstermin.createMany({
|
|
data: [
|
|
{
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
vorlageId: eintrag.id,
|
|
faelligAm,
|
|
automatischErzeugt: true,
|
|
},
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
angelegt += ergebnis.count;
|
|
}
|
|
}
|
|
|
|
log(`Wartungstermine: ${angelegt}`);
|
|
}
|
|
|
|
/**
|
|
* Ein abgeschlossenes Protokoll samt Mangel.
|
|
*
|
|
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
|
|
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
|
|
* Worker, sobald einer läuft.
|
|
*/
|
|
async function beispielPruefungAnlegen(
|
|
organisationId: string,
|
|
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
|
) {
|
|
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
|
|
if (!vorlage) return;
|
|
|
|
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log("Beispielprüfung: bereits vorhanden");
|
|
return;
|
|
}
|
|
|
|
const geraet = await prisma.geraet.findFirst({
|
|
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
|
|
select: { id: true, inventarnummer: true },
|
|
});
|
|
if (!geraet) return;
|
|
|
|
const pruefer = await prisma.person.findFirst({
|
|
where: {
|
|
organisationId,
|
|
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
|
|
},
|
|
select: { id: true, vorname: true, nachname: true },
|
|
});
|
|
|
|
const geprueftAm = plusMonate(new Date(), -2);
|
|
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
|
|
|
|
const punkte = await prisma.checklistenpunkt.findMany({
|
|
where: { vorlageId: vorlage.id },
|
|
orderBy: { sortierung: "asc" },
|
|
});
|
|
|
|
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
|
|
// Trigger aus Migration 20260903000300 lässt an einer bereits
|
|
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
|
|
// das ist auch für den Seed richtig so.
|
|
const pruefung = await prisma.pruefung.create({
|
|
data: {
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
vorlageId: vorlage.id,
|
|
art: PruefungArt.INTERN,
|
|
status: PruefungStatus.ENTWURF,
|
|
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
|
|
prueferId: pruefer?.id ?? null,
|
|
prueferName,
|
|
durchgefuehrtAm: geprueftAm,
|
|
begonnenUm: geprueftAm,
|
|
dauerMinuten: 12,
|
|
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
|
|
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
|
|
abgeschlossenAm: geprueftAm,
|
|
bemerkung:
|
|
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
|
|
ergebnisse: {
|
|
create: punkte.map((punkt, index) => ({
|
|
organisationId,
|
|
punktId: punkt.id,
|
|
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
|
|
text:
|
|
punkt.typ === ChecklistenpunktTyp.TEXT
|
|
? "Leine selbst ohne Befund, Beutel beschädigt."
|
|
: null,
|
|
inOrdnung: index !== 2,
|
|
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
|
|
})),
|
|
},
|
|
signaturen: {
|
|
create: [
|
|
{
|
|
organisationId,
|
|
rolle: SignaturRolle.PRUEFER,
|
|
personId: pruefer?.id ?? null,
|
|
name: prueferName,
|
|
unterzeichnetAm: geprueftAm,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
await prisma.pruefung.update({
|
|
where: { id: pruefung.id },
|
|
data: { status: PruefungStatus.ABGESCHLOSSEN },
|
|
});
|
|
|
|
await prisma.mangel.create({
|
|
data: {
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
pruefungId: pruefung.id,
|
|
melderId: pruefer?.id ?? null,
|
|
melderName: prueferName,
|
|
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
|
|
prioritaet: MangelPrioritaet.NIEDRIG,
|
|
status: MangelStatus.ZUGEWIESEN,
|
|
zugewiesenAnId: pruefer?.id ?? null,
|
|
},
|
|
});
|
|
|
|
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
|
|
}
|
|
|
|
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
|
|
async function rueckrufAnlegen(organisationId: string) {
|
|
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
|
|
if (vorhanden > 0) return;
|
|
|
|
const typ = await prisma.geraetetyp.findFirst({
|
|
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
|
|
select: { id: true },
|
|
});
|
|
|
|
await prisma.rueckruf.create({
|
|
data: {
|
|
organisationId,
|
|
hersteller: "Dräger",
|
|
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
|
|
referenz: "SI-2026-03",
|
|
beschreibung:
|
|
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
|
|
+ "Betroffen sind Masken mit Herstellung vor 2020.",
|
|
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
|
|
veroeffentlichtAm: plusMonate(new Date(), -1),
|
|
sperrtGeraete: false,
|
|
typen: typ
|
|
? {
|
|
create: [
|
|
{
|
|
organisationId,
|
|
geraetetypId: typ.id,
|
|
herstellungBis: datum(2019, 12, 31),
|
|
},
|
|
],
|
|
}
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
log("Rückruf: 1 Beispiel");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phase 3 — Einsätze und Übungen
|
|
//
|
|
// Ein Jahr im Leben einer Wehr: Brände, technische Hilfeleistungen, ein
|
|
// Fehlalarm einer Brandmeldeanlage und die Übungen dazwischen. Die Zahlen sind
|
|
// so gewählt, dass die Statistik etwas zeigt — auch eine Hilfsfrist, die
|
|
// gerade noch passt.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface EinsatzVorlage {
|
|
/** Tage vor heute. */
|
|
vorTagen: number;
|
|
uhrzeit: [number, number];
|
|
stichwort: string;
|
|
kategorie: EinsatzKategorie;
|
|
strasse: string;
|
|
hausnummer?: string;
|
|
objekt?: string;
|
|
/** Minuten nach der Alarmierung: ausgerückt, eingetroffen, Ende, eingerückt. */
|
|
ablauf: [number, number, number, number];
|
|
fahrzeuge: number[];
|
|
/** Wie viele Kräfte, und wer davon führt. */
|
|
mannschaft: number;
|
|
gerettet?: number;
|
|
verletzt?: number;
|
|
sachschaden?: number;
|
|
beschreibung: string;
|
|
bericht?: string;
|
|
meldungen?: Array<{ nachMinuten: number; text: string; wichtig?: boolean }>;
|
|
material?: Array<{ bezeichnung: string; menge: number; einheit: string }>;
|
|
status?: EinsatzStatus;
|
|
}
|
|
|
|
const EINSAETZE: EinsatzVorlage[] = [
|
|
{
|
|
vorTagen: 12,
|
|
uhrzeit: [3, 42],
|
|
stichwort: "B 3 Wohnhausbrand",
|
|
kategorie: EinsatzKategorie.BRAND_GROSS,
|
|
strasse: "Bahnhofstraße",
|
|
hausnummer: "14",
|
|
objekt: "Reihenhaus, zwei Vollgeschosse",
|
|
ablauf: [5, 9, 143, 168],
|
|
fahrzeuge: [0, 1],
|
|
mannschaft: 14,
|
|
gerettet: 2,
|
|
verletzt: 1,
|
|
sachschaden: 85000,
|
|
beschreibung:
|
|
"Bei Eintreffen Rauch aus dem Dachgeschoss, zwei Personen im Obergeschoss gemeldet.",
|
|
bericht:
|
|
"Menschenrettung über die Steckleiter durch den Angriffstrupp. Zwei Personen unverletzt gerettet, eine Person mit Rauchgasintoxikation an den Rettungsdienst übergeben.\n\nBrandbekämpfung mit einem C-Rohr unter Atemschutz vom Treppenraum aus. Nachlöscharbeiten und Kontrolle mit der Wärmebildkamera bis 06:05 Uhr. Einsatzstelle an die Polizei übergeben.",
|
|
meldungen: [
|
|
{ nachMinuten: 10, text: "Rauch aus Dachgeschoss, zwei Personen vermisst.", wichtig: true },
|
|
{ nachMinuten: 22, text: "Beide Personen gerettet, Menschenrettung abgeschlossen.", wichtig: true },
|
|
{ nachMinuten: 48, text: "Feuer unter Kontrolle." },
|
|
{ nachMinuten: 140, text: "Nachlöscharbeiten beendet, Kontrolle mit Wärmebildkamera." },
|
|
],
|
|
material: [
|
|
{ bezeichnung: "Atemluftflaschen 300 bar", menge: 6, einheit: "Stück" },
|
|
{ bezeichnung: "Löschwasser", menge: 4500, einheit: "Liter" },
|
|
],
|
|
},
|
|
{
|
|
vorTagen: 26,
|
|
uhrzeit: [14, 8],
|
|
stichwort: "THL 1 Ölspur",
|
|
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
|
strasse: "Hauptstraße",
|
|
ablauf: [6, 11, 55, 68],
|
|
fahrzeuge: [1],
|
|
mannschaft: 6,
|
|
beschreibung: "Ölspur über etwa 300 Meter, ausgehend von einem Traktor.",
|
|
bericht: "Ölspur mit Bindemittel abgestreut und aufgenommen. Straßenmeisterei verständigt.",
|
|
material: [{ bezeichnung: "Ölbindemittel", menge: 40, einheit: "kg" }],
|
|
},
|
|
{
|
|
vorTagen: 41,
|
|
uhrzeit: [19, 55],
|
|
stichwort: "THL 2 Verkehrsunfall",
|
|
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_MITTEL,
|
|
strasse: "Kreisstraße MÜ 12",
|
|
objekt: "zwischen Musterhausen und Nachbarhausen",
|
|
ablauf: [4, 12, 78, 96],
|
|
fahrzeuge: [0],
|
|
mannschaft: 9,
|
|
verletzt: 2,
|
|
sachschaden: 24000,
|
|
beschreibung: "Zwei Fahrzeuge, eine Person eingeklemmt.",
|
|
bericht:
|
|
"Person mit dem hydraulischen Rettungssatz befreit und an den Rettungsdienst übergeben. Brandschutz sichergestellt, Fahrzeuge gegen Wegrollen gesichert, Betriebsstoffe aufgenommen.",
|
|
meldungen: [
|
|
{ nachMinuten: 14, text: "Eine Person eingeklemmt, Rettungsdienst vor Ort.", wichtig: true },
|
|
{ nachMinuten: 39, text: "Person befreit und übergeben.", wichtig: true },
|
|
],
|
|
material: [{ bezeichnung: "Ölbindemittel", menge: 15, einheit: "kg" }],
|
|
},
|
|
{
|
|
vorTagen: 58,
|
|
uhrzeit: [9, 12],
|
|
stichwort: "BMA Auslösung",
|
|
kategorie: EinsatzKategorie.BRANDMELDEANLAGE,
|
|
strasse: "Schulstraße",
|
|
hausnummer: "3",
|
|
objekt: "Grundschule",
|
|
ablauf: [7, 13, 32, 44],
|
|
fahrzeuge: [0],
|
|
mannschaft: 8,
|
|
beschreibung: "Auslösung eines Rauchmelders im Werkraum.",
|
|
bericht: "Kein Schadenfeuer. Auslösung durch Staub bei Reinigungsarbeiten. Anlage zurückgestellt.",
|
|
},
|
|
{
|
|
vorTagen: 73,
|
|
uhrzeit: [16, 30],
|
|
stichwort: "B 1 Kleinbrand",
|
|
kategorie: EinsatzKategorie.BRAND_KLEIN,
|
|
strasse: "Am Anger",
|
|
hausnummer: "7",
|
|
ablauf: [5, 8, 41, 52],
|
|
fahrzeuge: [1],
|
|
mannschaft: 6,
|
|
sachschaden: 1200,
|
|
beschreibung: "Brennende Mülltonne an der Hauswand.",
|
|
bericht: "Ablöschen mit Schnellangriff, Kontrolle der Fassade mit der Wärmebildkamera.",
|
|
},
|
|
{
|
|
vorTagen: 95,
|
|
uhrzeit: [21, 47],
|
|
stichwort: "THL 1 Sturmschaden",
|
|
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
|
strasse: "Lindenweg",
|
|
ablauf: [8, 16, 62, 74],
|
|
fahrzeuge: [0],
|
|
mannschaft: 7,
|
|
beschreibung: "Baum auf Fahrbahn nach Gewitterfront.",
|
|
bericht: "Baum mit der Motorkettensäge zerlegt und beiseitegeräumt. Fahrbahn gereinigt.",
|
|
material: [{ bezeichnung: "Kettensägenbenzin", menge: 2, einheit: "Liter" }],
|
|
},
|
|
{
|
|
vorTagen: 118,
|
|
uhrzeit: [11, 20],
|
|
stichwort: "Sicherheitswache Pfarrfest",
|
|
kategorie: EinsatzKategorie.SICHERHEITSWACHE,
|
|
strasse: "Kirchplatz",
|
|
ablauf: [10, 20, 300, 310],
|
|
fahrzeuge: [2],
|
|
mannschaft: 4,
|
|
beschreibung: "Brandsicherheitswache beim Pfarrfest.",
|
|
bericht: "Ohne besondere Vorkommnisse.",
|
|
},
|
|
{
|
|
vorTagen: 140,
|
|
uhrzeit: [6, 5],
|
|
stichwort: "THL 1 Türöffnung",
|
|
kategorie: EinsatzKategorie.RETTUNGSDIENST,
|
|
strasse: "Rosenstraße",
|
|
hausnummer: "22",
|
|
ablauf: [6, 10, 28, 38],
|
|
fahrzeuge: [1],
|
|
mannschaft: 5,
|
|
gerettet: 1,
|
|
beschreibung: "Hilflose Person hinter verschlossener Tür, Rettungsdienst vor Ort.",
|
|
bericht: "Tür schonend geöffnet, Person an den Rettungsdienst übergeben.",
|
|
},
|
|
];
|
|
|
|
/** Funktionen einer Gruppe in der Reihenfolge, in der sie besetzt werden. */
|
|
const FUNKTIONSFOLGE: EinsatzFunktion[] = [
|
|
EinsatzFunktion.GRUPPENFUEHRER,
|
|
EinsatzFunktion.MASCHINIST,
|
|
EinsatzFunktion.MELDER,
|
|
EinsatzFunktion.TRUPPFUEHRER,
|
|
EinsatzFunktion.TRUPPMANN,
|
|
EinsatzFunktion.TRUPPFUEHRER,
|
|
EinsatzFunktion.TRUPPMANN,
|
|
EinsatzFunktion.TRUPPFUEHRER,
|
|
EinsatzFunktion.TRUPPMANN,
|
|
];
|
|
|
|
function minutenSpaeter(basis: Date, minuten: number): Date {
|
|
return new Date(basis.getTime() + minuten * 60_000);
|
|
}
|
|
|
|
async function einsaetzeAnlegen(
|
|
organisationId: string,
|
|
fahrzeuge: Array<{ id: string; funkrufname: string }>,
|
|
) {
|
|
const vorhanden = await prisma.einsatz.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log(`Einsätze: bereits ${vorhanden} vorhanden`);
|
|
return;
|
|
}
|
|
|
|
const personen = await prisma.person.findMany({
|
|
where: { organisationId, aktiv: true, geloeschtAm: null },
|
|
select: { id: true, vorname: true, nachname: true },
|
|
orderBy: { mitgliedsnummer: "asc" },
|
|
});
|
|
|
|
if (personen.length === 0 || fahrzeuge.length === 0) return;
|
|
|
|
let laufend = 0;
|
|
|
|
for (const vorlage of EINSAETZE) {
|
|
laufend += 1;
|
|
|
|
const alarmiert = new Date();
|
|
alarmiert.setUTCDate(alarmiert.getUTCDate() - vorlage.vorTagen);
|
|
alarmiert.setUTCHours(vorlage.uhrzeit[0], vorlage.uhrzeit[1], 0, 0);
|
|
|
|
const jahr = alarmiert.getUTCFullYear();
|
|
const [ausgerueckt, eingetroffen, ende, eingerueckt] = vorlage.ablauf;
|
|
|
|
const einsatz = await prisma.einsatz.create({
|
|
data: {
|
|
organisationId,
|
|
einsatznummer: `${jahr}-${String(laufend).padStart(4, "0")}`,
|
|
stichwort: vorlage.stichwort,
|
|
kategorie: vorlage.kategorie,
|
|
status: vorlage.status ?? EinsatzStatus.ABGESCHLOSSEN,
|
|
alarmierungsart: Alarmierungsart.MELDEEMPFAENGER,
|
|
strasse: vorlage.strasse,
|
|
hausnummer: vorlage.hausnummer ?? null,
|
|
plz: "84543",
|
|
ort: "Musterhausen",
|
|
objekt: vorlage.objekt ?? null,
|
|
alarmiertUm: alarmiert,
|
|
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt),
|
|
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen),
|
|
einsatzendeUm: minutenSpaeter(alarmiert, ende),
|
|
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
|
einsatzbereitUm: minutenSpaeter(alarmiert, eingerueckt + 30),
|
|
einsatzleiterId: personen[0]?.id ?? null,
|
|
beschreibung: vorlage.beschreibung,
|
|
bericht: vorlage.bericht ?? null,
|
|
geretteteePersonen: vorlage.gerettet ?? 0,
|
|
verletztePersonen: vorlage.verletzt ?? 0,
|
|
sachschaden: vorlage.sachschaden ?? null,
|
|
fahrzeuge: {
|
|
create: vorlage.fahrzeuge
|
|
.map((index) => fahrzeuge[index])
|
|
.filter((fahrzeug): fahrzeug is { id: string; funkrufname: string } => Boolean(fahrzeug))
|
|
.map((fahrzeug, position) => ({
|
|
organisationId,
|
|
fahrzeugId: fahrzeug.id,
|
|
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt + position),
|
|
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen + position),
|
|
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
|
kilometerAbfahrt: 12000 + laufend * 40,
|
|
kilometerRueckkehr: 12000 + laufend * 40 + 8 + position * 3,
|
|
wasserabgabe: vorlage.kategorie.startsWith("BRAND") ? 1500 : null,
|
|
})),
|
|
},
|
|
kraefte: {
|
|
create: Array.from({ length: Math.min(vorlage.mannschaft, personen.length) }).map(
|
|
(_, index) => ({
|
|
organisationId,
|
|
// Die Mannschaft rotiert, damit die Beteiligung je Person
|
|
// unterschiedlich ausfällt.
|
|
personId: personen[(index + laufend * 3) % personen.length]!.id,
|
|
funktion: FUNKTIONSFOLGE[index % FUNKTIONSFOLGE.length]!,
|
|
atemschutz: vorlage.kategorie.startsWith("BRAND") && index >= 3 && index <= 6,
|
|
vonUm: minutenSpaeter(alarmiert, ausgerueckt),
|
|
bisUm: minutenSpaeter(alarmiert, eingerueckt),
|
|
}),
|
|
),
|
|
},
|
|
lagemeldungen: {
|
|
create: (vorlage.meldungen ?? []).map((meldung) => ({
|
|
organisationId,
|
|
zeitpunkt: minutenSpaeter(alarmiert, meldung.nachMinuten),
|
|
text: meldung.text,
|
|
wichtig: meldung.wichtig ?? false,
|
|
verfasserId: personen[0]?.id ?? null,
|
|
verfasserName: personen[0]
|
|
? `${personen[0].vorname} ${personen[0].nachname}`
|
|
: "Einsatzleitung",
|
|
})),
|
|
},
|
|
material: {
|
|
create: (vorlage.material ?? []).map((posten) => ({
|
|
organisationId,
|
|
bezeichnung: posten.bezeichnung,
|
|
menge: posten.menge,
|
|
einheit: posten.einheit,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Der älteste Einsatz ist bereits freigegeben — damit die Ansicht eines
|
|
// festgeschriebenen Berichts nicht leer bleibt.
|
|
if (vorlage.vorTagen >= 140) {
|
|
await prisma.einsatz.update({
|
|
where: { id: einsatz.id },
|
|
data: {
|
|
status: EinsatzStatus.FREIGEGEBEN,
|
|
freigegebenAm: minutenSpaeter(alarmiert, 2880),
|
|
freigegebenVonId: personen[0]?.id ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
log(`Einsätze: ${EINSAETZE.length} mit Fahrzeugen, Mannschaft und Lagemeldungen`);
|
|
}
|
|
|
|
interface UebungVorlage {
|
|
vorTagen: number;
|
|
thema: string;
|
|
art: UebungsArt;
|
|
ort: string;
|
|
lernziele: string;
|
|
teilnehmer: number;
|
|
dauer: number;
|
|
}
|
|
|
|
const UEBUNGEN: UebungVorlage[] = [
|
|
{
|
|
vorTagen: 7,
|
|
thema: "Innenangriff mit Atemschutz",
|
|
art: UebungsArt.ATEMSCHUTZ,
|
|
ort: "Übungscontainer Nachbarhausen",
|
|
lernziele:
|
|
"Vorgehen im Trupp unter Nullsicht, Türöffnungsprozedur, Rückweg sichern, Kommunikation mit dem Gruppenführer.",
|
|
teilnehmer: 9,
|
|
dauer: 150,
|
|
},
|
|
{
|
|
vorTagen: 21,
|
|
thema: "Wasserförderung über lange Wegstrecke",
|
|
art: UebungsArt.PRAXIS,
|
|
ort: "Löschwasserentnahmestelle Mühlbach",
|
|
lernziele: "Aufbau einer B-Leitung über 600 m, Verstärkerpumpe setzen, Druckverluste erkennen.",
|
|
teilnehmer: 12,
|
|
dauer: 120,
|
|
},
|
|
{
|
|
vorTagen: 35,
|
|
thema: "Technische Hilfeleistung nach Verkehrsunfall",
|
|
art: UebungsArt.PRAXIS,
|
|
ort: "Übungsgelände am Bauhof",
|
|
lernziele:
|
|
"Fahrzeug sichern, Glasmanagement, patientengerechte Rettung, Zusammenarbeit mit dem Rettungsdienst.",
|
|
teilnehmer: 11,
|
|
dauer: 180,
|
|
},
|
|
{
|
|
vorTagen: 49,
|
|
thema: "Funkausbildung nach BOS-Richtlinie",
|
|
art: UebungsArt.FUNK,
|
|
ort: "Gerätehaus, Schulungsraum",
|
|
lernziele: "Funkdisziplin, Meldeschema, Umgang mit dem Handsprechfunkgerät.",
|
|
teilnehmer: 8,
|
|
dauer: 90,
|
|
},
|
|
{
|
|
vorTagen: 63,
|
|
thema: "Maschinistenausbildung Tragkraftspritze",
|
|
art: UebungsArt.MASCHINIST,
|
|
ort: "Gerätehaus",
|
|
lernziele: "Inbetriebnahme, Entlüften, Störungssuche, Wartung nach dem Einsatz.",
|
|
teilnehmer: 5,
|
|
dauer: 120,
|
|
},
|
|
{
|
|
vorTagen: 84,
|
|
thema: "Gemeinschaftsübung mit der FF Nachbarhausen",
|
|
art: UebungsArt.GEMEINSCHAFTSUEBUNG,
|
|
ort: "Sägewerk Nachbarhausen",
|
|
lernziele:
|
|
"Zusammenarbeit zweier Wehren, gemeinsame Einsatzleitung, Wasserversorgung aus offenem Gewässer.",
|
|
teilnehmer: 14,
|
|
dauer: 210,
|
|
},
|
|
];
|
|
|
|
async function uebungenAnlegen(organisationId: string) {
|
|
const vorhanden = await prisma.uebung.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log(`Übungen: bereits ${vorhanden} vorhanden`);
|
|
return;
|
|
}
|
|
|
|
const personen = await prisma.person.findMany({
|
|
where: { organisationId, aktiv: true, geloeschtAm: null },
|
|
select: { id: true },
|
|
orderBy: { mitgliedsnummer: "asc" },
|
|
});
|
|
|
|
if (personen.length === 0) return;
|
|
|
|
let laufend = 0;
|
|
|
|
for (const vorlage of UEBUNGEN) {
|
|
laufend += 1;
|
|
|
|
const datum = new Date();
|
|
datum.setUTCDate(datum.getUTCDate() - vorlage.vorTagen);
|
|
datum.setUTCHours(0, 0, 0, 0);
|
|
|
|
const beginn = new Date(datum);
|
|
beginn.setUTCHours(19, 0, 0, 0);
|
|
|
|
await prisma.uebung.create({
|
|
data: {
|
|
organisationId,
|
|
nummer: `${datum.getUTCFullYear()}-${String(laufend).padStart(4, "0")}`,
|
|
thema: vorlage.thema,
|
|
art: vorlage.art,
|
|
datum,
|
|
beginnUm: beginn,
|
|
endeUm: minutenSpaeter(beginn, vorlage.dauer),
|
|
ort: vorlage.ort,
|
|
leiterId: personen[0]?.id ?? null,
|
|
lernziele: vorlage.lernziele,
|
|
teilnahmen: {
|
|
create: Array.from({ length: Math.min(vorlage.teilnehmer, personen.length) }).map(
|
|
(_, index) => ({
|
|
organisationId,
|
|
personId: personen[(index + laufend * 2) % personen.length]!.id,
|
|
}),
|
|
),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 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);
|
|
|
|
log("Fertig.");
|
|
log("");
|
|
log("Anmeldung für den Erststart:");
|
|
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
|
|
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
|
|
log("");
|
|
log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203");
|
|
}
|
|
|
|
main()
|
|
.catch((fehler) => {
|
|
console.error("[seed] Abbruch:", fehler);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|