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

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),
),
);
}
}
}