4210 lines
137 KiB
TypeScript
4210 lines
137 KiB
TypeScript
/**
|
||
* Seed — legt eine vollständige Beispielwehr an.
|
||
*
|
||
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 16 Personen mit Rollen und
|
||
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
|
||
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
|
||
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
|
||
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
|
||
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
|
||
* Beispielrückruf. Ab Phase 3 dazu: acht Einsätze über das Jahr verteilt,
|
||
* sechs Übungen und die Beteiligung der Mannschaft. Ab Phase 4 dazu: ein
|
||
* Fahrtenbuch mit erledigten und einer laufenden Fahrt, Anwesenheiten am
|
||
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
|
||
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
|
||
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
||
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
||
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
||
* Feuerwehrplan. Ab Phase 7 dazu: der Jahresplan — Übungsabend als Serie,
|
||
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
|
||
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
|
||
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
||
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
|
||
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
|
||
* Ab Phase 9 dazu: das Kassenjahr — drei Konten, der Standardkontenrahmen,
|
||
* gut dreißig Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen,
|
||
* zwei Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres.
|
||
*
|
||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||
* die Organisation ja erst an, deren Kontext RLS bräuchte.
|
||
*
|
||
* Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits
|
||
* vorhandenen Daten.
|
||
*
|
||
* npm run db:seed
|
||
*/
|
||
import "dotenv/config";
|
||
import {
|
||
Alarmierungsart,
|
||
AnwesenheitAnlass,
|
||
AusweisTokenTyp,
|
||
BeitragStand,
|
||
BuchungsArt,
|
||
ChecklistenpunktTyp,
|
||
EinsatzFunktion,
|
||
EinsatzKategorie,
|
||
EinsatzStatus,
|
||
Dienstgrad,
|
||
KontoArt,
|
||
DruckArt,
|
||
EreignisArt,
|
||
FahrtZweck,
|
||
FahrzeugArt,
|
||
G26Ergebnis,
|
||
GeraeteKategorie,
|
||
GeraetStatus,
|
||
LebensdauerBezug,
|
||
LebensdauerKategorie,
|
||
MangelPrioritaet,
|
||
MitgliedschaftArt,
|
||
MangelStatus,
|
||
PrismaClient,
|
||
PruefErgebnis,
|
||
PruefungArt,
|
||
PruefungStatus,
|
||
QualifikationsArt,
|
||
RolleSchluessel,
|
||
RechnungsAnlass,
|
||
RechnungStand,
|
||
SignaturRolle,
|
||
HydrantStatus,
|
||
HydrantTyp,
|
||
ObjektKategorie,
|
||
Serienmuster,
|
||
StandortArt,
|
||
WikiKategorie,
|
||
Teilnahmestand,
|
||
TerminArt,
|
||
TruppStatus,
|
||
UebungsArt,
|
||
UebungsartAtemschutz,
|
||
WaescheStatus,
|
||
} from "@prisma/client";
|
||
import bcrypt from "bcryptjs";
|
||
import { serientermine } from "../src/lib/termin/serie";
|
||
|
||
const prisma = new PrismaClient({
|
||
datasources: {
|
||
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
|
||
},
|
||
});
|
||
|
||
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
|
||
|
||
/**
|
||
* Welche Wehr angelegt wird.
|
||
*
|
||
* Vorgabe ist die Beispielwehr, die in den Bildschirmfotos und in der
|
||
* Anleitung steht. Über die Umgebung lässt sich eine zweite anlegen — genau
|
||
* dafür gibt es `npm run db:test-reset`:
|
||
*
|
||
* SEED_SLUG=ff-teststelle SEED_NAME="Freiwillige Feuerwehr Teststelle" \
|
||
* SEED_KURZNAME="FF Teststelle" SEED_ADMIN_EMAIL=e2e@teststelle.de \
|
||
* npm run db:seed
|
||
*
|
||
* Der Grund ist ein handfester: die E2E-Tests haben bis Phase 8 in die
|
||
* Beispielwehr geschrieben. Nach fünf Phasen standen dort 94 Atemschutz-
|
||
* Überwachungen statt einer, und bei jedem Testlauf änderten sich alle
|
||
* Bildschirmfotos. Seitdem laufen sie gegen eine eigene Wehr.
|
||
*/
|
||
const ORGANISATION_SLUG = process.env.SEED_SLUG ?? "ff-musterhausen";
|
||
const ORGANISATION_NAME = process.env.SEED_NAME ?? "Freiwillige Feuerwehr Musterhausen";
|
||
const ORGANISATION_KURZNAME = process.env.SEED_KURZNAME ?? "FF Musterhausen";
|
||
|
||
function log(nachricht: string): void {
|
||
console.info(`[seed] ${nachricht}`);
|
||
}
|
||
|
||
/** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */
|
||
function datum(jahr: number, monat: number, tag: number): Date {
|
||
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
|
||
}
|
||
|
||
/** Verschiebt ein Datum um Monate — für Prüffristen. */
|
||
function plusMonate(basis: Date, monate: number): Date {
|
||
const neu = new Date(basis);
|
||
neu.setUTCMonth(neu.getUTCMonth() + monate);
|
||
return neu;
|
||
}
|
||
|
||
/** Verschiebt ein Datum um Jahre — für Lebensdauern. */
|
||
function plusJahre(basis: Date, jahre: number): Date {
|
||
const neu = new Date(basis);
|
||
neu.setUTCFullYear(neu.getUTCFullYear() + jahre);
|
||
return neu;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Berechtigungskatalog
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über
|
||
* einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft.
|
||
*/
|
||
async function katalogAbgleichen(): Promise<Map<string, string>> {
|
||
const { RECHTE } = await import("../src/lib/authz");
|
||
|
||
const karte = new Map<string, string>();
|
||
|
||
for (const [schluessel, angaben] of Object.entries(RECHTE)) {
|
||
const eintrag = await prisma.berechtigung.upsert({
|
||
where: { schluessel },
|
||
update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung },
|
||
create: {
|
||
schluessel,
|
||
bereich: angaben.bereich,
|
||
bezeichnung: angaben.bezeichnung,
|
||
},
|
||
});
|
||
karte.set(schluessel, eintrag.id);
|
||
}
|
||
|
||
log(`Berechtigungskatalog: ${karte.size} Einzelrechte`);
|
||
return karte;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Organisation und Rollen
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function organisationAnlegen() {
|
||
const organisation = await prisma.organisation.upsert({
|
||
where: { slug: ORGANISATION_SLUG },
|
||
update: {},
|
||
create: {
|
||
slug: ORGANISATION_SLUG,
|
||
name: ORGANISATION_NAME,
|
||
kurzname: ORGANISATION_KURZNAME,
|
||
strasse: "Hauptstraße 12",
|
||
plz: "85354",
|
||
ort: "Musterhausen",
|
||
landkreis: "Freising",
|
||
bundesland: "Bayern",
|
||
telefon: "08161 123456",
|
||
email: "kommandant@ff-musterhausen.de",
|
||
ilsKennung: "FS-MUS-01",
|
||
funkPraefix: "Florian Musterhausen",
|
||
},
|
||
});
|
||
|
||
log(`Organisation: ${organisation.name} (${organisation.id})`);
|
||
return organisation;
|
||
}
|
||
|
||
async function rollenAnlegen(organisationId: string, katalog: Map<string, string>) {
|
||
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
|
||
await import("../src/lib/authz");
|
||
|
||
const rollenKarte = new Map<string, string>();
|
||
|
||
// AUFSICHT ist keine vergebbare Rolle und steht deshalb auch nicht im
|
||
// Prisma-Enum. Sie ergibt sich seit Phase 10 daraus, dass ein Konto einer
|
||
// Wehr als Kreisaufsicht zugeordnet ist — sie hier anzulegen hiesse, dass
|
||
// jemand sie innerhalb einer Wehr vergeben könnte.
|
||
const vergebbar = ROLLEN.filter((schluessel) => schluessel !== "AUFSICHT");
|
||
|
||
for (const schluessel of vergebbar) {
|
||
const rolle = await prisma.rolle.upsert({
|
||
where: {
|
||
organisationId_schluessel: {
|
||
organisationId,
|
||
schluessel: schluessel as RolleSchluessel,
|
||
},
|
||
},
|
||
update: {
|
||
name: ROLLEN_BEZEICHNUNG[schluessel],
|
||
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
||
},
|
||
create: {
|
||
organisationId,
|
||
schluessel: schluessel as RolleSchluessel,
|
||
name: ROLLEN_BEZEICHNUNG[schluessel],
|
||
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
||
systemRolle: true,
|
||
},
|
||
});
|
||
rollenKarte.set(schluessel, rolle.id);
|
||
|
||
// Rechte der Rolle spiegeln — der Administrator bekommt alles.
|
||
const rechte =
|
||
schluessel === "SUPERADMIN"
|
||
? ALLE_RECHTE
|
||
: (ROLLEN_RECHTE[schluessel as Exclude<typeof schluessel, "SUPERADMIN">] ?? []);
|
||
|
||
for (const recht of rechte) {
|
||
const berechtigungId = katalog.get(recht);
|
||
if (!berechtigungId) continue;
|
||
|
||
await prisma.rolleBerechtigung.upsert({
|
||
where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } },
|
||
update: { gewaehrt: true },
|
||
create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true },
|
||
});
|
||
}
|
||
}
|
||
|
||
log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`);
|
||
return rollenKarte;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Personen
|
||
// -----------------------------------------------------------------------------
|
||
|
||
interface PersonVorlage {
|
||
vorname: string;
|
||
nachname: string;
|
||
dienstgrad: Dienstgrad;
|
||
rollen: RolleSchluessel[];
|
||
eintritt: Date;
|
||
abteilung: string;
|
||
/** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */
|
||
pin?: string;
|
||
/** RFID-Kennung der Ausweiskarte. */
|
||
rfid?: string;
|
||
}
|
||
|
||
const PERSONEN: PersonVorlage[] = [
|
||
{
|
||
vorname: "Andreas",
|
||
nachname: "Huber",
|
||
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
||
rollen: [RolleSchluessel.KOMMANDANT],
|
||
eintritt: datum(1998, 4, 1),
|
||
abteilung: "Aktive",
|
||
pin: "471203",
|
||
rfid: "04A3B7C1D2",
|
||
},
|
||
{
|
||
vorname: "Bernhard",
|
||
nachname: "Maier",
|
||
dienstgrad: Dienstgrad.OBERBRANDMEISTER,
|
||
rollen: [RolleSchluessel.KOMMANDANT],
|
||
eintritt: datum(2001, 9, 15),
|
||
abteilung: "Aktive",
|
||
pin: "938157",
|
||
},
|
||
{
|
||
vorname: "Christian",
|
||
nachname: "Bauer",
|
||
dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER,
|
||
rollen: [RolleSchluessel.GERAETEWART],
|
||
eintritt: datum(2005, 3, 1),
|
||
abteilung: "Aktive",
|
||
pin: "620394",
|
||
rfid: "04B8C2D3E4",
|
||
},
|
||
{
|
||
vorname: "Daniela",
|
||
nachname: "Schmid",
|
||
dienstgrad: Dienstgrad.OBERLOESCHMEISTER,
|
||
rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART],
|
||
eintritt: datum(2008, 7, 1),
|
||
abteilung: "Aktive",
|
||
pin: "184926",
|
||
rfid: "04C9D3E4F5",
|
||
},
|
||
{
|
||
vorname: "Elisabeth",
|
||
nachname: "Wagner",
|
||
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
||
rollen: [RolleSchluessel.KASSIER],
|
||
eintritt: datum(2010, 1, 15),
|
||
abteilung: "Aktive",
|
||
pin: "573081",
|
||
},
|
||
{
|
||
vorname: "Franz",
|
||
nachname: "Fischer",
|
||
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
||
rollen: [RolleSchluessel.SCHRIFTFUEHRER],
|
||
eintritt: datum(2009, 5, 20),
|
||
abteilung: "Aktive",
|
||
pin: "306159",
|
||
},
|
||
{
|
||
vorname: "Gabriele",
|
||
nachname: "Weber",
|
||
dienstgrad: Dienstgrad.BRANDMEISTER,
|
||
rollen: [RolleSchluessel.AUSBILDER],
|
||
eintritt: datum(2004, 11, 1),
|
||
abteilung: "Aktive",
|
||
pin: "849265",
|
||
},
|
||
{
|
||
vorname: "Hans",
|
||
nachname: "Becker",
|
||
dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN,
|
||
rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER],
|
||
eintritt: datum(2012, 2, 1),
|
||
abteilung: "Aktive",
|
||
pin: "715384",
|
||
},
|
||
{
|
||
vorname: "Ingrid",
|
||
nachname: "Hofmann",
|
||
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(2015, 6, 1),
|
||
abteilung: "Aktive",
|
||
},
|
||
{
|
||
vorname: "Josef",
|
||
nachname: "Schneider",
|
||
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(2016, 4, 1),
|
||
abteilung: "Aktive",
|
||
},
|
||
{
|
||
vorname: "Katharina",
|
||
nachname: "Koch",
|
||
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(2019, 9, 1),
|
||
abteilung: "Aktive",
|
||
},
|
||
{
|
||
vorname: "Ludwig",
|
||
nachname: "Richter",
|
||
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(2020, 3, 1),
|
||
abteilung: "Aktive",
|
||
},
|
||
{
|
||
vorname: "Martina",
|
||
nachname: "Klein",
|
||
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(2024, 1, 15),
|
||
abteilung: "Aktive",
|
||
},
|
||
{
|
||
vorname: "Nikolaus",
|
||
nachname: "Wolf",
|
||
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(2025, 2, 1),
|
||
abteilung: "Jugendfeuerwehr",
|
||
},
|
||
{
|
||
vorname: "Otto",
|
||
nachname: "Neumann",
|
||
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
||
rollen: [RolleSchluessel.MITGLIED],
|
||
eintritt: datum(1975, 5, 1),
|
||
abteilung: "Passive",
|
||
},
|
||
];
|
||
|
||
async function personenAnlegen(
|
||
organisationId: string,
|
||
rollenKarte: Map<string, string>,
|
||
): Promise<void> {
|
||
let nummer = 1;
|
||
|
||
for (const vorlage of PERSONEN) {
|
||
const mitgliedsnummer = String(nummer).padStart(4, "0");
|
||
const email = `${vorlage.vorname}.${vorlage.nachname}`
|
||
.toLowerCase()
|
||
.replace(/ä/g, "ae")
|
||
.replace(/ö/g, "oe")
|
||
.replace(/ü/g, "ue")
|
||
.replace(/ß/g, "ss") + "@ff-musterhausen.de";
|
||
|
||
const person = await prisma.person.upsert({
|
||
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } },
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
mitgliedsnummer,
|
||
vorname: vorlage.vorname,
|
||
nachname: vorlage.nachname,
|
||
dienstgrad: vorlage.dienstgrad,
|
||
email,
|
||
telefon: `08161 ${100000 + nummer * 7}`,
|
||
strasse: `Beispielweg ${nummer}`,
|
||
plz: "85354",
|
||
ort: "Musterhausen",
|
||
eintrittsdatum: vorlage.eintritt,
|
||
abteilung: vorlage.abteilung,
|
||
aktiv: vorlage.abteilung !== "Passive",
|
||
},
|
||
});
|
||
|
||
for (const schluessel of vorlage.rollen) {
|
||
const rolleId = rollenKarte.get(schluessel);
|
||
if (!rolleId) continue;
|
||
await prisma.personRolle.upsert({
|
||
where: { personId_rolleId: { personId: person.id, rolleId } },
|
||
update: {},
|
||
create: { organisationId, personId: person.id, rolleId },
|
||
});
|
||
}
|
||
|
||
// Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte.
|
||
if (vorlage.pin) {
|
||
const vorhanden = await prisma.ausweisToken.findFirst({
|
||
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN },
|
||
});
|
||
if (!vorhanden) {
|
||
await prisma.ausweisToken.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
typ: AusweisTokenTyp.PIN,
|
||
tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN),
|
||
bezeichnung: "Terminal-PIN",
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (vorlage.rfid) {
|
||
const vorhanden = await prisma.ausweisToken.findFirst({
|
||
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID },
|
||
});
|
||
if (!vorhanden) {
|
||
await prisma.ausweisToken.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
typ: AusweisTokenTyp.RFID,
|
||
tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN),
|
||
kennzeichen: vorlage.rfid.slice(-4),
|
||
bezeichnung: "Ausweiskarte",
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
nummer += 1;
|
||
}
|
||
|
||
log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`);
|
||
}
|
||
|
||
/**
|
||
* Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für
|
||
* den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor
|
||
* authentik eingerichtet ist.
|
||
*/
|
||
async function adminAnlegen(organisationId: string, rollenKarte: Map<string, string>) {
|
||
const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase();
|
||
const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||
|
||
const admin = await prisma.person.upsert({
|
||
where: { organisationId_email: { organisationId, email } },
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
mitgliedsnummer: "0000",
|
||
vorname: "System",
|
||
nachname: "Administrator",
|
||
email,
|
||
dienstgrad: Dienstgrad.KEIN,
|
||
abteilung: "Verwaltung",
|
||
},
|
||
});
|
||
|
||
// Das Konto seit Phase 10 getrennt von der Akte. Die E-Mail ist global
|
||
// eindeutig — wird derselbe Seed für eine zweite Wehr gefahren, entsteht
|
||
// deshalb kein zweites Konto, sondern eine zweite Mitgliedschaft. Genau so
|
||
// sieht Doppelmitgliedschaft in echt aus.
|
||
const konto = await prisma.benutzer.upsert({
|
||
where: { email },
|
||
update: {},
|
||
create: {
|
||
email,
|
||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
||
passwortGeaendertAm: new Date(),
|
||
istVerwalter: true,
|
||
},
|
||
});
|
||
|
||
await prisma.mitgliedschaft.upsert({
|
||
where: { benutzerId_organisationId: { benutzerId: konto.id, organisationId } },
|
||
update: {},
|
||
create: { organisationId, benutzerId: konto.id, personId: admin.id },
|
||
});
|
||
|
||
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
|
||
if (rolleId) {
|
||
await prisma.personRolle.upsert({
|
||
where: { personId_rolleId: { personId: admin.id, rolleId } },
|
||
update: {},
|
||
create: { organisationId, personId: admin.id, rolleId },
|
||
});
|
||
}
|
||
|
||
log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`);
|
||
return admin;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Standorte
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function standorteAnlegen(organisationId: string) {
|
||
const vorlagen = [
|
||
{
|
||
name: "Gerätehaus",
|
||
kuerzel: "GH",
|
||
art: StandortArt.GERAETEHAUS,
|
||
strasse: "Hauptstraße 12",
|
||
plz: "85354",
|
||
ort: "Musterhausen",
|
||
},
|
||
{
|
||
name: "Lager Bauhof",
|
||
kuerzel: "LAG",
|
||
art: StandortArt.LAGER,
|
||
strasse: "Industriestraße 4",
|
||
plz: "85354",
|
||
ort: "Musterhausen",
|
||
bemerkung: "Reserve und Ersatzteile, Schlüssel beim Bauhof",
|
||
},
|
||
{
|
||
name: "Atemschutzwerkstatt",
|
||
kuerzel: "ASW",
|
||
art: StandortArt.WERKSTATT,
|
||
strasse: "Hauptstraße 12",
|
||
plz: "85354",
|
||
ort: "Musterhausen",
|
||
bemerkung: "Im Gerätehaus, Untergeschoss",
|
||
},
|
||
];
|
||
|
||
const karte = new Map<string, string>();
|
||
|
||
for (const vorlage of vorlagen) {
|
||
const standort = await prisma.standort.upsert({
|
||
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
||
update: {},
|
||
create: { organisationId, ...vorlage },
|
||
});
|
||
karte.set(vorlage.name, standort.id);
|
||
}
|
||
|
||
log(`Standorte: ${karte.size}`);
|
||
return karte;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Gerätetypen
|
||
// -----------------------------------------------------------------------------
|
||
|
||
interface TypVorlage {
|
||
name: string;
|
||
kategorie: GeraeteKategorie;
|
||
hersteller: string;
|
||
pruefintervallMonate: number;
|
||
pruefvorschrift?: string;
|
||
lebensdauerKategorie?: LebensdauerKategorie;
|
||
lebensdauerJahre?: number;
|
||
lebensdauerBezug?: LebensdauerBezug;
|
||
}
|
||
|
||
/**
|
||
* Prüfintervalle und Lebensdauern nach den üblichen Vorgaben. Sie sind hier
|
||
* bewusst konkret hinterlegt — eine leere Beispielwehr zeigt nicht, worum es
|
||
* bei diesem Programm eigentlich geht.
|
||
*/
|
||
const GERAETETYPEN: TypVorlage[] = [
|
||
{
|
||
name: "Pressluftatmer PA 94",
|
||
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
||
hersteller: "Dräger",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
||
lebensdauerKategorie: LebensdauerKategorie.PRESSLUFTATMER,
|
||
lebensdauerJahre: 20,
|
||
},
|
||
{
|
||
name: "Atemschutzmaske FPS 7000",
|
||
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
||
hersteller: "Dräger",
|
||
pruefintervallMonate: 6,
|
||
pruefvorschrift: "DGUV Regel 112-190",
|
||
lebensdauerKategorie: LebensdauerKategorie.ATEMANSCHLUSS,
|
||
lebensdauerJahre: 10,
|
||
},
|
||
{
|
||
name: "Atemluftflasche 6,8 l / 300 bar",
|
||
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
||
hersteller: "Luxfer",
|
||
pruefintervallMonate: 60,
|
||
pruefvorschrift: "Betriebssicherheitsverordnung, wiederkehrende Prüfung",
|
||
lebensdauerKategorie: LebensdauerKategorie.DRUCKBEHAELTER,
|
||
lebensdauerJahre: 20,
|
||
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
||
},
|
||
{
|
||
name: "Feuerwehrhelm F220",
|
||
kategorie: GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG,
|
||
hersteller: "Schuberth",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DGUV Information 205-014",
|
||
lebensdauerKategorie: LebensdauerKategorie.HELM,
|
||
lebensdauerJahre: 15,
|
||
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
||
},
|
||
{
|
||
name: "Feuerwehrleine 30 m",
|
||
kategorie: GeraeteKategorie.RETTUNG,
|
||
hersteller: "Gustav Bauer",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DIN 14920, DGUV Grundsatz 305-002",
|
||
lebensdauerKategorie: LebensdauerKategorie.SEIL_GURT,
|
||
lebensdauerJahre: 8,
|
||
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
||
},
|
||
{
|
||
name: "Steckleiterteil",
|
||
kategorie: GeraeteKategorie.RETTUNG,
|
||
hersteller: "Iturri",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DIN 14711, DGUV Information 208-016",
|
||
lebensdauerKategorie: LebensdauerKategorie.LEITER,
|
||
lebensdauerJahre: 25,
|
||
},
|
||
{
|
||
name: "Tragkraftspritze PFPN 10-1000",
|
||
kategorie: GeraeteKategorie.WASSERFOERDERUNG,
|
||
hersteller: "Rosenbauer",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DIN 14420",
|
||
},
|
||
{
|
||
name: "Hydraulischer Rettungssatz",
|
||
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
||
hersteller: "Weber Rescue",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DGUV Grundsatz 305-002",
|
||
},
|
||
{
|
||
name: "Motorkettensäge MS 261",
|
||
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
||
hersteller: "Stihl",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DGUV Vorschrift 3, Betriebsanleitung",
|
||
},
|
||
{
|
||
name: "Stromerzeuger 8 kVA",
|
||
kategorie: GeraeteKategorie.ELEKTRO,
|
||
hersteller: "Endress",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DGUV Vorschrift 3",
|
||
},
|
||
{
|
||
name: "Wärmebildkamera",
|
||
kategorie: GeraeteKategorie.MESSTECHNIK,
|
||
hersteller: "Dräger",
|
||
pruefintervallMonate: 12,
|
||
},
|
||
{
|
||
name: "Handsprechfunkgerät TPH900",
|
||
kategorie: GeraeteKategorie.FUNK,
|
||
hersteller: "Airbus",
|
||
pruefintervallMonate: 24,
|
||
},
|
||
{
|
||
name: "B-Druckschlauch 20 m",
|
||
kategorie: GeraeteKategorie.SCHLAUCHMATERIAL,
|
||
hersteller: "Ziegler",
|
||
pruefintervallMonate: 12,
|
||
pruefvorschrift: "DIN 14811",
|
||
lebensdauerKategorie: LebensdauerKategorie.SCHLAUCH,
|
||
lebensdauerJahre: 10,
|
||
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
||
},
|
||
{
|
||
name: "Überdrucklüfter",
|
||
kategorie: GeraeteKategorie.BRANDBEKAEMPFUNG,
|
||
hersteller: "Leader",
|
||
pruefintervallMonate: 12,
|
||
},
|
||
];
|
||
|
||
async function geraetetypenAnlegen(organisationId: string) {
|
||
const karte = new Map<string, string>();
|
||
|
||
for (const vorlage of GERAETETYPEN) {
|
||
const typ = await prisma.geraetetyp.upsert({
|
||
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
name: vorlage.name,
|
||
kategorie: vorlage.kategorie,
|
||
hersteller: vorlage.hersteller,
|
||
pruefintervallMonate: vorlage.pruefintervallMonate,
|
||
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
||
lebensdauerKategorie: vorlage.lebensdauerKategorie ?? LebensdauerKategorie.KEINE,
|
||
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
|
||
lebensdauerBezug: vorlage.lebensdauerBezug ?? LebensdauerBezug.INDIENSTSTELLUNG,
|
||
},
|
||
});
|
||
karte.set(vorlage.name, typ.id);
|
||
}
|
||
|
||
log(`Gerätetypen: ${karte.size}`);
|
||
return karte;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Fahrzeuge samt Geräteräumen
|
||
// -----------------------------------------------------------------------------
|
||
|
||
const FAHRZEUGE = [
|
||
{
|
||
funkrufname: "Florian Musterhausen 40/1",
|
||
bezeichnung: "Löschgruppenfahrzeug",
|
||
dinTyp: "LF 20",
|
||
art: FahrzeugArt.LOESCHFAHRZEUG,
|
||
kennzeichen: "FS-MU 401",
|
||
hersteller: "MAN",
|
||
aufbauhersteller: "Rosenbauer",
|
||
fahrgestellnummer: "WMAN12ZZ8JY123456",
|
||
baujahr: 2018,
|
||
erstzulassung: datum(2018, 6, 14),
|
||
indienststellung: datum(2018, 7, 1),
|
||
sitzplaetze: 9,
|
||
tankinhalt: 2000,
|
||
gesamtmasse: 16_000,
|
||
tuevBis: datum(2027, 5, 31),
|
||
uvvBis: datum(2026, 11, 30),
|
||
},
|
||
{
|
||
funkrufname: "Florian Musterhausen 42/1",
|
||
bezeichnung: "Tragkraftspritzenfahrzeug Wasser",
|
||
dinTyp: "TSF-W",
|
||
art: FahrzeugArt.LOESCHFAHRZEUG,
|
||
kennzeichen: "FS-MU 421",
|
||
hersteller: "Mercedes-Benz",
|
||
aufbauhersteller: "Ziegler",
|
||
fahrgestellnummer: "WDB90661310123789",
|
||
baujahr: 2011,
|
||
erstzulassung: datum(2011, 9, 20),
|
||
indienststellung: datum(2011, 10, 15),
|
||
sitzplaetze: 6,
|
||
tankinhalt: 750,
|
||
gesamtmasse: 7500,
|
||
tuevBis: datum(2026, 11, 30),
|
||
uvvBis: datum(2026, 10, 15),
|
||
},
|
||
{
|
||
funkrufname: "Florian Musterhausen 11/1",
|
||
bezeichnung: "Mannschaftstransportwagen",
|
||
dinTyp: "MTW",
|
||
art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
|
||
kennzeichen: "FS-MU 111",
|
||
hersteller: "Volkswagen",
|
||
fahrgestellnummer: "WV2ZZZ7HZMH098765",
|
||
baujahr: 2021,
|
||
erstzulassung: datum(2021, 3, 8),
|
||
indienststellung: datum(2021, 3, 15),
|
||
sitzplaetze: 9,
|
||
gesamtmasse: 3500,
|
||
tuevBis: datum(2027, 3, 31),
|
||
},
|
||
];
|
||
|
||
/** Beladeordnung. Der MTW hat weniger Fächer als ein Löschfahrzeug. */
|
||
const RAEUME_GROSS = [
|
||
{ bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
|
||
{ bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
|
||
{ bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
|
||
{ bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
|
||
{ bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
|
||
{ bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
|
||
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
|
||
{ bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
|
||
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
|
||
];
|
||
|
||
const RAEUME_KLEIN = [
|
||
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 10 },
|
||
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 20 },
|
||
];
|
||
|
||
async function fahrzeugeAnlegen(organisationId: string, standorte: Map<string, string>) {
|
||
const fahrzeuge: Array<{ id: string; funkrufname: string; raeume: Map<string, string> }> = [];
|
||
|
||
for (const vorlage of FAHRZEUGE) {
|
||
const fahrzeug = await prisma.fahrzeug.upsert({
|
||
where: {
|
||
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
|
||
},
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
...vorlage,
|
||
standortId: standorte.get("Gerätehaus") ?? null,
|
||
},
|
||
});
|
||
|
||
const vorlagen =
|
||
vorlage.art === FahrzeugArt.MANNSCHAFTSTRANSPORT ? RAEUME_KLEIN : RAEUME_GROSS;
|
||
|
||
const raeume = new Map<string, string>();
|
||
for (const raumVorlage of vorlagen) {
|
||
const raum = await prisma.geraeteraum.upsert({
|
||
where: {
|
||
fahrzeugId_bezeichnung: {
|
||
fahrzeugId: fahrzeug.id,
|
||
bezeichnung: raumVorlage.bezeichnung,
|
||
},
|
||
},
|
||
update: {},
|
||
create: { organisationId, fahrzeugId: fahrzeug.id, ...raumVorlage },
|
||
});
|
||
raeume.set(raumVorlage.bezeichnung, raum.id);
|
||
}
|
||
|
||
fahrzeuge.push({ id: fahrzeug.id, funkrufname: fahrzeug.funkrufname, raeume });
|
||
}
|
||
|
||
const raeumeGesamt = fahrzeuge.reduce((summe, f) => summe + f.raeume.size, 0);
|
||
log(`Fahrzeuge: ${fahrzeuge.length}, Geräteräume: ${raeumeGesamt}`);
|
||
return fahrzeuge;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Geräte
|
||
// -----------------------------------------------------------------------------
|
||
|
||
interface GeraetVorlage {
|
||
typ: string;
|
||
anzahl: number;
|
||
/** Index in FAHRZEUGE, oder null für einen Standort. */
|
||
aufFahrzeug: number | null;
|
||
raum?: string;
|
||
standort?: string;
|
||
lagerort?: string;
|
||
}
|
||
|
||
const GERAETE: GeraetVorlage[] = [
|
||
{ typ: "Pressluftatmer PA 94", anzahl: 4, aufFahrzeug: 0, raum: "MR" },
|
||
{ typ: "Atemschutzmaske FPS 7000", anzahl: 6, aufFahrzeug: null, standort: "Atemschutzwerkstatt", lagerort: "Schrank 1" },
|
||
{ typ: "Atemluftflasche 6,8 l / 300 bar", anzahl: 8, aufFahrzeug: 0, raum: "G4" },
|
||
{ typ: "Feuerwehrhelm F220", anzahl: 6, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Umkleide, Spind-Reihe A" },
|
||
{ typ: "Feuerwehrleine 30 m", anzahl: 3, aufFahrzeug: 0, raum: "G2" },
|
||
{ typ: "Steckleiterteil", anzahl: 4, aufFahrzeug: 0, raum: "Dach" },
|
||
{ typ: "Tragkraftspritze PFPN 10-1000", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
||
{ typ: "Hydraulischer Rettungssatz", anzahl: 1, aufFahrzeug: 0, raum: "G3" },
|
||
{ typ: "Motorkettensäge MS 261", anzahl: 2, aufFahrzeug: 0, raum: "G5" },
|
||
{ typ: "Stromerzeuger 8 kVA", anzahl: 1, aufFahrzeug: 0, raum: "G6" },
|
||
{ typ: "Wärmebildkamera", anzahl: 1, aufFahrzeug: 0, raum: "MR" },
|
||
{ typ: "Handsprechfunkgerät TPH900", anzahl: 3, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Funkraum, Ladeschale" },
|
||
{ typ: "B-Druckschlauch 20 m", anzahl: 8, aufFahrzeug: 0, raum: "G1" },
|
||
{ typ: "Überdrucklüfter", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
||
];
|
||
|
||
async function geraeteAnlegen(
|
||
organisationId: string,
|
||
typen: Map<string, string>,
|
||
fahrzeuge: Array<{ id: string; raeume: Map<string, string> }>,
|
||
standorte: Map<string, string>,
|
||
) {
|
||
const heute = new Date();
|
||
let laufend = 1;
|
||
let angelegt = 0;
|
||
|
||
for (const vorlage of GERAETE) {
|
||
const typId = typen.get(vorlage.typ);
|
||
const typVorlage = GERAETETYPEN.find((t) => t.name === vorlage.typ);
|
||
if (!typId || !typVorlage) continue;
|
||
|
||
for (let index = 0; index < vorlage.anzahl; index += 1) {
|
||
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
|
||
|
||
// Ein Teil der Geräte bekommt eine überschrittene Frist, damit die
|
||
// Statusanzeige nicht durchgehend grün ist und man sieht, wofür sie da
|
||
// ist.
|
||
const ueberfaellig = laufend % 4 === 0;
|
||
const indienststellung = datum(2015 + (laufend % 9), (laufend % 12) + 1, 15);
|
||
const herstellung = datum(indienststellung.getUTCFullYear() - 1, 6, 1);
|
||
|
||
const letztePruefung = ueberfaellig
|
||
? plusMonate(heute, -(typVorlage.pruefintervallMonate + 2))
|
||
: plusMonate(heute, -(laufend % typVorlage.pruefintervallMonate));
|
||
|
||
const naechstePruefung = plusMonate(letztePruefung, typVorlage.pruefintervallMonate);
|
||
|
||
const lebensdauerBis = typVorlage.lebensdauerJahre
|
||
? plusJahre(
|
||
typVorlage.lebensdauerBezug === LebensdauerBezug.HERSTELLUNG
|
||
? herstellung
|
||
: indienststellung,
|
||
typVorlage.lebensdauerJahre,
|
||
)
|
||
: null;
|
||
|
||
const status =
|
||
lebensdauerBis && lebensdauerBis < heute
|
||
? GeraetStatus.GESPERRT
|
||
: naechstePruefung < heute
|
||
? GeraetStatus.PRUEFUNG_FAELLIG
|
||
: laufend % 19 === 0
|
||
? GeraetStatus.IN_REPARATUR
|
||
: GeraetStatus.EINSATZBEREIT;
|
||
|
||
const fahrzeug = vorlage.aufFahrzeug !== null ? fahrzeuge[vorlage.aufFahrzeug] : null;
|
||
|
||
await prisma.geraet.upsert({
|
||
where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
inventarnummer,
|
||
bezeichnung: vorlage.typ,
|
||
geraetetypId: typId,
|
||
hersteller: typVorlage.hersteller,
|
||
seriennummer: `SN-${typVorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
|
||
herstellungsdatum: herstellung,
|
||
indienststellung,
|
||
anschaffungspreis: preisFuer(typVorlage.kategorie, laufend),
|
||
status,
|
||
letztePruefung,
|
||
naechstePruefung,
|
||
lebensdauerBis,
|
||
fahrzeugId: fahrzeug?.id ?? null,
|
||
geraeteraumId: fahrzeug && vorlage.raum ? (fahrzeug.raeume.get(vorlage.raum) ?? null) : null,
|
||
standortId: fahrzeug ? null : (standorte.get(vorlage.standort ?? "Gerätehaus") ?? null),
|
||
lagerort: vorlage.lagerort ?? null,
|
||
},
|
||
});
|
||
|
||
laufend += 1;
|
||
angelegt += 1;
|
||
}
|
||
}
|
||
|
||
log(`Geräte: ${angelegt}`);
|
||
}
|
||
|
||
/** Grobe Anschaffungspreise, damit Auswertungen später etwas zu rechnen haben. */
|
||
function preisFuer(kategorie: GeraeteKategorie, streuung: number): number {
|
||
const grundpreise: Partial<Record<GeraeteKategorie, number>> = {
|
||
[GeraeteKategorie.ATEMSCHUTZ]: 1800,
|
||
[GeraeteKategorie.RETTUNG]: 450,
|
||
[GeraeteKategorie.TECHNISCHE_HILFELEISTUNG]: 6500,
|
||
[GeraeteKategorie.ELEKTRO]: 3200,
|
||
[GeraeteKategorie.MESSTECHNIK]: 4800,
|
||
[GeraeteKategorie.FUNK]: 1400,
|
||
[GeraeteKategorie.SCHLAUCHMATERIAL]: 120,
|
||
[GeraeteKategorie.WASSERFOERDERUNG]: 9500,
|
||
[GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG]: 320,
|
||
[GeraeteKategorie.BRANDBEKAEMPFUNG]: 2100,
|
||
};
|
||
const grund = grundpreise[kategorie] ?? 250;
|
||
return Math.round((grund + (streuung % 7) * grund * 0.03) * 100) / 100;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Qualifikationen
|
||
// -----------------------------------------------------------------------------
|
||
|
||
interface QualiTypVorlage {
|
||
name: string;
|
||
kuerzel: string;
|
||
art: QualifikationsArt;
|
||
gueltigkeitMonate?: number;
|
||
vorwarnzeitTage?: number;
|
||
einsatzrelevant?: boolean;
|
||
}
|
||
|
||
const QUALIFIKATIONSTYPEN: QualiTypVorlage[] = [
|
||
{ name: "Truppmann Teil 1 (MTA Basismodul)", kuerzel: "TM1", art: QualifikationsArt.LEHRGANG, einsatzrelevant: true },
|
||
{ name: "Truppmann Teil 2", kuerzel: "TM2", art: QualifikationsArt.LEHRGANG },
|
||
{ name: "Truppführer", kuerzel: "TF", art: QualifikationsArt.LEHRGANG },
|
||
{ name: "Gruppenführer", kuerzel: "GF", art: QualifikationsArt.LEHRGANG },
|
||
{ name: "Zugführer", kuerzel: "ZF", art: QualifikationsArt.LEHRGANG },
|
||
{ name: "Maschinist für Löschfahrzeuge", kuerzel: "MA", art: QualifikationsArt.LEHRGANG },
|
||
{ name: "Sprechfunker", kuerzel: "SF", art: QualifikationsArt.LEHRGANG },
|
||
{
|
||
name: "Atemschutzgeräteträger",
|
||
kuerzel: "AGT",
|
||
art: QualifikationsArt.BEFAEHIGUNG,
|
||
gueltigkeitMonate: 12,
|
||
vorwarnzeitTage: 60,
|
||
einsatzrelevant: true,
|
||
},
|
||
{
|
||
name: "Arbeitsmedizinische Vorsorge G26.3",
|
||
kuerzel: "G26.3",
|
||
art: QualifikationsArt.UNTERSUCHUNG,
|
||
gueltigkeitMonate: 36,
|
||
vorwarnzeitTage: 120,
|
||
einsatzrelevant: true,
|
||
},
|
||
{
|
||
name: "Belastungsübung Atemschutz",
|
||
kuerzel: "BÜ",
|
||
art: QualifikationsArt.UNTERWEISUNG,
|
||
gueltigkeitMonate: 12,
|
||
vorwarnzeitTage: 60,
|
||
einsatzrelevant: true,
|
||
},
|
||
{ name: "Motorsägenführer", kuerzel: "MS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 60 },
|
||
{ name: "Führerschein C/CE", kuerzel: "C/CE", art: QualifikationsArt.FUEHRERSCHEIN, gueltigkeitMonate: 60, vorwarnzeitTage: 90 },
|
||
{ name: "Erste Hilfe", kuerzel: "EH", art: QualifikationsArt.UNTERWEISUNG, gueltigkeitMonate: 24 },
|
||
{ name: "Absturzsicherung", kuerzel: "AS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 12 },
|
||
];
|
||
|
||
async function qualifikationstypenAnlegen(organisationId: string) {
|
||
const karte = new Map<string, string>();
|
||
|
||
for (const vorlage of QUALIFIKATIONSTYPEN) {
|
||
const typ = await prisma.qualifikationstyp.upsert({
|
||
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
name: vorlage.name,
|
||
kuerzel: vorlage.kuerzel,
|
||
art: vorlage.art,
|
||
gueltigkeitMonate: vorlage.gueltigkeitMonate ?? null,
|
||
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 90,
|
||
einsatzrelevant: vorlage.einsatzrelevant ?? false,
|
||
},
|
||
});
|
||
karte.set(vorlage.name, typ.id);
|
||
}
|
||
|
||
log(`Qualifikationstypen: ${karte.size}`);
|
||
return karte;
|
||
}
|
||
|
||
/**
|
||
* Verteilt Qualifikationen auf die Mannschaft.
|
||
*
|
||
* Ein Teil läuft absichtlich demnächst ab oder ist bereits abgelaufen — sonst
|
||
* bliebe die Ablaufwarnung in der Beispielwehr leer, und man sähe nicht, wofür
|
||
* sie da ist.
|
||
*/
|
||
async function qualifikationenAnlegen(organisationId: string, typen: Map<string, string>) {
|
||
const personen = await prisma.person.findMany({
|
||
where: { organisationId, geloeschtAm: null, abteilung: { not: "Verwaltung" } },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
select: { id: true, mitgliedsnummer: true, eintrittsdatum: true },
|
||
});
|
||
|
||
const heute = new Date();
|
||
let angelegt = 0;
|
||
|
||
for (const [index, person] of personen.entries()) {
|
||
const eintritt = person.eintrittsdatum ?? datum(2015, 1, 1);
|
||
|
||
/** Grundausbildung hat jeder, weitere je nach Dienstalter. */
|
||
const zuweisungen: Array<{ typ: string; erworben: Date; gueltigBis?: Date | null }> = [
|
||
{ typ: "Truppmann Teil 1 (MTA Basismodul)", erworben: plusMonate(eintritt, 6) },
|
||
];
|
||
|
||
if (index < 12) {
|
||
zuweisungen.push({ typ: "Truppmann Teil 2", erworben: plusMonate(eintritt, 24) });
|
||
zuweisungen.push({ typ: "Sprechfunker", erworben: plusMonate(eintritt, 12) });
|
||
}
|
||
if (index < 8) {
|
||
zuweisungen.push({ typ: "Maschinist für Löschfahrzeuge", erworben: plusMonate(eintritt, 36) });
|
||
}
|
||
if (index < 6) {
|
||
zuweisungen.push({ typ: "Truppführer", erworben: plusMonate(eintritt, 48) });
|
||
}
|
||
if (index < 3) {
|
||
zuweisungen.push({ typ: "Gruppenführer", erworben: plusMonate(eintritt, 72) });
|
||
}
|
||
|
||
// Atemschutz: die Fristen werden gestreut, damit die Ampel etwas zeigt.
|
||
if (index < 9) {
|
||
const versatz = [-2, -1, 1, 2, 3, 5, 8, 10, 11][index] ?? 6;
|
||
zuweisungen.push({
|
||
typ: "Atemschutzgeräteträger",
|
||
erworben: plusMonate(heute, versatz - 12),
|
||
gueltigBis: plusMonate(heute, versatz),
|
||
});
|
||
zuweisungen.push({
|
||
typ: "Arbeitsmedizinische Vorsorge G26.3",
|
||
erworben: plusMonate(heute, versatz - 36),
|
||
gueltigBis: plusMonate(heute, versatz),
|
||
});
|
||
zuweisungen.push({
|
||
typ: "Belastungsübung Atemschutz",
|
||
erworben: plusMonate(heute, versatz - 12),
|
||
gueltigBis: plusMonate(heute, versatz),
|
||
});
|
||
}
|
||
|
||
if (index < 5) {
|
||
zuweisungen.push({
|
||
typ: "Führerschein C/CE",
|
||
erworben: plusMonate(eintritt, 12),
|
||
gueltigBis: plusMonate(heute, 6 + index * 4),
|
||
});
|
||
}
|
||
if (index < 4) {
|
||
zuweisungen.push({
|
||
typ: "Motorsägenführer",
|
||
erworben: plusMonate(heute, -30),
|
||
gueltigBis: plusMonate(heute, 30),
|
||
});
|
||
}
|
||
|
||
zuweisungen.push({
|
||
typ: "Erste Hilfe",
|
||
erworben: plusMonate(heute, -20 - index),
|
||
gueltigBis: plusMonate(heute, 4 - index),
|
||
});
|
||
|
||
for (const zuweisung of zuweisungen) {
|
||
const typId = typen.get(zuweisung.typ);
|
||
if (!typId) continue;
|
||
|
||
const vorhanden = await prisma.qualifikation.findFirst({
|
||
where: { organisationId, personId: person.id, typId },
|
||
select: { id: true },
|
||
});
|
||
if (vorhanden) continue;
|
||
|
||
await prisma.qualifikation.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
typId,
|
||
erworbenAm: zuweisung.erworben,
|
||
gueltigBis: zuweisung.gueltigBis ?? null,
|
||
ausstellendeStelle:
|
||
zuweisung.typ.includes("G26") ? "Betriebsarzt Dr. Berger" : "Kreisbrandinspektion Freising",
|
||
},
|
||
});
|
||
angelegt += 1;
|
||
}
|
||
}
|
||
|
||
log(`Qualifikationen: ${angelegt}`);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
|
||
//
|
||
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
|
||
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
|
||
// heißt, zeigt nicht, worum es geht.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
interface PunktVorlage {
|
||
text: string;
|
||
typ: ChecklistenpunktTyp;
|
||
pflicht?: boolean;
|
||
sollwert?: string;
|
||
einheit?: string;
|
||
minWert?: number;
|
||
maxWert?: number;
|
||
hinweis?: string;
|
||
}
|
||
|
||
interface WartungsVorlage {
|
||
name: string;
|
||
geraetetyp: string;
|
||
intervallMonate: number;
|
||
vorwarnzeitTage?: number;
|
||
pruefvorschrift?: string;
|
||
standardDauerMinuten?: number;
|
||
punkte: PunktVorlage[];
|
||
}
|
||
|
||
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
|
||
{
|
||
name: "Pressluftatmer — Jahresprüfung",
|
||
geraetetyp: "Pressluftatmer PA 94",
|
||
intervallMonate: 12,
|
||
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
||
standardDauerMinuten: 25,
|
||
punkte: [
|
||
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{
|
||
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "höchstens 10 bar in einer Minute",
|
||
einheit: "bar",
|
||
minWert: 0,
|
||
maxWert: 10,
|
||
},
|
||
{
|
||
text: "Ansprechdruck der Warneinrichtung",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "50 bis 60 bar",
|
||
einheit: "bar",
|
||
minWert: 50,
|
||
maxWert: 60,
|
||
},
|
||
{
|
||
text: "Lungenautomat: Öffnungsdruck",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "bis 3,5 mbar",
|
||
einheit: "mbar",
|
||
maxWert: 3.5,
|
||
},
|
||
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
||
],
|
||
},
|
||
{
|
||
name: "Atemanschluss — Halbjahresprüfung",
|
||
geraetetyp: "Atemschutzmaske FPS 7000",
|
||
intervallMonate: 6,
|
||
pruefvorschrift: "DGUV Regel 112-190",
|
||
standardDauerMinuten: 15,
|
||
punkte: [
|
||
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{
|
||
text: "Dichtprüfung: Druckabfall in einer Minute",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "höchstens 0,5 mbar",
|
||
einheit: "mbar",
|
||
minWert: 0,
|
||
maxWert: 0.5,
|
||
},
|
||
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
|
||
],
|
||
},
|
||
{
|
||
name: "Feuerwehrleine — Jahresprüfung",
|
||
geraetetyp: "Feuerwehrleine 30 m",
|
||
intervallMonate: 12,
|
||
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
|
||
standardDauerMinuten: 10,
|
||
punkte: [
|
||
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
|
||
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
||
],
|
||
},
|
||
{
|
||
name: "Druckschlauch — Druckprüfung",
|
||
geraetetyp: "B-Druckschlauch 20 m",
|
||
intervallMonate: 12,
|
||
pruefvorschrift: "DIN 14811",
|
||
standardDauerMinuten: 8,
|
||
punkte: [
|
||
{
|
||
text: "Prüfdruck halten",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "mindestens 8 bar über 5 Minuten",
|
||
einheit: "bar",
|
||
minWert: 8,
|
||
},
|
||
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
],
|
||
},
|
||
{
|
||
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
|
||
geraetetyp: "Stromerzeuger 8 kVA",
|
||
intervallMonate: 12,
|
||
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
|
||
standardDauerMinuten: 30,
|
||
punkte: [
|
||
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
{
|
||
text: "Schutzleiterwiderstand",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "höchstens 0,3 Ohm",
|
||
einheit: "Ohm",
|
||
minWert: 0,
|
||
maxWert: 0.3,
|
||
},
|
||
{
|
||
text: "Isolationswiderstand",
|
||
typ: ChecklistenpunktTyp.MESSWERT,
|
||
sollwert: "mindestens 1 Megaohm",
|
||
einheit: "MOhm",
|
||
minWert: 1,
|
||
},
|
||
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
|
||
],
|
||
},
|
||
];
|
||
|
||
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
|
||
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
|
||
|
||
for (const vorlage of WARTUNGSVORLAGEN) {
|
||
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
|
||
|
||
const angelegt = await prisma.wartungsvorlage.upsert({
|
||
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
||
update: {},
|
||
create: {
|
||
organisationId,
|
||
name: vorlage.name,
|
||
geraetetypId,
|
||
intervallMonate: vorlage.intervallMonate,
|
||
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
|
||
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
||
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
|
||
},
|
||
});
|
||
|
||
const punktIds: string[] = [];
|
||
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
|
||
|
||
if (vorhandene === 0) {
|
||
for (const [index, punkt] of vorlage.punkte.entries()) {
|
||
const erzeugt = await prisma.checklistenpunkt.create({
|
||
data: {
|
||
organisationId,
|
||
vorlageId: angelegt.id,
|
||
text: punkt.text,
|
||
typ: punkt.typ,
|
||
pflicht: punkt.pflicht ?? true,
|
||
sollwert: punkt.sollwert ?? null,
|
||
einheit: punkt.einheit ?? null,
|
||
minWert: punkt.minWert ?? null,
|
||
maxWert: punkt.maxWert ?? null,
|
||
hinweis: punkt.hinweis ?? null,
|
||
sortierung: (index + 1) * 10,
|
||
},
|
||
});
|
||
punktIds.push(erzeugt.id);
|
||
}
|
||
} else {
|
||
const bestand = await prisma.checklistenpunkt.findMany({
|
||
where: { vorlageId: angelegt.id },
|
||
orderBy: { sortierung: "asc" },
|
||
select: { id: true },
|
||
});
|
||
punktIds.push(...bestand.map((punkt) => punkt.id));
|
||
}
|
||
|
||
karte.set(vorlage.name, {
|
||
id: angelegt.id,
|
||
intervallMonate: vorlage.intervallMonate,
|
||
punkte: punktIds,
|
||
});
|
||
}
|
||
|
||
log(`Wartungsvorlagen: ${karte.size}`);
|
||
return karte;
|
||
}
|
||
|
||
/**
|
||
* Termine für alle Geräte der jeweiligen Bauart.
|
||
*
|
||
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
|
||
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
|
||
* Fristen-Dashboard hat.
|
||
*/
|
||
async function wartungstermineAnlegen(
|
||
organisationId: string,
|
||
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
||
) {
|
||
let angelegt = 0;
|
||
|
||
for (const vorlage of WARTUNGSVORLAGEN) {
|
||
const eintrag = vorlagen.get(vorlage.name);
|
||
if (!eintrag) continue;
|
||
|
||
const geraete = await prisma.geraet.findMany({
|
||
where: {
|
||
organisationId,
|
||
geloeschtAm: null,
|
||
geraetetyp: { name: vorlage.geraetetyp },
|
||
},
|
||
select: { id: true, letztePruefung: true, indienststellung: true },
|
||
});
|
||
|
||
for (const geraet of geraete) {
|
||
const basis = geraet.letztePruefung ?? geraet.indienststellung;
|
||
if (!basis) continue;
|
||
|
||
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
|
||
|
||
const ergebnis = await prisma.wartungstermin.createMany({
|
||
data: [
|
||
{
|
||
organisationId,
|
||
geraetId: geraet.id,
|
||
vorlageId: eintrag.id,
|
||
faelligAm,
|
||
automatischErzeugt: true,
|
||
},
|
||
],
|
||
skipDuplicates: true,
|
||
});
|
||
|
||
angelegt += ergebnis.count;
|
||
}
|
||
}
|
||
|
||
log(`Wartungstermine: ${angelegt}`);
|
||
}
|
||
|
||
/**
|
||
* Ein abgeschlossenes Protokoll samt Mangel.
|
||
*
|
||
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
|
||
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
|
||
* Worker, sobald einer läuft.
|
||
*/
|
||
async function beispielPruefungAnlegen(
|
||
organisationId: string,
|
||
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
||
) {
|
||
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
|
||
if (!vorlage) return;
|
||
|
||
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log("Beispielprüfung: bereits vorhanden");
|
||
return;
|
||
}
|
||
|
||
const geraet = await prisma.geraet.findFirst({
|
||
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
|
||
select: { id: true, inventarnummer: true },
|
||
});
|
||
if (!geraet) return;
|
||
|
||
const pruefer = await prisma.person.findFirst({
|
||
where: {
|
||
organisationId,
|
||
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
|
||
},
|
||
select: { id: true, vorname: true, nachname: true },
|
||
});
|
||
|
||
const geprueftAm = plusMonate(new Date(), -2);
|
||
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
|
||
|
||
const punkte = await prisma.checklistenpunkt.findMany({
|
||
where: { vorlageId: vorlage.id },
|
||
orderBy: { sortierung: "asc" },
|
||
});
|
||
|
||
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
|
||
// Trigger aus Migration 20260903000300 lässt an einer bereits
|
||
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
|
||
// das ist auch für den Seed richtig so.
|
||
const pruefung = await prisma.pruefung.create({
|
||
data: {
|
||
organisationId,
|
||
geraetId: geraet.id,
|
||
vorlageId: vorlage.id,
|
||
art: PruefungArt.INTERN,
|
||
status: PruefungStatus.ENTWURF,
|
||
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
|
||
prueferId: pruefer?.id ?? null,
|
||
prueferName,
|
||
durchgefuehrtAm: geprueftAm,
|
||
begonnenUm: geprueftAm,
|
||
dauerMinuten: 12,
|
||
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
|
||
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
|
||
abgeschlossenAm: geprueftAm,
|
||
bemerkung:
|
||
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
|
||
ergebnisse: {
|
||
create: punkte.map((punkt, index) => ({
|
||
organisationId,
|
||
punktId: punkt.id,
|
||
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
|
||
text:
|
||
punkt.typ === ChecklistenpunktTyp.TEXT
|
||
? "Leine selbst ohne Befund, Beutel beschädigt."
|
||
: null,
|
||
inOrdnung: index !== 2,
|
||
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
|
||
})),
|
||
},
|
||
signaturen: {
|
||
create: [
|
||
{
|
||
organisationId,
|
||
rolle: SignaturRolle.PRUEFER,
|
||
personId: pruefer?.id ?? null,
|
||
name: prueferName,
|
||
unterzeichnetAm: geprueftAm,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
await prisma.pruefung.update({
|
||
where: { id: pruefung.id },
|
||
data: { status: PruefungStatus.ABGESCHLOSSEN },
|
||
});
|
||
|
||
await prisma.mangel.create({
|
||
data: {
|
||
organisationId,
|
||
geraetId: geraet.id,
|
||
pruefungId: pruefung.id,
|
||
melderId: pruefer?.id ?? null,
|
||
melderName: prueferName,
|
||
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
|
||
prioritaet: MangelPrioritaet.NIEDRIG,
|
||
status: MangelStatus.ZUGEWIESEN,
|
||
zugewiesenAnId: pruefer?.id ?? null,
|
||
},
|
||
});
|
||
|
||
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
|
||
}
|
||
|
||
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
|
||
async function rueckrufAnlegen(organisationId: string) {
|
||
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
|
||
if (vorhanden > 0) return;
|
||
|
||
const typ = await prisma.geraetetyp.findFirst({
|
||
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
|
||
select: { id: true },
|
||
});
|
||
|
||
await prisma.rueckruf.create({
|
||
data: {
|
||
organisationId,
|
||
hersteller: "Dräger",
|
||
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
|
||
referenz: "SI-2026-03",
|
||
beschreibung:
|
||
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
|
||
+ "Betroffen sind Masken mit Herstellung vor 2020.",
|
||
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
|
||
veroeffentlichtAm: plusMonate(new Date(), -1),
|
||
sperrtGeraete: false,
|
||
typen: typ
|
||
? {
|
||
create: [
|
||
{
|
||
organisationId,
|
||
geraetetypId: typ.id,
|
||
herstellungBis: datum(2019, 12, 31),
|
||
},
|
||
],
|
||
}
|
||
: undefined,
|
||
},
|
||
});
|
||
|
||
log("Rückruf: 1 Beispiel");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 3 — Einsätze und Übungen
|
||
//
|
||
// Ein Jahr im Leben einer Wehr: Brände, technische Hilfeleistungen, ein
|
||
// Fehlalarm einer Brandmeldeanlage und die Übungen dazwischen. Die Zahlen sind
|
||
// so gewählt, dass die Statistik etwas zeigt — auch eine Hilfsfrist, die
|
||
// gerade noch passt.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
interface EinsatzVorlage {
|
||
/** Tage vor heute. */
|
||
vorTagen: number;
|
||
uhrzeit: [number, number];
|
||
stichwort: string;
|
||
kategorie: EinsatzKategorie;
|
||
strasse: string;
|
||
hausnummer?: string;
|
||
objekt?: string;
|
||
/** Minuten nach der Alarmierung: ausgerückt, eingetroffen, Ende, eingerückt. */
|
||
ablauf: [number, number, number, number];
|
||
fahrzeuge: number[];
|
||
/** Wie viele Kräfte, und wer davon führt. */
|
||
mannschaft: number;
|
||
gerettet?: number;
|
||
verletzt?: number;
|
||
sachschaden?: number;
|
||
beschreibung: string;
|
||
bericht?: string;
|
||
meldungen?: Array<{ nachMinuten: number; text: string; wichtig?: boolean }>;
|
||
material?: Array<{ bezeichnung: string; menge: number; einheit: string }>;
|
||
status?: EinsatzStatus;
|
||
}
|
||
|
||
const EINSAETZE: EinsatzVorlage[] = [
|
||
{
|
||
vorTagen: 12,
|
||
uhrzeit: [3, 42],
|
||
stichwort: "B 3 Wohnhausbrand",
|
||
kategorie: EinsatzKategorie.BRAND_GROSS,
|
||
strasse: "Bahnhofstraße",
|
||
hausnummer: "14",
|
||
objekt: "Reihenhaus, zwei Vollgeschosse",
|
||
ablauf: [5, 9, 143, 168],
|
||
fahrzeuge: [0, 1],
|
||
mannschaft: 14,
|
||
gerettet: 2,
|
||
verletzt: 1,
|
||
sachschaden: 85000,
|
||
beschreibung:
|
||
"Bei Eintreffen Rauch aus dem Dachgeschoss, zwei Personen im Obergeschoss gemeldet.",
|
||
bericht:
|
||
"Menschenrettung über die Steckleiter durch den Angriffstrupp. Zwei Personen unverletzt gerettet, eine Person mit Rauchgasintoxikation an den Rettungsdienst übergeben.\n\nBrandbekämpfung mit einem C-Rohr unter Atemschutz vom Treppenraum aus. Nachlöscharbeiten und Kontrolle mit der Wärmebildkamera bis 06:05 Uhr. Einsatzstelle an die Polizei übergeben.",
|
||
meldungen: [
|
||
{ nachMinuten: 10, text: "Rauch aus Dachgeschoss, zwei Personen vermisst.", wichtig: true },
|
||
{ nachMinuten: 22, text: "Beide Personen gerettet, Menschenrettung abgeschlossen.", wichtig: true },
|
||
{ nachMinuten: 48, text: "Feuer unter Kontrolle." },
|
||
{ nachMinuten: 140, text: "Nachlöscharbeiten beendet, Kontrolle mit Wärmebildkamera." },
|
||
],
|
||
material: [
|
||
{ bezeichnung: "Atemluftflaschen 300 bar", menge: 6, einheit: "Stück" },
|
||
{ bezeichnung: "Löschwasser", menge: 4500, einheit: "Liter" },
|
||
],
|
||
},
|
||
{
|
||
vorTagen: 26,
|
||
uhrzeit: [14, 8],
|
||
stichwort: "THL 1 Ölspur",
|
||
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
||
strasse: "Hauptstraße",
|
||
ablauf: [6, 11, 55, 68],
|
||
fahrzeuge: [1],
|
||
mannschaft: 6,
|
||
beschreibung: "Ölspur über etwa 300 Meter, ausgehend von einem Traktor.",
|
||
bericht: "Ölspur mit Bindemittel abgestreut und aufgenommen. Straßenmeisterei verständigt.",
|
||
material: [{ bezeichnung: "Ölbindemittel", menge: 40, einheit: "kg" }],
|
||
},
|
||
{
|
||
vorTagen: 41,
|
||
uhrzeit: [19, 55],
|
||
stichwort: "THL 2 Verkehrsunfall",
|
||
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_MITTEL,
|
||
strasse: "Kreisstraße MÜ 12",
|
||
objekt: "zwischen Musterhausen und Nachbarhausen",
|
||
ablauf: [4, 12, 78, 96],
|
||
fahrzeuge: [0],
|
||
mannschaft: 9,
|
||
verletzt: 2,
|
||
sachschaden: 24000,
|
||
beschreibung: "Zwei Fahrzeuge, eine Person eingeklemmt.",
|
||
bericht:
|
||
"Person mit dem hydraulischen Rettungssatz befreit und an den Rettungsdienst übergeben. Brandschutz sichergestellt, Fahrzeuge gegen Wegrollen gesichert, Betriebsstoffe aufgenommen.",
|
||
meldungen: [
|
||
{ nachMinuten: 14, text: "Eine Person eingeklemmt, Rettungsdienst vor Ort.", wichtig: true },
|
||
{ nachMinuten: 39, text: "Person befreit und übergeben.", wichtig: true },
|
||
],
|
||
material: [{ bezeichnung: "Ölbindemittel", menge: 15, einheit: "kg" }],
|
||
},
|
||
{
|
||
vorTagen: 58,
|
||
uhrzeit: [9, 12],
|
||
stichwort: "BMA Auslösung",
|
||
kategorie: EinsatzKategorie.BRANDMELDEANLAGE,
|
||
strasse: "Schulstraße",
|
||
hausnummer: "3",
|
||
objekt: "Grundschule",
|
||
ablauf: [7, 13, 32, 44],
|
||
fahrzeuge: [0],
|
||
mannschaft: 8,
|
||
beschreibung: "Auslösung eines Rauchmelders im Werkraum.",
|
||
bericht: "Kein Schadenfeuer. Auslösung durch Staub bei Reinigungsarbeiten. Anlage zurückgestellt.",
|
||
},
|
||
{
|
||
vorTagen: 73,
|
||
uhrzeit: [16, 30],
|
||
stichwort: "B 1 Kleinbrand",
|
||
kategorie: EinsatzKategorie.BRAND_KLEIN,
|
||
strasse: "Am Anger",
|
||
hausnummer: "7",
|
||
ablauf: [5, 8, 41, 52],
|
||
fahrzeuge: [1],
|
||
mannschaft: 6,
|
||
sachschaden: 1200,
|
||
beschreibung: "Brennende Mülltonne an der Hauswand.",
|
||
bericht: "Ablöschen mit Schnellangriff, Kontrolle der Fassade mit der Wärmebildkamera.",
|
||
},
|
||
{
|
||
vorTagen: 95,
|
||
uhrzeit: [21, 47],
|
||
stichwort: "THL 1 Sturmschaden",
|
||
kategorie: EinsatzKategorie.TECHNISCHE_HILFE_KLEIN,
|
||
strasse: "Lindenweg",
|
||
ablauf: [8, 16, 62, 74],
|
||
fahrzeuge: [0],
|
||
mannschaft: 7,
|
||
beschreibung: "Baum auf Fahrbahn nach Gewitterfront.",
|
||
bericht: "Baum mit der Motorkettensäge zerlegt und beiseitegeräumt. Fahrbahn gereinigt.",
|
||
material: [{ bezeichnung: "Kettensägenbenzin", menge: 2, einheit: "Liter" }],
|
||
},
|
||
{
|
||
vorTagen: 118,
|
||
uhrzeit: [11, 20],
|
||
stichwort: "Sicherheitswache Pfarrfest",
|
||
kategorie: EinsatzKategorie.SICHERHEITSWACHE,
|
||
strasse: "Kirchplatz",
|
||
ablauf: [10, 20, 300, 310],
|
||
fahrzeuge: [2],
|
||
mannschaft: 4,
|
||
beschreibung: "Brandsicherheitswache beim Pfarrfest.",
|
||
bericht: "Ohne besondere Vorkommnisse.",
|
||
},
|
||
{
|
||
vorTagen: 140,
|
||
uhrzeit: [6, 5],
|
||
stichwort: "THL 1 Türöffnung",
|
||
kategorie: EinsatzKategorie.RETTUNGSDIENST,
|
||
strasse: "Rosenstraße",
|
||
hausnummer: "22",
|
||
ablauf: [6, 10, 28, 38],
|
||
fahrzeuge: [1],
|
||
mannschaft: 5,
|
||
gerettet: 1,
|
||
beschreibung: "Hilflose Person hinter verschlossener Tür, Rettungsdienst vor Ort.",
|
||
bericht: "Tür schonend geöffnet, Person an den Rettungsdienst übergeben.",
|
||
},
|
||
];
|
||
|
||
/** Funktionen einer Gruppe in der Reihenfolge, in der sie besetzt werden. */
|
||
const FUNKTIONSFOLGE: EinsatzFunktion[] = [
|
||
EinsatzFunktion.GRUPPENFUEHRER,
|
||
EinsatzFunktion.MASCHINIST,
|
||
EinsatzFunktion.MELDER,
|
||
EinsatzFunktion.TRUPPFUEHRER,
|
||
EinsatzFunktion.TRUPPMANN,
|
||
EinsatzFunktion.TRUPPFUEHRER,
|
||
EinsatzFunktion.TRUPPMANN,
|
||
EinsatzFunktion.TRUPPFUEHRER,
|
||
EinsatzFunktion.TRUPPMANN,
|
||
];
|
||
|
||
function minutenSpaeter(basis: Date, minuten: number): Date {
|
||
return new Date(basis.getTime() + minuten * 60_000);
|
||
}
|
||
|
||
async function einsaetzeAnlegen(
|
||
organisationId: string,
|
||
fahrzeuge: Array<{ id: string; funkrufname: string }>,
|
||
) {
|
||
const vorhanden = await prisma.einsatz.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log(`Einsätze: bereits ${vorhanden} vorhanden`);
|
||
return;
|
||
}
|
||
|
||
const personen = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||
select: { id: true, vorname: true, nachname: true },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
});
|
||
|
||
if (personen.length === 0 || fahrzeuge.length === 0) return;
|
||
|
||
let laufend = 0;
|
||
|
||
for (const vorlage of EINSAETZE) {
|
||
laufend += 1;
|
||
|
||
const alarmiert = new Date();
|
||
alarmiert.setUTCDate(alarmiert.getUTCDate() - vorlage.vorTagen);
|
||
alarmiert.setUTCHours(vorlage.uhrzeit[0], vorlage.uhrzeit[1], 0, 0);
|
||
|
||
const jahr = alarmiert.getUTCFullYear();
|
||
const [ausgerueckt, eingetroffen, ende, eingerueckt] = vorlage.ablauf;
|
||
|
||
const einsatz = await prisma.einsatz.create({
|
||
data: {
|
||
organisationId,
|
||
einsatznummer: `${jahr}-${String(laufend).padStart(4, "0")}`,
|
||
stichwort: vorlage.stichwort,
|
||
kategorie: vorlage.kategorie,
|
||
status: vorlage.status ?? EinsatzStatus.ABGESCHLOSSEN,
|
||
alarmierungsart: Alarmierungsart.MELDEEMPFAENGER,
|
||
strasse: vorlage.strasse,
|
||
hausnummer: vorlage.hausnummer ?? null,
|
||
plz: "84543",
|
||
ort: "Musterhausen",
|
||
objekt: vorlage.objekt ?? null,
|
||
alarmiertUm: alarmiert,
|
||
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt),
|
||
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen),
|
||
einsatzendeUm: minutenSpaeter(alarmiert, ende),
|
||
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
||
einsatzbereitUm: minutenSpaeter(alarmiert, eingerueckt + 30),
|
||
einsatzleiterId: personen[0]?.id ?? null,
|
||
beschreibung: vorlage.beschreibung,
|
||
bericht: vorlage.bericht ?? null,
|
||
geretteteePersonen: vorlage.gerettet ?? 0,
|
||
verletztePersonen: vorlage.verletzt ?? 0,
|
||
sachschaden: vorlage.sachschaden ?? null,
|
||
fahrzeuge: {
|
||
create: vorlage.fahrzeuge
|
||
.map((index) => fahrzeuge[index])
|
||
.filter((fahrzeug): fahrzeug is { id: string; funkrufname: string } => Boolean(fahrzeug))
|
||
.map((fahrzeug, position) => ({
|
||
organisationId,
|
||
fahrzeugId: fahrzeug.id,
|
||
ausgeruecktUm: minutenSpaeter(alarmiert, ausgerueckt + position),
|
||
eingetroffenUm: minutenSpaeter(alarmiert, eingetroffen + position),
|
||
eingeruecktUm: minutenSpaeter(alarmiert, eingerueckt),
|
||
kilometerAbfahrt: 12000 + laufend * 40,
|
||
kilometerRueckkehr: 12000 + laufend * 40 + 8 + position * 3,
|
||
wasserabgabe: vorlage.kategorie.startsWith("BRAND") ? 1500 : null,
|
||
})),
|
||
},
|
||
kraefte: {
|
||
create: Array.from({ length: Math.min(vorlage.mannschaft, personen.length) }).map(
|
||
(_, index) => ({
|
||
organisationId,
|
||
// Die Mannschaft rotiert, damit die Beteiligung je Person
|
||
// unterschiedlich ausfällt.
|
||
personId: personen[(index + laufend * 3) % personen.length]!.id,
|
||
funktion: FUNKTIONSFOLGE[index % FUNKTIONSFOLGE.length]!,
|
||
atemschutz: vorlage.kategorie.startsWith("BRAND") && index >= 3 && index <= 6,
|
||
vonUm: minutenSpaeter(alarmiert, ausgerueckt),
|
||
bisUm: minutenSpaeter(alarmiert, eingerueckt),
|
||
}),
|
||
),
|
||
},
|
||
lagemeldungen: {
|
||
create: (vorlage.meldungen ?? []).map((meldung) => ({
|
||
organisationId,
|
||
zeitpunkt: minutenSpaeter(alarmiert, meldung.nachMinuten),
|
||
text: meldung.text,
|
||
wichtig: meldung.wichtig ?? false,
|
||
verfasserId: personen[0]?.id ?? null,
|
||
verfasserName: personen[0]
|
||
? `${personen[0].vorname} ${personen[0].nachname}`
|
||
: "Einsatzleitung",
|
||
})),
|
||
},
|
||
material: {
|
||
create: (vorlage.material ?? []).map((posten) => ({
|
||
organisationId,
|
||
bezeichnung: posten.bezeichnung,
|
||
menge: posten.menge,
|
||
einheit: posten.einheit,
|
||
})),
|
||
},
|
||
},
|
||
});
|
||
|
||
// Der älteste Einsatz ist bereits freigegeben — damit die Ansicht eines
|
||
// festgeschriebenen Berichts nicht leer bleibt.
|
||
if (vorlage.vorTagen >= 140) {
|
||
await prisma.einsatz.update({
|
||
where: { id: einsatz.id },
|
||
data: {
|
||
status: EinsatzStatus.FREIGEGEBEN,
|
||
freigegebenAm: minutenSpaeter(alarmiert, 2880),
|
||
freigegebenVonId: personen[0]?.id ?? null,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
log(`Einsätze: ${EINSAETZE.length} mit Fahrzeugen, Mannschaft und Lagemeldungen`);
|
||
}
|
||
|
||
interface UebungVorlage {
|
||
vorTagen: number;
|
||
thema: string;
|
||
art: UebungsArt;
|
||
ort: string;
|
||
lernziele: string;
|
||
teilnehmer: number;
|
||
dauer: number;
|
||
}
|
||
|
||
const UEBUNGEN: UebungVorlage[] = [
|
||
{
|
||
vorTagen: 7,
|
||
thema: "Innenangriff mit Atemschutz",
|
||
art: UebungsArt.ATEMSCHUTZ,
|
||
ort: "Übungscontainer Nachbarhausen",
|
||
lernziele:
|
||
"Vorgehen im Trupp unter Nullsicht, Türöffnungsprozedur, Rückweg sichern, Kommunikation mit dem Gruppenführer.",
|
||
teilnehmer: 9,
|
||
dauer: 150,
|
||
},
|
||
{
|
||
vorTagen: 21,
|
||
thema: "Wasserförderung über lange Wegstrecke",
|
||
art: UebungsArt.PRAXIS,
|
||
ort: "Löschwasserentnahmestelle Mühlbach",
|
||
lernziele: "Aufbau einer B-Leitung über 600 m, Verstärkerpumpe setzen, Druckverluste erkennen.",
|
||
teilnehmer: 12,
|
||
dauer: 120,
|
||
},
|
||
{
|
||
vorTagen: 35,
|
||
thema: "Technische Hilfeleistung nach Verkehrsunfall",
|
||
art: UebungsArt.PRAXIS,
|
||
ort: "Übungsgelände am Bauhof",
|
||
lernziele:
|
||
"Fahrzeug sichern, Glasmanagement, patientengerechte Rettung, Zusammenarbeit mit dem Rettungsdienst.",
|
||
teilnehmer: 11,
|
||
dauer: 180,
|
||
},
|
||
{
|
||
vorTagen: 49,
|
||
thema: "Funkausbildung nach BOS-Richtlinie",
|
||
art: UebungsArt.FUNK,
|
||
ort: "Gerätehaus, Schulungsraum",
|
||
lernziele: "Funkdisziplin, Meldeschema, Umgang mit dem Handsprechfunkgerät.",
|
||
teilnehmer: 8,
|
||
dauer: 90,
|
||
},
|
||
{
|
||
vorTagen: 63,
|
||
thema: "Maschinistenausbildung Tragkraftspritze",
|
||
art: UebungsArt.MASCHINIST,
|
||
ort: "Gerätehaus",
|
||
lernziele: "Inbetriebnahme, Entlüften, Störungssuche, Wartung nach dem Einsatz.",
|
||
teilnehmer: 5,
|
||
dauer: 120,
|
||
},
|
||
{
|
||
vorTagen: 84,
|
||
thema: "Gemeinschaftsübung mit der FF Nachbarhausen",
|
||
art: UebungsArt.GEMEINSCHAFTSUEBUNG,
|
||
ort: "Sägewerk Nachbarhausen",
|
||
lernziele:
|
||
"Zusammenarbeit zweier Wehren, gemeinsame Einsatzleitung, Wasserversorgung aus offenem Gewässer.",
|
||
teilnehmer: 14,
|
||
dauer: 210,
|
||
},
|
||
];
|
||
|
||
async function uebungenAnlegen(organisationId: string) {
|
||
const vorhanden = await prisma.uebung.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log(`Übungen: bereits ${vorhanden} vorhanden`);
|
||
return;
|
||
}
|
||
|
||
const personen = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||
select: { id: true },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
});
|
||
|
||
if (personen.length === 0) return;
|
||
|
||
let laufend = 0;
|
||
|
||
for (const vorlage of UEBUNGEN) {
|
||
laufend += 1;
|
||
|
||
const datum = new Date();
|
||
datum.setUTCDate(datum.getUTCDate() - vorlage.vorTagen);
|
||
datum.setUTCHours(0, 0, 0, 0);
|
||
|
||
const beginn = new Date(datum);
|
||
beginn.setUTCHours(19, 0, 0, 0);
|
||
|
||
await prisma.uebung.create({
|
||
data: {
|
||
organisationId,
|
||
nummer: `${datum.getUTCFullYear()}-${String(laufend).padStart(4, "0")}`,
|
||
thema: vorlage.thema,
|
||
art: vorlage.art,
|
||
datum,
|
||
beginnUm: beginn,
|
||
endeUm: minutenSpaeter(beginn, vorlage.dauer),
|
||
ort: vorlage.ort,
|
||
leiterId: personen[0]?.id ?? null,
|
||
lernziele: vorlage.lernziele,
|
||
teilnahmen: {
|
||
create: Array.from({ length: Math.min(vorlage.teilnehmer, personen.length) }).map(
|
||
(_, index) => ({
|
||
organisationId,
|
||
personId: personen[(index + laufend * 2) % personen.length]!.id,
|
||
}),
|
||
),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
log(`Übungen: ${UEBUNGEN.length} mit Teilnahmen`);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 4 — was am Terminal zusammenkommt
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fahrtenbuch, Anwesenheit und Wäsche.
|
||
*
|
||
* Alles drei entsteht im Betrieb am Wandtablet. Der Seed legt genug davon an,
|
||
* dass die Terminalübersicht beim ersten Start etwas zeigt — darunter bewusst
|
||
* je ein offener Fall: eine Fahrt, die noch läuft, jemand, der noch im Haus
|
||
* ist, und Kleidung, die noch nicht zurück ist. Das sind die Zustände, an
|
||
* denen sich die Ansichten beweisen müssen.
|
||
*/
|
||
async function terminaldatenAnlegen(organisationId: string) {
|
||
const vorhanden = await prisma.fahrt.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log(`Terminal: bereits ${vorhanden} Fahrten vorhanden`);
|
||
return;
|
||
}
|
||
|
||
const personen = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||
select: { id: true },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
});
|
||
|
||
const fahrzeuge = await prisma.fahrzeug.findMany({
|
||
where: { organisationId, geloeschtAm: null },
|
||
select: { id: true, kilometerstand: true },
|
||
orderBy: { funkrufname: "asc" },
|
||
});
|
||
|
||
if (personen.length === 0 || fahrzeuge.length === 0) return;
|
||
|
||
const jetzt = new Date();
|
||
|
||
function vorStunden(stunden: number): Date {
|
||
return new Date(jetzt.getTime() - stunden * 60 * 60 * 1000);
|
||
}
|
||
|
||
// --- Fahrtenbuch ----------------------------------------------------------
|
||
const FAHRTEN: Array<{
|
||
fahrzeug: number;
|
||
fahrer: number;
|
||
zweck: FahrtZweck;
|
||
ziel: string;
|
||
vorStunden: number;
|
||
dauer: number;
|
||
kilometer: number;
|
||
/** Offen heißt: das Fahrzeug ist noch unterwegs. */
|
||
offen?: boolean;
|
||
tankmenge?: number;
|
||
}> = [
|
||
{
|
||
fahrzeug: 0,
|
||
fahrer: 2,
|
||
zweck: FahrtZweck.WERKSTATT,
|
||
ziel: "Fahrzeugbau Huber, Landshut",
|
||
vorStunden: 26 * 24,
|
||
dauer: 5,
|
||
kilometer: 84,
|
||
tankmenge: 42.5,
|
||
},
|
||
{
|
||
fahrzeug: 1,
|
||
fahrer: 7,
|
||
zweck: FahrtZweck.UEBUNG,
|
||
ziel: "Übungsgelände Kiesgrube",
|
||
vorStunden: 8 * 24,
|
||
dauer: 3,
|
||
kilometer: 22,
|
||
},
|
||
{
|
||
fahrzeug: 2,
|
||
fahrer: 5,
|
||
zweck: FahrtZweck.SONSTIGES,
|
||
ziel: "Materialabholung Kreisbrandinspektion",
|
||
vorStunden: 3 * 24,
|
||
dauer: 2,
|
||
kilometer: 47,
|
||
},
|
||
{
|
||
fahrzeug: 0,
|
||
fahrer: 0,
|
||
zweck: FahrtZweck.AUSBILDUNG,
|
||
ziel: "Feuerwehrschule Geretsried",
|
||
vorStunden: 2,
|
||
dauer: 0,
|
||
kilometer: 0,
|
||
offen: true,
|
||
},
|
||
];
|
||
|
||
// Der Kilometerstand muss über die Fahrten hinweg vorwärts laufen — sonst
|
||
// schlägt die Prüfung in der Datenbank zu, und zwar zu Recht. Deshalb werden
|
||
// die erledigten Fahrten rückwärts vom heutigen Stand aus gerechnet.
|
||
const laufenderStand = new Map(
|
||
fahrzeuge.map((fahrzeug) => [fahrzeug.id, fahrzeug.kilometerstand]),
|
||
);
|
||
|
||
for (const vorlage of FAHRTEN) {
|
||
const fahrzeug = fahrzeuge[vorlage.fahrzeug % fahrzeuge.length]!;
|
||
const fahrer = personen[vorlage.fahrer % personen.length]!;
|
||
const abfahrt = vorStunden(vorlage.vorStunden);
|
||
|
||
const stand = laufenderStand.get(fahrzeug.id) ?? 0;
|
||
const abfahrtKm = Math.max(0, stand - vorlage.kilometer);
|
||
|
||
await prisma.fahrt.create({
|
||
data: {
|
||
organisationId,
|
||
fahrzeugId: fahrzeug.id,
|
||
fahrerId: fahrer.id,
|
||
zweck: vorlage.zweck,
|
||
ziel: vorlage.ziel,
|
||
abfahrtUm: abfahrt,
|
||
kilometerAbfahrt: vorlage.offen ? stand : abfahrtKm,
|
||
...(vorlage.offen
|
||
? {}
|
||
: {
|
||
rueckkehrUm: new Date(abfahrt.getTime() + vorlage.dauer * 60 * 60 * 1000),
|
||
kilometerRueckkehr: stand,
|
||
tankmenge: vorlage.tankmenge ?? null,
|
||
}),
|
||
},
|
||
});
|
||
|
||
if (!vorlage.offen) laufenderStand.set(fahrzeug.id, abfahrtKm);
|
||
}
|
||
|
||
// --- Anwesenheit ----------------------------------------------------------
|
||
const ANWESENHEITEN: Array<{
|
||
person: number;
|
||
anlass: AnwesenheitAnlass;
|
||
vorStunden: number;
|
||
/** null heißt: noch im Haus. */
|
||
dauer: number | null;
|
||
}> = [
|
||
{ person: 0, anlass: AnwesenheitAnlass.UEBUNG, vorStunden: 8 * 24, dauer: 3 },
|
||
{ person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 5 * 24, dauer: 4 },
|
||
{ person: 4, anlass: AnwesenheitAnlass.DIENST, vorStunden: 5 * 24, dauer: 2 },
|
||
{ person: 2, anlass: AnwesenheitAnlass.ARBEITSDIENST, vorStunden: 2, dauer: null },
|
||
];
|
||
|
||
for (const vorlage of ANWESENHEITEN) {
|
||
const person = personen[vorlage.person % personen.length]!;
|
||
const angemeldet = vorStunden(vorlage.vorStunden);
|
||
|
||
await prisma.anwesenheit.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
anlass: vorlage.anlass,
|
||
angemeldetUm: angemeldet,
|
||
abgemeldetUm:
|
||
vorlage.dauer === null
|
||
? null
|
||
: new Date(angemeldet.getTime() + vorlage.dauer * 60 * 60 * 1000),
|
||
},
|
||
});
|
||
}
|
||
|
||
// --- Wäsche ---------------------------------------------------------------
|
||
const WAESCHE: Array<{
|
||
person: number;
|
||
bezeichnung: string;
|
||
menge: number;
|
||
grund: string;
|
||
status: WaescheStatus;
|
||
vorStunden: number;
|
||
}> = [
|
||
{
|
||
person: 3,
|
||
bezeichnung: "Einsatzjacke",
|
||
menge: 1,
|
||
grund: "Einsatzverschmutzung",
|
||
status: WaescheStatus.IN_REINIGUNG,
|
||
vorStunden: 4 * 24,
|
||
},
|
||
{
|
||
person: 6,
|
||
bezeichnung: "Einsatzhose",
|
||
menge: 1,
|
||
grund: "Kontamination",
|
||
status: WaescheStatus.ABGEGEBEN,
|
||
vorStunden: 20,
|
||
},
|
||
{
|
||
person: 1,
|
||
bezeichnung: "Flammschutzhaube",
|
||
menge: 2,
|
||
grund: "Routine",
|
||
status: WaescheStatus.ZURUECK,
|
||
vorStunden: 30 * 24,
|
||
},
|
||
];
|
||
|
||
for (const vorlage of WAESCHE) {
|
||
const person = personen[vorlage.person % personen.length]!;
|
||
const abgegeben = vorStunden(vorlage.vorStunden);
|
||
|
||
await prisma.waescheabgabe.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
bezeichnung: vorlage.bezeichnung,
|
||
menge: vorlage.menge,
|
||
grund: vorlage.grund,
|
||
status: vorlage.status,
|
||
abgegebenAm: abgegeben,
|
||
zurueckAm:
|
||
vorlage.status === WaescheStatus.ZURUECK
|
||
? new Date(abgegeben.getTime() + 6 * 24 * 60 * 60 * 1000)
|
||
: null,
|
||
},
|
||
});
|
||
}
|
||
|
||
log(
|
||
`Terminal: ${FAHRTEN.length} Fahrten (eine läuft), ${ANWESENHEITEN.length} Anwesenheiten, ${WAESCHE.length} Wäscheabgaben`,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 5 — Atemschutz nach FwDV 7
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Atemschutz: Nachweise und eine abgeschlossene Überwachung.
|
||
*
|
||
* Die Nachweise sind bewusst nicht bei allen vollständig — genau so sieht es
|
||
* in einer Wehr aus. Einer hat eine abgelaufene G26, einem fehlt die
|
||
* Belastungsübung, einer ist bald fällig. Die Tauglichkeitsampel zeigt
|
||
* dadurch beim ersten Start alle drei Zustände, statt nur Grün.
|
||
*
|
||
* Dazu ein vollständig durchgespielter Einsatz: zwei Trupps, Eingangsdrücke,
|
||
* Zielmeldung, Zwischenmeldungen, Rückweg, Ausgangsdrücke — das ist die
|
||
* Vorlage, an der sich das Protokoll ansehen lässt, ohne selbst einen Einsatz
|
||
* zu fahren.
|
||
*/
|
||
async function atemschutzAnlegen(organisationId: string) {
|
||
const vorhanden = await prisma.atemschutzeinsatz.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log(`Atemschutz: bereits ${vorhanden} Überwachungen vorhanden`);
|
||
return;
|
||
}
|
||
|
||
const personen = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||
select: { id: true, vorname: true, nachname: true },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
});
|
||
|
||
if (personen.length < 6) return;
|
||
|
||
const jetzt = new Date();
|
||
|
||
function vorTagen(tage: number): Date {
|
||
const datum = new Date(jetzt);
|
||
datum.setUTCDate(datum.getUTCDate() - tage);
|
||
datum.setUTCHours(0, 0, 0, 0);
|
||
return datum;
|
||
}
|
||
|
||
function inTagen(tage: number): Date {
|
||
return vorTagen(-tage);
|
||
}
|
||
|
||
// --- G26.3 ----------------------------------------------------------------
|
||
//
|
||
// Die ersten acht sind Atemschutzgeräteträger. Drei Zustände absichtlich:
|
||
// gültig, läuft bald ab, abgelaufen.
|
||
const G26: Array<{ person: number; untersuchtVorTagen: number; gueltigInTagen: number; ergebnis: G26Ergebnis }> = [
|
||
{ person: 0, untersuchtVorTagen: 400, gueltigInTagen: 690, ergebnis: G26Ergebnis.TAUGLICH },
|
||
{ person: 2, untersuchtVorTagen: 200, gueltigInTagen: 890, ergebnis: G26Ergebnis.TAUGLICH },
|
||
{ person: 3, untersuchtVorTagen: 120, gueltigInTagen: 975, ergebnis: G26Ergebnis.TAUGLICH },
|
||
{ person: 5, untersuchtVorTagen: 90, gueltigInTagen: 1005, ergebnis: G26Ergebnis.TAUGLICH },
|
||
{ person: 7, untersuchtVorTagen: 30, gueltigInTagen: 1065, ergebnis: G26Ergebnis.TAUGLICH },
|
||
// Läuft in gut zwei Monaten ab — die Ampel steht auf Gelb.
|
||
{ person: 6, untersuchtVorTagen: 1030, gueltigInTagen: 65, ergebnis: G26Ergebnis.TAUGLICH },
|
||
// Abgelaufen: die Person ist nicht einsetzbar, und das soll man sehen.
|
||
{ person: 8, untersuchtVorTagen: 1200, gueltigInTagen: -105, ergebnis: G26Ergebnis.TAUGLICH },
|
||
// Befristet tauglich — zählt, aber der Arzt hat etwas dazugeschrieben.
|
||
{ person: 1, untersuchtVorTagen: 250, gueltigInTagen: 480, ergebnis: G26Ergebnis.BEFRISTET_TAUGLICH },
|
||
];
|
||
|
||
for (const eintrag of G26) {
|
||
const person = personen[eintrag.person % personen.length]!;
|
||
await prisma.g26Untersuchung.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
untersuchtAm: vorTagen(eintrag.untersuchtVorTagen),
|
||
gueltigBis: inTagen(eintrag.gueltigInTagen),
|
||
ergebnis: eintrag.ergebnis,
|
||
arzt: "Betriebsarzt Dr. Wagner, Landshut",
|
||
bemerkung:
|
||
eintrag.ergebnis === G26Ergebnis.BEFRISTET_TAUGLICH
|
||
? "Nachuntersuchung des Blutdrucks in einem Jahr empfohlen."
|
||
: null,
|
||
},
|
||
});
|
||
}
|
||
|
||
// --- Belastungs- und Einsatzübungen ---------------------------------------
|
||
//
|
||
// Person 5 fehlt bewusst in der Belastungsübung: Tauglichkeit ist nicht
|
||
// allein eine Frage der G26.
|
||
const UEBUNGSTEILNEHMER = [0, 1, 2, 3, 6, 7];
|
||
|
||
await prisma.belastungsuebung.createMany({
|
||
data: UEBUNGSTEILNEHMER.map((nummer) => ({
|
||
organisationId,
|
||
personId: personen[nummer % personen.length]!.id,
|
||
datum: vorTagen(120),
|
||
art: UebungsartAtemschutz.UEBUNGSSTRECKE,
|
||
ort: "Atemschutzübungsstrecke Landshut",
|
||
dauerMinuten: 45,
|
||
})),
|
||
});
|
||
|
||
await prisma.belastungsuebung.createMany({
|
||
data: [0, 1, 2, 3, 5, 7].map((nummer) => ({
|
||
organisationId,
|
||
personId: personen[nummer % personen.length]!.id,
|
||
datum: vorTagen(60),
|
||
art: UebungsartAtemschutz.EINSATZUEBUNG,
|
||
ort: "Übungsobjekt ehemalige Schreinerei",
|
||
dauerMinuten: 90,
|
||
})),
|
||
});
|
||
|
||
// --- Eine abgeschlossene Überwachung --------------------------------------
|
||
const beginn = new Date(jetzt.getTime() - 26 * 24 * 60 * 60 * 1000);
|
||
beginn.setUTCHours(14, 12, 0, 0);
|
||
|
||
function nach(minuten: number): Date {
|
||
return new Date(beginn.getTime() + minuten * 60 * 1000);
|
||
}
|
||
|
||
const einsatz = await prisma.einsatz.findFirst({
|
||
where: { organisationId, geloeschtAm: null },
|
||
orderBy: { alarmiertUm: "desc" },
|
||
select: { id: true },
|
||
});
|
||
|
||
const ueberwacher = personen[1]!;
|
||
|
||
const ueberwachung = await prisma.atemschutzeinsatz.create({
|
||
data: {
|
||
organisationId,
|
||
bezeichnung: "Zimmerbrand Hauptstraße 12",
|
||
einsatzId: einsatz?.id ?? null,
|
||
ueberwacherId: ueberwacher.id,
|
||
beginnUm: beginn,
|
||
endeUm: nach(38),
|
||
abfrageIntervall: 600,
|
||
sicherheitszuschlag: 10,
|
||
warndruck: 55,
|
||
bemerkung: "Regulärer Verlauf, keine besonderen Vorkommnisse.",
|
||
},
|
||
});
|
||
|
||
/** Legt einen Trupp samt Messungen und Protokollzeilen an. */
|
||
async function truppAnlegen(angaben: {
|
||
funkrufname: string;
|
||
auftrag: string;
|
||
fuehrer: number;
|
||
mann: number;
|
||
eingang: [number, number];
|
||
ziel: [number, number] | null;
|
||
zwischen: Array<[number, number, number]>;
|
||
ausgang: [number, number];
|
||
einstiegMinute: number;
|
||
rueckwegMinute: number;
|
||
draussenMinute: number;
|
||
rueckzugsdruck: number;
|
||
}) {
|
||
const fuehrer = personen[angaben.fuehrer % personen.length]!;
|
||
const mann = personen[angaben.mann % personen.length]!;
|
||
|
||
const trupp = await prisma.atemschutztrupp.create({
|
||
data: {
|
||
organisationId,
|
||
atemschutzeinsatzId: ueberwachung.id,
|
||
funkrufname: angaben.funkrufname,
|
||
auftrag: angaben.auftrag,
|
||
status: TruppStatus.DRAUSSEN,
|
||
angelegtUm: nach(angaben.einstiegMinute - 3),
|
||
einstiegUm: nach(angaben.einstiegMinute),
|
||
rueckzugUm: nach(angaben.rueckwegMinute),
|
||
drausenUm: nach(angaben.draussenMinute),
|
||
rueckzugsdruck: angaben.rueckzugsdruck,
|
||
mitglieder: {
|
||
create: [
|
||
{ organisationId, personId: fuehrer.id, truppfuehrer: true },
|
||
{ organisationId, personId: mann.id, truppfuehrer: false },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
const messungen: Array<{ personId: string; druck: number; art: DruckArt; zeitpunkt: Date }> = [
|
||
{ personId: fuehrer.id, druck: angaben.eingang[0], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) },
|
||
{ personId: mann.id, druck: angaben.eingang[1], art: DruckArt.EINGANG, zeitpunkt: nach(angaben.einstiegMinute - 2) },
|
||
];
|
||
|
||
if (angaben.ziel) {
|
||
messungen.push(
|
||
{ personId: fuehrer.id, druck: angaben.ziel[0], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) },
|
||
{ personId: mann.id, druck: angaben.ziel[1], art: DruckArt.ZIEL, zeitpunkt: nach(angaben.einstiegMinute + 4) },
|
||
);
|
||
}
|
||
|
||
for (const [minute, fuehrerDruck, mannDruck] of angaben.zwischen) {
|
||
messungen.push(
|
||
{ personId: fuehrer.id, druck: fuehrerDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) },
|
||
{ personId: mann.id, druck: mannDruck, art: DruckArt.ZWISCHEN, zeitpunkt: nach(minute) },
|
||
);
|
||
}
|
||
|
||
messungen.push(
|
||
{ personId: fuehrer.id, druck: angaben.ausgang[0], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) },
|
||
{ personId: mann.id, druck: angaben.ausgang[1], art: DruckArt.AUSGANG, zeitpunkt: nach(angaben.draussenMinute) },
|
||
);
|
||
|
||
await prisma.druckmessung.createMany({
|
||
data: messungen.map((messung) => ({
|
||
organisationId,
|
||
truppId: trupp.id,
|
||
personId: messung.personId,
|
||
druck: messung.druck,
|
||
art: messung.art,
|
||
zeitpunkt: messung.zeitpunkt,
|
||
erfasstVonId: ueberwacher.id,
|
||
})),
|
||
});
|
||
|
||
const ereignisse: Array<{ art: EreignisArt; text: string; minute: number }> = [
|
||
{ art: EreignisArt.TRUPP_ANGELEGT, text: `Trupp ${angaben.funkrufname} angelegt, Rückzugsdruck ${angaben.rueckzugsdruck} bar.`, minute: angaben.einstiegMinute - 3 },
|
||
{ art: EreignisArt.EINSTIEG, text: `Trupp ${angaben.funkrufname} unter Atemschutz eingestiegen.`, minute: angaben.einstiegMinute },
|
||
{ art: EreignisArt.RUECKZUG_ANGETRETEN, text: `Trupp ${angaben.funkrufname} tritt den Rückweg an.`, minute: angaben.rueckwegMinute },
|
||
{ art: EreignisArt.HERAUSGEKOMMEN, text: `Trupp ${angaben.funkrufname} ist draußen.`, minute: angaben.draussenMinute },
|
||
];
|
||
|
||
await prisma.atemschutzEreignis.createMany({
|
||
data: ereignisse.map((ereignis) => ({
|
||
organisationId,
|
||
atemschutzeinsatzId: ueberwachung.id,
|
||
truppId: trupp.id,
|
||
art: ereignis.art,
|
||
text: ereignis.text,
|
||
zeitpunkt: nach(ereignis.minute),
|
||
personId: ueberwacher.id,
|
||
})),
|
||
});
|
||
}
|
||
|
||
await truppAnlegen({
|
||
funkrufname: "Angriffstrupp",
|
||
auftrag: "Menschenrettung 1. OG, Brandbekämpfung",
|
||
fuehrer: 0,
|
||
mann: 7,
|
||
eingang: [300, 290],
|
||
ziel: [255, 240],
|
||
zwischen: [
|
||
[16, 205, 185],
|
||
[26, 165, 140],
|
||
],
|
||
ausgang: [120, 95],
|
||
einstiegMinute: 6,
|
||
rueckwegMinute: 27,
|
||
draussenMinute: 32,
|
||
rueckzugsdruck: 165,
|
||
});
|
||
|
||
await truppAnlegen({
|
||
funkrufname: "Sicherheitstrupp",
|
||
auftrag: "Bereitstellung, auf Abruf",
|
||
fuehrer: 2,
|
||
mann: 3,
|
||
eingang: [295, 300],
|
||
ziel: null,
|
||
zwischen: [[22, 270, 275]],
|
||
ausgang: [250, 258],
|
||
einstiegMinute: 14,
|
||
rueckwegMinute: 28,
|
||
draussenMinute: 30,
|
||
rueckzugsdruck: 207,
|
||
});
|
||
|
||
await prisma.atemschutzEreignis.createMany({
|
||
data: [
|
||
{
|
||
organisationId,
|
||
atemschutzeinsatzId: ueberwachung.id,
|
||
art: EreignisArt.HINWEIS,
|
||
text: `Überwachung eröffnet durch ${ueberwacher.vorname} ${ueberwacher.nachname}.`,
|
||
zeitpunkt: beginn,
|
||
personId: ueberwacher.id,
|
||
},
|
||
{
|
||
organisationId,
|
||
atemschutzeinsatzId: ueberwachung.id,
|
||
art: EreignisArt.HINWEIS,
|
||
text: "Überwachung beendet. Alle Trupps zurück, keine besonderen Vorkommnisse.",
|
||
zeitpunkt: nach(38),
|
||
personId: ueberwacher.id,
|
||
},
|
||
],
|
||
});
|
||
|
||
log(
|
||
`Atemschutz: ${G26.length} G26-Untersuchungen, ${UEBUNGSTEILNEHMER.length + 6} Übungsnachweise, 1 abgeschlossene Überwachung mit 2 Trupps`,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 6 — Kartenmodule
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/** Der Ortskern von Musterhausen — frei erfunden, aber in Niederbayern plausibel. */
|
||
const ORTSMITTE = { breitengrad: 48.5, laengengrad: 12.15 };
|
||
|
||
/**
|
||
* Löschwasser und Objekte.
|
||
*
|
||
* Die Hydranten liegen in einem Raster um den Ortskern, mit den Zuständen, die
|
||
* eine Wehr tatsächlich vorfindet: die meisten in Ordnung, einige ungeprüft,
|
||
* zwei defekt. Dazu ein Weiher und eine Zisterne am Ortsrand — im Außenbereich
|
||
* gibt es keine Leitung.
|
||
*
|
||
* Die drei Objekte sind die, für die eine Wehr zuerst einen Plan schreibt:
|
||
* Schule, Pflegeheim, Aussiedlerhof.
|
||
*/
|
||
async function kartendatenAnlegen(organisationId: string) {
|
||
const vorhanden = await prisma.hydrant.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log(`Karte: bereits ${vorhanden} Löschwasserstellen vorhanden`);
|
||
return;
|
||
}
|
||
|
||
// --- Standorte auf die Karte holen ---------------------------------------
|
||
//
|
||
// Ohne Koordinaten am Gerätehaus wüsste die Karte nicht, wohin sie schauen
|
||
// soll. Die Standorte gibt es seit Phase 1, die Dezimalgrade seit Phase 6.
|
||
const standorte = await prisma.standort.findMany({
|
||
where: { organisationId, geloeschtAm: null },
|
||
select: { id: true },
|
||
orderBy: { erstelltAm: "asc" },
|
||
});
|
||
|
||
for (const [index, standort] of standorte.entries()) {
|
||
await prisma.standort.update({
|
||
where: { id: standort.id },
|
||
data: {
|
||
breitengrad: ORTSMITTE.breitengrad + index * 0.004,
|
||
laengengrad: ORTSMITTE.laengengrad + index * 0.006,
|
||
},
|
||
});
|
||
}
|
||
|
||
// --- Löschwasserstellen ---------------------------------------------------
|
||
const STRASSEN = [
|
||
"Hauptstraße",
|
||
"Bahnhofstraße",
|
||
"Schulstraße",
|
||
"Kirchplatz",
|
||
"Am Anger",
|
||
"Industriestraße",
|
||
];
|
||
|
||
const hydranten: Array<{
|
||
bezeichnung: string;
|
||
typ: HydrantTyp;
|
||
status: HydrantStatus;
|
||
nennweite: number | null;
|
||
durchfluss: number | null;
|
||
strasse: string;
|
||
breitengrad: number;
|
||
laengengrad: number;
|
||
letztePruefung: Date | null;
|
||
hinweis: string | null;
|
||
}> = [];
|
||
|
||
const heute = new Date();
|
||
|
||
function vorTagen(tage: number): Date {
|
||
const datum = new Date(heute);
|
||
datum.setUTCDate(datum.getUTCDate() - tage);
|
||
datum.setUTCHours(0, 0, 0, 0);
|
||
return datum;
|
||
}
|
||
|
||
for (let nummer = 1; nummer <= 20; nummer += 1) {
|
||
const strasse = STRASSEN[nummer % STRASSEN.length]!;
|
||
// Ein Raster von rund 120 Metern Kantenlänge um die Ortsmitte.
|
||
const reihe = Math.floor((nummer - 1) / 5);
|
||
const spalte = (nummer - 1) % 5;
|
||
|
||
const ungeprueft = nummer % 7 === 0;
|
||
const defekt = nummer === 9 || nummer === 17;
|
||
|
||
hydranten.push({
|
||
bezeichnung: `H-${String(nummer).padStart(4, "0")}`,
|
||
typ: nummer % 6 === 0 ? HydrantTyp.UEBERFLUR : HydrantTyp.UNTERFLUR,
|
||
status: defekt
|
||
? HydrantStatus.DEFEKT
|
||
: ungeprueft
|
||
? HydrantStatus.UNBEKANNT
|
||
: HydrantStatus.EINSATZBEREIT,
|
||
nennweite: nummer % 4 === 0 ? 150 : nummer % 3 === 0 ? 80 : 100,
|
||
durchfluss: nummer % 4 === 0 ? 1600 : nummer % 3 === 0 ? 400 : 800,
|
||
strasse,
|
||
breitengrad: ORTSMITTE.breitengrad + reihe * 0.0011 - 0.002,
|
||
laengengrad: ORTSMITTE.laengengrad + spalte * 0.0016 - 0.003,
|
||
letztePruefung: ungeprueft ? null : vorTagen(60 + nummer * 9),
|
||
hinweis: defekt
|
||
? "Deckel verzogen, Standrohr passt nicht. Stadtwerke gemeldet."
|
||
: nummer === 3
|
||
? "Im Winter oft zugeschoben — Schneepflug fährt hier durch."
|
||
: null,
|
||
});
|
||
}
|
||
|
||
// Im Außenbereich gibt es keine Leitung.
|
||
hydranten.push(
|
||
{
|
||
bezeichnung: "Weiher Kiesgrube",
|
||
typ: HydrantTyp.OFFENES_GEWAESSER,
|
||
status: HydrantStatus.EINSATZBEREIT,
|
||
nennweite: null,
|
||
durchfluss: 2400,
|
||
strasse: "Kiesgrubenweg",
|
||
breitengrad: ORTSMITTE.breitengrad - 0.008,
|
||
laengengrad: ORTSMITTE.laengengrad + 0.012,
|
||
letztePruefung: vorTagen(200),
|
||
hinweis: "Zufahrt für LF geeignet, im Winter vereist. Saughöhe rund 3 m.",
|
||
},
|
||
{
|
||
bezeichnung: "Zisterne Aussiedlerhof",
|
||
typ: HydrantTyp.ZISTERNE,
|
||
status: HydrantStatus.EINSATZBEREIT,
|
||
nennweite: null,
|
||
durchfluss: 800,
|
||
strasse: "Hofweg 2",
|
||
breitengrad: ORTSMITTE.breitengrad + 0.011,
|
||
laengengrad: ORTSMITTE.laengengrad + 0.018,
|
||
letztePruefung: vorTagen(120),
|
||
hinweis: "50 m³, Saugstutzen an der Nordseite der Scheune.",
|
||
},
|
||
{
|
||
bezeichnung: "Saugstelle Mühlbach",
|
||
typ: HydrantTyp.SAUGSTELLE,
|
||
status: HydrantStatus.EINGESCHRAENKT,
|
||
nennweite: null,
|
||
durchfluss: 600,
|
||
strasse: "Mühlweg",
|
||
breitengrad: ORTSMITTE.breitengrad - 0.004,
|
||
laengengrad: ORTSMITTE.laengengrad - 0.009,
|
||
letztePruefung: vorTagen(400),
|
||
hinweis: "Im Sommer zu wenig Wasser. Nur nach Regen brauchbar.",
|
||
},
|
||
{
|
||
bezeichnung: "H-0021",
|
||
typ: HydrantTyp.UEBERFLUR,
|
||
status: HydrantStatus.EINSATZBEREIT,
|
||
nennweite: 150,
|
||
durchfluss: 1600,
|
||
strasse: "Industriestraße",
|
||
breitengrad: ORTSMITTE.breitengrad + 0.006,
|
||
laengengrad: ORTSMITTE.laengengrad + 0.008,
|
||
letztePruefung: vorTagen(45),
|
||
hinweis: "Am Werkstor, ergiebigster Hydrant im Ort.",
|
||
},
|
||
);
|
||
|
||
for (const hydrant of hydranten) {
|
||
await prisma.hydrant.create({
|
||
data: {
|
||
organisationId,
|
||
...hydrant,
|
||
betreiber: "Stadtwerke Musterhausen",
|
||
quelle: "seed",
|
||
externeNummer: `SW-${hydrant.bezeichnung}`,
|
||
},
|
||
});
|
||
}
|
||
|
||
// --- Objekte mit Feuerwehrplan -------------------------------------------
|
||
const OBJEKTE: Array<{
|
||
name: string;
|
||
kategorie: ObjektKategorie;
|
||
strasse: string;
|
||
hausnummer: string;
|
||
breitengrad: number;
|
||
laengengrad: number;
|
||
personenzahl: number;
|
||
zufahrt: string;
|
||
gefahren: string;
|
||
schluesseldepot: string;
|
||
loeschwasser: string;
|
||
kontakte: Array<{ name: string; funktion: string; telefon: string; jederzeit: boolean }>;
|
||
}> = [
|
||
{
|
||
name: "Grundschule Musterhausen",
|
||
kategorie: ObjektKategorie.SCHULE,
|
||
strasse: "Schulstraße",
|
||
hausnummer: "4",
|
||
breitengrad: ORTSMITTE.breitengrad + 0.0015,
|
||
laengengrad: ORTSMITTE.laengengrad + 0.0022,
|
||
personenzahl: 180,
|
||
zufahrt:
|
||
"Aufstellfläche auf dem Pausenhof, Zufahrt über die Schulstraße. Poller mit Feuerwehrschlüssel, Durchfahrt 3,10 m.",
|
||
gefahren:
|
||
"Photovoltaik auf dem Süddach (Gleichstrom bis zum Wechselrichter im Keller). Chemieraum im 1. OG Nordseite.",
|
||
schluesseldepot: "FSD rechts neben dem Haupteingang, Schlüssel für alle Türen.",
|
||
loeschwasser: "Wandhydranten in jedem Geschoss, Steigleitung trocken im Treppenhaus B.",
|
||
kontakte: [
|
||
{ name: "Ursula Berger", funktion: "Schulleitung", telefon: "08721 4711", jederzeit: false },
|
||
{ name: "Karl Steininger", funktion: "Hausmeister", telefon: "0170 1234567", jederzeit: true },
|
||
],
|
||
},
|
||
{
|
||
name: "Seniorenheim St. Anna",
|
||
kategorie: ObjektKategorie.PFLEGEHEIM,
|
||
strasse: "Am Anger",
|
||
hausnummer: "11",
|
||
breitengrad: ORTSMITTE.breitengrad - 0.0018,
|
||
laengengrad: ORTSMITTE.laengengrad + 0.0009,
|
||
personenzahl: 84,
|
||
zufahrt:
|
||
"Anleiterbare Seite nur nach Norden. Aufstellfläche für DL im Innenhof, Einfahrt an der Ostseite.",
|
||
gefahren:
|
||
"62 Bewohner, davon 28 bettlägerig. Sauerstoffflaschen in den Zimmern 12, 14 und 31. Sammelplatz auf dem Parkplatz an der Ostseite.",
|
||
schluesseldepot: "FSD links vom Haupteingang, dazu Objektschlüssel in der Pforte.",
|
||
loeschwasser: "Wandhydranten je Geschoss, nächster Überflurhydrant 40 m westlich.",
|
||
kontakte: [
|
||
{ name: "Pflegedienstleitung", funktion: "Nachtdienst", telefon: "08721 5522", jederzeit: true },
|
||
{ name: "Hermann Falk", funktion: "Heimleitung", telefon: "0171 9876543", jederzeit: false },
|
||
],
|
||
},
|
||
{
|
||
name: "Aussiedlerhof Huber",
|
||
kategorie: ObjektKategorie.LANDWIRTSCHAFT,
|
||
strasse: "Hofweg",
|
||
hausnummer: "2",
|
||
breitengrad: ORTSMITTE.breitengrad + 0.0108,
|
||
laengengrad: ORTSMITTE.laengengrad + 0.0178,
|
||
personenzahl: 6,
|
||
zufahrt:
|
||
"Feldweg ab der Kreisstraße, 600 m unbefestigt. Für TLF befahrbar, bei Nässe nur mit Allrad.",
|
||
gefahren:
|
||
"Silo mit Gärgasen, Biogasanlage 80 m nordöstlich. 60 Rinder im Stall, Notausgang Südseite. PV auf allen Dächern.",
|
||
schluesseldepot: "Kein FSD. Familie wohnt auf dem Hof, Schlüssel beim Betriebsleiter.",
|
||
loeschwasser: "Zisterne 50 m³ an der Scheune, sonst kein Löschwasser im Umkreis von 1,5 km.",
|
||
kontakte: [
|
||
{ name: "Josef Huber", funktion: "Betriebsleiter", telefon: "0175 3344556", jederzeit: true },
|
||
],
|
||
},
|
||
];
|
||
|
||
for (const vorlage of OBJEKTE) {
|
||
const { kontakte, ...felder } = vorlage;
|
||
|
||
await prisma.objekt.create({
|
||
data: {
|
||
organisationId,
|
||
...felder,
|
||
plz: "84359",
|
||
ort: "Musterhausen",
|
||
kontakte: {
|
||
create: kontakte.map((kontakt, index) => ({
|
||
organisationId,
|
||
...kontakt,
|
||
reihenfolge: index,
|
||
})),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
log(
|
||
`Karte: ${hydranten.length} Löschwasserstellen, ${OBJEKTE.length} Objekte mit Ansprechpartnern, ${standorte.length} Standorte verortet`,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 7 — Benachrichtigungen und Termine
|
||
//
|
||
// Der Jahresplan einer Wehr, wie er wirklich aussieht: der Übungsabend jeden
|
||
// zweiten Dienstag, ein Lehrgang mit begrenzten Plätzen (samt Warteliste, weil
|
||
// die immer entsteht), der Arbeitsdienst im Frühjahr, die
|
||
// Jahreshauptversammlung — und ein Termin, der abgesagt wurde, damit man sieht,
|
||
// wie das aussieht.
|
||
//
|
||
// Bewusst KEINE Zustellkanäle im Seed: niemand wird ungefragt benachrichtigt,
|
||
// und das gilt auch für Beispieldaten. Wer Push ausprobieren will, richtet ihn
|
||
// unter /benachrichtigungen selbst ein — genau so, wie es später jedes
|
||
// Mitglied tut.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function termineAnlegen(organisationId: string): Promise<void> {
|
||
const vorhanden = await prisma.termin.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log("Termine bereits vorhanden — übersprungen.");
|
||
return;
|
||
}
|
||
|
||
const start = new Date();
|
||
start.setUTCHours(0, 0, 0, 0);
|
||
|
||
/** Ein Zeitpunkt in n Tagen, zur angegebenen Stunde (Ortszeit-nah, UTC+1). */
|
||
function inTagen(tage: number, stunde = 19, minute = 0): Date {
|
||
const wann = new Date(start);
|
||
wann.setUTCDate(wann.getUTCDate() + tage);
|
||
wann.setUTCHours(stunde - 1, minute, 0, 0);
|
||
return wann;
|
||
}
|
||
|
||
const personen = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
select: { id: true },
|
||
});
|
||
|
||
const kommandant = await prisma.person.findFirst({
|
||
where: {
|
||
organisationId,
|
||
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
|
||
},
|
||
select: { id: true },
|
||
});
|
||
|
||
// --- Der Übungsabend als Serie -------------------------------------------
|
||
//
|
||
// Alle zwei Wochen, ein halbes Jahr im Voraus. Ausgeschrieben als einzelne
|
||
// Termine, nicht als Regel — warum, steht in lib/termin/serie.ts.
|
||
const ersterAbend = await prisma.termin.create({
|
||
data: {
|
||
organisationId,
|
||
art: TerminArt.UEBUNG,
|
||
titel: "Übungsabend",
|
||
beschreibung:
|
||
"Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.",
|
||
ort: "Gerätehaus Musterhausen",
|
||
beginn: inTagen(4),
|
||
ende: inTagen(4, 21),
|
||
leiterId: kommandant?.id ?? null,
|
||
serienmuster: Serienmuster.ZWEIWOECHENTLICH,
|
||
},
|
||
});
|
||
|
||
await prisma.termin.update({
|
||
where: { id: ersterAbend.id },
|
||
data: { serieId: ersterAbend.id },
|
||
});
|
||
|
||
const weitereAbende = serientermine({
|
||
beginn: ersterAbend.beginn,
|
||
muster: Serienmuster.ZWEIWOECHENTLICH,
|
||
bis: inTagen(4 + 182),
|
||
}).slice(1);
|
||
|
||
await prisma.termin.createMany({
|
||
data: weitereAbende.map((zeitpunkt) => ({
|
||
organisationId,
|
||
art: TerminArt.UEBUNG,
|
||
titel: "Übungsabend",
|
||
beschreibung:
|
||
"Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.",
|
||
ort: "Gerätehaus Musterhausen",
|
||
beginn: zeitpunkt,
|
||
ende: new Date(zeitpunkt.getTime() + 2 * 3_600_000),
|
||
leiterId: kommandant?.id ?? null,
|
||
serieId: ersterAbend.id,
|
||
serienmuster: Serienmuster.ZWEIWOECHENTLICH,
|
||
})),
|
||
});
|
||
|
||
// --- Lehrgang mit Platzbegrenzung ----------------------------------------
|
||
//
|
||
// Acht Plätze, elf Anmeldungen. Die Warteliste ist der Regelfall, nicht der
|
||
// Sonderfall — deshalb steht sie schon im Seed.
|
||
const lehrgang = await prisma.termin.create({
|
||
data: {
|
||
organisationId,
|
||
art: TerminArt.LEHRGANG,
|
||
titel: "Maschinistenlehrgang Teil 1",
|
||
beschreibung:
|
||
"Pumpenkunde, Wasserförderung, Fahrzeugtechnik. Voraussetzung für die Ausbildung zum Maschinisten.",
|
||
ort: "Feuerwehrschule Landkreis",
|
||
beginn: inTagen(21, 8),
|
||
ende: inTagen(21, 17),
|
||
leiterId: kommandant?.id ?? null,
|
||
anmeldungNoetig: true,
|
||
teilnehmerLimit: 8,
|
||
anmeldeschluss: inTagen(14, 23, 59),
|
||
},
|
||
});
|
||
|
||
const lehrgangsplaetze = personen.slice(0, 11);
|
||
|
||
await prisma.terminanmeldung.createMany({
|
||
data: lehrgangsplaetze.map((person, stelle) => ({
|
||
organisationId,
|
||
terminId: lehrgang.id,
|
||
personId: person.id,
|
||
// Die ersten acht haben einen Platz, der Rest steht in der Reihenfolge
|
||
// der Anmeldung auf der Warteliste.
|
||
stand: stelle < 8 ? Teilnahmestand.ZUGESAGT : Teilnahmestand.WARTELISTE,
|
||
gemeldetAm: new Date(Date.now() - (11 - stelle) * 3_600_000),
|
||
})),
|
||
});
|
||
|
||
// --- Arbeitsdienst --------------------------------------------------------
|
||
const arbeitsdienst = await prisma.termin.create({
|
||
data: {
|
||
organisationId,
|
||
art: TerminArt.ARBEITSDIENST,
|
||
titel: "Frühjahrsputz am Gerätehaus",
|
||
beschreibung:
|
||
"Fahrzeughalle, Schlauchturm und Hof. Arbeitskleidung, kein Atemschutz nötig. Brotzeit ist gestellt.",
|
||
ort: "Gerätehaus Musterhausen",
|
||
beginn: inTagen(11, 9),
|
||
ende: inTagen(11, 13),
|
||
anmeldungNoetig: true,
|
||
},
|
||
});
|
||
|
||
// Gemischte Rückmeldungen — mit Gründen, wie sie wirklich kommen.
|
||
const GRUENDE = ["Schicht", "Urlaub", "Kind krank", "auswärts"];
|
||
|
||
await prisma.terminanmeldung.createMany({
|
||
data: personen.slice(0, 12).map((person, stelle) => {
|
||
const abgesagtVon = stelle % 4 === 3;
|
||
return {
|
||
organisationId,
|
||
terminId: arbeitsdienst.id,
|
||
personId: person.id,
|
||
stand: abgesagtVon ? Teilnahmestand.ABGESAGT : Teilnahmestand.ZUGESAGT,
|
||
grund: abgesagtVon ? (GRUENDE[stelle % GRUENDE.length] ?? null) : null,
|
||
};
|
||
}),
|
||
});
|
||
|
||
// --- Jahreshauptversammlung ----------------------------------------------
|
||
await prisma.termin.create({
|
||
data: {
|
||
organisationId,
|
||
art: TerminArt.SITZUNG,
|
||
titel: "Jahreshauptversammlung",
|
||
beschreibung:
|
||
"Berichte des Kommandanten, des Kassiers und des Jugendwarts. Ehrungen, Neuwahl der Kassenprüfer.",
|
||
ort: "Gasthaus zur Post, Nebenzimmer",
|
||
beginn: inTagen(45, 19, 30),
|
||
ende: inTagen(45, 22),
|
||
leiterId: kommandant?.id ?? null,
|
||
},
|
||
});
|
||
|
||
// --- Ein abgesagter Termin ------------------------------------------------
|
||
//
|
||
// Damit gleich sichtbar ist, wie eine Absage aussieht: durchgestrichen im
|
||
// Kalender, mit Grund — nicht verschwunden.
|
||
const abgesagt = await prisma.termin.create({
|
||
data: {
|
||
organisationId,
|
||
art: TerminArt.VERANSTALTUNG,
|
||
titel: "Waldfest der Feuerwehr",
|
||
beschreibung: "Aufbau ab 14 Uhr, Ausschank ab 17 Uhr.",
|
||
ort: "Festplatz am Mühlweg",
|
||
beginn: inTagen(30, 14),
|
||
ende: inTagen(30, 23),
|
||
abgesagtAm: new Date(),
|
||
absageGrund: "Dauerregen angekündigt, Festplatz nicht befahrbar. Neuer Termin folgt.",
|
||
},
|
||
});
|
||
|
||
await prisma.terminanmeldung.createMany({
|
||
data: personen.slice(0, 6).map((person) => ({
|
||
organisationId,
|
||
terminId: abgesagt.id,
|
||
personId: person.id,
|
||
stand: Teilnahmestand.ZUGESAGT,
|
||
})),
|
||
});
|
||
|
||
// --- Ein vergangener Termin mit abgehakter Anwesenheit --------------------
|
||
//
|
||
// Der Nachweis, um den es am Ende geht: wer war da. Mit zweien, die zugesagt
|
||
// hatten und nicht kamen — das ist der Fall, für den die Trennung von Zusage
|
||
// und Anwesenheit überhaupt existiert.
|
||
const vergangen = await prisma.termin.create({
|
||
data: {
|
||
organisationId,
|
||
art: TerminArt.UEBUNG,
|
||
titel: "Übungsabend Löschangriff",
|
||
beschreibung: "Wasserentnahme aus offenem Gewässer, Löschangriff nach FwDV 3.",
|
||
ort: "Mühlweg, Saugstelle",
|
||
beginn: inTagen(-10),
|
||
ende: inTagen(-10, 21),
|
||
leiterId: kommandant?.id ?? null,
|
||
},
|
||
});
|
||
|
||
await prisma.terminanmeldung.createMany({
|
||
data: personen.slice(0, 14).map((person, stelle) => ({
|
||
organisationId,
|
||
terminId: vergangen.id,
|
||
personId: person.id,
|
||
stand: Teilnahmestand.ZUGESAGT,
|
||
anwesend: stelle !== 5 && stelle !== 9,
|
||
})),
|
||
});
|
||
|
||
const gesamt = await prisma.termin.count({ where: { organisationId } });
|
||
|
||
log(
|
||
`Termine: ${gesamt} insgesamt (Übungsserie mit ${weitereAbende.length + 1} Abenden, ` +
|
||
`Lehrgang mit 8 Plätzen und ${lehrgangsplaetze.length - 8} auf der Warteliste, ` +
|
||
`ein abgesagter Termin, eine abgehakte Anwesenheitsliste)`,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 8 — Wissen
|
||
//
|
||
// Vier Seiten, wie eine Wehr sie wirklich hätte: eine Zusammenfassung der
|
||
// Prüffristen, ein Merkblatt zum Atemschutz, eine Anleitung zur Schlauchpflege
|
||
// und die Alarmierungsordnung.
|
||
//
|
||
// Der Inhalt ist bewusst **eigener Text** und keine abgeschriebene Vorschrift.
|
||
// Eine Dienstvorschrift ist urheberrechtlich geschützt; was hier steht, ist
|
||
// eine Zusammenfassung mit Fundstellenangabe — genau so, wie eine Wehr ihre
|
||
// eigenen Merkblätter schreibt.
|
||
//
|
||
// Ohne Sprachmodell bleiben die Seiten uneingebettet. Das ist kein Mangel: sie
|
||
// sind über die Wortsuche auffindbar, und die Seitenansicht sagt, dass der
|
||
// Suchstand fehlt.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function wikiAnlegen(organisationId: string): Promise<void> {
|
||
const vorhanden = await prisma.wikiseite.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log("Wiki bereits vorhanden — übersprungen.");
|
||
return;
|
||
}
|
||
|
||
const autor = await prisma.person.findFirst({
|
||
where: {
|
||
organisationId,
|
||
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
|
||
},
|
||
select: { id: true },
|
||
});
|
||
|
||
const SEITEN: Array<{
|
||
slug: string;
|
||
titel: string;
|
||
art: WikiKategorie;
|
||
quelle: string;
|
||
schlagworte: string[];
|
||
inhalt: string;
|
||
}> = [
|
||
{
|
||
slug: "pruefristen-im-ueberblick",
|
||
titel: "Prüffristen im Überblick",
|
||
art: WikiKategorie.GERAETE,
|
||
quelle: "Zusammenfassung nach DGUV Vorschrift 49 und Herstellerangaben",
|
||
schlagworte: ["prüffrist", "dguv", "gerätewart", "wartung"],
|
||
inhalt: [
|
||
"Diese Seite fasst zusammen, was bei uns in welchem Abstand geprüft wird.",
|
||
"Verbindlich sind die Vorschrift und die Herstellerangabe — im Zweifel gilt",
|
||
"die kürzere Frist.",
|
||
"",
|
||
"## Jährlich",
|
||
"",
|
||
"- Pressluftatmer: Sicht- und Dichtprüfung",
|
||
"- Atemschutzmasken: Sichtprüfung, Dichtprüfung",
|
||
"- Tragbare Leitern: Sichtprüfung nach DGUV",
|
||
"- Rettungsgeräte und Sprungpolster",
|
||
"- Handscheinwerfer und Handfunkgeräte: Funktionsprüfung",
|
||
"",
|
||
"## Alle zwei Jahre",
|
||
"",
|
||
"- Feuerwehrschläuche: Druckprüfung",
|
||
"- Kupplungen und Armaturen: Dichtheit",
|
||
"",
|
||
"## Alle drei Jahre",
|
||
"",
|
||
"- Feuerlöscher: Wartung durch einen Sachkundigen",
|
||
"",
|
||
"## Alle sechs Jahre",
|
||
"",
|
||
"- Pressluftatmer: Grundüberholung durch die Werkstatt",
|
||
"- Atemluftflaschen: wiederkehrende Prüfung nach Druckgeräterichtlinie",
|
||
"",
|
||
"## Was passiert, wenn eine Frist abläuft",
|
||
"",
|
||
"Das Gerät wechselt in FLORIAN von selbst auf **Prüfung fällig**. Benutzen",
|
||
"darf man es weiter — es ist nicht gesperrt. Gesperrt wird nur von Hand und",
|
||
"immer mit Begründung.",
|
||
"",
|
||
"Wer eine Frist nicht halten kann, meldet das dem Gerätewart. Ein",
|
||
"stillschweigend abgelaufener Nachweis ist im Schadensfall das Problem des",
|
||
"Kommandanten, nicht des Geräts.",
|
||
].join("\n"),
|
||
},
|
||
{
|
||
slug: "atemschutz-merkblatt",
|
||
titel: "Atemschutz — was vor jedem Einsatz gilt",
|
||
art: WikiKategorie.DIENSTVORSCHRIFT,
|
||
quelle: "Zusammenfassung nach FwDV 7, Ausgabe 2002 mit Änderung 2005",
|
||
schlagworte: ["atemschutz", "fwdv 7", "überwachung", "rückzugsdruck"],
|
||
inhalt: [
|
||
"## Wer darf unter Atemschutz",
|
||
"",
|
||
"Drei Nachweise müssen gleichzeitig gültig sein:",
|
||
"",
|
||
"1. **G 26.3** — arbeitsmedizinische Vorsorge, in der Regel drei Jahre gültig,",
|
||
" ab 50 Jahren jährlich.",
|
||
"2. **Belastungsübung** — jährlich, in einer Atemschutzübungsanlage.",
|
||
"3. **Einsatz oder Übung unter Atemschutz** — jährlich.",
|
||
"",
|
||
"Fehlt einer davon, ist der Einsatz unter Atemschutz nicht zulässig. FLORIAN",
|
||
"zeigt das an, sperrt aber nicht: die Entscheidung trifft der",
|
||
"Atemschutzgerätewart, nicht die Software.",
|
||
"",
|
||
"## Rückzugsdruck",
|
||
"",
|
||
"Der Rückzugsdruck ist der Druck, bei dem der Trupp den Rückweg antreten",
|
||
"muss. Er wird aus dem **doppelten Anmarschverbrauch** plus einem",
|
||
"Sicherheitszuschlag gerechnet:",
|
||
"",
|
||
"```",
|
||
"Rückzugsdruck = Eingangsdruck - 2 x (Eingangsdruck - Druck am Ziel) + Zuschlag",
|
||
"```",
|
||
"",
|
||
"Ist der Anmarsch noch nicht gemessen, gilt die Zwei-Drittel-Regel: bei zwei",
|
||
"Dritteln des Eingangsdrucks wird zurückgegangen.",
|
||
"",
|
||
"## Überwachung",
|
||
"",
|
||
"Dokumentiert werden: Truppzusammensetzung, Einsatzbeginn, Druckwerte in",
|
||
"Abständen von höchstens zehn Minuten, Rückzugsdruck und Ende.",
|
||
"",
|
||
"> Die analoge Überwachung bleibt führend, solange die Wehr die digitale",
|
||
"> nicht ausdrücklich freigegeben hat. Das ist keine Formalie — eine",
|
||
"> Software, die im Einsatz ausfällt, darf keine Lücke im Nachweis",
|
||
"> hinterlassen.",
|
||
].join("\n"),
|
||
},
|
||
{
|
||
slug: "schlaeuche-pflegen",
|
||
titel: "Schläuche pflegen und prüfen",
|
||
art: WikiKategorie.GERAETE,
|
||
quelle: "Merkblatt der Wehr, beschlossen in der Kommandantensitzung",
|
||
schlagworte: ["schlauch", "pflege", "druckprüfung", "ablegereife"],
|
||
inhalt: [
|
||
"## Nach jedem Einsatz",
|
||
"",
|
||
"1. Schläuche vom Fahrzeug nehmen und durch neue ersetzen — nie einen",
|
||
" benutzten Schlauch wieder aufs Fahrzeug legen.",
|
||
"2. Grob reinigen, bei Verunreinigung mit Öl oder Chemikalien gesondert",
|
||
" behandeln und dem Gerätewart melden.",
|
||
"3. Trocknen, bevor sie gerollt werden. Ein feucht gerollter Schlauch",
|
||
" schimmelt von innen.",
|
||
"",
|
||
"## Ablegereife",
|
||
"",
|
||
"Ein Schlauch wird ausgesondert, wenn:",
|
||
"",
|
||
"- das Gewebe durchgescheuert oder eingerissen ist,",
|
||
"- die Einbindung sich gelöst hat,",
|
||
"- er die Druckprüfung nicht besteht,",
|
||
"- die Innenbeschichtung sich ablöst.",
|
||
"",
|
||
"Ein ausgesonderter Schlauch wird gekennzeichnet und **sofort** aus dem",
|
||
"Fahrzeug genommen. Ein abgelegter Schlauch, der noch im Regal liegt, kommt",
|
||
"irgendwann wieder aufs Fahrzeug.",
|
||
"",
|
||
"## Druckprüfung",
|
||
"",
|
||
"Alle zwei Jahre. Prüfdruck nach Herstellerangabe, in der Regel:",
|
||
"",
|
||
"| Größe | Prüfdruck |",
|
||
"|---|---|",
|
||
"| C 42 / C 52 | 12 bar |",
|
||
"| B 75 | 12 bar |",
|
||
"| A 110 | 8 bar |",
|
||
"",
|
||
"Das Ergebnis wird in FLORIAN am Schlauch eingetragen, nicht auf einem",
|
||
"Zettel im Schlauchturm.",
|
||
].join("\n"),
|
||
},
|
||
{
|
||
slug: "alarmierungsordnung",
|
||
titel: "Alarmierungsordnung",
|
||
art: WikiKategorie.ORGANISATION,
|
||
quelle: "Beschluss der Kommandantschaft",
|
||
schlagworte: ["alarm", "ausrücken", "einsatzleitung"],
|
||
inhalt: [
|
||
"## Wer rückt aus",
|
||
"",
|
||
"Alarmiert wird über Meldeempfänger und Sirene. Wer alarmiert wird, kommt",
|
||
"zum Gerätehaus — die Entscheidung, welches Fahrzeug besetzt wird, trifft",
|
||
"der Einsatzleiter vor Ort.",
|
||
"",
|
||
"Die Mindestbesetzung ist die **Staffel** (1/5). Kommt sie in fünf Minuten",
|
||
"nicht zusammen, wird mit dem nachrückenden Personal aufgefüllt und",
|
||
"ausgerückt.",
|
||
"",
|
||
"## Einsatzleitung",
|
||
"",
|
||
"Bis zum Eintreffen des Kommandanten führt der ranghöchste Anwesende. Die",
|
||
"Übergabe wird ausdrücklich ausgesprochen und in der Lagemeldung vermerkt —",
|
||
"ein stillschweigender Wechsel der Einsatzleitung ist im Nachhinein nicht",
|
||
"rekonstruierbar.",
|
||
"",
|
||
"## Was im Bericht stehen muss",
|
||
"",
|
||
"- Lage beim Eintreffen",
|
||
"- Getroffene Maßnahmen, in der Reihenfolge",
|
||
"- Übergabe: an wen, wann",
|
||
"",
|
||
"Nicht in den Bericht gehören Vermutungen über die Brandursache und",
|
||
"Bewertungen des Verhaltens Beteiligter. Beides ist Sache der Polizei.",
|
||
].join("\n"),
|
||
},
|
||
];
|
||
|
||
for (const seite of SEITEN) {
|
||
await prisma.wikiseite.create({
|
||
data: {
|
||
organisationId,
|
||
...seite,
|
||
veroeffentlicht: true,
|
||
autorId: autor?.id ?? null,
|
||
// Bewusst kein eingebettetAm: ohne Sprachmodell gibt es keine
|
||
// Einbettungen. Die Seitenansicht sagt das, und die Wortsuche
|
||
// funktioniert trotzdem.
|
||
},
|
||
});
|
||
}
|
||
|
||
log(
|
||
`Wissen: ${SEITEN.length} Seiten angelegt (noch nicht eingebettet — dafür braucht es ein Sprachmodell)`,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 9 — Kassenverwaltung
|
||
//
|
||
// Ein Kassenjahr, wie es eine Wehr wirklich hat: drei Konten, der
|
||
// Standardkontenrahmen, gut dreißig Buchungen über zwei Jahre, die
|
||
// Mitgliedsbeiträge, zwei Rechnungen und die abgeschlossene Kassenprüfung
|
||
// des Vorjahres.
|
||
//
|
||
// Die Reihenfolge ist hier nicht beliebig, und das ist kein Zufall des
|
||
// Skripts, sondern die Absicherung selbst: sobald der Abschluss für das
|
||
// Vorjahr festgeschrieben ist, lehnt ein Trigger jede weitere Buchung mit
|
||
// einem Datum in diesem Zeitraum ab. Erst die Buchungen, dann der Abschluss —
|
||
// wer es umdreht, bekommt vom Seed dieselbe Fehlermeldung wie der Kassier,
|
||
// der drei Wochen nach der Prüfung noch eine Quittung findet.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function kassendatenAnlegen(organisationId: string): Promise<void> {
|
||
const vorhanden = await prisma.kassenkonto.count({ where: { organisationId } });
|
||
if (vorhanden > 0) {
|
||
log("Kassendaten bereits vorhanden — übersprungen.");
|
||
return;
|
||
}
|
||
|
||
const { STANDARDKATEGORIEN } = await import("../src/lib/kasse/vorschlag");
|
||
|
||
const jahr = new Date().getUTCFullYear();
|
||
const vorjahr = jahr - 1;
|
||
|
||
// --- Konten ---------------------------------------------------------------
|
||
|
||
const bar = await prisma.kassenkonto.create({
|
||
data: {
|
||
organisationId,
|
||
bezeichnung: "Barkasse",
|
||
art: KontoArt.BAR,
|
||
anfangsbestand: "250.00",
|
||
bemerkung: "Kassette im Kommandantenzimmer. Wechselgeld für Feste.",
|
||
},
|
||
});
|
||
|
||
const giro = await prisma.kassenkonto.create({
|
||
data: {
|
||
organisationId,
|
||
bezeichnung: "Girokonto Raiffeisenbank",
|
||
art: KontoArt.BANK,
|
||
iban: "DE02701500000000594937",
|
||
bic: "SSKMDEMM",
|
||
anfangsbestand: "4800.00",
|
||
},
|
||
});
|
||
|
||
const ruecklage = await prisma.kassenkonto.create({
|
||
data: {
|
||
organisationId,
|
||
bezeichnung: "Rücklage Fahrzeugbeschaffung",
|
||
art: KontoArt.RUECKLAGE,
|
||
iban: "DE02120300000000202051",
|
||
anfangsbestand: "12000.00",
|
||
bemerkung: "Angespart für den Ersatz des MTW. Wird nicht laufend bewegt.",
|
||
},
|
||
});
|
||
|
||
// --- Kontenrahmen ---------------------------------------------------------
|
||
//
|
||
// Zwei Durchgänge: erst die Oberkategorien, dann die Unterkategorien mit
|
||
// ihrem Verweis. Anders ginge es nicht, der Baum verweist auf sich selbst.
|
||
|
||
const kategorien = new Map<string, string>();
|
||
|
||
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
|
||
const angelegt = await prisma.buchungskategorie.create({
|
||
data: { organisationId, name: eintrag.name, art: eintrag.art },
|
||
});
|
||
kategorien.set(eintrag.name, angelegt.id);
|
||
}
|
||
|
||
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
|
||
const angelegt = await prisma.buchungskategorie.create({
|
||
data: {
|
||
organisationId,
|
||
name: eintrag.name,
|
||
art: eintrag.art,
|
||
oberkategorieId: kategorien.get(eintrag.oberkategorie!) ?? null,
|
||
},
|
||
});
|
||
kategorien.set(eintrag.name, angelegt.id);
|
||
}
|
||
|
||
const kategorie = (name: string): string => {
|
||
const id = kategorien.get(name);
|
||
if (!id) throw new Error(`Kategorie fehlt im Standardkontenrahmen: ${name}`);
|
||
return id;
|
||
};
|
||
|
||
const kassier = await prisma.person.findFirst({
|
||
where: {
|
||
organisationId,
|
||
rollen: { some: { rolle: { schluessel: RolleSchluessel.KASSIER } } },
|
||
},
|
||
select: { id: true },
|
||
});
|
||
|
||
// --- Buchungen ------------------------------------------------------------
|
||
//
|
||
// Das Vorjahr vollständig und festgeschrieben, das laufende Jahr bis in den
|
||
// Sommer und noch offen. So zeigt die Ansicht beide Zustände: das
|
||
// geschlossene Buch und das, in dem noch gearbeitet wird.
|
||
|
||
type Zeile = {
|
||
konto: string;
|
||
monat: number;
|
||
tag: number;
|
||
art: BuchungsArt;
|
||
betrag: string;
|
||
kategorie: string;
|
||
zweck: string;
|
||
gegenueber?: string;
|
||
beleg?: string;
|
||
};
|
||
|
||
const vorjahrszeilen: Zeile[] = [
|
||
{
|
||
konto: giro.id,
|
||
monat: 1,
|
||
tag: 15,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "6500.00",
|
||
kategorie: "Zuschüsse",
|
||
zweck: "Gemeindezuschuss laufender Betrieb",
|
||
gegenueber: "Gemeinde Musterhausen",
|
||
beleg: "Z-2024-01",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 2,
|
||
tag: 3,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "1284.50",
|
||
kategorie: "Versicherungen",
|
||
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
|
||
gegenueber: "Bayerische Versicherungskammer",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 3,
|
||
tag: 12,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "412.90",
|
||
kategorie: "Kraftstoff",
|
||
zweck: "Diesel LF 20, Sammelrechnung Februar",
|
||
gegenueber: "Tankstelle Musterhausen",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 4,
|
||
tag: 8,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "2340.00",
|
||
kategorie: "Atemschutz",
|
||
zweck: "Zwei Pressluftatmer, Ersatzbeschaffung",
|
||
gegenueber: "Dräger Safety",
|
||
beleg: "RE-2024-114",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 5,
|
||
tag: 22,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "868.00",
|
||
kategorie: "Prüfungen",
|
||
zweck: "Wiederkehrende Prüfung Drehleiter",
|
||
gegenueber: "TÜV Süd",
|
||
},
|
||
{
|
||
konto: bar.id,
|
||
monat: 6,
|
||
tag: 15,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "3860.00",
|
||
kategorie: "Veranstaltungen",
|
||
zweck: "Maifest, Getränke- und Speisenverkauf",
|
||
},
|
||
{
|
||
konto: bar.id,
|
||
monat: 6,
|
||
tag: 15,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "1420.60",
|
||
kategorie: "Verpflegung",
|
||
zweck: "Maifest, Einkauf Getränke und Fleisch",
|
||
gegenueber: "Getränke Hofmann",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 7,
|
||
tag: 2,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "1150.00",
|
||
kategorie: "Schutzkleidung",
|
||
zweck: "Vier Einsatzjacken, Ersatz nach Verschleiß",
|
||
gegenueber: "Feuerwehrbedarf Barth",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 9,
|
||
tag: 18,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "624.00",
|
||
kategorie: "Ausbildung",
|
||
zweck: "Lehrgangsgebühren Maschinistenlehrgang",
|
||
gegenueber: "Landkreis Musterkreis",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 10,
|
||
tag: 30,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "398.20",
|
||
kategorie: "Wasser und Strom",
|
||
zweck: "Abschlagszahlung Strom Gerätehaus",
|
||
gegenueber: "Stadtwerke Musterhausen",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 11,
|
||
tag: 14,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "780.00",
|
||
kategorie: "Spenden",
|
||
zweck: "Spenden aus der Haussammlung",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 12,
|
||
tag: 20,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "36.00",
|
||
kategorie: "Bankgebühren",
|
||
zweck: "Kontoführung viertes Quartal",
|
||
},
|
||
];
|
||
|
||
const laufendeZeilen: Zeile[] = [
|
||
{
|
||
konto: giro.id,
|
||
monat: 1,
|
||
tag: 20,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "6500.00",
|
||
kategorie: "Zuschüsse",
|
||
zweck: "Gemeindezuschuss laufender Betrieb",
|
||
gegenueber: "Gemeinde Musterhausen",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 2,
|
||
tag: 5,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "1341.80",
|
||
kategorie: "Versicherungen",
|
||
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
|
||
gegenueber: "Bayerische Versicherungskammer",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 2,
|
||
tag: 28,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "487.40",
|
||
kategorie: "Kraftstoff",
|
||
zweck: "Diesel LF 20 und MTW, Sammelrechnung Februar",
|
||
gegenueber: "Tankstelle Musterhausen",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 3,
|
||
tag: 14,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "1890.00",
|
||
kategorie: "Reparatur",
|
||
zweck: "Kupplung MTW, Werkstattrechnung",
|
||
gegenueber: "Werkstatt Müller GmbH",
|
||
beleg: "RE-4471",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 4,
|
||
tag: 3,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "212.00",
|
||
kategorie: "Verwaltung",
|
||
zweck: "Büromaterial und Druckerpatronen",
|
||
gegenueber: "Schreibwaren Ludwig",
|
||
},
|
||
{
|
||
konto: bar.id,
|
||
monat: 5,
|
||
tag: 1,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "4120.00",
|
||
kategorie: "Veranstaltungen",
|
||
zweck: "Maifest, Getränke- und Speisenverkauf",
|
||
},
|
||
{
|
||
konto: bar.id,
|
||
monat: 5,
|
||
tag: 1,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "1588.30",
|
||
kategorie: "Verpflegung",
|
||
zweck: "Maifest, Einkauf Getränke und Fleisch",
|
||
gegenueber: "Getränke Hofmann",
|
||
},
|
||
{
|
||
konto: bar.id,
|
||
monat: 5,
|
||
tag: 6,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "2500.00",
|
||
kategorie: "Verwaltung",
|
||
zweck: "Einzahlung Barkasse auf das Girokonto nach dem Maifest",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 5,
|
||
tag: 6,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "2500.00",
|
||
kategorie: "Veranstaltungen",
|
||
zweck: "Einzahlung aus der Barkasse nach dem Maifest",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 6,
|
||
tag: 11,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "742.00",
|
||
kategorie: "Prüfungen",
|
||
zweck: "Schlauchprüfung, 40 Stück B und C",
|
||
gegenueber: "Schlauchpflege Nordbayern",
|
||
},
|
||
{
|
||
konto: giro.id,
|
||
monat: 6,
|
||
tag: 25,
|
||
art: BuchungsArt.AUSGABE,
|
||
betrag: "36.00",
|
||
kategorie: "Bankgebühren",
|
||
zweck: "Kontoführung zweites Quartal",
|
||
},
|
||
{
|
||
konto: ruecklage.id,
|
||
monat: 7,
|
||
tag: 1,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "3000.00",
|
||
kategorie: "Zuschüsse",
|
||
zweck: "Jährliche Zuführung zur Fahrzeugrücklage",
|
||
gegenueber: "Gemeinde Musterhausen",
|
||
},
|
||
];
|
||
|
||
async function buchungAnlegen(zeile: Zeile, festgeschrieben: boolean, buchungsjahr: number) {
|
||
return prisma.buchung.create({
|
||
data: {
|
||
organisationId,
|
||
kontoId: zeile.konto,
|
||
kategorieId: kategorie(zeile.kategorie),
|
||
datum: datum(buchungsjahr, zeile.monat, zeile.tag),
|
||
art: zeile.art,
|
||
betrag: zeile.betrag,
|
||
verwendungszweck: zeile.zweck,
|
||
lieferant: zeile.gegenueber ?? null,
|
||
belegnummer: zeile.beleg ?? null,
|
||
erfasstVonId: kassier?.id ?? null,
|
||
festgeschriebenAm: festgeschrieben ? datum(vorjahr + 1, 2, 10) : null,
|
||
},
|
||
});
|
||
}
|
||
|
||
for (const zeile of vorjahrszeilen) {
|
||
await buchungAnlegen(zeile, true, vorjahr);
|
||
}
|
||
for (const zeile of laufendeZeilen) {
|
||
await buchungAnlegen(zeile, false, jahr);
|
||
}
|
||
|
||
// --- Beiträge -------------------------------------------------------------
|
||
|
||
const satzAktive = await prisma.beitragssatz.create({
|
||
data: {
|
||
organisationId,
|
||
bezeichnung: "Aktive und Passive",
|
||
betrag: "25.00",
|
||
abteilung: null,
|
||
abJahr: vorjahr,
|
||
},
|
||
});
|
||
|
||
const satzJugend = await prisma.beitragssatz.create({
|
||
data: {
|
||
organisationId,
|
||
bezeichnung: "Jugendfeuerwehr",
|
||
betrag: "10.00",
|
||
abteilung: "Jugendfeuerwehr",
|
||
abJahr: vorjahr,
|
||
},
|
||
});
|
||
|
||
const mitglieder = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, austrittsdatum: null },
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
select: { id: true, abteilung: true },
|
||
});
|
||
|
||
const beitragsKategorie = kategorie("Mitgliedsbeiträge");
|
||
|
||
// Die meisten haben bezahlt, zwei stehen offen, einer ist gemahnt, einem
|
||
// wurde erlassen. Das ist die Verteilung, die eine Wehr im Herbst wirklich
|
||
// vor sich hat — und der Grund, warum es die Rückstandsliste gibt.
|
||
for (const [stelle, person] of mitglieder.entries()) {
|
||
const jugend = person.abteilung === "Jugendfeuerwehr";
|
||
const satz = jugend ? satzJugend : satzAktive;
|
||
const betrag = jugend ? "10.00" : "25.00";
|
||
|
||
let buchungId: string | null = null;
|
||
let stand: BeitragStand = BeitragStand.BEZAHLT;
|
||
let bezahltAm: Date | null = datum(jahr, 2, 12 + (stelle % 10));
|
||
let mahnstufe = 0;
|
||
let gemahntAm: Date | null = null;
|
||
let erlassGrund: string | null = null;
|
||
|
||
if (stelle % 7 === 3) {
|
||
stand = BeitragStand.OFFEN;
|
||
bezahltAm = null;
|
||
} else if (stelle % 11 === 5) {
|
||
stand = BeitragStand.GEMAHNT;
|
||
bezahltAm = null;
|
||
mahnstufe = 1;
|
||
gemahntAm = datum(jahr, 6, 30);
|
||
} else if (stelle % 13 === 9) {
|
||
stand = BeitragStand.ERLASSEN;
|
||
bezahltAm = null;
|
||
erlassGrund = "Längere Erkrankung, Beschluss der Kommandantschaft vom 12.03.";
|
||
}
|
||
|
||
if (stand === BeitragStand.BEZAHLT && bezahltAm) {
|
||
const buchung = await prisma.buchung.create({
|
||
data: {
|
||
organisationId,
|
||
kontoId: giro.id,
|
||
kategorieId: beitragsKategorie,
|
||
datum: bezahltAm,
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag,
|
||
verwendungszweck: `Mitgliedsbeitrag ${jahr}`,
|
||
personId: person.id,
|
||
erfasstVonId: kassier?.id ?? null,
|
||
},
|
||
});
|
||
buchungId = buchung.id;
|
||
}
|
||
|
||
await prisma.beitrag.create({
|
||
data: {
|
||
organisationId,
|
||
personId: person.id,
|
||
jahr,
|
||
satzId: satz.id,
|
||
betrag,
|
||
stand,
|
||
buchungId,
|
||
bezahltAm,
|
||
mahnstufe,
|
||
gemahntAm,
|
||
erlassGrund,
|
||
},
|
||
});
|
||
}
|
||
|
||
// --- Rechnungen -----------------------------------------------------------
|
||
|
||
const kostenersatz = kategorie("Kostenersatz");
|
||
|
||
const fehlalarmBuchung = await prisma.buchung.create({
|
||
data: {
|
||
organisationId,
|
||
kontoId: giro.id,
|
||
kategorieId: kostenersatz,
|
||
datum: datum(jahr, 4, 28),
|
||
art: BuchungsArt.EINNAHME,
|
||
betrag: "612.00",
|
||
verwendungszweck: `Zahlungseingang Rechnung ${jahr}-0001`,
|
||
lieferant: "Möbelhaus Sonnleitner GmbH",
|
||
erfasstVonId: kassier?.id ?? null,
|
||
},
|
||
});
|
||
|
||
await prisma.rechnung.create({
|
||
data: {
|
||
organisationId,
|
||
nummer: `${jahr}-0001`,
|
||
anlass: RechnungsAnlass.FEHLALARM,
|
||
stand: RechnungStand.BEZAHLT,
|
||
empfaenger: "Möbelhaus Sonnleitner GmbH",
|
||
strasse: "Industriestraße 14",
|
||
plz: "91315",
|
||
ort: "Musterhausen",
|
||
datum: datum(jahr, 3, 28),
|
||
faelligAm: datum(jahr, 4, 25),
|
||
betrag: "612.00",
|
||
bemerkung:
|
||
"Fehlalarm der Brandmeldeanlage am 26.03., verursacht durch Staub bei Umbauarbeiten. " +
|
||
"Kostenersatz nach Art. 28 Abs. 2 Nr. 4 BayFwG.",
|
||
buchungId: fehlalarmBuchung.id,
|
||
bezahltAm: datum(jahr, 4, 28),
|
||
erstelltVonId: kassier?.id ?? null,
|
||
positionen: {
|
||
create: [
|
||
{
|
||
organisationId,
|
||
reihenfolge: 0,
|
||
bezeichnung: "Einsatz LF 20, angefangene Stunde",
|
||
menge: "1",
|
||
einheit: "Std.",
|
||
einzelpreis: "396.00",
|
||
gesamtpreis: "396.00",
|
||
},
|
||
{
|
||
organisationId,
|
||
reihenfolge: 1,
|
||
bezeichnung: "Einsatzkräfte, je angefangene Stunde",
|
||
menge: "9",
|
||
einheit: "Pers.",
|
||
einzelpreis: "24.00",
|
||
gesamtpreis: "216.00",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
await prisma.rechnung.create({
|
||
data: {
|
||
organisationId,
|
||
nummer: `${jahr}-0002`,
|
||
anlass: RechnungsAnlass.VERMIETUNG,
|
||
stand: RechnungStand.VERSENDET,
|
||
empfaenger: "Trachtenverein Almrausch Musterhausen e. V.",
|
||
empfaengerZusatz: "z. Hd. Frau Vorstand Berger",
|
||
strasse: "Dorfplatz 2",
|
||
plz: "91315",
|
||
ort: "Musterhausen",
|
||
datum: datum(jahr, 6, 20),
|
||
faelligAm: datum(jahr, 7, 18),
|
||
betrag: "180.00",
|
||
bemerkung: "Überlassung des Schulungsraums für die Jahresversammlung am 14.06.",
|
||
erstelltVonId: kassier?.id ?? null,
|
||
positionen: {
|
||
create: [
|
||
{
|
||
organisationId,
|
||
reihenfolge: 0,
|
||
bezeichnung: "Nutzung Schulungsraum, Tagespauschale",
|
||
menge: "1",
|
||
einheit: "Tag",
|
||
einzelpreis: "150.00",
|
||
gesamtpreis: "150.00",
|
||
},
|
||
{
|
||
organisationId,
|
||
reihenfolge: 1,
|
||
bezeichnung: "Reinigung",
|
||
menge: "1",
|
||
einheit: "pauschal",
|
||
einzelpreis: "30.00",
|
||
gesamtpreis: "30.00",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
// --- Kassenprüfung des Vorjahres -----------------------------------------
|
||
//
|
||
// Zuletzt, und das ist der Punkt: ab hier ist der Zeitraum zu. Jede weitere
|
||
// Buchung mit einem Datum aus dem Vorjahr wird von der Datenbank abgelehnt.
|
||
|
||
const pruefer = await prisma.person.findMany({
|
||
where: { organisationId, aktiv: true, austrittsdatum: null },
|
||
orderBy: { mitgliedsnummer: "desc" },
|
||
take: 2,
|
||
select: { id: true },
|
||
});
|
||
|
||
// Die Zahlen sind nicht erfunden, sondern die Summe der Buchungen oben:
|
||
// Barkasse 250,00 + 3.860,00 − 1.420,60, Giro 4.800,00 + 7.280,00 − 7.113,60,
|
||
// Rücklage unberührt. Ein Seed mit erfundenen Beständen wäre genau der
|
||
// Fall, den die Kassenprüfung finden soll.
|
||
const vorjahrsposten = [
|
||
{ konto: bar.id, buch: "2689.40", gezaehlt: "2686.20" },
|
||
{ konto: giro.id, buch: "4966.40", gezaehlt: "4966.40" },
|
||
{ konto: ruecklage.id, buch: "12000.00", gezaehlt: "12000.00" },
|
||
];
|
||
|
||
const abschluss = await prisma.kassenabschluss.create({
|
||
data: {
|
||
organisationId,
|
||
vonAm: datum(vorjahr, 1, 1),
|
||
bisAm: datum(vorjahr, 12, 31),
|
||
bestandLautBuch: "19655.80",
|
||
gezaehlterBestand: "19652.60",
|
||
differenz: "-3.20",
|
||
erlaeuterung:
|
||
"In der Barkasse fehlen 3,20 €. Zurückzuführen auf das Maifest: das Wechselgeld " +
|
||
"wurde am Abend gezählt, ein Beleg über den Nachkauf von Eis fehlt. Die Prüfer " +
|
||
"halten den Betrag für erklärbar und schlagen Entlastung vor.",
|
||
prueferId: pruefer[0]?.id ?? null,
|
||
zweiterPrueferId: pruefer[1]?.id ?? null,
|
||
geprueftAm: datum(vorjahr + 1, 2, 8),
|
||
festgeschriebenAm: datum(vorjahr + 1, 2, 10),
|
||
posten: {
|
||
create: vorjahrsposten.map((posten) => ({
|
||
organisationId,
|
||
kontoId: posten.konto,
|
||
bestandLautBuch: posten.buch,
|
||
gezaehlterBestand: posten.gezaehlt,
|
||
differenz: (Number(posten.gezaehlt) - Number(posten.buch)).toFixed(2),
|
||
})),
|
||
},
|
||
},
|
||
});
|
||
|
||
log(
|
||
`Kasse: 3 Konten, ${STANDARDKATEGORIEN.length} Kategorien, ` +
|
||
`${vorjahrszeilen.length + laufendeZeilen.length} Buchungen, ` +
|
||
`${mitglieder.length} Beiträge, 2 Rechnungen und die Kassenprüfung ${vorjahr} ` +
|
||
`(festgeschrieben — der Zeitraum ist zu, Abschluss ${abschluss.id.slice(0, 8)})`,
|
||
);
|
||
}
|
||
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Phase 10 — Mandantenfähigkeit
|
||
//
|
||
// Eine zweite, kleine Wehr und ein Konto, das beide sieht.
|
||
//
|
||
// Ohne die zweite Wehr lässt sich die ganze Phase nicht ausprobieren: der
|
||
// Wehrwähler erscheint erst, wenn es etwas zu wählen gibt, und eine
|
||
// Mandantentrennung, die nie zwei Mandanten hatte, ist keine geprüfte.
|
||
//
|
||
// Die Geschichte ist die übliche in Bayern: der Kommandant von Musterhausen
|
||
// ist zugleich Kreisbrandinspektor und betreut die Nachbarwehr. In seiner
|
||
// eigenen Wehr führt er alles, in der betreuten sieht er zu — mehr nicht.
|
||
//
|
||
// Nachbarshausen bleibt bewusst dünn: drei Leute, ein Fahrzeug. Es soll
|
||
// zeigen, dass die Trennung hält, und nicht die Beispieldaten verdoppeln.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Die Nachbarwehr leitet sich aus der Wehr ab, die gerade angelegt wird.
|
||
*
|
||
* Fest verdrahtet wäre sie das, was Phase 8 gerade beseitigt hat: der
|
||
* Testlauf und die Beispielwehr griffen auf denselben Datensatz zu. So bekommt
|
||
* jeder Mandant sein eigenes Paar — und die Trennung gilt auch für das, womit
|
||
* sie geprüft wird.
|
||
*/
|
||
const NACHBAR_SLUG = `${ORGANISATION_SLUG}-nachbar`;
|
||
const NACHBAR_NAME = `${ORGANISATION_NAME} — Nachbarwehr`;
|
||
const NACHBAR_KURZNAME = `${ORGANISATION_KURZNAME} Nachbar`;
|
||
const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${ORGANISATION_SLUG}.de`;
|
||
|
||
async function kreisebeneAnlegen(
|
||
heimatOrganisationId: string,
|
||
katalog: Map<string, string>,
|
||
): Promise<void> {
|
||
/**
|
||
* Zwei Teile, zwei Prüfungen — und das ist wichtiger, als es aussieht.
|
||
*
|
||
* Ein gemeinsamer Ausstieg am Anfang hätte heißen: steht die Nachbarwehr,
|
||
* geschieht gar nichts mehr. Genau das ist beim Zurücksetzen der Testwehr
|
||
* passiert — die Nachbarwehr blieb stehen, die Mitgliedschaft in der neuen
|
||
* Testwehr wurde nie wieder angelegt, und der Wehrwähler zeigte nur noch
|
||
* die betreute Wehr. Gefunden hat das ein E2E-Test.
|
||
*/
|
||
const nachbar =
|
||
(await prisma.organisation.findUnique({ where: { slug: NACHBAR_SLUG } })) ??
|
||
(await nachbarwehrAnlegen(katalog));
|
||
|
||
// --- Das Konto, das beide Wehren sieht ------------------------------------
|
||
|
||
await kreiskontoAnlegen(heimatOrganisationId, nachbar.id);
|
||
}
|
||
|
||
/** Die Nachbarwehr: dünn besetzt, ein Fahrzeug. Sie soll zeigen, dass die
|
||
* Trennung hält, und nicht die Beispieldaten verdoppeln. */
|
||
async function nachbarwehrAnlegen(katalog: Map<string, string>) {
|
||
const nachbar = await prisma.organisation.create({
|
||
data: {
|
||
slug: NACHBAR_SLUG,
|
||
name: NACHBAR_NAME,
|
||
kurzname: NACHBAR_KURZNAME,
|
||
strasse: "Dorfstraße 3",
|
||
plz: "91316",
|
||
ort: "Nachbarshausen",
|
||
landkreis: "Musterkreis",
|
||
funkPraefix: "Florian Nachbarshausen",
|
||
},
|
||
});
|
||
|
||
const nachbarRollen = await rollenAnlegen(nachbar.id, katalog);
|
||
|
||
const besetzung: Array<{
|
||
vorname: string;
|
||
nachname: string;
|
||
nummer: string;
|
||
dienstgrad: Dienstgrad;
|
||
rolle: RolleSchluessel;
|
||
}> = [
|
||
{
|
||
vorname: "Georg",
|
||
nachname: "Sailer",
|
||
nummer: "0001",
|
||
dienstgrad: Dienstgrad.BRANDMEISTER,
|
||
rolle: RolleSchluessel.KOMMANDANT,
|
||
},
|
||
{
|
||
vorname: "Marianne",
|
||
nachname: "Kraus",
|
||
nummer: "0002",
|
||
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
||
rolle: RolleSchluessel.GERAETEWART,
|
||
},
|
||
{
|
||
vorname: "Tobias",
|
||
nachname: "Ostermeier",
|
||
nummer: "0003",
|
||
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
||
rolle: RolleSchluessel.MITGLIED,
|
||
},
|
||
];
|
||
|
||
for (const eintrag of besetzung) {
|
||
const person = await prisma.person.create({
|
||
data: {
|
||
organisationId: nachbar.id,
|
||
vorname: eintrag.vorname,
|
||
nachname: eintrag.nachname,
|
||
mitgliedsnummer: eintrag.nummer,
|
||
dienstgrad: eintrag.dienstgrad,
|
||
abteilung: "Aktive",
|
||
eintrittsdatum: datum(2012, 4, 1),
|
||
},
|
||
});
|
||
|
||
const rolleId = nachbarRollen.get(eintrag.rolle);
|
||
if (rolleId) {
|
||
await prisma.personRolle.create({
|
||
data: { organisationId: nachbar.id, personId: person.id, rolleId },
|
||
});
|
||
}
|
||
}
|
||
|
||
const standort = await prisma.standort.create({
|
||
data: {
|
||
organisationId: nachbar.id,
|
||
name: "Gerätehaus Nachbarshausen",
|
||
art: StandortArt.GERAETEHAUS,
|
||
strasse: "Dorfstraße 3",
|
||
plz: "91316",
|
||
ort: "Nachbarshausen",
|
||
},
|
||
});
|
||
|
||
await prisma.fahrzeug.create({
|
||
data: {
|
||
organisationId: nachbar.id,
|
||
funkrufname: "Florian Nachbarshausen 42/1",
|
||
bezeichnung: "TSF-W",
|
||
art: FahrzeugArt.LOESCHFAHRZEUG,
|
||
kennzeichen: "MK-FW 421",
|
||
baujahr: 2009,
|
||
standortId: standort.id,
|
||
},
|
||
});
|
||
|
||
log(`Nachbarwehr angelegt: ${besetzung.length} Personen, 1 Fahrzeug.`);
|
||
return nachbar;
|
||
}
|
||
|
||
/** Das Konto der Kreisebene: Mitglied in der Heimatwehr, Aufsicht nebenan. */
|
||
async function kreiskontoAnlegen(heimatOrganisationId: string, nachbarId: string) {
|
||
const kommandant = await prisma.person.findFirst({
|
||
where: {
|
||
organisationId: heimatOrganisationId,
|
||
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
|
||
},
|
||
orderBy: { mitgliedsnummer: "asc" },
|
||
});
|
||
|
||
if (!kommandant) {
|
||
log("Kein Kommandant gefunden — die Kreisebene wird übersprungen.");
|
||
return;
|
||
}
|
||
|
||
const email = KBI_EMAIL;
|
||
const passwort = process.env.SEED_KBI_PASSWORT ?? "Feuerwehr2026";
|
||
|
||
const konto = await prisma.benutzer.upsert({
|
||
where: { email },
|
||
update: {},
|
||
create: {
|
||
email,
|
||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
||
passwortGeaendertAm: new Date(),
|
||
},
|
||
});
|
||
|
||
// In der eigenen Wehr: Mitglied mit allem, was seine Rollen hergeben.
|
||
await prisma.mitgliedschaft.upsert({
|
||
where: {
|
||
benutzerId_organisationId: {
|
||
benutzerId: konto.id,
|
||
organisationId: heimatOrganisationId,
|
||
},
|
||
},
|
||
update: {},
|
||
create: {
|
||
organisationId: heimatOrganisationId,
|
||
benutzerId: konto.id,
|
||
personId: kommandant.id,
|
||
art: MitgliedschaftArt.MITGLIED,
|
||
},
|
||
});
|
||
|
||
// In der Nachbarwehr: Aufsicht. Ohne Akte — er gehört ihr nicht an und
|
||
// steht deshalb auch in keiner ihrer Mitgliederlisten.
|
||
await prisma.mitgliedschaft.upsert({
|
||
where: {
|
||
benutzerId_organisationId: { benutzerId: konto.id, organisationId: nachbarId },
|
||
},
|
||
update: {},
|
||
create: {
|
||
organisationId: nachbarId,
|
||
benutzerId: konto.id,
|
||
art: MitgliedschaftArt.AUFSICHT,
|
||
},
|
||
});
|
||
|
||
log(
|
||
`Kreisebene: ${email} — Mitglied in ${ORGANISATION_KURZNAME}, ` +
|
||
`Aufsicht in ${NACHBAR_KURZNAME}.`,
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Ablauf
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function main(): Promise<void> {
|
||
log("Starte ...");
|
||
|
||
const katalog = await katalogAbgleichen();
|
||
const organisation = await organisationAnlegen();
|
||
const rollen = await rollenAnlegen(organisation.id, katalog);
|
||
await adminAnlegen(organisation.id, rollen);
|
||
await personenAnlegen(organisation.id, rollen);
|
||
|
||
// Phase 1 — Stammdaten
|
||
const standorte = await standorteAnlegen(organisation.id);
|
||
const typen = await geraetetypenAnlegen(organisation.id);
|
||
const fahrzeuge = await fahrzeugeAnlegen(organisation.id, standorte);
|
||
await geraeteAnlegen(organisation.id, typen, fahrzeuge, standorte);
|
||
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
|
||
await qualifikationenAnlegen(organisation.id, qualiTypen);
|
||
|
||
// Phase 2 — Wartung und Prüfung
|
||
const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen);
|
||
await wartungstermineAnlegen(organisation.id, wartungsvorlagen);
|
||
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
|
||
await rueckrufAnlegen(organisation.id);
|
||
|
||
// Phase 3 — Einsätze und Übungen
|
||
await einsaetzeAnlegen(organisation.id, fahrzeuge);
|
||
await uebungenAnlegen(organisation.id);
|
||
|
||
// Phase 4 — Terminal im Gerätehaus
|
||
await terminaldatenAnlegen(organisation.id);
|
||
|
||
// Phase 5 — Atemschutz nach FwDV 7
|
||
await atemschutzAnlegen(organisation.id);
|
||
|
||
// Phase 6 — Kartenmodule
|
||
await kartendatenAnlegen(organisation.id);
|
||
|
||
// Phase 7 — Benachrichtigungen und Termine
|
||
await termineAnlegen(organisation.id);
|
||
|
||
// Phase 8 — Lokale KI
|
||
await wikiAnlegen(organisation.id);
|
||
|
||
// Phase 9 — Kassenverwaltung
|
||
await kassendatenAnlegen(organisation.id);
|
||
|
||
// Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene
|
||
await kreisebeneAnlegen(organisation.id, katalog);
|
||
|
||
log("Fertig.");
|
||
log("");
|
||
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
||
log("");
|
||
log("Anmeldung für den Erststart:");
|
||
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
|
||
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
|
||
log("");
|
||
log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203");
|
||
}
|
||
|
||
main()
|
||
.catch((fehler) => {
|
||
console.error("[seed] Abbruch:", fehler);
|
||
process.exit(1);
|
||
})
|
||
.finally(async () => {
|
||
await prisma.$disconnect();
|
||
});
|