/** * Seed — legt eine vollständige Beispielwehr an. * * Inhalt: Berechtigungskatalog, 9 Systemrollen, 16 Personen mit Rollen und * Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen, * 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen * Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der * Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus * fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein * Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt, * sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein * Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am * Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht * beim ersten Start nicht leer ist. * * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt * die Organisation ja erst an, deren Kontext RLS bräuchte. * * Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits * vorhandenen Daten. * * npm run db:seed */ import "dotenv/config"; import { Alarmierungsart, AnwesenheitAnlass, AusweisTokenTyp, ChecklistenpunktTyp, EinsatzFunktion, EinsatzKategorie, EinsatzStatus, Dienstgrad, FahrtZweck, FahrzeugArt, GeraeteKategorie, GeraetStatus, LebensdauerBezug, LebensdauerKategorie, MangelPrioritaet, MangelStatus, PrismaClient, PruefErgebnis, PruefungArt, PruefungStatus, QualifikationsArt, RolleSchluessel, SignaturRolle, StandortArt, UebungsArt, WaescheStatus, } from "@prisma/client"; import bcrypt from "bcryptjs"; const prisma = new PrismaClient({ datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" }, }, }); const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12); const ORGANISATION_SLUG = "ff-musterhausen"; function log(nachricht: string): void { console.info(`[seed] ${nachricht}`); } /** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */ function datum(jahr: number, monat: number, tag: number): Date { return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0)); } /** Verschiebt ein Datum um Monate — für Prüffristen. */ function plusMonate(basis: Date, monate: number): Date { const neu = new Date(basis); neu.setUTCMonth(neu.getUTCMonth() + monate); return neu; } /** Verschiebt ein Datum um Jahre — für Lebensdauern. */ function plusJahre(basis: Date, jahre: number): Date { const neu = new Date(basis); neu.setUTCFullYear(neu.getUTCFullYear() + jahre); return neu; } // ----------------------------------------------------------------------------- // Berechtigungskatalog // ----------------------------------------------------------------------------- /** * Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über * einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft. */ async function katalogAbgleichen(): Promise> { 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}`); } // ----------------------------------------------------------------------------- // 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) { const karte = new Map(); 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, ) { 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, ) { const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung"); if (!vorlage) return; const vorhanden = await prisma.pruefung.count({ where: { organisationId } }); if (vorhanden > 0) { log("Beispielprüfung: bereits vorhanden"); return; } const geraet = await prisma.geraet.findFirst({ where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null }, select: { id: true, inventarnummer: true }, }); if (!geraet) return; const pruefer = await prisma.person.findFirst({ where: { organisationId, rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } }, }, select: { id: true, vorname: true, nachname: true }, }); const geprueftAm = plusMonate(new Date(), -2); const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart"; const punkte = await prisma.checklistenpunkt.findMany({ where: { vorlageId: vorlage.id }, orderBy: { sortierung: "asc" }, }); // Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der // Trigger aus Migration 20260903000300 lässt an einer bereits // abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und // das ist auch für den Seed richtig so. const pruefung = await prisma.pruefung.create({ data: { organisationId, geraetId: geraet.id, vorlageId: vorlage.id, art: PruefungArt.INTERN, status: PruefungStatus.ENTWURF, ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN, prueferId: pruefer?.id ?? null, prueferName, durchgefuehrtAm: geprueftAm, begonnenUm: geprueftAm, dauerMinuten: 12, naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate), protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`, abgeschlossenAm: geprueftAm, bemerkung: "Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.", ergebnisse: { create: punkte.map((punkt, index) => ({ organisationId, punktId: punkt.id, jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null, text: punkt.typ === ChecklistenpunktTyp.TEXT ? "Leine selbst ohne Befund, Beutel beschädigt." : null, inOrdnung: index !== 2, bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null, })), }, signaturen: { create: [ { organisationId, rolle: SignaturRolle.PRUEFER, personId: pruefer?.id ?? null, name: prueferName, unterzeichnetAm: geprueftAm, }, ], }, }, }); await prisma.pruefung.update({ where: { id: pruefung.id }, data: { status: PruefungStatus.ABGESCHLOSSEN }, }); await prisma.mangel.create({ data: { organisationId, geraetId: geraet.id, pruefungId: pruefung.id, melderId: pruefer?.id ?? null, melderName: prueferName, beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.", prioritaet: MangelPrioritaet.NIEDRIG, status: MangelStatus.ZUGEWIESEN, zugewiesenAnId: pruefer?.id ?? null, }, }); log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`); } /** Ein Rückruf, damit die Ansicht nicht leer ist. */ async function rueckrufAnlegen(organisationId: string) { const vorhanden = await prisma.rueckruf.count({ where: { organisationId } }); if (vorhanden > 0) return; const typ = await prisma.geraetetyp.findFirst({ where: { organisationId, name: "Atemschutzmaske FPS 7000" }, select: { id: true }, }); await prisma.rueckruf.create({ data: { organisationId, hersteller: "Dräger", titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000", referenz: "SI-2026-03", beschreibung: "Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. " + "Betroffen sind Masken mit Herstellung vor 2020.", massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.", veroeffentlichtAm: plusMonate(new Date(), -1), sperrtGeraete: false, typen: typ ? { create: [ { organisationId, geraetetypId: typ.id, herstellungBis: datum(2019, 12, 31), }, ], } : undefined, }, }); log("Rückruf: 1 Beispiel"); } // ----------------------------------------------------------------------------- // Phase 3 — Einsätze und Übungen // // Ein Jahr im Leben einer Wehr: Brände, technische Hilfeleistungen, ein // Fehlalarm einer Brandmeldeanlage und die Übungen dazwischen. Die Zahlen sind // so gewählt, dass die Statistik etwas zeigt — auch eine Hilfsfrist, die // gerade noch passt. // ----------------------------------------------------------------------------- interface EinsatzVorlage { /** Tage vor heute. */ vorTagen: number; uhrzeit: [number, number]; stichwort: string; kategorie: EinsatzKategorie; strasse: string; hausnummer?: string; objekt?: string; /** Minuten nach der Alarmierung: ausgerückt, eingetroffen, Ende, eingerückt. */ ablauf: [number, number, number, number]; fahrzeuge: number[]; /** Wie viele Kräfte, und wer davon führt. */ mannschaft: number; gerettet?: number; verletzt?: number; sachschaden?: number; beschreibung: string; bericht?: string; meldungen?: Array<{ nachMinuten: number; text: string; wichtig?: boolean }>; material?: Array<{ bezeichnung: string; menge: number; einheit: string }>; status?: EinsatzStatus; } const EINSAETZE: EinsatzVorlage[] = [ { vorTagen: 12, uhrzeit: [3, 42], stichwort: "B 3 Wohnhausbrand", kategorie: EinsatzKategorie.BRAND_GROSS, strasse: "Bahnhofstraße", hausnummer: "14", objekt: "Reihenhaus, zwei Vollgeschosse", ablauf: [5, 9, 143, 168], fahrzeuge: [0, 1], mannschaft: 14, gerettet: 2, verletzt: 1, sachschaden: 85000, beschreibung: "Bei Eintreffen Rauch aus dem Dachgeschoss, zwei Personen im Obergeschoss gemeldet.", bericht: "Menschenrettung über die Steckleiter durch den Angriffstrupp. Zwei Personen unverletzt gerettet, eine Person mit Rauchgasintoxikation an den Rettungsdienst übergeben.\n\nBrandbekämpfung mit einem C-Rohr unter Atemschutz vom Treppenraum aus. Nachlöscharbeiten und Kontrolle mit der Wärmebildkamera bis 06:05 Uhr. Einsatzstelle an die Polizei übergeben.", meldungen: [ { nachMinuten: 10, text: "Rauch aus Dachgeschoss, zwei Personen vermisst.", wichtig: true }, { nachMinuten: 22, text: "Beide Personen gerettet, Menschenrettung abgeschlossen.", wichtig: true }, { nachMinuten: 48, text: "Feuer unter Kontrolle." }, { nachMinuten: 140, text: "Nachlöscharbeiten beendet, Kontrolle mit Wärmebildkamera." }, ], material: [ { bezeichnung: "Atemluftflaschen 300 bar", menge: 6, einheit: "Stück" }, { bezeichnung: "Löschwasser", menge: 4500, einheit: "Liter" }, ], }, { vorTagen: 26, uhrzeit: [14, 8], stichwort: "THL 1 Ölspur", kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN, strasse: "Hauptstraße", ablauf: [6, 11, 55, 68], fahrzeuge: [1], mannschaft: 6, beschreibung: "Ölspur über etwa 300 Meter, ausgehend von einem Traktor.", bericht: "Ölspur mit Bindemittel abgestreut und aufgenommen. Straßenmeisterei verständigt.", material: [{ bezeichnung: "Ölbindemittel", menge: 40, einheit: "kg" }], }, { vorTagen: 41, uhrzeit: [19, 55], stichwort: "THL 2 Verkehrsunfall", kategorie: EinsatzKategorie.TECHNISCHE_HILFE_MITTEL, strasse: "Kreisstraße MÜ 12", objekt: "zwischen Musterhausen und Nachbarhausen", ablauf: [4, 12, 78, 96], fahrzeuge: [0], mannschaft: 9, verletzt: 2, sachschaden: 24000, beschreibung: "Zwei Fahrzeuge, eine Person eingeklemmt.", bericht: "Person mit dem hydraulischen Rettungssatz befreit und an den Rettungsdienst übergeben. Brandschutz sichergestellt, Fahrzeuge gegen Wegrollen gesichert, Betriebsstoffe aufgenommen.", meldungen: [ { nachMinuten: 14, text: "Eine Person eingeklemmt, Rettungsdienst vor Ort.", wichtig: true }, { nachMinuten: 39, text: "Person befreit und übergeben.", wichtig: true }, ], material: [{ bezeichnung: "Ölbindemittel", menge: 15, einheit: "kg" }], }, { vorTagen: 58, uhrzeit: [9, 12], stichwort: "BMA Auslösung", kategorie: EinsatzKategorie.BRANDMELDEANLAGE, strasse: "Schulstraße", hausnummer: "3", objekt: "Grundschule", ablauf: [7, 13, 32, 44], fahrzeuge: [0], mannschaft: 8, beschreibung: "Auslösung eines Rauchmelders im Werkraum.", bericht: "Kein Schadenfeuer. Auslösung durch Staub bei Reinigungsarbeiten. Anlage zurückgestellt.", }, { vorTagen: 73, uhrzeit: [16, 30], stichwort: "B 1 Kleinbrand", kategorie: EinsatzKategorie.BRAND_KLEIN, strasse: "Am Anger", hausnummer: "7", ablauf: [5, 8, 41, 52], fahrzeuge: [1], mannschaft: 6, sachschaden: 1200, beschreibung: "Brennende Mülltonne an der Hauswand.", bericht: "Ablöschen mit Schnellangriff, Kontrolle der Fassade mit der Wärmebildkamera.", }, { vorTagen: 95, uhrzeit: [21, 47], stichwort: "THL 1 Sturmschaden", kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN, strasse: "Lindenweg", ablauf: [8, 16, 62, 74], fahrzeuge: [0], mannschaft: 7, beschreibung: "Baum auf Fahrbahn nach Gewitterfront.", bericht: "Baum mit der Motorkettensäge zerlegt und beiseitegeräumt. Fahrbahn gereinigt.", material: [{ bezeichnung: "Kettensägenbenzin", menge: 2, einheit: "Liter" }], }, { vorTagen: 118, uhrzeit: [11, 20], stichwort: "Sicherheitswache Pfarrfest", kategorie: EinsatzKategorie.SICHERHEITSWACHE, strasse: "Kirchplatz", ablauf: [10, 20, 300, 310], fahrzeuge: [2], mannschaft: 4, beschreibung: "Brandsicherheitswache beim Pfarrfest.", bericht: "Ohne besondere Vorkommnisse.", }, { vorTagen: 140, uhrzeit: [6, 5], stichwort: "THL 1 Türöffnung", kategorie: EinsatzKategorie.RETTUNGSDIENST, strasse: "Rosenstraße", hausnummer: "22", ablauf: [6, 10, 28, 38], fahrzeuge: [1], mannschaft: 5, gerettet: 1, beschreibung: "Hilflose Person hinter verschlossener Tür, Rettungsdienst vor Ort.", bericht: "Tür schonend geöffnet, Person an den Rettungsdienst übergeben.", }, ]; /** Funktionen einer Gruppe in der Reihenfolge, in der sie besetzt werden. */ const FUNKTIONSFOLGE: EinsatzFunktion[] = [ EinsatzFunktion.GRUPPENFUEHRER, EinsatzFunktion.MASCHINIST, EinsatzFunktion.MELDER, EinsatzFunktion.TRUPPFUEHRER, EinsatzFunktion.TRUPPMANN, EinsatzFunktion.TRUPPFUEHRER, EinsatzFunktion.TRUPPMANN, EinsatzFunktion.TRUPPFUEHRER, EinsatzFunktion.TRUPPMANN, ]; function minutenSpaeter(basis: Date, minuten: number): Date { return new Date(basis.getTime() + minuten * 60_000); } async function einsaetzeAnlegen( organisationId: string, fahrzeuge: Array<{ id: string; funkrufname: string }>, ) { const vorhanden = await prisma.einsatz.count({ where: { organisationId } }); if (vorhanden > 0) { log(`Einsätze: bereits ${vorhanden} vorhanden`); return; } const personen = await prisma.person.findMany({ where: { organisationId, aktiv: true, geloeschtAm: null }, select: { id: true, vorname: true, nachname: true }, orderBy: { mitgliedsnummer: "asc" }, }); if (personen.length === 0 || fahrzeuge.length === 0) return; let laufend = 0; for (const vorlage of EINSAETZE) { laufend += 1; const alarmiert = new Date(); alarmiert.setUTCDate(alarmiert.getUTCDate() - vorlage.vorTagen); alarmiert.setUTCHours(vorlage.uhrzeit[0], vorlage.uhrzeit[1], 0, 0); const jahr = alarmiert.getUTCFullYear(); const [ausgerueckt, eingetroffen, ende, eingerueckt] = vorlage.ablauf; const einsatz = await prisma.einsatz.create({ data: { organisationId, einsatznummer: `${jahr}-${String(laufend).padStart(4, "0")}`, stichwort: vorlage.stichwort, kategorie: vorlage.kategorie, status: vorlage.status ?? EinsatzStatus.ABGESCHLOSSEN, alarmierungsart: Alarmierungsart.MELDEEMPFAENGER, strasse: vorlage.strasse, hausnummer: vorlage.hausnummer ?? null, plz: "84543", ort: "Musterhausen", objekt: vorlage.objekt ?? null, alarmiertUm: alarmiert, ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt), eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen), einsatzendeUm: minutenSpaeter(alarmiert, ende), eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt), einsatzbereitUm: minutenSpaeter(alarmiert, eingerueckt + 30), einsatzleiterId: personen[0]?.id ?? null, beschreibung: vorlage.beschreibung, bericht: vorlage.bericht ?? null, geretteteePersonen: vorlage.gerettet ?? 0, verletztePersonen: vorlage.verletzt ?? 0, sachschaden: vorlage.sachschaden ?? null, fahrzeuge: { create: vorlage.fahrzeuge .map((index) => fahrzeuge[index]) .filter((fahrzeug): fahrzeug is { id: string; funkrufname: string } => Boolean(fahrzeug)) .map((fahrzeug, position) => ({ organisationId, fahrzeugId: fahrzeug.id, ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt + position), eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen + position), eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt), kilometerAbfahrt: 12000 + laufend * 40, kilometerRueckkehr: 12000 + laufend * 40 + 8 + position * 3, wasserabgabe: vorlage.kategorie.startsWith("BRAND") ? 1500 : null, })), }, kraefte: { create: Array.from({ length: Math.min(vorlage.mannschaft, personen.length) }).map( (_, index) => ({ organisationId, // Die Mannschaft rotiert, damit die Beteiligung je Person // unterschiedlich ausfällt. personId: personen[(index + laufend * 3) % personen.length]!.id, funktion: FUNKTIONSFOLGE[index % FUNKTIONSFOLGE.length]!, atemschutz: vorlage.kategorie.startsWith("BRAND") && index >= 3 && index <= 6, vonUm: minutenSpaeter(alarmiert, ausgerueckt), bisUm: minutenSpaeter(alarmiert, eingerueckt), }), ), }, lagemeldungen: { create: (vorlage.meldungen ?? []).map((meldung) => ({ organisationId, zeitpunkt: minutenSpaeter(alarmiert, meldung.nachMinuten), text: meldung.text, wichtig: meldung.wichtig ?? false, verfasserId: personen[0]?.id ?? null, verfasserName: personen[0] ? `${personen[0].vorname} ${personen[0].nachname}` : "Einsatzleitung", })), }, material: { create: (vorlage.material ?? []).map((posten) => ({ organisationId, bezeichnung: posten.bezeichnung, menge: posten.menge, einheit: posten.einheit, })), }, }, }); // Der älteste Einsatz ist bereits freigegeben — damit die Ansicht eines // festgeschriebenen Berichts nicht leer bleibt. if (vorlage.vorTagen >= 140) { await prisma.einsatz.update({ where: { id: einsatz.id }, data: { status: EinsatzStatus.FREIGEGEBEN, freigegebenAm: minutenSpaeter(alarmiert, 2880), freigegebenVonId: personen[0]?.id ?? null, }, }); } } log(`Einsätze: ${EINSAETZE.length} mit Fahrzeugen, Mannschaft und Lagemeldungen`); } interface UebungVorlage { vorTagen: number; thema: string; art: UebungsArt; ort: string; lernziele: string; teilnehmer: number; dauer: number; } const UEBUNGEN: UebungVorlage[] = [ { vorTagen: 7, thema: "Innenangriff mit Atemschutz", art: UebungsArt.ATEMSCHUTZ, ort: "Übungscontainer Nachbarhausen", lernziele: "Vorgehen im Trupp unter Nullsicht, Türöffnungsprozedur, Rückweg sichern, Kommunikation mit dem Gruppenführer.", teilnehmer: 9, dauer: 150, }, { vorTagen: 21, thema: "Wasserförderung über lange Wegstrecke", art: UebungsArt.PRAXIS, ort: "Löschwasserentnahmestelle Mühlbach", lernziele: "Aufbau einer B-Leitung über 600 m, Verstärkerpumpe setzen, Druckverluste erkennen.", teilnehmer: 12, dauer: 120, }, { vorTagen: 35, thema: "Technische Hilfeleistung nach Verkehrsunfall", art: UebungsArt.PRAXIS, ort: "Übungsgelände am Bauhof", lernziele: "Fahrzeug sichern, Glasmanagement, patientengerechte Rettung, Zusammenarbeit mit dem Rettungsdienst.", teilnehmer: 11, dauer: 180, }, { vorTagen: 49, thema: "Funkausbildung nach BOS-Richtlinie", art: UebungsArt.FUNK, ort: "Gerätehaus, Schulungsraum", lernziele: "Funkdisziplin, Meldeschema, Umgang mit dem Handsprechfunkgerät.", teilnehmer: 8, dauer: 90, }, { vorTagen: 63, thema: "Maschinistenausbildung Tragkraftspritze", art: UebungsArt.MASCHINIST, ort: "Gerätehaus", lernziele: "Inbetriebnahme, Entlüften, Störungssuche, Wartung nach dem Einsatz.", teilnehmer: 5, dauer: 120, }, { vorTagen: 84, thema: "Gemeinschaftsübung mit der FF Nachbarhausen", art: UebungsArt.GEMEINSCHAFTSUEBUNG, ort: "Sägewerk Nachbarhausen", lernziele: "Zusammenarbeit zweier Wehren, gemeinsame Einsatzleitung, Wasserversorgung aus offenem Gewässer.", teilnehmer: 14, dauer: 210, }, ]; async function uebungenAnlegen(organisationId: string) { const vorhanden = await prisma.uebung.count({ where: { organisationId } }); if (vorhanden > 0) { log(`Übungen: bereits ${vorhanden} vorhanden`); return; } const personen = await prisma.person.findMany({ where: { organisationId, aktiv: true, geloeschtAm: null }, select: { id: true }, orderBy: { mitgliedsnummer: "asc" }, }); if (personen.length === 0) return; let laufend = 0; for (const vorlage of UEBUNGEN) { laufend += 1; const datum = new Date(); datum.setUTCDate(datum.getUTCDate() - vorlage.vorTagen); datum.setUTCHours(0, 0, 0, 0); const beginn = new Date(datum); beginn.setUTCHours(19, 0, 0, 0); await prisma.uebung.create({ data: { organisationId, nummer: `${datum.getUTCFullYear()}-${String(laufend).padStart(4, "0")}`, thema: vorlage.thema, art: vorlage.art, datum, beginnUm: beginn, endeUm: minutenSpaeter(beginn, vorlage.dauer), ort: vorlage.ort, leiterId: personen[0]?.id ?? null, lernziele: vorlage.lernziele, teilnahmen: { create: Array.from({ length: Math.min(vorlage.teilnehmer, personen.length) }).map( (_, index) => ({ organisationId, personId: personen[(index + laufend * 2) % personen.length]!.id, }), ), }, }, }); } log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`); } // ----------------------------------------------------------------------------- // Phase 4 — was am Terminal zusammenkommt // ----------------------------------------------------------------------------- /** * Fahrtenbuch, Anwesenheit und Wäsche. * * Alles drei entsteht im Betrieb am Wandtablet. Der Seed legt genug davon an, * dass die Terminalübersicht beim ersten Start etwas zeigt — darunter bewusst * je ein offener Fall: eine Fahrt, die noch läuft, jemand, der noch im Haus * ist, und Kleidung, die noch nicht zurück ist. Das sind die Zustände, an * denen sich die Ansichten beweisen müssen. */ async function terminaldatenAnlegen(organisationId: string) { const vorhanden = await prisma.fahrt.count({ where: { organisationId } }); if (vorhanden > 0) { log(`Terminal: bereits ${vorhanden} Fahrten vorhanden`); return; } const personen = await prisma.person.findMany({ where: { organisationId, aktiv: true, geloeschtAm: null }, select: { id: true }, orderBy: { mitgliedsnummer: "asc" }, }); const fahrzeuge = await prisma.fahrzeug.findMany({ where: { organisationId, geloeschtAm: null }, select: { id: true, kilometerstand: true }, orderBy: { funkrufname: "asc" }, }); if (personen.length === 0 || fahrzeuge.length === 0) return; const jetzt = new Date(); function vorStunden(stunden: number): Date { return new Date(jetzt.getTime() - stunden * 60 * 60 * 1000); } // --- Fahrtenbuch ---------------------------------------------------------- const FAHRTEN: Array<{ fahrzeug: number; fahrer: number; zweck: FahrtZweck; ziel: string; vorStunden: number; dauer: number; kilometer: number; /** Offen heißt: das Fahrzeug ist noch unterwegs. */ offen?: boolean; tankmenge?: number; }> = [ { fahrzeug: 0, fahrer: 2, zweck: FahrtZweck.WERKSTATT, ziel: "Fahrzeugbau Huber, Landshut", vorStunden: 26 * 24, dauer: 5, kilometer: 84, tankmenge: 42.5, }, { fahrzeug: 1, fahrer: 7, zweck: FahrtZweck.UEBUNG, ziel: "Übungsgelände Kiesgrube", vorStunden: 8 * 24, dauer: 3, kilometer: 22, }, { fahrzeug: 2, fahrer: 5, zweck: FahrtZweck.SONSTIGES, ziel: "Materialabholung Kreisbrandinspektion", vorStunden: 3 * 24, dauer: 2, kilometer: 47, }, { fahrzeug: 0, fahrer: 0, zweck: FahrtZweck.AUSBILDUNG, ziel: "Feuerwehrschule Geretsried", vorStunden: 2, dauer: 0, kilometer: 0, offen: true, }, ]; // Der Kilometerstand muss über die Fahrten hinweg vorwärts laufen — sonst // schlägt die Prüfung in der Datenbank zu, und zwar zu Recht. Deshalb werden // die erledigten Fahrten rückwärts vom heutigen Stand aus gerechnet. const laufenderStand = new Map( fahrzeuge.map((fahrzeug) => [fahrzeug.id, fahrzeug.kilometerstand]), ); for (const vorlage of FAHRTEN) { const fahrzeug = fahrzeuge[vorlage.fahrzeug % fahrzeuge.length]!; const fahrer = personen[vorlage.fahrer % personen.length]!; const abfahrt = vorStunden(vorlage.vorStunden); const stand = laufenderStand.get(fahrzeug.id) ?? 0; const abfahrtKm = Math.max(0, stand - vorlage.kilometer); await prisma.fahrt.create({ data: { organisationId, fahrzeugId: fahrzeug.id, fahrerId: fahrer.id, zweck: vorlage.zweck, ziel: vorlage.ziel, abfahrtUm: abfahrt, kilometerAbfahrt: vorlage.offen ? stand : abfahrtKm, ...(vorlage.offen ? {} : { rueckkehrUm: new Date(abfahrt.getTime() + vorlage.dauer * 60 * 60 * 1000), kilometerRueckkehr: stand, tankmenge: vorlage.tankmenge ?? null, }), }, }); if (!vorlage.offen) laufenderStand.set(fahrzeug.id, abfahrtKm); } // --- Anwesenheit ---------------------------------------------------------- const ANWESENHEITEN: Array<{ person: number; anlass: AnwesenheitAnlass; vorStunden: number; /** null heißt: noch im Haus. */ dauer: number | null; }> = [ { person: 0, anlass: AnwesenheitAnlass.UEBUNG, vorStunden: 8 * 24, dauer: 3 }, { person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 5 * 24, dauer: 4 }, { person: 4, anlass: AnwesenheitAnlass.DIENST, vorStunden: 5 * 24, dauer: 2 }, { person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 2, dauer: null }, ]; for (const vorlage of ANWESENHEITEN) { const person = personen[vorlage.person % personen.length]!; const angemeldet = vorStunden(vorlage.vorStunden); await prisma.anwesenheit.create({ data: { organisationId, personId: person.id, anlass: vorlage.anlass, angemeldetUm: angemeldet, abgemeldetUm: vorlage.dauer === null ? null : new Date(angemeldet.getTime() + vorlage.dauer * 60 * 60 * 1000), }, }); } // --- Wäsche --------------------------------------------------------------- const WAESCHE: Array<{ person: number; bezeichnung: string; menge: number; grund: string; status: WaescheStatus; vorStunden: number; }> = [ { person: 3, bezeichnung: "Einsatzjacke", menge: 1, grund: "Einsatzverschmutzung", status: WaescheStatus.IN_REINIGUNG, vorStunden: 4 * 24, }, { person: 6, bezeichnung: "Einsatzhose", menge: 1, grund: "Kontamination", status: WaescheStatus.ABGEGEBEN, vorStunden: 20, }, { person: 1, bezeichnung: "Flammschutzhaube", menge: 2, grund: "Routine", status: WaescheStatus.ZURUECK, vorStunden: 30 * 24, }, ]; for (const vorlage of WAESCHE) { const person = personen[vorlage.person % personen.length]!; const abgegeben = vorStunden(vorlage.vorStunden); await prisma.waescheabgabe.create({ data: { organisationId, personId: person.id, bezeichnung: vorlage.bezeichnung, menge: vorlage.menge, grund: vorlage.grund, status: vorlage.status, abgegebenAm: abgegeben, zurueckAm: vorlage.status === WaescheStatus.ZURUECK ? new Date(abgegeben.getTime() + 6 * 24 * 60 * 60 * 1000) : null, }, }); } log( `Terminal: ${FAHRTEN.length} Fahrten (eine läuft), ${ANWESENHEITEN.length} Anwesenheiten, ${WAESCHE.length} Wäscheabgaben`, ); } // ----------------------------------------------------------------------------- // 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); // Phase 2 — Wartung und Prüfung const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen); await wartungstermineAnlegen(organisation.id, wartungsvorlagen); await beispielPruefungAnlegen(organisation.id, wartungsvorlagen); await rueckrufAnlegen(organisation.id); // Phase 3 — Einsätze und Übungen await einsaetzeAnlegen(organisation.id, fahrzeuge); await uebungenAnlegen(organisation.id); // Phase 4 — Terminal im Gerätehaus await terminaldatenAnlegen(organisation.id); 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(); });