Workstream 9: Seed-Daten aus NÖ-Katalog (Phase 6)

Idempotente Katalog-Seeds, die docs/reference/fahrzeug-katalog-noelfv.md
als Code abbilden. Importiert ausschließlich das bestehende Drizzle-Schema
(WS2), definiert keine Tabellen neu.

- 34 Merkmale (+ Enum-Optionen: feuerloeschpumpe_typ=8, anzahl_achsen=3,
  stromerzeuger_bauart=3); Funkrufname ist Spalte, kein Merkmal.
- 11 Fahrzeug-Vorlagen mit Pflichtmerkmalen, typisierten Vorgabewerten
  (vorgabewert_num/_text/_bool) und Aliassen mit `bestaetigt`.
  RLF/RLFA 2000 + 2000-4000 = true; kein HLFA-Alias (Laufzeitregel ist
  kanonisch); HLF 4-U als Alias auf HLF 4 mit Pulver-Pflichtmerkmalen.
- 11 Geräte-Kategorien (Natural Key name).
- upsert.ts: ausschließlich onConflictDoUpdate auf Natural Keys
  (slug/code/name + Verknüpfungs-PKs); index.ts seedet in EINER Transaktion
  (Merkmale -> Optionen -> Vorlagen -> Vorlagen-Merkmale -> Aliasse ->
  Kategorien) via Slug->ID-Map, sequenzielle Awaits.
- Reiner Offline-Unit-Test (seed.test.ts) prüft alle fachlichen Invarianten
  ohne DB; package.json-Script db:seed ergänzt.

Verifikation offline: tsc --noEmit (0), drizzle-kit check (0),
next build (0), vitest run (191 passed, 7 DB-Tests skipped).
Seed-Ausführung selbst deferred (kein Postgres im Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 12:07:39 +02:00
parent 6975679c4e
commit 034fdb175f
7 changed files with 1054 additions and 0 deletions

View File

@@ -14,6 +14,7 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts", "db:migrate": "tsx scripts/migrate.ts",
"db:seed-auth": "tsx scripts/seed-auth.ts", "db:seed-auth": "tsx scripts/seed-auth.ts",
"db:seed": "tsx src/db/seed/index.ts",
"test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts", "test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",

View File

@@ -0,0 +1,34 @@
/**
* Geräte-Kategorien als Seed-Daten (Workstream 9).
*
* Abgeleitet aus den Beladungs-Highlights in
* docs/reference/fahrzeug-katalog-noelfv.md, Abschnitt 3.
* Natural Key ist `name` (UNIQUE in `equipment_categories`).
*
* `merkmalSlugs` verweist optional auf Merkmale aus dem Katalog mit
* Geltungsbereich `equipment`/`both` (z. B. hydraulischer Rettungssatz).
*/
export interface EquipmentCategorySeed {
name: string;
reihenfolge: number;
merkmalSlugs?: string[];
}
export const EQUIPMENT_CATEGORIES: EquipmentCategorySeed[] = [
{ name: "Löschgeräte", reihenfolge: 0 },
{ name: "Schläuche & Armaturen", reihenfolge: 1 },
{ name: "Atemschutz", reihenfolge: 2 },
{
name: "Technische Rettung",
reihenfolge: 3,
merkmalSlugs: ["hydraulischer_rettungssatz"],
},
{ name: "Beleuchtung & Stromerzeugung", reihenfolge: 4 },
{ name: "Zug- & Anschlagmittel", reihenfolge: 5 },
{ name: "Schadstoff & Gefahrgut", reihenfolge: 6 },
{ name: "Atemluftversorgung", reihenfolge: 7 },
{ name: "Logistik & Ladungssicherung", reihenfolge: 8 },
{ name: "Sanität & Erstversorgung", reihenfolge: 9 },
{ name: "Werkzeug & Räumgerät", reihenfolge: 10 },
];

View File

@@ -0,0 +1,273 @@
/**
* Merkmal-Katalog als Seed-Daten (Workstream 9).
*
* Abgeleitet aus docs/reference/fahrzeug-katalog-noelfv.md, Abschnitt 2.
* Genau 34 Merkmale — "Funkrufname" ist eine Spalte auf `vehicles`,
* KEIN Merkmal (siehe Plan, Phase 1).
*
* `slug` ist der Idempotenz-Key (UNIQUE in `merkmale`). Enum-Merkmale tragen
* ihre `optionen` (Werte sind die slugartigen DB-Werte, `label` die Anzeige).
*/
export type MerkmalTyp = "number" | "enum" | "boolean" | "text";
export type Geltungsbereich = "vehicle" | "equipment" | "both";
export interface MerkmalOptionSeed {
wert: string;
label: string;
reihenfolge: number;
}
export interface MerkmalSeed {
slug: string;
name: string;
typ: MerkmalTyp;
einheit?: string;
geltungsbereich: Geltungsbereich;
optionen?: MerkmalOptionSeed[];
}
export const MERKMALE: MerkmalSeed[] = [
{
slug: "loeschwassertank",
name: "Löschwassertank",
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
},
{
slug: "schaummitteltank",
name: "Schaummitteltank",
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
},
{
slug: "schaumzumischung",
name: "Schaumzumischung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "pulverloeschanlage",
name: "Pulverlöschanlage",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "pulvermenge",
name: "Pulvermenge",
typ: "number",
einheit: "kg",
geltungsbereich: "vehicle",
},
{
slug: "feuerloeschpumpe_typ",
name: "Feuerlöschpumpe (Typ)",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [
{ wert: "keine", label: "keine", reihenfolge: 0 },
{ wert: "fpn_10_750", label: "FPN 10-750", reihenfolge: 1 },
{ wert: "fpn_10_1000", label: "FPN 10-1000", reihenfolge: 2 },
{ wert: "fpn_10_2000", label: "FPN 10-2000", reihenfolge: 3 },
{ wert: "fpn_10_3000", label: "FPN 10-3000", reihenfolge: 4 },
{ wert: "fpn_10_6000", label: "FPN 10-6000", reihenfolge: 5 },
{ wert: "fph_40_250", label: "FPH 40-250", reihenfolge: 6 },
{ wert: "tragkraftspritze", label: "Tragkraftspritze", reihenfolge: 7 },
],
},
{
slug: "pumpen_foerderleistung",
name: "Pumpen-Förderleistung",
typ: "number",
einheit: "l/min",
geltungsbereich: "vehicle",
},
{
slug: "schnellangriffseinrichtung",
name: "Schnellangriffseinrichtung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "wasserwerfer",
name: "Wasserwerfer",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "wasserwerfer_foerderstrom",
name: "Wasserwerfer-Förderstrom",
typ: "number",
einheit: "l/min",
geltungsbereich: "vehicle",
},
{
slug: "besatzung_sitzplaetze",
name: "Besatzung / Sitzplätze",
typ: "number",
einheit: "Plätze",
geltungsbereich: "vehicle",
},
{
slug: "zulaessiges_gesamtgewicht",
name: "Zulässiges Gesamtgewicht",
typ: "number",
einheit: "t",
geltungsbereich: "vehicle",
},
{
slug: "anzahl_achsen",
name: "Anzahl Achsen",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [
{ wert: "2", label: "2", reihenfolge: 0 },
{ wert: "3", label: "3", reihenfolge: 1 },
{ wert: "4", label: "4", reihenfolge: 2 },
],
},
{
slug: "allradantrieb",
name: "Allradantrieb",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "motorleistung",
name: "Motorleistung",
typ: "number",
einheit: "kW",
geltungsbereich: "vehicle",
},
{
slug: "laenge",
name: "Länge",
typ: "number",
einheit: "mm",
geltungsbereich: "vehicle",
},
{
slug: "breite",
name: "Breite",
typ: "number",
einheit: "mm",
geltungsbereich: "vehicle",
},
{
slug: "hoehe",
name: "Höhe",
typ: "number",
einheit: "mm",
geltungsbereich: "vehicle",
},
{
slug: "stromerzeuger_bauart",
name: "Stromerzeuger-Bauart",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [
{ wert: "keiner", label: "keiner", reihenfolge: 0 },
{ wert: "tragbar", label: "tragbar", reihenfolge: 1 },
{ wert: "einbaugenerator", label: "Einbaugenerator", reihenfolge: 2 },
],
},
{
slug: "stromerzeuger_nennleistung",
name: "Stromerzeuger-Nennleistung",
typ: "number",
einheit: "kVA",
geltungsbereich: "vehicle",
},
{
slug: "seilwinde",
name: "Seilwinde",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "seilwinden_zugkraft",
name: "Seilwinden-Zugkraft",
typ: "number",
einheit: "kN",
geltungsbereich: "vehicle",
},
{
slug: "lichtmast",
name: "Lichtmast",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "hydraulischer_rettungssatz",
name: "Hydraulischer Rettungssatz",
typ: "boolean",
geltungsbereich: "both",
},
{
slug: "ladekran",
name: "Ladekran",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "kran_hubmoment",
name: "Kran-Hubmoment",
typ: "number",
einheit: "kNm",
geltungsbereich: "vehicle",
},
{
slug: "ladebordwand",
name: "Ladebordwand",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "ladebordwand_traglast",
name: "Ladebordwand-Traglast",
typ: "number",
einheit: "kg",
geltungsbereich: "vehicle",
},
{
slug: "wechselladeeinrichtung",
name: "Wechselladeeinrichtung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "atemluftkompressor_lieferleistung",
name: "Atemluftkompressor-Lieferleistung",
typ: "number",
einheit: "l/min",
geltungsbereich: "vehicle",
},
{
slug: "atemluft_speichervolumen",
name: "Atemluft-Speichervolumen",
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
},
{
slug: "anhaengekupplung",
name: "Anhängekupplung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "funkanlage",
name: "Funkanlage",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "baujahr",
name: "Baujahr",
typ: "number",
einheit: "Jahr",
geltungsbereich: "vehicle",
},
];

View File

@@ -0,0 +1,206 @@
/**
* Fahrzeug-Vorlagen als Seed-Daten (Workstream 9).
*
* Abgeleitet aus docs/reference/fahrzeug-katalog-noelfv.md, Abschnitte 1 und 4.
* Genau 11 Vorlagen. HLF 4-U ist KEINE eigene Vorlage, sondern ein (offener)
* Alias auf HLF 4 mit Pulver-Pflichtmerkmalen.
*
* Aliasse leben in `vehicle_template_aliasse` mit `bestaetigt`-Flag:
* - RLF 2000 / RLFA 2000 (HLF 2) = bestätigt
* - RLF 2000-4000 / RLFA 2000-4000 (HLF 3) = bestätigt
* - alle anderen = offen (bestaetigt=false)
* KEIN HLFA-Alias: Die Allrad-Namensregel ("A" eingeschoben) ist eine
* Laufzeitregel im Such-Workstream + das Merkmal `allradantrieb`.
*
* Vorgabewerte werden typgerecht in genau eine der drei Spalten geschrieben.
*/
export interface AliasSeed {
alias: string;
bestaetigt: boolean;
}
export interface TemplateMerkmalSeed {
slug: string;
vorgabewertNum?: number;
vorgabewertText?: string;
vorgabewertBool?: boolean;
pflicht?: boolean;
}
export interface VehicleTemplateSeed {
code: string;
name: string;
beschreibung?: string;
aliasse: AliasSeed[];
merkmale: TemplateMerkmalSeed[];
}
export const VEHICLE_TEMPLATES: VehicleTemplateSeed[] = [
{
code: "HLF 1",
name: "Hilfeleistungsfahrzeug 1",
beschreibung:
"Brandbekämpfung/Löschwasserförderung mit Tragkraftspritze plus einfache technische Hilfeleistung (NÖ LFV-RL FA 01).",
aliasse: [
{ alias: "KLF", bestaetigt: false },
{ alias: "KLFA", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "tragkraftspritze", pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 1 W",
name: "Hilfeleistungsfahrzeug 1 Wasser",
beschreibung:
"Wasserführendes HLF 1 mit Tank, Einbaupumpe und Schnellangriff (NÖ LFV-RL FA 01/W).",
aliasse: [
{ alias: "KLFA-W", bestaetigt: false },
{ alias: "kleines LFA mit Tank", bestaetigt: false },
],
merkmale: [
{ slug: "loeschwassertank", vorgabewertNum: 800, pflicht: true },
{ slug: "schnellangriffseinrichtung", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 2",
name: "Hilfeleistungsfahrzeug 2",
beschreibung:
"Brandbekämpfung und technische Einsatzleistung, Tank 8002.000 l (NÖ LFV-RL FA 02).",
aliasse: [
{ alias: "RLF 2000", bestaetigt: true },
{ alias: "RLFA 2000", bestaetigt: true },
{ alias: "LF", bestaetigt: false },
{ alias: "LFA", bestaetigt: false },
{ alias: "TLFA 2000", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_1000", pflicht: true },
{ slug: "loeschwassertank", vorgabewertNum: 2000, pflicht: true },
{ slug: "schnellangriffseinrichtung", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 3",
name: "Hilfeleistungsfahrzeug 3",
beschreibung:
"Große Brandbekämpfung und technische Einsatzleistung, Tank >2.0004.000 l (NÖ LFV-RL FA 03).",
aliasse: [
{ alias: "RLF 2000-4000", bestaetigt: true },
{ alias: "RLFA 2000-4000", bestaetigt: true },
{ alias: "TLFA 2000-4000", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_2000", pflicht: true },
{ slug: "loeschwassertank", vorgabewertNum: 4000, pflicht: true },
{ slug: "wasserwerfer", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 4",
name: "Hilfeleistungsfahrzeug 4",
beschreibung:
"Großtanklöschfahrzeug / Wasserversorgung, Tank >5.00014.000 l (NÖ LFV-RL FA 07). HLF 4-U: Universallöschmittel mit Pulveranlage ≥250 kg.",
aliasse: [
{ alias: "HLF 4-U", bestaetigt: false },
{ alias: "großes TLFA", bestaetigt: false },
{ alias: "GTLF", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_3000", pflicht: true },
{ slug: "loeschwassertank", vorgabewertNum: 8000, pflicht: true },
{ slug: "wasserwerfer", vorgabewertBool: true, pflicht: true },
{ slug: "pulverloeschanlage", vorgabewertBool: true, pflicht: true },
{ slug: "pulvermenge", vorgabewertNum: 250 },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "VRF",
name: "Vorausrüstfahrzeug",
beschreibung:
"Technische Hilfeleistung mit hydraulischem Rettungssatz als Kern (NÖ LFV-RL FA 04).",
aliasse: [
{ alias: "KRF", bestaetigt: false },
{ alias: "KRFA", bestaetigt: false },
{ alias: "Vorausrüster", bestaetigt: false },
],
merkmale: [
{ slug: "hydraulischer_rettungssatz", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "VF",
name: "Versorgungs-Logistikfahrzeug",
beschreibung:
"Transport und Logistik mit Ladebordwand, optional Kran (NÖ LFV-RL FA 06).",
aliasse: [
{ alias: "LAST", bestaetigt: false },
{ alias: "Versorgungsfahrzeug", bestaetigt: false },
],
merkmale: [
{ slug: "ladebordwand", vorgabewertBool: true, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "ALF",
name: "Atemluftfahrzeug",
beschreibung:
"Atemluftversorgung und Flaschenfüllung mit Atemluftkompressor (NÖ LFV-RL FA 09).",
aliasse: [{ alias: "Atemluftfahrzeug", bestaetigt: false }],
merkmale: [
{ slug: "atemluftkompressor_lieferleistung", vorgabewertNum: 250, pflicht: true },
{ slug: "atemluft_speichervolumen", vorgabewertNum: 30000, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "SSTF",
name: "Schadstofffahrzeug",
beschreibung:
"Gefahrgut- und Schadstoffeinsatz: Abdichten, Auffangen, Messen (NÖ LFV-RL FA 10).",
aliasse: [
{ alias: "Schadstofffahrzeug", bestaetigt: false },
{ alias: "Gefahrgutfahrzeug", bestaetigt: false },
],
merkmale: [
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "WLF",
name: "Wechselladerfahrzeug",
beschreibung:
"Transport von Abrollbehältern, optional Kran/Winde (NÖ LFV-RL FA 05).",
aliasse: [{ alias: "WLFA", bestaetigt: false }],
merkmale: [
{ slug: "wechselladeeinrichtung", vorgabewertBool: true, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "MTF",
name: "Mannschaftstransportfahrzeug",
beschreibung:
"Reiner Mannschaftstransport, 714 Sitzplätze, kein Tank/Pumpe (ÖBFV-RL FA 30).",
aliasse: [{ alias: "MTFA", bestaetigt: false }],
merkmale: [
{ slug: "besatzung_sitzplaetze", vorgabewertNum: 9, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
];

117
src/db/seed/index.ts Normal file
View File

@@ -0,0 +1,117 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "@/db/schema";
import type { Tx } from "@/lib/audit";
import { MERKMALE } from "./data/merkmale";
import { VEHICLE_TEMPLATES } from "./data/vehicle-templates";
import { EQUIPMENT_CATEGORIES } from "./data/equipment-categories";
import {
upsertMerkmal,
upsertVehicleTemplate,
upsertTemplateMerkmal,
upsertTemplateAlias,
upsertEquipmentCategory,
upsertCategoryMerkmal,
pruneTemplateAliasse,
} from "./upsert";
/**
* Katalog-Seed (Workstream 9): füllt Merkmale, Enum-Optionen, Fahrzeug-Vorlagen,
* deren Pflichtmerkmale + Aliasse sowie Geräte-Kategorien aus dem NÖ-Katalog.
*
* Idempotent (Querschnittsstandard 7): ausschließlich Upserts auf Natural Keys;
* mehrfaches Ausführen ändert keine Counts. Läuft in EINER Transaktion in der
* Reihenfolge Merkmale → Optionen → Vorlagen → Vorlagen-Merkmale → Aliasse →
* Kategorien (sequenzielle Awaits, Slug→ID-Map).
*
* Liest `DATABASE_URL` direkt aus der Umgebung (keine Next.js-Env-Validierung,
* wie scripts/migrate.ts und scripts/seed-auth.ts).
*/
/** Reine Seed-Logik gegen eine bestehende Transaktion (für Tests injizierbar). */
export async function seedCatalog(tx: Tx): Promise<void> {
// 1. Merkmale (+ Optionen) → Slug→ID-Map.
const merkmalIdBySlug = new Map<string, string>();
for (const m of MERKMALE) {
const id = await upsertMerkmal(tx, m);
merkmalIdBySlug.set(m.slug, id);
}
// 2. Vorlagen → Pflichtmerkmale → Aliasse.
for (let i = 0; i < VEHICLE_TEMPLATES.length; i++) {
const t = VEHICLE_TEMPLATES[i]!;
const templateId = await upsertVehicleTemplate(tx, t, i);
for (let j = 0; j < t.merkmale.length; j++) {
const tm = t.merkmale[j]!;
const merkmalId = merkmalIdBySlug.get(tm.slug);
if (!merkmalId) {
throw new Error(
`Vorlage ${t.code} referenziert unbekanntes Merkmal '${tm.slug}'`,
);
}
await upsertTemplateMerkmal(tx, templateId, merkmalId, tm, j);
}
for (const a of t.aliasse) {
await upsertTemplateAlias(tx, templateId, a.alias, a.bestaetigt);
}
await pruneTemplateAliasse(
tx,
templateId,
t.aliasse.map((a) => a.alias),
);
}
// 3. Geräte-Kategorien (+ optionale Merkmal-Verknüpfungen).
for (const c of EQUIPMENT_CATEGORIES) {
const categoryId = await upsertEquipmentCategory(tx, c);
const slugs = c.merkmalSlugs ?? [];
for (let k = 0; k < slugs.length; k++) {
const merkmalId = merkmalIdBySlug.get(slugs[k]!);
if (!merkmalId) {
throw new Error(
`Kategorie ${c.name} referenziert unbekanntes Merkmal '${slugs[k]}'`,
);
}
await upsertCategoryMerkmal(tx, categoryId, merkmalId, k);
}
}
}
/** Standalone-Runner (npm run db:seed). */
export async function main(): Promise<void> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt.");
}
const pool = new Pool({ connectionString, max: 1 });
const db = drizzle(pool, { schema });
try {
await db.transaction(async (tx) => {
await seedCatalog(tx as Tx);
});
console.log("Katalog-Seed erfolgreich (idempotent).");
console.log(` Merkmale: ${MERKMALE.length}`);
console.log(` Fahrzeug-Vorlagen: ${VEHICLE_TEMPLATES.length}`);
console.log(` Geräte-Kategorien: ${EQUIPMENT_CATEGORIES.length}`);
} finally {
await pool.end();
}
}
// Nur ausführen, wenn direkt gestartet (nicht beim Import in Tests).
const isMain =
typeof process !== "undefined" &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err: unknown) => {
console.error("Katalog-Seed fehlgeschlagen:", err);
process.exit(1);
});
}

205
src/db/seed/seed.test.ts Normal file
View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from "vitest";
import { MERKMALE } from "./data/merkmale";
import { VEHICLE_TEMPLATES } from "./data/vehicle-templates";
import { EQUIPMENT_CATEGORIES } from "./data/equipment-categories";
/**
* Reiner Offline-Unit-Test der Seed-DATEN (kein Postgres nötig).
*
* Prüft die fachlichen Invarianten aus Workstream 9 statisch:
* - 34 Merkmale (Funkrufname ist Spalte, NICHT Merkmal)
* - 11 Vorlagen, 11 Geräte-Kategorien
* - Enum-Optionen: feuerloeschpumpe_typ=8, anzahl_achsen=3, stromerzeuger_bauart=3
* - Aliasse mit `bestaetigt`; RLF/RLFA 2000 + 2000-4000 = true
* - KEIN HLFA-Alias (Laufzeitregel ist kanonisch)
* - HLF 4-U Alias auf HLF 4 + Pulver-Pflichtmerkmale
* - Idempotenz-Keys eindeutig (slug, code, name)
* - Jeder Template-Merkmal-slug existiert im Merkmal-Katalog
* - Vorgabewerte typgerecht zur Merkmal-typ
*/
describe("Seed-Daten: Merkmale", () => {
it("enthält genau 34 Merkmale", () => {
expect(MERKMALE).toHaveLength(34);
});
it("enthält KEIN Funkrufname-Merkmal (ist Spalte auf vehicles)", () => {
const namen = MERKMALE.map((m) => m.name.toLowerCase());
expect(namen).not.toContain("funkrufname");
const slugs = MERKMALE.map((m) => m.slug);
expect(slugs).not.toContain("funkrufname");
});
it("hat eindeutige slugs", () => {
const slugs = MERKMALE.map((m) => m.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it("hat eindeutige Namen", () => {
const namen = MERKMALE.map((m) => m.name);
expect(new Set(namen).size).toBe(namen.length);
});
it("feuerloeschpumpe_typ hat 8 Enum-Optionen", () => {
const m = MERKMALE.find((x) => x.slug === "feuerloeschpumpe_typ");
expect(m).toBeDefined();
expect(m?.typ).toBe("enum");
expect(m?.optionen).toHaveLength(8);
});
it("anzahl_achsen hat 3 Enum-Optionen", () => {
const m = MERKMALE.find((x) => x.slug === "anzahl_achsen");
expect(m?.optionen).toHaveLength(3);
});
it("stromerzeuger_bauart hat 3 Enum-Optionen", () => {
const m = MERKMALE.find((x) => x.slug === "stromerzeuger_bauart");
expect(m?.optionen).toHaveLength(3);
});
it("Enum-Merkmale haben Optionen, Nicht-Enum-Merkmale keine", () => {
for (const m of MERKMALE) {
if (m.typ === "enum") {
expect(m.optionen, `${m.slug} braucht Optionen`).toBeDefined();
expect((m.optionen ?? []).length).toBeGreaterThan(0);
} else {
expect(m.optionen ?? [], `${m.slug} darf keine Optionen haben`).toHaveLength(0);
}
}
});
it("jede Enum-Option hat eindeutige Werte je Merkmal", () => {
for (const m of MERKMALE) {
const werte = (m.optionen ?? []).map((o) => o.wert);
expect(new Set(werte).size, `${m.slug} doppelte Optionswerte`).toBe(werte.length);
}
});
it("nur erlaubte geltungsbereich-Werte", () => {
const erlaubt = new Set(["vehicle", "equipment", "both"]);
for (const m of MERKMALE) {
expect(erlaubt.has(m.geltungsbereich), `${m.slug}`).toBe(true);
}
});
});
describe("Seed-Daten: Vehicle-Templates", () => {
it("enthält genau 11 Vorlagen", () => {
expect(VEHICLE_TEMPLATES).toHaveLength(11);
});
it("hat eindeutige codes", () => {
const codes = VEHICLE_TEMPLATES.map((t) => t.code);
expect(new Set(codes).size).toBe(codes.length);
});
it("enthält KEINE eigene HLFA-Vorlage (Allrad ist Laufzeitregel)", () => {
const codes = VEHICLE_TEMPLATES.map((t) => t.code);
expect(codes.some((c) => c.startsWith("HLFA"))).toBe(false);
});
it("enthält die 11 erwarteten Codes", () => {
const codes = VEHICLE_TEMPLATES.map((t) => t.code).sort();
expect(codes).toEqual(
["HLF 1", "HLF 1 W", "HLF 2", "HLF 3", "HLF 4", "VRF", "VF", "ALF", "SSTF", "WLF", "MTF"].sort(),
);
});
it("hat KEINEN HLFA-Alias", () => {
const aliasse = VEHICLE_TEMPLATES.flatMap((t) => t.aliasse.map((a) => a.alias));
expect(aliasse.some((a) => a.toUpperCase().startsWith("HLFA"))).toBe(false);
});
it("HLF 2: RLF 2000 und RLFA 2000 sind bestätigte Aliasse", () => {
const hlf2 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 2");
const byAlias = new Map(hlf2!.aliasse.map((a) => [a.alias, a.bestaetigt]));
expect(byAlias.get("RLF 2000")).toBe(true);
expect(byAlias.get("RLFA 2000")).toBe(true);
});
it("HLF 3: RLF 2000-4000 und RLFA 2000-4000 sind bestätigte Aliasse", () => {
const hlf3 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 3");
const byAlias = new Map(hlf3!.aliasse.map((a) => [a.alias, a.bestaetigt]));
expect(byAlias.get("RLF 2000-4000")).toBe(true);
expect(byAlias.get("RLFA 2000-4000")).toBe(true);
});
it("HLF 4: HLF 4-U ist ein (offener) Alias", () => {
const hlf4 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 4");
const alias = hlf4!.aliasse.find((a) => a.alias === "HLF 4-U");
expect(alias).toBeDefined();
expect(alias?.bestaetigt).toBe(false);
});
it("HLF 4: pulverloeschanlage ist Pflichtmerkmal", () => {
const hlf4 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 4");
const m = hlf4!.merkmale.find((x) => x.slug === "pulverloeschanlage");
expect(m).toBeDefined();
expect(m?.pflicht).toBe(true);
expect(m?.vorgabewertBool).toBe(true);
});
it("nur bestätigte Aliasse sind RLF/RLFA 2000 bzw. 2000-4000", () => {
const bestaetigt = VEHICLE_TEMPLATES.flatMap((t) =>
t.aliasse.filter((a) => a.bestaetigt).map((a) => a.alias),
).sort();
expect(bestaetigt).toEqual(
["RLF 2000", "RLFA 2000", "RLF 2000-4000", "RLFA 2000-4000"].sort(),
);
});
it("alle Aliasse je Vorlage eindeutig", () => {
for (const t of VEHICLE_TEMPLATES) {
const a = t.aliasse.map((x) => x.alias);
expect(new Set(a).size, `${t.code} doppelte Aliasse`).toBe(a.length);
}
});
it("jeder Template-Merkmal-slug existiert im Merkmal-Katalog", () => {
const known = new Set(MERKMALE.map((m) => m.slug));
for (const t of VEHICLE_TEMPLATES) {
for (const m of t.merkmale) {
expect(known.has(m.slug), `${t.code} -> unbekannter slug ${m.slug}`).toBe(true);
}
}
});
it("Vorgabewerte sind typgerecht zum Merkmal-typ gesetzt", () => {
const bySlug = new Map(MERKMALE.map((m) => [m.slug, m]));
for (const t of VEHICLE_TEMPLATES) {
for (const tm of t.merkmale) {
const def = bySlug.get(tm.slug)!;
if (def.typ === "number" && tm.vorgabewertNum !== undefined) {
expect(tm.vorgabewertText, `${t.code}/${tm.slug}`).toBeUndefined();
expect(tm.vorgabewertBool, `${t.code}/${tm.slug}`).toBeUndefined();
}
if (def.typ === "boolean" && tm.vorgabewertBool !== undefined) {
expect(tm.vorgabewertNum, `${t.code}/${tm.slug}`).toBeUndefined();
expect(tm.vorgabewertText, `${t.code}/${tm.slug}`).toBeUndefined();
}
if ((def.typ === "enum" || def.typ === "text") && tm.vorgabewertText !== undefined) {
expect(tm.vorgabewertNum, `${t.code}/${tm.slug}`).toBeUndefined();
expect(tm.vorgabewertBool, `${t.code}/${tm.slug}`).toBeUndefined();
}
// Enum-Vorgabewert muss eine gültige Option sein
if (def.typ === "enum" && tm.vorgabewertText !== undefined) {
const werte = (def.optionen ?? []).map((o) => o.wert);
expect(werte, `${t.code}/${tm.slug}=${tm.vorgabewertText}`).toContain(
tm.vorgabewertText,
);
}
}
}
});
});
describe("Seed-Daten: Equipment-Categories", () => {
it("enthält genau 11 Kategorien", () => {
expect(EQUIPMENT_CATEGORIES).toHaveLength(11);
});
it("hat eindeutige Namen", () => {
const namen = EQUIPMENT_CATEGORIES.map((c) => c.name);
expect(new Set(namen).size).toBe(namen.length);
});
});

218
src/db/seed/upsert.ts Normal file
View File

@@ -0,0 +1,218 @@
import { eq, and } from "drizzle-orm";
import type { Tx } from "@/lib/audit";
import * as schema from "@/db/schema";
import type { MerkmalSeed } from "./data/merkmale";
import type { VehicleTemplateSeed } from "./data/vehicle-templates";
import type { EquipmentCategorySeed } from "./data/equipment-categories";
/**
* Idempotente Upserts für Workstream-9-Seeds (Querschnittsstandard 7:
* ausschließlich `onConflictDoUpdate` auf Natural Keys, mehrfaches Ausführen
* ändert keine Counts).
*
* Natural Keys:
* - merkmale.slug
* - merkmal_optionen(merkmalId, wert)
* - vehicle_templates.code
* - vehicle_template_merkmale(templateId, merkmalId) [PK]
* - vehicle_template_aliasse(templateId, alias)
* - equipment_categories.name
* - equipment_category_merkmale(categoryId, merkmalId) [PK]
*
* Alle Funktionen nehmen die laufende Transaktion `tx`, damit der gesamte Seed
* atomar bleibt.
*/
/** Upsert eines Merkmals (+ Enum-Optionen). Liefert die Merkmal-UUID. */
export async function upsertMerkmal(tx: Tx, m: MerkmalSeed): Promise<string> {
const [row] = await tx
.insert(schema.merkmale)
.values({
slug: m.slug,
name: m.name,
typ: m.typ,
einheit: m.einheit ?? null,
geltungsbereich: m.geltungsbereich,
// Katalog-Merkmale gelten unmittelbar als aktiv (kein Vorschlag).
status: "active",
})
.onConflictDoUpdate({
target: schema.merkmale.slug,
set: {
name: m.name,
typ: m.typ,
einheit: m.einheit ?? null,
geltungsbereich: m.geltungsbereich,
status: "active",
},
})
.returning({ id: schema.merkmale.id });
if (!row) throw new Error(`Merkmal-Upsert ohne Rückgabe: ${m.slug}`);
for (const opt of m.optionen ?? []) {
await tx
.insert(schema.merkmalOptionen)
.values({
merkmalId: row.id,
wert: opt.wert,
label: opt.label,
reihenfolge: opt.reihenfolge,
})
.onConflictDoUpdate({
target: [schema.merkmalOptionen.merkmalId, schema.merkmalOptionen.wert],
set: { label: opt.label, reihenfolge: opt.reihenfolge },
});
}
return row.id;
}
/** Upsert einer Vorlage (ohne Merkmale/Aliasse). Liefert die Template-UUID. */
export async function upsertVehicleTemplate(
tx: Tx,
t: VehicleTemplateSeed,
reihenfolge: number,
): Promise<string> {
const [row] = await tx
.insert(schema.vehicleTemplates)
.values({
code: t.code,
name: t.name,
beschreibung: t.beschreibung ?? null,
reihenfolge,
})
.onConflictDoUpdate({
target: schema.vehicleTemplates.code,
set: { name: t.name, beschreibung: t.beschreibung ?? null, reihenfolge },
})
.returning({ id: schema.vehicleTemplates.id });
if (!row) throw new Error(`Vorlagen-Upsert ohne Rückgabe: ${t.code}`);
return row.id;
}
/** Upsert eines Vorlagen-Pflichtmerkmals (PK templateId+merkmalId). */
export async function upsertTemplateMerkmal(
tx: Tx,
templateId: string,
merkmalId: string,
m: VehicleTemplateSeed["merkmale"][number],
reihenfolge: number,
): Promise<void> {
await tx
.insert(schema.vehicleTemplateMerkmale)
.values({
templateId,
merkmalId,
vorgabewertNum: m.vorgabewertNum ?? null,
vorgabewertText: m.vorgabewertText ?? null,
vorgabewertBool: m.vorgabewertBool ?? null,
pflicht: m.pflicht ?? false,
reihenfolge,
})
.onConflictDoUpdate({
target: [
schema.vehicleTemplateMerkmale.templateId,
schema.vehicleTemplateMerkmale.merkmalId,
],
set: {
vorgabewertNum: m.vorgabewertNum ?? null,
vorgabewertText: m.vorgabewertText ?? null,
vorgabewertBool: m.vorgabewertBool ?? null,
pflicht: m.pflicht ?? false,
reihenfolge,
},
});
}
/** Upsert eines Alias (Natural Key templateId+alias). */
export async function upsertTemplateAlias(
tx: Tx,
templateId: string,
alias: string,
bestaetigt: boolean,
): Promise<void> {
await tx
.insert(schema.vehicleTemplateAliasse)
.values({ templateId, alias, bestaetigt })
.onConflictDoUpdate({
target: [
schema.vehicleTemplateAliasse.templateId,
schema.vehicleTemplateAliasse.alias,
],
set: { bestaetigt },
});
}
/** Upsert einer Geräte-Kategorie (Natural Key name). Liefert die UUID. */
export async function upsertEquipmentCategory(
tx: Tx,
c: EquipmentCategorySeed,
): Promise<string> {
const [row] = await tx
.insert(schema.equipmentCategories)
.values({ name: c.name, reihenfolge: c.reihenfolge })
.onConflictDoUpdate({
target: schema.equipmentCategories.name,
set: { reihenfolge: c.reihenfolge },
})
.returning({ id: schema.equipmentCategories.id });
if (!row) throw new Error(`Kategorie-Upsert ohne Rückgabe: ${c.name}`);
return row.id;
}
/**
* Upsert der Kategorie-Merkmal-Verknüpfung (PK categoryId+merkmalId).
* Idempotent über `onConflictDoNothing` (keine zusätzlichen Felder zu
* aktualisieren außer reihenfolge).
*/
export async function upsertCategoryMerkmal(
tx: Tx,
categoryId: string,
merkmalId: string,
reihenfolge: number,
): Promise<void> {
await tx
.insert(schema.equipmentCategoryMerkmale)
.values({ categoryId, merkmalId, reihenfolge })
.onConflictDoUpdate({
target: [
schema.equipmentCategoryMerkmale.categoryId,
schema.equipmentCategoryMerkmale.merkmalId,
],
set: { reihenfolge },
});
}
/**
* Entfernt Aliasse zu einer Vorlage, die NICHT mehr im Seed stehen
* (z. B. nachdem ein Alias aus dem Katalog gestrichen wurde). Hält den
* Aliasse-Bestand exakt deckungsgleich mit dem Seed und damit idempotent,
* ohne verwaiste Aliasse zu hinterlassen.
*/
export async function pruneTemplateAliasse(
tx: Tx,
templateId: string,
keepAliasse: readonly string[],
): Promise<void> {
const existing = await tx
.select({ alias: schema.vehicleTemplateAliasse.alias })
.from(schema.vehicleTemplateAliasse)
.where(eq(schema.vehicleTemplateAliasse.templateId, templateId));
const keep = new Set(keepAliasse);
for (const e of existing) {
if (!keep.has(e.alias)) {
await tx
.delete(schema.vehicleTemplateAliasse)
.where(
and(
eq(schema.vehicleTemplateAliasse.templateId, templateId),
eq(schema.vehicleTemplateAliasse.alias, e.alias),
),
);
}
}
}