Files
FireAdministration/prisma/seed.ts
T
Florian WildeandClaude Opus 5 e503aafc18 Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:07:11 +02:00

1559 lines
50 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.
*
* 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 {
AusweisTokenTyp,
ChecklistenpunktTyp,
Dienstgrad,
FahrzeugArt,
GeraeteKategorie,
GeraetStatus,
LebensdauerBezug,
LebensdauerKategorie,
MangelPrioritaet,
MangelStatus,
PrismaClient,
PruefErgebnis,
PruefungArt,
PruefungStatus,
QualifikationsArt,
RolleSchluessel,
SignaturRolle,
StandortArt,
} 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");
}
// -----------------------------------------------------------------------------
// 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);
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();
});