/** * 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. * * 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, Dienstgrad, FahrzeugArt, GeraeteKategorie, GeraetStatus, LebensdauerBezug, LebensdauerKategorie, PrismaClient, QualifikationsArt, RolleSchluessel, 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> { const { RECHTE } = await import("../src/lib/authz"); const karte = new Map(); 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) { const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } = await import("../src/lib/authz"); const rollenKarte = new Map(); 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] ?? []); 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, ): Promise { 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) { 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(); 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(); 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) { const fahrzeuge: Array<{ id: string; funkrufname: string; raeume: Map }> = []; 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(); 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, fahrzeuge: Array<{ id: string; raeume: Map }>, standorte: Map, ) { 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> = { [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(); 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) { 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}`); } // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- async function main(): Promise { 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); 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(); });