From 034fdb175f0d28daf0fcfaf7c82668b04586a586 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 9 Jun 2026 12:07:39 +0200 Subject: [PATCH] =?UTF-8?q?Workstream=209:=20Seed-Daten=20aus=20N=C3=96-Ka?= =?UTF-8?q?talog=20(Phase=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 1 + src/db/seed/data/equipment-categories.ts | 34 +++ src/db/seed/data/merkmale.ts | 273 +++++++++++++++++++++++ src/db/seed/data/vehicle-templates.ts | 206 +++++++++++++++++ src/db/seed/index.ts | 117 ++++++++++ src/db/seed/seed.test.ts | 205 +++++++++++++++++ src/db/seed/upsert.ts | 218 ++++++++++++++++++ 7 files changed, 1054 insertions(+) create mode 100644 src/db/seed/data/equipment-categories.ts create mode 100644 src/db/seed/data/merkmale.ts create mode 100644 src/db/seed/data/vehicle-templates.ts create mode 100644 src/db/seed/index.ts create mode 100644 src/db/seed/seed.test.ts create mode 100644 src/db/seed/upsert.ts diff --git a/package.json b/package.json index 93466bd..e8025f9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "tsx scripts/migrate.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", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", diff --git a/src/db/seed/data/equipment-categories.ts b/src/db/seed/data/equipment-categories.ts new file mode 100644 index 0000000..70636a9 --- /dev/null +++ b/src/db/seed/data/equipment-categories.ts @@ -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 }, +]; diff --git a/src/db/seed/data/merkmale.ts b/src/db/seed/data/merkmale.ts new file mode 100644 index 0000000..d268fff --- /dev/null +++ b/src/db/seed/data/merkmale.ts @@ -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", + }, +]; diff --git a/src/db/seed/data/vehicle-templates.ts b/src/db/seed/data/vehicle-templates.ts new file mode 100644 index 0000000..e8822af --- /dev/null +++ b/src/db/seed/data/vehicle-templates.ts @@ -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 800–2.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.000–4.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.000–14.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, 7–14 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 }, + ], + }, +]; diff --git a/src/db/seed/index.ts b/src/db/seed/index.ts new file mode 100644 index 0000000..d64c96e --- /dev/null +++ b/src/db/seed/index.ts @@ -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 { + // 1. Merkmale (+ Optionen) → Slug→ID-Map. + const merkmalIdBySlug = new Map(); + 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 { + 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); + }); +} diff --git a/src/db/seed/seed.test.ts b/src/db/seed/seed.test.ts new file mode 100644 index 0000000..4726b86 --- /dev/null +++ b/src/db/seed/seed.test.ts @@ -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); + }); +}); diff --git a/src/db/seed/upsert.ts b/src/db/seed/upsert.ts new file mode 100644 index 0000000..86f8bfe --- /dev/null +++ b/src/db/seed/upsert.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + ), + ); + } + } +}