Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1559 lines
50 KiB
TypeScript
1559 lines
50 KiB
TypeScript
/**
|
|
* Seed — legt eine vollständige Beispielwehr an.
|
|
*
|
|
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 16 Personen mit Rollen und
|
|
* Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
|
|
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
|
|
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
|
|
* Mannschaft. Dazu ab Phase 2: 5 Wartungsvorlagen mit Checklisten, die daraus
|
|
* fälligen Prüftermine, ein abgeschlossenes Protokoll samt Mangel und ein
|
|
* Beispielrückruf.
|
|
*
|
|
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
|
* die Organisation ja erst an, deren Kontext RLS bräuchte.
|
|
*
|
|
* Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits
|
|
* vorhandenen Daten.
|
|
*
|
|
* npm run db:seed
|
|
*/
|
|
import "dotenv/config";
|
|
import {
|
|
AusweisTokenTyp,
|
|
ChecklistenpunktTyp,
|
|
Dienstgrad,
|
|
FahrzeugArt,
|
|
GeraeteKategorie,
|
|
GeraetStatus,
|
|
LebensdauerBezug,
|
|
LebensdauerKategorie,
|
|
MangelPrioritaet,
|
|
MangelStatus,
|
|
PrismaClient,
|
|
PruefErgebnis,
|
|
PruefungArt,
|
|
PruefungStatus,
|
|
QualifikationsArt,
|
|
RolleSchluessel,
|
|
SignaturRolle,
|
|
StandortArt,
|
|
} from "@prisma/client";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const prisma = new PrismaClient({
|
|
datasources: {
|
|
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
|
|
},
|
|
});
|
|
|
|
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
|
|
const ORGANISATION_SLUG = "ff-musterhausen";
|
|
|
|
function log(nachricht: string): void {
|
|
console.info(`[seed] ${nachricht}`);
|
|
}
|
|
|
|
/** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */
|
|
function datum(jahr: number, monat: number, tag: number): Date {
|
|
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
|
|
}
|
|
|
|
/** Verschiebt ein Datum um Monate — für Prüffristen. */
|
|
function plusMonate(basis: Date, monate: number): Date {
|
|
const neu = new Date(basis);
|
|
neu.setUTCMonth(neu.getUTCMonth() + monate);
|
|
return neu;
|
|
}
|
|
|
|
/** Verschiebt ein Datum um Jahre — für Lebensdauern. */
|
|
function plusJahre(basis: Date, jahre: number): Date {
|
|
const neu = new Date(basis);
|
|
neu.setUTCFullYear(neu.getUTCFullYear() + jahre);
|
|
return neu;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Berechtigungskatalog
|
|
// -----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über
|
|
* einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft.
|
|
*/
|
|
async function katalogAbgleichen(): Promise<Map<string, string>> {
|
|
const { RECHTE } = await import("../src/lib/authz");
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const [schluessel, angaben] of Object.entries(RECHTE)) {
|
|
const eintrag = await prisma.berechtigung.upsert({
|
|
where: { schluessel },
|
|
update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung },
|
|
create: {
|
|
schluessel,
|
|
bereich: angaben.bereich,
|
|
bezeichnung: angaben.bezeichnung,
|
|
},
|
|
});
|
|
karte.set(schluessel, eintrag.id);
|
|
}
|
|
|
|
log(`Berechtigungskatalog: ${karte.size} Einzelrechte`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Organisation und Rollen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function organisationAnlegen() {
|
|
const organisation = await prisma.organisation.upsert({
|
|
where: { slug: ORGANISATION_SLUG },
|
|
update: {},
|
|
create: {
|
|
slug: ORGANISATION_SLUG,
|
|
name: "Freiwillige Feuerwehr Musterhausen",
|
|
kurzname: "FF Musterhausen",
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
landkreis: "Freising",
|
|
bundesland: "Bayern",
|
|
telefon: "08161 123456",
|
|
email: "kommandant@ff-musterhausen.de",
|
|
ilsKennung: "FS-MUS-01",
|
|
funkPraefix: "Florian Musterhausen",
|
|
},
|
|
});
|
|
|
|
log(`Organisation: ${organisation.name} (${organisation.id})`);
|
|
return organisation;
|
|
}
|
|
|
|
async function rollenAnlegen(organisationId: string, katalog: Map<string, string>) {
|
|
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
|
|
await import("../src/lib/authz");
|
|
|
|
const rollenKarte = new Map<string, string>();
|
|
|
|
for (const schluessel of ROLLEN) {
|
|
const rolle = await prisma.rolle.upsert({
|
|
where: {
|
|
organisationId_schluessel: {
|
|
organisationId,
|
|
schluessel: schluessel as RolleSchluessel,
|
|
},
|
|
},
|
|
update: {
|
|
name: ROLLEN_BEZEICHNUNG[schluessel],
|
|
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
|
},
|
|
create: {
|
|
organisationId,
|
|
schluessel: schluessel as RolleSchluessel,
|
|
name: ROLLEN_BEZEICHNUNG[schluessel],
|
|
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
|
|
systemRolle: true,
|
|
},
|
|
});
|
|
rollenKarte.set(schluessel, rolle.id);
|
|
|
|
// Rechte der Rolle spiegeln — der Administrator bekommt alles.
|
|
const rechte =
|
|
schluessel === "SUPERADMIN"
|
|
? ALLE_RECHTE
|
|
: (ROLLEN_RECHTE[schluessel as Exclude<typeof schluessel, "SUPERADMIN">] ?? []);
|
|
|
|
for (const recht of rechte) {
|
|
const berechtigungId = katalog.get(recht);
|
|
if (!berechtigungId) continue;
|
|
|
|
await prisma.rolleBerechtigung.upsert({
|
|
where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } },
|
|
update: { gewaehrt: true },
|
|
create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`);
|
|
return rollenKarte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Personen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface PersonVorlage {
|
|
vorname: string;
|
|
nachname: string;
|
|
dienstgrad: Dienstgrad;
|
|
rollen: RolleSchluessel[];
|
|
eintritt: Date;
|
|
abteilung: string;
|
|
/** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */
|
|
pin?: string;
|
|
/** RFID-Kennung der Ausweiskarte. */
|
|
rfid?: string;
|
|
}
|
|
|
|
const PERSONEN: PersonVorlage[] = [
|
|
{
|
|
vorname: "Andreas",
|
|
nachname: "Huber",
|
|
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
|
rollen: [RolleSchluessel.KOMMANDANT],
|
|
eintritt: datum(1998, 4, 1),
|
|
abteilung: "Aktive",
|
|
pin: "471203",
|
|
rfid: "04A3B7C1D2",
|
|
},
|
|
{
|
|
vorname: "Bernhard",
|
|
nachname: "Maier",
|
|
dienstgrad: Dienstgrad.OBERBRANDMEISTER,
|
|
rollen: [RolleSchluessel.KOMMANDANT],
|
|
eintritt: datum(2001, 9, 15),
|
|
abteilung: "Aktive",
|
|
pin: "938157",
|
|
},
|
|
{
|
|
vorname: "Christian",
|
|
nachname: "Bauer",
|
|
dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER,
|
|
rollen: [RolleSchluessel.GERAETEWART],
|
|
eintritt: datum(2005, 3, 1),
|
|
abteilung: "Aktive",
|
|
pin: "620394",
|
|
rfid: "04B8C2D3E4",
|
|
},
|
|
{
|
|
vorname: "Daniela",
|
|
nachname: "Schmid",
|
|
dienstgrad: Dienstgrad.OBERLOESCHMEISTER,
|
|
rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART],
|
|
eintritt: datum(2008, 7, 1),
|
|
abteilung: "Aktive",
|
|
pin: "184926",
|
|
rfid: "04C9D3E4F5",
|
|
},
|
|
{
|
|
vorname: "Elisabeth",
|
|
nachname: "Wagner",
|
|
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
|
rollen: [RolleSchluessel.KASSIER],
|
|
eintritt: datum(2010, 1, 15),
|
|
abteilung: "Aktive",
|
|
pin: "573081",
|
|
},
|
|
{
|
|
vorname: "Franz",
|
|
nachname: "Fischer",
|
|
dienstgrad: Dienstgrad.LOESCHMEISTER,
|
|
rollen: [RolleSchluessel.SCHRIFTFUEHRER],
|
|
eintritt: datum(2009, 5, 20),
|
|
abteilung: "Aktive",
|
|
pin: "306159",
|
|
},
|
|
{
|
|
vorname: "Gabriele",
|
|
nachname: "Weber",
|
|
dienstgrad: Dienstgrad.BRANDMEISTER,
|
|
rollen: [RolleSchluessel.AUSBILDER],
|
|
eintritt: datum(2004, 11, 1),
|
|
abteilung: "Aktive",
|
|
pin: "849265",
|
|
},
|
|
{
|
|
vorname: "Hans",
|
|
nachname: "Becker",
|
|
dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER],
|
|
eintritt: datum(2012, 2, 1),
|
|
abteilung: "Aktive",
|
|
pin: "715384",
|
|
},
|
|
{
|
|
vorname: "Ingrid",
|
|
nachname: "Hofmann",
|
|
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2015, 6, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Josef",
|
|
nachname: "Schneider",
|
|
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2016, 4, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Katharina",
|
|
nachname: "Koch",
|
|
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2019, 9, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Ludwig",
|
|
nachname: "Richter",
|
|
dienstgrad: Dienstgrad.FEUERWEHRMANN,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2020, 3, 1),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Martina",
|
|
nachname: "Klein",
|
|
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2024, 1, 15),
|
|
abteilung: "Aktive",
|
|
},
|
|
{
|
|
vorname: "Nikolaus",
|
|
nachname: "Wolf",
|
|
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(2025, 2, 1),
|
|
abteilung: "Jugendfeuerwehr",
|
|
},
|
|
{
|
|
vorname: "Otto",
|
|
nachname: "Neumann",
|
|
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
|
|
rollen: [RolleSchluessel.MITGLIED],
|
|
eintritt: datum(1975, 5, 1),
|
|
abteilung: "Passive",
|
|
},
|
|
];
|
|
|
|
async function personenAnlegen(
|
|
organisationId: string,
|
|
rollenKarte: Map<string, string>,
|
|
): Promise<void> {
|
|
let nummer = 1;
|
|
|
|
for (const vorlage of PERSONEN) {
|
|
const mitgliedsnummer = String(nummer).padStart(4, "0");
|
|
const email = `${vorlage.vorname}.${vorlage.nachname}`
|
|
.toLowerCase()
|
|
.replace(/ä/g, "ae")
|
|
.replace(/ö/g, "oe")
|
|
.replace(/ü/g, "ue")
|
|
.replace(/ß/g, "ss") + "@ff-musterhausen.de";
|
|
|
|
const person = await prisma.person.upsert({
|
|
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
mitgliedsnummer,
|
|
vorname: vorlage.vorname,
|
|
nachname: vorlage.nachname,
|
|
dienstgrad: vorlage.dienstgrad,
|
|
email,
|
|
telefon: `08161 ${100000 + nummer * 7}`,
|
|
strasse: `Beispielweg ${nummer}`,
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
eintrittsdatum: vorlage.eintritt,
|
|
abteilung: vorlage.abteilung,
|
|
aktiv: vorlage.abteilung !== "Passive",
|
|
},
|
|
});
|
|
|
|
for (const schluessel of vorlage.rollen) {
|
|
const rolleId = rollenKarte.get(schluessel);
|
|
if (!rolleId) continue;
|
|
await prisma.personRolle.upsert({
|
|
where: { personId_rolleId: { personId: person.id, rolleId } },
|
|
update: {},
|
|
create: { organisationId, personId: person.id, rolleId },
|
|
});
|
|
}
|
|
|
|
// Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte.
|
|
if (vorlage.pin) {
|
|
const vorhanden = await prisma.ausweisToken.findFirst({
|
|
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN },
|
|
});
|
|
if (!vorhanden) {
|
|
await prisma.ausweisToken.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typ: AusweisTokenTyp.PIN,
|
|
tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN),
|
|
bezeichnung: "Terminal-PIN",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (vorlage.rfid) {
|
|
const vorhanden = await prisma.ausweisToken.findFirst({
|
|
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID },
|
|
});
|
|
if (!vorhanden) {
|
|
await prisma.ausweisToken.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typ: AusweisTokenTyp.RFID,
|
|
tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN),
|
|
kennzeichen: vorlage.rfid.slice(-4),
|
|
bezeichnung: "Ausweiskarte",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
nummer += 1;
|
|
}
|
|
|
|
log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`);
|
|
}
|
|
|
|
/**
|
|
* Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für
|
|
* den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor
|
|
* authentik eingerichtet ist.
|
|
*/
|
|
async function adminAnlegen(organisationId: string, rollenKarte: Map<string, string>) {
|
|
const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase();
|
|
const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
|
|
|
const admin = await prisma.person.upsert({
|
|
where: { organisationId_email: { organisationId, email } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
mitgliedsnummer: "0000",
|
|
vorname: "System",
|
|
nachname: "Administrator",
|
|
email,
|
|
dienstgrad: Dienstgrad.KEIN,
|
|
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
|
passwortGeaendertAm: new Date(),
|
|
abteilung: "Verwaltung",
|
|
},
|
|
});
|
|
|
|
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
|
|
if (rolleId) {
|
|
await prisma.personRolle.upsert({
|
|
where: { personId_rolleId: { personId: admin.id, rolleId } },
|
|
update: {},
|
|
create: { organisationId, personId: admin.id, rolleId },
|
|
});
|
|
}
|
|
|
|
log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`);
|
|
return admin;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Standorte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function standorteAnlegen(organisationId: string) {
|
|
const vorlagen = [
|
|
{
|
|
name: "Gerätehaus",
|
|
kuerzel: "GH",
|
|
art: StandortArt.GERAETEHAUS,
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
},
|
|
{
|
|
name: "Lager Bauhof",
|
|
kuerzel: "LAG",
|
|
art: StandortArt.LAGER,
|
|
strasse: "Industriestraße 4",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
bemerkung: "Reserve und Ersatzteile, Schlüssel beim Bauhof",
|
|
},
|
|
{
|
|
name: "Atemschutzwerkstatt",
|
|
kuerzel: "ASW",
|
|
art: StandortArt.WERKSTATT,
|
|
strasse: "Hauptstraße 12",
|
|
plz: "85354",
|
|
ort: "Musterhausen",
|
|
bemerkung: "Im Gerätehaus, Untergeschoss",
|
|
},
|
|
];
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of vorlagen) {
|
|
const standort = await prisma.standort.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: { organisationId, ...vorlage },
|
|
});
|
|
karte.set(vorlage.name, standort.id);
|
|
}
|
|
|
|
log(`Standorte: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Gerätetypen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface TypVorlage {
|
|
name: string;
|
|
kategorie: GeraeteKategorie;
|
|
hersteller: string;
|
|
pruefintervallMonate: number;
|
|
pruefvorschrift?: string;
|
|
lebensdauerKategorie?: LebensdauerKategorie;
|
|
lebensdauerJahre?: number;
|
|
lebensdauerBezug?: LebensdauerBezug;
|
|
}
|
|
|
|
/**
|
|
* Prüfintervalle und Lebensdauern nach den üblichen Vorgaben. Sie sind hier
|
|
* bewusst konkret hinterlegt — eine leere Beispielwehr zeigt nicht, worum es
|
|
* bei diesem Programm eigentlich geht.
|
|
*/
|
|
const GERAETETYPEN: TypVorlage[] = [
|
|
{
|
|
name: "Pressluftatmer PA 94",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
|
lebensdauerKategorie: LebensdauerKategorie.PRESSLUFTATMER,
|
|
lebensdauerJahre: 20,
|
|
},
|
|
{
|
|
name: "Atemschutzmaske FPS 7000",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 6,
|
|
pruefvorschrift: "DGUV Regel 112-190",
|
|
lebensdauerKategorie: LebensdauerKategorie.ATEMANSCHLUSS,
|
|
lebensdauerJahre: 10,
|
|
},
|
|
{
|
|
name: "Atemluftflasche 6,8 l / 300 bar",
|
|
kategorie: GeraeteKategorie.ATEMSCHUTZ,
|
|
hersteller: "Luxfer",
|
|
pruefintervallMonate: 60,
|
|
pruefvorschrift: "Betriebssicherheitsverordnung, wiederkehrende Prüfung",
|
|
lebensdauerKategorie: LebensdauerKategorie.DRUCKBEHAELTER,
|
|
lebensdauerJahre: 20,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Feuerwehrhelm F220",
|
|
kategorie: GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG,
|
|
hersteller: "Schuberth",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Information 205-014",
|
|
lebensdauerKategorie: LebensdauerKategorie.HELM,
|
|
lebensdauerJahre: 15,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Feuerwehrleine 30 m",
|
|
kategorie: GeraeteKategorie.RETTUNG,
|
|
hersteller: "Gustav Bauer",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14920, DGUV Grundsatz 305-002",
|
|
lebensdauerKategorie: LebensdauerKategorie.SEIL_GURT,
|
|
lebensdauerJahre: 8,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Steckleiterteil",
|
|
kategorie: GeraeteKategorie.RETTUNG,
|
|
hersteller: "Iturri",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14711, DGUV Information 208-016",
|
|
lebensdauerKategorie: LebensdauerKategorie.LEITER,
|
|
lebensdauerJahre: 25,
|
|
},
|
|
{
|
|
name: "Tragkraftspritze PFPN 10-1000",
|
|
kategorie: GeraeteKategorie.WASSERFOERDERUNG,
|
|
hersteller: "Rosenbauer",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14420",
|
|
},
|
|
{
|
|
name: "Hydraulischer Rettungssatz",
|
|
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
|
hersteller: "Weber Rescue",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Grundsatz 305-002",
|
|
},
|
|
{
|
|
name: "Motorkettensäge MS 261",
|
|
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
|
|
hersteller: "Stihl",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3, Betriebsanleitung",
|
|
},
|
|
{
|
|
name: "Stromerzeuger 8 kVA",
|
|
kategorie: GeraeteKategorie.ELEKTRO,
|
|
hersteller: "Endress",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3",
|
|
},
|
|
{
|
|
name: "Wärmebildkamera",
|
|
kategorie: GeraeteKategorie.MESSTECHNIK,
|
|
hersteller: "Dräger",
|
|
pruefintervallMonate: 12,
|
|
},
|
|
{
|
|
name: "Handsprechfunkgerät TPH900",
|
|
kategorie: GeraeteKategorie.FUNK,
|
|
hersteller: "Airbus",
|
|
pruefintervallMonate: 24,
|
|
},
|
|
{
|
|
name: "B-Druckschlauch 20 m",
|
|
kategorie: GeraeteKategorie.SCHLAUCHMATERIAL,
|
|
hersteller: "Ziegler",
|
|
pruefintervallMonate: 12,
|
|
pruefvorschrift: "DIN 14811",
|
|
lebensdauerKategorie: LebensdauerKategorie.SCHLAUCH,
|
|
lebensdauerJahre: 10,
|
|
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
|
|
},
|
|
{
|
|
name: "Überdrucklüfter",
|
|
kategorie: GeraeteKategorie.BRANDBEKAEMPFUNG,
|
|
hersteller: "Leader",
|
|
pruefintervallMonate: 12,
|
|
},
|
|
];
|
|
|
|
async function geraetetypenAnlegen(organisationId: string) {
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of GERAETETYPEN) {
|
|
const typ = await prisma.geraetetyp.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
kategorie: vorlage.kategorie,
|
|
hersteller: vorlage.hersteller,
|
|
pruefintervallMonate: vorlage.pruefintervallMonate,
|
|
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
|
lebensdauerKategorie: vorlage.lebensdauerKategorie ?? LebensdauerKategorie.KEINE,
|
|
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
|
|
lebensdauerBezug: vorlage.lebensdauerBezug ?? LebensdauerBezug.INDIENSTSTELLUNG,
|
|
},
|
|
});
|
|
karte.set(vorlage.name, typ.id);
|
|
}
|
|
|
|
log(`Gerätetypen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Fahrzeuge samt Geräteräumen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const FAHRZEUGE = [
|
|
{
|
|
funkrufname: "Florian Musterhausen 40/1",
|
|
bezeichnung: "Löschgruppenfahrzeug",
|
|
dinTyp: "LF 20",
|
|
art: FahrzeugArt.LOESCHFAHRZEUG,
|
|
kennzeichen: "FS-MU 401",
|
|
hersteller: "MAN",
|
|
aufbauhersteller: "Rosenbauer",
|
|
fahrgestellnummer: "WMAN12ZZ8JY123456",
|
|
baujahr: 2018,
|
|
erstzulassung: datum(2018, 6, 14),
|
|
indienststellung: datum(2018, 7, 1),
|
|
sitzplaetze: 9,
|
|
tankinhalt: 2000,
|
|
gesamtmasse: 16_000,
|
|
tuevBis: datum(2027, 5, 31),
|
|
uvvBis: datum(2026, 11, 30),
|
|
},
|
|
{
|
|
funkrufname: "Florian Musterhausen 42/1",
|
|
bezeichnung: "Tragkraftspritzenfahrzeug Wasser",
|
|
dinTyp: "TSF-W",
|
|
art: FahrzeugArt.LOESCHFAHRZEUG,
|
|
kennzeichen: "FS-MU 421",
|
|
hersteller: "Mercedes-Benz",
|
|
aufbauhersteller: "Ziegler",
|
|
fahrgestellnummer: "WDB90661310123789",
|
|
baujahr: 2011,
|
|
erstzulassung: datum(2011, 9, 20),
|
|
indienststellung: datum(2011, 10, 15),
|
|
sitzplaetze: 6,
|
|
tankinhalt: 750,
|
|
gesamtmasse: 7500,
|
|
tuevBis: datum(2026, 11, 30),
|
|
uvvBis: datum(2026, 10, 15),
|
|
},
|
|
{
|
|
funkrufname: "Florian Musterhausen 11/1",
|
|
bezeichnung: "Mannschaftstransportwagen",
|
|
dinTyp: "MTW",
|
|
art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
|
|
kennzeichen: "FS-MU 111",
|
|
hersteller: "Volkswagen",
|
|
fahrgestellnummer: "WV2ZZZ7HZMH098765",
|
|
baujahr: 2021,
|
|
erstzulassung: datum(2021, 3, 8),
|
|
indienststellung: datum(2021, 3, 15),
|
|
sitzplaetze: 9,
|
|
gesamtmasse: 3500,
|
|
tuevBis: datum(2027, 3, 31),
|
|
},
|
|
];
|
|
|
|
/** Beladeordnung. Der MTW hat weniger Fächer als ein Löschfahrzeug. */
|
|
const RAEUME_GROSS = [
|
|
{ bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
|
|
{ bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
|
|
{ bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
|
|
{ bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
|
|
{ bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
|
|
{ bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
|
|
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
|
|
{ bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
|
|
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
|
|
];
|
|
|
|
const RAEUME_KLEIN = [
|
|
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 10 },
|
|
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 20 },
|
|
];
|
|
|
|
async function fahrzeugeAnlegen(organisationId: string, standorte: Map<string, string>) {
|
|
const fahrzeuge: Array<{ id: string; funkrufname: string; raeume: Map<string, string> }> = [];
|
|
|
|
for (const vorlage of FAHRZEUGE) {
|
|
const fahrzeug = await prisma.fahrzeug.upsert({
|
|
where: {
|
|
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
|
|
},
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
...vorlage,
|
|
standortId: standorte.get("Gerätehaus") ?? null,
|
|
},
|
|
});
|
|
|
|
const vorlagen =
|
|
vorlage.art === FahrzeugArt.MANNSCHAFTSTRANSPORT ? RAEUME_KLEIN : RAEUME_GROSS;
|
|
|
|
const raeume = new Map<string, string>();
|
|
for (const raumVorlage of vorlagen) {
|
|
const raum = await prisma.geraeteraum.upsert({
|
|
where: {
|
|
fahrzeugId_bezeichnung: {
|
|
fahrzeugId: fahrzeug.id,
|
|
bezeichnung: raumVorlage.bezeichnung,
|
|
},
|
|
},
|
|
update: {},
|
|
create: { organisationId, fahrzeugId: fahrzeug.id, ...raumVorlage },
|
|
});
|
|
raeume.set(raumVorlage.bezeichnung, raum.id);
|
|
}
|
|
|
|
fahrzeuge.push({ id: fahrzeug.id, funkrufname: fahrzeug.funkrufname, raeume });
|
|
}
|
|
|
|
const raeumeGesamt = fahrzeuge.reduce((summe, f) => summe + f.raeume.size, 0);
|
|
log(`Fahrzeuge: ${fahrzeuge.length}, Geräteräume: ${raeumeGesamt}`);
|
|
return fahrzeuge;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Geräte
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface GeraetVorlage {
|
|
typ: string;
|
|
anzahl: number;
|
|
/** Index in FAHRZEUGE, oder null für einen Standort. */
|
|
aufFahrzeug: number | null;
|
|
raum?: string;
|
|
standort?: string;
|
|
lagerort?: string;
|
|
}
|
|
|
|
const GERAETE: GeraetVorlage[] = [
|
|
{ typ: "Pressluftatmer PA 94", anzahl: 4, aufFahrzeug: 0, raum: "MR" },
|
|
{ typ: "Atemschutzmaske FPS 7000", anzahl: 6, aufFahrzeug: null, standort: "Atemschutzwerkstatt", lagerort: "Schrank 1" },
|
|
{ typ: "Atemluftflasche 6,8 l / 300 bar", anzahl: 8, aufFahrzeug: 0, raum: "G4" },
|
|
{ typ: "Feuerwehrhelm F220", anzahl: 6, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Umkleide, Spind-Reihe A" },
|
|
{ typ: "Feuerwehrleine 30 m", anzahl: 3, aufFahrzeug: 0, raum: "G2" },
|
|
{ typ: "Steckleiterteil", anzahl: 4, aufFahrzeug: 0, raum: "Dach" },
|
|
{ typ: "Tragkraftspritze PFPN 10-1000", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
|
{ typ: "Hydraulischer Rettungssatz", anzahl: 1, aufFahrzeug: 0, raum: "G3" },
|
|
{ typ: "Motorkettensäge MS 261", anzahl: 2, aufFahrzeug: 0, raum: "G5" },
|
|
{ typ: "Stromerzeuger 8 kVA", anzahl: 1, aufFahrzeug: 0, raum: "G6" },
|
|
{ typ: "Wärmebildkamera", anzahl: 1, aufFahrzeug: 0, raum: "MR" },
|
|
{ typ: "Handsprechfunkgerät TPH900", anzahl: 3, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Funkraum, Ladeschale" },
|
|
{ typ: "B-Druckschlauch 20 m", anzahl: 8, aufFahrzeug: 0, raum: "G1" },
|
|
{ typ: "Überdrucklüfter", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
|
|
];
|
|
|
|
async function geraeteAnlegen(
|
|
organisationId: string,
|
|
typen: Map<string, string>,
|
|
fahrzeuge: Array<{ id: string; raeume: Map<string, string> }>,
|
|
standorte: Map<string, string>,
|
|
) {
|
|
const heute = new Date();
|
|
let laufend = 1;
|
|
let angelegt = 0;
|
|
|
|
for (const vorlage of GERAETE) {
|
|
const typId = typen.get(vorlage.typ);
|
|
const typVorlage = GERAETETYPEN.find((t) => t.name === vorlage.typ);
|
|
if (!typId || !typVorlage) continue;
|
|
|
|
for (let index = 0; index < vorlage.anzahl; index += 1) {
|
|
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
|
|
|
|
// Ein Teil der Geräte bekommt eine überschrittene Frist, damit die
|
|
// Statusanzeige nicht durchgehend grün ist und man sieht, wofür sie da
|
|
// ist.
|
|
const ueberfaellig = laufend % 4 === 0;
|
|
const indienststellung = datum(2015 + (laufend % 9), (laufend % 12) + 1, 15);
|
|
const herstellung = datum(indienststellung.getUTCFullYear() - 1, 6, 1);
|
|
|
|
const letztePruefung = ueberfaellig
|
|
? plusMonate(heute, -(typVorlage.pruefintervallMonate + 2))
|
|
: plusMonate(heute, -(laufend % typVorlage.pruefintervallMonate));
|
|
|
|
const naechstePruefung = plusMonate(letztePruefung, typVorlage.pruefintervallMonate);
|
|
|
|
const lebensdauerBis = typVorlage.lebensdauerJahre
|
|
? plusJahre(
|
|
typVorlage.lebensdauerBezug === LebensdauerBezug.HERSTELLUNG
|
|
? herstellung
|
|
: indienststellung,
|
|
typVorlage.lebensdauerJahre,
|
|
)
|
|
: null;
|
|
|
|
const status =
|
|
lebensdauerBis && lebensdauerBis < heute
|
|
? GeraetStatus.GESPERRT
|
|
: naechstePruefung < heute
|
|
? GeraetStatus.PRUEFUNG_FAELLIG
|
|
: laufend % 19 === 0
|
|
? GeraetStatus.IN_REPARATUR
|
|
: GeraetStatus.EINSATZBEREIT;
|
|
|
|
const fahrzeug = vorlage.aufFahrzeug !== null ? fahrzeuge[vorlage.aufFahrzeug] : null;
|
|
|
|
await prisma.geraet.upsert({
|
|
where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
inventarnummer,
|
|
bezeichnung: vorlage.typ,
|
|
geraetetypId: typId,
|
|
hersteller: typVorlage.hersteller,
|
|
seriennummer: `SN-${typVorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
|
|
herstellungsdatum: herstellung,
|
|
indienststellung,
|
|
anschaffungspreis: preisFuer(typVorlage.kategorie, laufend),
|
|
status,
|
|
letztePruefung,
|
|
naechstePruefung,
|
|
lebensdauerBis,
|
|
fahrzeugId: fahrzeug?.id ?? null,
|
|
geraeteraumId: fahrzeug && vorlage.raum ? (fahrzeug.raeume.get(vorlage.raum) ?? null) : null,
|
|
standortId: fahrzeug ? null : (standorte.get(vorlage.standort ?? "Gerätehaus") ?? null),
|
|
lagerort: vorlage.lagerort ?? null,
|
|
},
|
|
});
|
|
|
|
laufend += 1;
|
|
angelegt += 1;
|
|
}
|
|
}
|
|
|
|
log(`Geräte: ${angelegt}`);
|
|
}
|
|
|
|
/** Grobe Anschaffungspreise, damit Auswertungen später etwas zu rechnen haben. */
|
|
function preisFuer(kategorie: GeraeteKategorie, streuung: number): number {
|
|
const grundpreise: Partial<Record<GeraeteKategorie, number>> = {
|
|
[GeraeteKategorie.ATEMSCHUTZ]: 1800,
|
|
[GeraeteKategorie.RETTUNG]: 450,
|
|
[GeraeteKategorie.TECHNISCHE_HILFELEISTUNG]: 6500,
|
|
[GeraeteKategorie.ELEKTRO]: 3200,
|
|
[GeraeteKategorie.MESSTECHNIK]: 4800,
|
|
[GeraeteKategorie.FUNK]: 1400,
|
|
[GeraeteKategorie.SCHLAUCHMATERIAL]: 120,
|
|
[GeraeteKategorie.WASSERFOERDERUNG]: 9500,
|
|
[GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG]: 320,
|
|
[GeraeteKategorie.BRANDBEKAEMPFUNG]: 2100,
|
|
};
|
|
const grund = grundpreise[kategorie] ?? 250;
|
|
return Math.round((grund + (streuung % 7) * grund * 0.03) * 100) / 100;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Qualifikationen
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface QualiTypVorlage {
|
|
name: string;
|
|
kuerzel: string;
|
|
art: QualifikationsArt;
|
|
gueltigkeitMonate?: number;
|
|
vorwarnzeitTage?: number;
|
|
einsatzrelevant?: boolean;
|
|
}
|
|
|
|
const QUALIFIKATIONSTYPEN: QualiTypVorlage[] = [
|
|
{ name: "Truppmann Teil 1 (MTA Basismodul)", kuerzel: "TM1", art: QualifikationsArt.LEHRGANG, einsatzrelevant: true },
|
|
{ name: "Truppmann Teil 2", kuerzel: "TM2", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Truppführer", kuerzel: "TF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Gruppenführer", kuerzel: "GF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Zugführer", kuerzel: "ZF", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Maschinist für Löschfahrzeuge", kuerzel: "MA", art: QualifikationsArt.LEHRGANG },
|
|
{ name: "Sprechfunker", kuerzel: "SF", art: QualifikationsArt.LEHRGANG },
|
|
{
|
|
name: "Atemschutzgeräteträger",
|
|
kuerzel: "AGT",
|
|
art: QualifikationsArt.BEFAEHIGUNG,
|
|
gueltigkeitMonate: 12,
|
|
vorwarnzeitTage: 60,
|
|
einsatzrelevant: true,
|
|
},
|
|
{
|
|
name: "Arbeitsmedizinische Vorsorge G26.3",
|
|
kuerzel: "G26.3",
|
|
art: QualifikationsArt.UNTERSUCHUNG,
|
|
gueltigkeitMonate: 36,
|
|
vorwarnzeitTage: 120,
|
|
einsatzrelevant: true,
|
|
},
|
|
{
|
|
name: "Belastungsübung Atemschutz",
|
|
kuerzel: "BÜ",
|
|
art: QualifikationsArt.UNTERWEISUNG,
|
|
gueltigkeitMonate: 12,
|
|
vorwarnzeitTage: 60,
|
|
einsatzrelevant: true,
|
|
},
|
|
{ name: "Motorsägenführer", kuerzel: "MS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 60 },
|
|
{ name: "Führerschein C/CE", kuerzel: "C/CE", art: QualifikationsArt.FUEHRERSCHEIN, gueltigkeitMonate: 60, vorwarnzeitTage: 90 },
|
|
{ name: "Erste Hilfe", kuerzel: "EH", art: QualifikationsArt.UNTERWEISUNG, gueltigkeitMonate: 24 },
|
|
{ name: "Absturzsicherung", kuerzel: "AS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 12 },
|
|
];
|
|
|
|
async function qualifikationstypenAnlegen(organisationId: string) {
|
|
const karte = new Map<string, string>();
|
|
|
|
for (const vorlage of QUALIFIKATIONSTYPEN) {
|
|
const typ = await prisma.qualifikationstyp.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
kuerzel: vorlage.kuerzel,
|
|
art: vorlage.art,
|
|
gueltigkeitMonate: vorlage.gueltigkeitMonate ?? null,
|
|
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 90,
|
|
einsatzrelevant: vorlage.einsatzrelevant ?? false,
|
|
},
|
|
});
|
|
karte.set(vorlage.name, typ.id);
|
|
}
|
|
|
|
log(`Qualifikationstypen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
/**
|
|
* Verteilt Qualifikationen auf die Mannschaft.
|
|
*
|
|
* Ein Teil läuft absichtlich demnächst ab oder ist bereits abgelaufen — sonst
|
|
* bliebe die Ablaufwarnung in der Beispielwehr leer, und man sähe nicht, wofür
|
|
* sie da ist.
|
|
*/
|
|
async function qualifikationenAnlegen(organisationId: string, typen: Map<string, string>) {
|
|
const personen = await prisma.person.findMany({
|
|
where: { organisationId, geloeschtAm: null, abteilung: { not: "Verwaltung" } },
|
|
orderBy: { mitgliedsnummer: "asc" },
|
|
select: { id: true, mitgliedsnummer: true, eintrittsdatum: true },
|
|
});
|
|
|
|
const heute = new Date();
|
|
let angelegt = 0;
|
|
|
|
for (const [index, person] of personen.entries()) {
|
|
const eintritt = person.eintrittsdatum ?? datum(2015, 1, 1);
|
|
|
|
/** Grundausbildung hat jeder, weitere je nach Dienstalter. */
|
|
const zuweisungen: Array<{ typ: string; erworben: Date; gueltigBis?: Date | null }> = [
|
|
{ typ: "Truppmann Teil 1 (MTA Basismodul)", erworben: plusMonate(eintritt, 6) },
|
|
];
|
|
|
|
if (index < 12) {
|
|
zuweisungen.push({ typ: "Truppmann Teil 2", erworben: plusMonate(eintritt, 24) });
|
|
zuweisungen.push({ typ: "Sprechfunker", erworben: plusMonate(eintritt, 12) });
|
|
}
|
|
if (index < 8) {
|
|
zuweisungen.push({ typ: "Maschinist für Löschfahrzeuge", erworben: plusMonate(eintritt, 36) });
|
|
}
|
|
if (index < 6) {
|
|
zuweisungen.push({ typ: "Truppführer", erworben: plusMonate(eintritt, 48) });
|
|
}
|
|
if (index < 3) {
|
|
zuweisungen.push({ typ: "Gruppenführer", erworben: plusMonate(eintritt, 72) });
|
|
}
|
|
|
|
// Atemschutz: die Fristen werden gestreut, damit die Ampel etwas zeigt.
|
|
if (index < 9) {
|
|
const versatz = [-2, -1, 1, 2, 3, 5, 8, 10, 11][index] ?? 6;
|
|
zuweisungen.push({
|
|
typ: "Atemschutzgeräteträger",
|
|
erworben: plusMonate(heute, versatz - 12),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
zuweisungen.push({
|
|
typ: "Arbeitsmedizinische Vorsorge G26.3",
|
|
erworben: plusMonate(heute, versatz - 36),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
zuweisungen.push({
|
|
typ: "Belastungsübung Atemschutz",
|
|
erworben: plusMonate(heute, versatz - 12),
|
|
gueltigBis: plusMonate(heute, versatz),
|
|
});
|
|
}
|
|
|
|
if (index < 5) {
|
|
zuweisungen.push({
|
|
typ: "Führerschein C/CE",
|
|
erworben: plusMonate(eintritt, 12),
|
|
gueltigBis: plusMonate(heute, 6 + index * 4),
|
|
});
|
|
}
|
|
if (index < 4) {
|
|
zuweisungen.push({
|
|
typ: "Motorsägenführer",
|
|
erworben: plusMonate(heute, -30),
|
|
gueltigBis: plusMonate(heute, 30),
|
|
});
|
|
}
|
|
|
|
zuweisungen.push({
|
|
typ: "Erste Hilfe",
|
|
erworben: plusMonate(heute, -20 - index),
|
|
gueltigBis: plusMonate(heute, 4 - index),
|
|
});
|
|
|
|
for (const zuweisung of zuweisungen) {
|
|
const typId = typen.get(zuweisung.typ);
|
|
if (!typId) continue;
|
|
|
|
const vorhanden = await prisma.qualifikation.findFirst({
|
|
where: { organisationId, personId: person.id, typId },
|
|
select: { id: true },
|
|
});
|
|
if (vorhanden) continue;
|
|
|
|
await prisma.qualifikation.create({
|
|
data: {
|
|
organisationId,
|
|
personId: person.id,
|
|
typId,
|
|
erworbenAm: zuweisung.erworben,
|
|
gueltigBis: zuweisung.gueltigBis ?? null,
|
|
ausstellendeStelle:
|
|
zuweisung.typ.includes("G26") ? "Betriebsarzt Dr. Berger" : "Kreisbrandinspektion Freising",
|
|
},
|
|
});
|
|
angelegt += 1;
|
|
}
|
|
}
|
|
|
|
log(`Qualifikationen: ${angelegt}`);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Phase 2 — Wartungsvorlagen, Termine, ein Beispielprotokoll
|
|
//
|
|
// Die Vorlagen sind bewusst echt: Intervalle und Vorschriften stammen aus den
|
|
// gängigen Regelwerken. Eine Beispielwehr, in der jede Prüfung "Prüfung 1"
|
|
// heißt, zeigt nicht, worum es geht.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface PunktVorlage {
|
|
text: string;
|
|
typ: ChecklistenpunktTyp;
|
|
pflicht?: boolean;
|
|
sollwert?: string;
|
|
einheit?: string;
|
|
minWert?: number;
|
|
maxWert?: number;
|
|
hinweis?: string;
|
|
}
|
|
|
|
interface WartungsVorlage {
|
|
name: string;
|
|
geraetetyp: string;
|
|
intervallMonate: number;
|
|
vorwarnzeitTage?: number;
|
|
pruefvorschrift?: string;
|
|
standardDauerMinuten?: number;
|
|
punkte: PunktVorlage[];
|
|
}
|
|
|
|
const WARTUNGSVORLAGEN: WartungsVorlage[] = [
|
|
{
|
|
name: "Pressluftatmer — Jahresprüfung",
|
|
geraetetyp: "Pressluftatmer PA 94",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
|
|
standardDauerMinuten: 25,
|
|
punkte: [
|
|
{ text: "Trageeinrichtung auf Beschädigung und Vollständigkeit prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Bänderung auf Risse, Schnitte und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Hochdruckdichtheit: Druckabfall in einer Minute",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 10 bar in einer Minute",
|
|
einheit: "bar",
|
|
minWert: 0,
|
|
maxWert: 10,
|
|
},
|
|
{
|
|
text: "Ansprechdruck der Warneinrichtung",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "50 bis 60 bar",
|
|
einheit: "bar",
|
|
minWert: 50,
|
|
maxWert: 60,
|
|
},
|
|
{
|
|
text: "Lungenautomat: Öffnungsdruck",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "bis 3,5 mbar",
|
|
einheit: "mbar",
|
|
maxWert: 3.5,
|
|
},
|
|
{ text: "Auffälligkeiten", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Atemanschluss — Halbjahresprüfung",
|
|
geraetetyp: "Atemschutzmaske FPS 7000",
|
|
intervallMonate: 6,
|
|
pruefvorschrift: "DGUV Regel 112-190",
|
|
standardDauerMinuten: 15,
|
|
punkte: [
|
|
{ text: "Maskenkörper auf Risse, Verformung und Verhärtung prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Sichtscheibe frei von Kratzern im Sichtfeld", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Ausatemventil auf Dichtheit und Sitz prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Dichtprüfung: Druckabfall in einer Minute",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 0,5 mbar",
|
|
einheit: "mbar",
|
|
minWert: 0,
|
|
maxWert: 0.5,
|
|
},
|
|
{ text: "Foto der Maske nach der Reinigung", typ: ChecklistenpunktTyp.FOTO, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Feuerwehrleine — Jahresprüfung",
|
|
geraetetyp: "Feuerwehrleine 30 m",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Information 205-014, DIN 14920",
|
|
standardDauerMinuten: 10,
|
|
punkte: [
|
|
{ text: "Leine über die gesamte Länge durch die Hand ziehen, Beschädigungen prüfen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Kennzeichnung und Herstellungsjahr lesbar", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Leinenbeutel unbeschädigt und trocken", typ: ChecklistenpunktTyp.JA_NEIN, pflicht: false },
|
|
{ text: "Befund", typ: ChecklistenpunktTyp.TEXT, pflicht: false },
|
|
],
|
|
},
|
|
{
|
|
name: "Druckschlauch — Druckprüfung",
|
|
geraetetyp: "B-Druckschlauch 20 m",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DIN 14811",
|
|
standardDauerMinuten: 8,
|
|
punkte: [
|
|
{
|
|
text: "Prüfdruck halten",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "mindestens 8 bar über 5 Minuten",
|
|
einheit: "bar",
|
|
minWert: 8,
|
|
},
|
|
{ text: "Kupplungen fest, Dichtringe vorhanden", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{ text: "Schlauchgewebe ohne Scheuerstellen und Löcher", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
],
|
|
},
|
|
{
|
|
name: "Elektrische Betriebsmittel — DGUV-V3-Prüfung",
|
|
geraetetyp: "Stromerzeuger 8 kVA",
|
|
intervallMonate: 12,
|
|
pruefvorschrift: "DGUV Vorschrift 3, DIN VDE 0701-0702",
|
|
standardDauerMinuten: 30,
|
|
punkte: [
|
|
{ text: "Sichtprüfung Gehäuse, Leitungen und Steckvorrichtungen", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
{
|
|
text: "Schutzleiterwiderstand",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "höchstens 0,3 Ohm",
|
|
einheit: "Ohm",
|
|
minWert: 0,
|
|
maxWert: 0.3,
|
|
},
|
|
{
|
|
text: "Isolationswiderstand",
|
|
typ: ChecklistenpunktTyp.MESSWERT,
|
|
sollwert: "mindestens 1 Megaohm",
|
|
einheit: "MOhm",
|
|
minWert: 1,
|
|
},
|
|
{ text: "Funktionsprüfung unter Last", typ: ChecklistenpunktTyp.JA_NEIN },
|
|
],
|
|
},
|
|
];
|
|
|
|
async function wartungsvorlagenAnlegen(organisationId: string, typen: Map<string, string>) {
|
|
const karte = new Map<string, { id: string; intervallMonate: number; punkte: string[] }>();
|
|
|
|
for (const vorlage of WARTUNGSVORLAGEN) {
|
|
const geraetetypId = typen.get(vorlage.geraetetyp) ?? null;
|
|
|
|
const angelegt = await prisma.wartungsvorlage.upsert({
|
|
where: { organisationId_name: { organisationId, name: vorlage.name } },
|
|
update: {},
|
|
create: {
|
|
organisationId,
|
|
name: vorlage.name,
|
|
geraetetypId,
|
|
intervallMonate: vorlage.intervallMonate,
|
|
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 30,
|
|
pruefvorschrift: vorlage.pruefvorschrift ?? null,
|
|
standardDauerMinuten: vorlage.standardDauerMinuten ?? null,
|
|
},
|
|
});
|
|
|
|
const punktIds: string[] = [];
|
|
const vorhandene = await prisma.checklistenpunkt.count({ where: { vorlageId: angelegt.id } });
|
|
|
|
if (vorhandene === 0) {
|
|
for (const [index, punkt] of vorlage.punkte.entries()) {
|
|
const erzeugt = await prisma.checklistenpunkt.create({
|
|
data: {
|
|
organisationId,
|
|
vorlageId: angelegt.id,
|
|
text: punkt.text,
|
|
typ: punkt.typ,
|
|
pflicht: punkt.pflicht ?? true,
|
|
sollwert: punkt.sollwert ?? null,
|
|
einheit: punkt.einheit ?? null,
|
|
minWert: punkt.minWert ?? null,
|
|
maxWert: punkt.maxWert ?? null,
|
|
hinweis: punkt.hinweis ?? null,
|
|
sortierung: (index + 1) * 10,
|
|
},
|
|
});
|
|
punktIds.push(erzeugt.id);
|
|
}
|
|
} else {
|
|
const bestand = await prisma.checklistenpunkt.findMany({
|
|
where: { vorlageId: angelegt.id },
|
|
orderBy: { sortierung: "asc" },
|
|
select: { id: true },
|
|
});
|
|
punktIds.push(...bestand.map((punkt) => punkt.id));
|
|
}
|
|
|
|
karte.set(vorlage.name, {
|
|
id: angelegt.id,
|
|
intervallMonate: vorlage.intervallMonate,
|
|
punkte: punktIds,
|
|
});
|
|
}
|
|
|
|
log(`Wartungsvorlagen: ${karte.size}`);
|
|
return karte;
|
|
}
|
|
|
|
/**
|
|
* Termine für alle Geräte der jeweiligen Bauart.
|
|
*
|
|
* Das ist dieselbe Rechnung, die der nächtliche Lauf anstellt — hier nur ohne
|
|
* Worker, damit eine frisch aufgesetzte Wehr sofort ein gefülltes
|
|
* Fristen-Dashboard hat.
|
|
*/
|
|
async function wartungstermineAnlegen(
|
|
organisationId: string,
|
|
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
|
) {
|
|
let angelegt = 0;
|
|
|
|
for (const vorlage of WARTUNGSVORLAGEN) {
|
|
const eintrag = vorlagen.get(vorlage.name);
|
|
if (!eintrag) continue;
|
|
|
|
const geraete = await prisma.geraet.findMany({
|
|
where: {
|
|
organisationId,
|
|
geloeschtAm: null,
|
|
geraetetyp: { name: vorlage.geraetetyp },
|
|
},
|
|
select: { id: true, letztePruefung: true, indienststellung: true },
|
|
});
|
|
|
|
for (const geraet of geraete) {
|
|
const basis = geraet.letztePruefung ?? geraet.indienststellung;
|
|
if (!basis) continue;
|
|
|
|
const faelligAm = plusMonate(basis, vorlage.intervallMonate);
|
|
|
|
const ergebnis = await prisma.wartungstermin.createMany({
|
|
data: [
|
|
{
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
vorlageId: eintrag.id,
|
|
faelligAm,
|
|
automatischErzeugt: true,
|
|
},
|
|
],
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
angelegt += ergebnis.count;
|
|
}
|
|
}
|
|
|
|
log(`Wartungstermine: ${angelegt}`);
|
|
}
|
|
|
|
/**
|
|
* Ein abgeschlossenes Protokoll samt Mangel.
|
|
*
|
|
* Ohne ein Beispiel bleibt die Protokollansicht leer, und niemand sieht, wie
|
|
* ein fertiger Nachweis aussieht. Das PDF fehlt bewusst: es entsteht im
|
|
* Worker, sobald einer läuft.
|
|
*/
|
|
async function beispielPruefungAnlegen(
|
|
organisationId: string,
|
|
vorlagen: Map<string, { id: string; intervallMonate: number; punkte: string[] }>,
|
|
) {
|
|
const vorlage = vorlagen.get("Feuerwehrleine — Jahresprüfung");
|
|
if (!vorlage) return;
|
|
|
|
const vorhanden = await prisma.pruefung.count({ where: { organisationId } });
|
|
if (vorhanden > 0) {
|
|
log("Beispielprüfung: bereits vorhanden");
|
|
return;
|
|
}
|
|
|
|
const geraet = await prisma.geraet.findFirst({
|
|
where: { organisationId, geraetetyp: { name: "Feuerwehrleine 30 m" }, geloeschtAm: null },
|
|
select: { id: true, inventarnummer: true },
|
|
});
|
|
if (!geraet) return;
|
|
|
|
const pruefer = await prisma.person.findFirst({
|
|
where: {
|
|
organisationId,
|
|
rollen: { some: { rolle: { schluessel: RolleSchluessel.GERAETEWART } } },
|
|
},
|
|
select: { id: true, vorname: true, nachname: true },
|
|
});
|
|
|
|
const geprueftAm = plusMonate(new Date(), -2);
|
|
const prueferName = pruefer ? `${pruefer.vorname} ${pruefer.nachname}` : "Gerätewart";
|
|
|
|
const punkte = await prisma.checklistenpunkt.findMany({
|
|
where: { vorlageId: vorlage.id },
|
|
orderBy: { sortierung: "asc" },
|
|
});
|
|
|
|
// Erst als Entwurf anlegen, dann abschließen — genau wie im Wizard. Der
|
|
// Trigger aus Migration 20260903000300 lässt an einer bereits
|
|
// abgeschlossenen Prüfung keine Ergebnisse und Unterschriften mehr zu, und
|
|
// das ist auch für den Seed richtig so.
|
|
const pruefung = await prisma.pruefung.create({
|
|
data: {
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
vorlageId: vorlage.id,
|
|
art: PruefungArt.INTERN,
|
|
status: PruefungStatus.ENTWURF,
|
|
ergebnis: PruefErgebnis.BESTANDEN_MIT_MAENGELN,
|
|
prueferId: pruefer?.id ?? null,
|
|
prueferName,
|
|
durchgefuehrtAm: geprueftAm,
|
|
begonnenUm: geprueftAm,
|
|
dauerMinuten: 12,
|
|
naechstePruefungAm: plusMonate(geprueftAm, vorlage.intervallMonate),
|
|
protokollNummer: `${geprueftAm.getUTCFullYear()}-0001`,
|
|
abgeschlossenAm: geprueftAm,
|
|
bemerkung:
|
|
"Leine vollständig durch die Hand gezogen. Leinenbeutel an einer Naht aufgescheuert.",
|
|
ergebnisse: {
|
|
create: punkte.map((punkt, index) => ({
|
|
organisationId,
|
|
punktId: punkt.id,
|
|
jaNein: punkt.typ === ChecklistenpunktTyp.JA_NEIN ? index !== 2 : null,
|
|
text:
|
|
punkt.typ === ChecklistenpunktTyp.TEXT
|
|
? "Leine selbst ohne Befund, Beutel beschädigt."
|
|
: null,
|
|
inOrdnung: index !== 2,
|
|
bemerkung: index === 2 ? "Naht am Beutel aufgescheuert" : null,
|
|
})),
|
|
},
|
|
signaturen: {
|
|
create: [
|
|
{
|
|
organisationId,
|
|
rolle: SignaturRolle.PRUEFER,
|
|
personId: pruefer?.id ?? null,
|
|
name: prueferName,
|
|
unterzeichnetAm: geprueftAm,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
await prisma.pruefung.update({
|
|
where: { id: pruefung.id },
|
|
data: { status: PruefungStatus.ABGESCHLOSSEN },
|
|
});
|
|
|
|
await prisma.mangel.create({
|
|
data: {
|
|
organisationId,
|
|
geraetId: geraet.id,
|
|
pruefungId: pruefung.id,
|
|
melderId: pruefer?.id ?? null,
|
|
melderName: prueferName,
|
|
beschreibung: "Leinenbeutel an einer Naht aufgescheuert — Beutel tauschen.",
|
|
prioritaet: MangelPrioritaet.NIEDRIG,
|
|
status: MangelStatus.ZUGEWIESEN,
|
|
zugewiesenAnId: pruefer?.id ?? null,
|
|
},
|
|
});
|
|
|
|
log(`Beispielprüfung: Protokoll ${pruefung.protokollNummer} an ${geraet.inventarnummer}`);
|
|
}
|
|
|
|
/** Ein Rückruf, damit die Ansicht nicht leer ist. */
|
|
async function rueckrufAnlegen(organisationId: string) {
|
|
const vorhanden = await prisma.rueckruf.count({ where: { organisationId } });
|
|
if (vorhanden > 0) return;
|
|
|
|
const typ = await prisma.geraetetyp.findFirst({
|
|
where: { organisationId, name: "Atemschutzmaske FPS 7000" },
|
|
select: { id: true },
|
|
});
|
|
|
|
await prisma.rueckruf.create({
|
|
data: {
|
|
organisationId,
|
|
hersteller: "Dräger",
|
|
titel: "Sicherheitshinweis Ausatemventil Baureihe FPS 7000",
|
|
referenz: "SI-2026-03",
|
|
beschreibung:
|
|
"Bei einzelnen Chargen kann die Ventilscheibe des Ausatemventils verkleben. "
|
|
+ "Betroffen sind Masken mit Herstellung vor 2020.",
|
|
massnahme: "Ventilscheibe tauschen, danach Dichtprüfung wiederholen.",
|
|
veroeffentlichtAm: plusMonate(new Date(), -1),
|
|
sperrtGeraete: false,
|
|
typen: typ
|
|
? {
|
|
create: [
|
|
{
|
|
organisationId,
|
|
geraetetypId: typ.id,
|
|
herstellungBis: datum(2019, 12, 31),
|
|
},
|
|
],
|
|
}
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
log("Rückruf: 1 Beispiel");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Ablauf
|
|
// -----------------------------------------------------------------------------
|
|
|
|
async function main(): Promise<void> {
|
|
log("Starte ...");
|
|
|
|
const katalog = await katalogAbgleichen();
|
|
const organisation = await organisationAnlegen();
|
|
const rollen = await rollenAnlegen(organisation.id, katalog);
|
|
await adminAnlegen(organisation.id, rollen);
|
|
await personenAnlegen(organisation.id, rollen);
|
|
|
|
// Phase 1 — Stammdaten
|
|
const standorte = await standorteAnlegen(organisation.id);
|
|
const typen = await geraetetypenAnlegen(organisation.id);
|
|
const fahrzeuge = await fahrzeugeAnlegen(organisation.id, standorte);
|
|
await geraeteAnlegen(organisation.id, typen, fahrzeuge, standorte);
|
|
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
|
|
await qualifikationenAnlegen(organisation.id, qualiTypen);
|
|
|
|
// Phase 2 — Wartung und Prüfung
|
|
const wartungsvorlagen = await wartungsvorlagenAnlegen(organisation.id, typen);
|
|
await wartungstermineAnlegen(organisation.id, wartungsvorlagen);
|
|
await beispielPruefungAnlegen(organisation.id, wartungsvorlagen);
|
|
await rueckrufAnlegen(organisation.id);
|
|
|
|
log("Fertig.");
|
|
log("");
|
|
log("Anmeldung für den Erststart:");
|
|
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
|
|
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
|
|
log("");
|
|
log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203");
|
|
}
|
|
|
|
main()
|
|
.catch((fehler) => {
|
|
console.error("[seed] Abbruch:", fehler);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|