Read an import file without guessing what it means

First half of the mass import: a file becomes named sheets with typed rows,
and every rule that could reject a row is stated in one place.

Nothing here touches a database. The parser turns bytes into sheets, the
schema says which columns exist, and validation reports findings — the
existing state is passed in as a parameter. That is what makes 36 tests
possible without a connection, and the rules are the part worth testing.

Three decisions where the easy choice would have been silent corruption:

  - A two-digit year is refused. "15.08.68" is 1968 as a birth date and 2068
    as a contract end, and any rule invented here creates people not yet
    born.
  - "31.02.2026" is refused. Date turns it into March 3rd without complaint.
  - An unrecognised value in a yes/no column is an error, not "no". Read the
    other way, a typo in "Betriebsrat" quietly removes someone's dismissal
    protection.

CSV is parsed rather than split. German Excel writes semicolons because the
comma is the decimal separator, so the delimiter is sniffed from the header;
a semicolon inside a quoted address would otherwise shift every following
column and import the row plausibly wrong. Quoted newlines, doubled quotes
and the byte-order mark Excel prepends are all handled — the last one makes
the first column read as "?Personalnummer", which is invisible in an editor.

Validation collects every finding instead of stopping at the first. With 800
rows that is the difference between correcting once and uploading eight
hundred times.

One rule earns its place from experience: a history event dated before the
entry it belongs to is refused here, with a row number, because the database
refuses it too — mid-insert, without one.

My own slip, caught by the type checker: `a ?? b ? c : d` does not mean what
it looks like; ?? binds tighter than the conditional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:27:49 +02:00
parent 73656461d1
commit 16b37244c8
6 changed files with 1576 additions and 0 deletions

242
lib/import/parse.ts Normal file
View File

@@ -0,0 +1,242 @@
// Einlesen einer Importdatei — XLSX oder CSV.
//
// Zwei Dinge macht diese Datei und sonst nichts: aus Bytes werden benannte
// Blätter mit Zeilen, und aus Zellen werden verlässliche Rohwerte. Was die
// Werte *bedeuten* dürfen, steht in schema.ts; ob sie stimmen, entscheidet
// validate.ts. Diese Trennung ist der Grund, warum sich das Format testen
// lässt, ohne eine Datenbank oder eine Tabellenkalkulation zu brauchen.
import ExcelJS from "exceljs";
/** Eine Zeile, mit der Nummer aus der Datei — ohne die ist ein Fehler nutzlos. */
export type ImportRow = {
/** Zeilennummer wie in Excel angezeigt, Kopfzeile ist 1. */
zeile: number;
werte: Record<string, string>;
};
export type ImportSheet = {
name: string;
spalten: string[];
zeilen: ImportRow[];
};
export type ParseResult = {
blaetter: ImportSheet[];
/** Probleme beim Lesen selbst — kaputte Datei, leeres Blatt, doppelte Spalte. */
fehler: string[];
};
/**
* Trennzeichen einer CSV-Datei bestimmen.
*
* Deutschsprachiges Excel schreibt Semikolon, weil das Komma das
* Dezimaltrennzeichen ist. Eine feste Annahme auf `,` liest solche Dateien
* als eine einzige Spalte ein — und das sieht dann aus wie „die Datei hat
* keine der erwarteten Spalten", was in die Irre führt.
*
* Gezählt wird nur in der Kopfzeile, und nur ausserhalb von Anführungszeichen.
*/
export function trennzeichenErkennen(kopfzeile: string): string {
const kandidaten = [";", ",", "\t", "|"];
let bestes = ";";
let meiste = -1;
for (const k of kandidaten) {
let anzahl = 0;
let inAnfuehrung = false;
for (let i = 0; i < kopfzeile.length; i++) {
const c = kopfzeile[i];
if (c === '"') inAnfuehrung = !inAnfuehrung;
else if (c === k && !inAnfuehrung) anzahl++;
}
if (anzahl > meiste) {
meiste = anzahl;
bestes = k;
}
}
return bestes;
}
/**
* CSV nach RFC 4180, mit den Abweichungen, die in der Praxis vorkommen:
* Zeilenumbrüche innerhalb von Anführungszeichen, verdoppelte
* Anführungszeichen als Escape, CRLF wie LF.
*
* Eine eigene Zerlegung statt einer Bibliothek, weil genau diese drei Fälle
* das sind, woran naive Zerlegungen scheitern — und weil ein Feld mit einem
* Semikolon darin (eine Adresse, eine Beschreibung) sonst still die Spalten
* verschiebt und die Zeile plausibel falsch importiert wird.
*/
export function csvZerlegen(text: string, trennzeichen?: string): string[][] {
const ohneBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
const trenner = trennzeichen ?? trennzeichenErkennen(ohneBom.split(/\r?\n/, 1)[0] ?? "");
const zeilen: string[][] = [];
let feld = "";
let zeile: string[] = [];
let inAnfuehrung = false;
for (let i = 0; i < ohneBom.length; i++) {
const c = ohneBom[i];
if (inAnfuehrung) {
if (c === '"') {
if (ohneBom[i + 1] === '"') {
feld += '"';
i++;
} else {
inAnfuehrung = false;
}
} else {
feld += c;
}
continue;
}
if (c === '"') {
inAnfuehrung = true;
} else if (c === trenner) {
zeile.push(feld);
feld = "";
} else if (c === "\n") {
zeile.push(feld);
zeilen.push(zeile);
zeile = [];
feld = "";
} else if (c !== "\r") {
feld += c;
}
}
// Letzte Zeile ohne abschliessenden Umbruch.
if (feld !== "" || zeile.length > 0) {
zeile.push(feld);
zeilen.push(zeile);
}
return zeilen;
}
/** Leerzeichen weg, doppelte innen zusammenziehen — Kopfzeilen sind selten sauber. */
function spaltenName(roh: unknown): string {
return String(roh ?? "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Zellwert als Zeichenkette.
*
* Datumswerte werden hier **nicht** interpretiert, sondern als ISO-Tag
* ausgegeben, wenn Excel sie bereits als Datum führt. Der Rest bleibt Text
* und wird erst in schema.ts gedeutet — dort weiss man, ob eine Spalte ein
* Datum sein soll, und kann einen Fehler melden statt zu raten.
*/
function zellText(wert: ExcelJS.CellValue): string {
if (wert === null || wert === undefined) return "";
if (wert instanceof Date) {
// Excel führt Datumswerte ohne Zeitzone; toISOString() würde sie über UTC
// schieben und in Österreich einen Tag zu früh ausgeben.
const m = String(wert.getUTCMonth() + 1).padStart(2, "0");
const t = String(wert.getUTCDate()).padStart(2, "0");
return `${wert.getUTCFullYear()}-${m}-${t}`;
}
if (typeof wert === "object") {
const o = wert as { text?: unknown; result?: unknown; richText?: { text: string }[]; error?: unknown };
if (Array.isArray(o.richText)) return o.richText.map((t) => t.text).join("");
// Formelzellen: das Ergebnis zählt, nicht die Formel. Eine Fehlerzelle
// (#NV, #WERT!) wird als Text durchgereicht und fällt in der Prüfung auf.
if (o.error !== undefined) return String(o.error);
if (o.result !== undefined) return zellText(o.result as ExcelJS.CellValue);
if (o.text !== undefined) return String(o.text);
return "";
}
return String(wert);
}
function zeilenAusMatrix(name: string, matrix: string[][]): { blatt: ImportSheet; fehler: string[] } {
const fehler: string[] = [];
const kopf = (matrix[0] ?? []).map(spaltenName);
// Doppelte Spaltennamen: die zweite überschriebe die erste stillschweigend.
const gesehen = new Set<string>();
for (const s of kopf) {
if (!s) continue;
if (gesehen.has(s)) fehler.push(`Blatt „${name}“: Spalte „${s}“ kommt mehrfach vor.`);
gesehen.add(s);
}
const zeilen: ImportRow[] = [];
for (let i = 1; i < matrix.length; i++) {
const roh = matrix[i];
const werte: Record<string, string> = {};
let leer = true;
for (let j = 0; j < kopf.length; j++) {
const spalte = kopf[j];
if (!spalte) continue;
const wert = (roh[j] ?? "").trim();
werte[spalte] = wert;
if (wert !== "") leer = false;
}
// Leerzeilen kommen in gepflegten Dateien ständig vor (Abstand, gelöschte
// Einträge) und sind keine Fehler.
if (!leer) zeilen.push({ zeile: i + 1, werte });
}
return { blatt: { name, spalten: kopf.filter(Boolean), zeilen }, fehler };
}
/** Liest eine XLSX-Mappe; jedes Arbeitsblatt wird ein Blatt. */
export async function xlsxLesen(daten: ArrayBuffer): Promise<ParseResult> {
const mappe = new ExcelJS.Workbook();
try {
await mappe.xlsx.load(daten);
} catch {
return { blaetter: [], fehler: ["Die Datei liess sich nicht als Excel-Mappe lesen."] };
}
const blaetter: ImportSheet[] = [];
const fehler: string[] = [];
mappe.eachSheet((arbeitsblatt) => {
const matrix: string[][] = [];
arbeitsblatt.eachRow({ includeEmpty: true }, (zeile) => {
const werte: string[] = [];
// `values` ist 1-basiert und hat an Position 0 eine Lücke.
const roh = zeile.values as ExcelJS.CellValue[];
for (let i = 1; i < roh.length; i++) werte.push(zellText(roh[i]));
matrix.push(werte);
});
if (matrix.length === 0) return;
const { blatt, fehler: f } = zeilenAusMatrix(arbeitsblatt.name.trim(), matrix);
blaetter.push(blatt);
fehler.push(...f);
});
return { blaetter, fehler };
}
/**
* Liest eine CSV-Datei als *ein* Blatt.
*
* Den Blattnamen liefert der Dateiname, weil eine CSV keinen kennt: aus
* „Personen.csv" wird das Blatt „Personen". Damit lassen sich mehrere CSVs
* genau wie die Blätter einer Mappe zusammensetzen.
*/
export function csvLesen(text: string, dateiname: string): ParseResult {
const matrix = csvZerlegen(text);
if (matrix.length === 0) return { blaetter: [], fehler: [`${dateiname}“ ist leer.`] };
const name = dateiname.replace(/\.[^.]+$/, "").trim();
const { blatt, fehler } = zeilenAusMatrix(name, matrix);
return { blaetter: [blatt], fehler };
}
/** Erkennt am Dateinamen, welcher Leser zuständig ist. */
export async function dateiLesen(dateiname: string, daten: ArrayBuffer): Promise<ParseResult> {
const endung = dateiname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
if (endung === "xlsx" || endung === "xlsm") return xlsxLesen(daten);
if (endung === "csv" || endung === "txt") return csvLesen(new TextDecoder("utf-8").decode(daten), dateiname);
return {
blaetter: [],
fehler: [`${dateiname}“: unbekannte Dateiendung. Erwartet werden .xlsx oder .csv.`],
};
}

439
lib/import/schema.ts Normal file
View File

@@ -0,0 +1,439 @@
import { ABSENCE_TYPES } from "@/lib/absence";
// Was in einer Importdatei stehen darf.
//
// Diese Datei ist die einzige Quelle für drei Dinge, die sonst
// auseinanderlaufen: die Prüfung beim Import, die Vorlage zum Herunterladen
// und die Hilfetexte in der Oberfläche. Eine Spalte, die hier nicht steht,
// gibt es nirgends — und eine, die hier dazukommt, taucht überall auf.
//
// Die Blattnamen sind zugleich die Dateinamen für den CSV-Weg: „Personen.csv"
// wird als Blatt „Personen" gelesen.
export type FeldArt =
| { art: "text" }
| { art: "datum" }
| { art: "zahl" }
| { art: "ganzzahl" }
| { art: "janein" }
| { art: "liste"; werte?: readonly string[] }
| { art: "auswahl"; werte: readonly string[] };
export type Spalte = {
/** Überschrift in der Datei. */
name: string;
/** Feld im Datensatz, den der Lader schreibt. */
ziel: string;
pflicht: boolean;
typ: FeldArt;
hinweis: string;
beispiel: string;
};
export type BlattSchema = {
name: string;
zweck: string;
/**
* Spalte, an der eine Zeile erkennbar ist. Doppelte Werte darin sind ein
* Fehler — sonst überschriebe die zweite Zeile die erste, und welche
* gewinnt, hinge an der Reihenfolge in der Datei.
*/
schluessel: string | null;
spalten: Spalte[];
};
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
const VERHAELTNIS = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"] as const;
const EREIGNIS = [
"Eintritt",
"Beförderung",
"Versetzung",
"Karenz",
"Vertragsänderung",
"Stammdatenänderung",
"Austritt",
"Wiedereintritt",
"Reorganisation",
"Gehaltsanpassung",
"Rückkehr",
] as const;
export const BLATT_STANDORTE: BlattSchema = {
name: "Standorte",
zweck: "Betriebsstätten, auf die sich Personen beziehen.",
schluessel: "Bezeichnung",
spalten: [
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "Wien" },
{
name: "Land",
ziel: "country",
pflicht: true,
typ: { art: "text" },
hinweis: "Ausgeschrieben. Steuert unter anderem, ob eine SV-Nummer verlangt wird.",
beispiel: "Österreich",
},
],
};
export const BLATT_ORGANISATION: BlattSchema = {
name: "Organisation",
zweck: "Der Aufbau: Gesellschaft, Bereiche, Abteilungen, Teams.",
schluessel: "Orgnummer",
spalten: [
{
name: "Orgnummer",
ziel: "org_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Eindeutig. Wird von Planstellen als Verweis benutzt.",
beispiel: "10000001",
},
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Produktion" },
{
name: "Art",
ziel: "unit_type",
pflicht: true,
typ: { art: "auswahl", werte: ["Gesellschaft", "Bereich", "Abteilung", "Team"] },
hinweis: "Genau eine Zeile darf „Gesellschaft“ sein — sie ist die Wurzel.",
beispiel: "Bereich",
},
{
name: "Übergeordnet",
ziel: "parent_org_number",
pflicht: false,
typ: { art: "text" },
hinweis: "Orgnummer der übergeordneten Einheit. Nur bei „Gesellschaft“ leer.",
beispiel: "10000000",
},
{
name: "Gültig ab",
ziel: "valid_from",
pflicht: false,
typ: { art: "datum" },
hinweis: "Leer = heute.",
beispiel: "01.01.2020",
},
{
name: "Gültig bis",
ziel: "valid_to",
pflicht: false,
typ: { art: "datum" },
hinweis: "Leer = unbefristet.",
beispiel: "",
},
],
};
export const BLATT_JOBKATALOG: BlattSchema = {
name: "Jobkatalog",
zweck: "Die Tätigkeiten, auf die Planstellen verweisen.",
schluessel: "Jobcode",
spalten: [
{ name: "Jobcode", ziel: "code", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "50000123" },
{
name: "Bezeichnung",
ziel: "title",
pflicht: true,
typ: { art: "text" },
hinweis: "",
beispiel: "Maschinenführer:in",
},
],
};
export const BLATT_PLANSTELLEN: BlattSchema = {
name: "Planstellen",
zweck: "Die Stellen selbst — unabhängig davon, wer sie besetzt.",
schluessel: "Planstellennummer",
spalten: [
{
name: "Planstellennummer",
ziel: "position_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Eindeutig. Personen verweisen darauf.",
beispiel: "60000124",
},
{
name: "Orgnummer",
ziel: "org_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Organisation“ oder bereits im System stehen.",
beispiel: "10000001",
},
{
name: "Jobcode",
ziel: "job_code",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Jobkatalog“ oder bereits im System stehen.",
beispiel: "50000123",
},
{
name: "Leitung",
ziel: "is_chief",
pflicht: false,
typ: { art: "janein" },
hinweis: "Ja = Leitungsstelle der Einheit. Höchstens eine je Einheit.",
beispiel: "nein",
},
{ name: "Gültig ab", ziel: "valid_from", pflicht: false, typ: { art: "datum" }, hinweis: "Leer = heute.", beispiel: "01.01.2020" },
{ name: "Gültig bis", ziel: "valid_to", pflicht: false, typ: { art: "datum" }, hinweis: "", beispiel: "" },
],
};
export const BLATT_PERSONEN: BlattSchema = {
name: "Personen",
zweck: "Stammdaten, Vertrag und die Planstelle, auf der die Person sitzt.",
schluessel: "Personalnummer",
spalten: [
{
name: "Personalnummer",
ziel: "personnel_number",
pflicht: true,
typ: { art: "ganzzahl" },
hinweis: "Eindeutig. Historie und Angehörige verweisen darauf.",
beispiel: "2219",
},
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Sabine" },
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
{
name: "Geschlecht",
ziel: "gender",
pflicht: true,
typ: { art: "auswahl", werte: ["m", "w"] },
hinweis: "Wird für die SV-Nummer und die Anrede gebraucht.",
beispiel: "w",
},
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "15.08.1968" },
{
name: "SV-Nummer",
ziel: "sv_nummer",
pflicht: false,
typ: { art: "text" },
hinweis: "Zehnstellig. Prüfziffer und Geburtsdatum müssen zusammenpassen.",
beispiel: "7960 150868",
},
{
name: "Staatsbürgerschaft",
ziel: "nationality",
pflicht: false,
typ: { art: "text" },
hinweis: "Leer = Österreich.",
beispiel: "Österreich",
},
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
{ name: "Land", ziel: "address_country", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Österreich" },
{
name: "Titel vorangestellt",
ziel: "title_prefix",
pflicht: false,
typ: { art: "liste" },
hinweis: "Mehrere mit Semikolon.",
beispiel: "Mag.",
},
{ name: "Titel nachgestellt", ziel: "title_suffix", pflicht: false, typ: { art: "liste" }, hinweis: "", beispiel: "MSc" },
{
name: "Tätigkeit",
ziel: "job_title",
pflicht: true,
typ: { art: "text" },
hinweis: "Die Bezeichnung der Person; kann von der Planstelle abweichen.",
beispiel: "Projektingenieur:in",
},
{
name: "Standort",
ziel: "location",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Standorte“ oder bereits im System stehen.",
beispiel: "Wien",
},
{
name: "Planstellennummer",
ziel: "position_number",
pflicht: true,
typ: { art: "text" },
hinweis: "Muss im Blatt „Planstellen“ oder bereits im System stehen und frei sein.",
beispiel: "60000124",
},
{ name: "Eintritt", ziel: "entry_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
{
name: "Austritt",
ziel: "exit_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Leer = weiterhin beschäftigt. Muss nach dem Eintritt liegen.",
beispiel: "",
},
{ name: "Austrittsgrund", ziel: "exit_reason", pflicht: false, typ: { art: "text" }, hinweis: "Pflicht, wenn ein Austritt steht.", beispiel: "" },
{
name: "Beschäftigung",
ziel: "employment_type",
pflicht: false,
typ: { art: "auswahl", werte: ["Vollzeit", "Teilzeit"] },
hinweis: "Leer = Vollzeit.",
beispiel: "Vollzeit",
},
{ name: "Wochenstunden", ziel: "weekly_hours", pflicht: false, typ: { art: "zahl" }, hinweis: "Leer = 38,5.", beispiel: "38,5" },
{
name: "Arbeitstage",
ziel: "work_days",
pflicht: false,
typ: { art: "liste", werte: WOCHENTAGE },
hinweis: "Leer = Mo;Di;Mi;Do;Fr.",
beispiel: "Mo;Di;Mi;Do;Fr",
},
{
name: "Vertragsart",
ziel: "contract_type",
pflicht: false,
typ: { art: "auswahl", werte: ["unbefristet", "befristet"] },
hinweis: "Leer = unbefristet.",
beispiel: "unbefristet",
},
{
name: "Befristet bis",
ziel: "contract_end_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Pflicht bei „befristet“, sonst leer.",
beispiel: "",
},
{
name: "Verwendungsgruppe",
ziel: "paygrade",
pflicht: false,
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
hinweis: "Leer = B.",
beispiel: "C",
},
{
name: "Kollektivvertrag",
ziel: "collective_agreement",
pflicht: false,
typ: { art: "auswahl", werte: ["Handel", "Süßwaren"] },
hinweis: "Leer = Handel.",
beispiel: "Handel",
},
{
name: "Beschäftigtengruppe",
ziel: "worker_type",
pflicht: false,
typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] },
hinweis: "Leer = Angestellte:r.",
beispiel: "Angestellte:r",
},
{ name: "Monatsgehalt brutto", ziel: "monthly_salary_gross", pflicht: false, typ: { art: "zahl" }, hinweis: "", beispiel: "3450,00" },
{
name: "Herkunft",
ziel: "source",
pflicht: false,
typ: { art: "auswahl", werte: ["Intern", "Extern"] },
hinweis: "Leer = Extern.",
beispiel: "Extern",
},
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
{
name: "Abwesenheit ab",
ziel: "karenz_start_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Beginn einer Langzeitabwesenheit.",
beispiel: "",
},
{
name: "Rückkehr geplant",
ziel: "karenz_return_date",
pflicht: false,
typ: { art: "datum" },
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
beispiel: "",
},
{
name: "Abwesenheitsart",
ziel: "absence_type",
pflicht: false,
typ: { art: "auswahl", werte: ABSENCE_TYPES },
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
beispiel: "",
},
],
};
export const BLATT_HISTORIE: BlattSchema = {
name: "Historie",
zweck: "Ereignisse je Person. Alles, was vor dem Eintritt liegt, wird abgewiesen.",
schluessel: null,
spalten: [
{
name: "Personalnummer",
ziel: "personnel_number",
pflicht: true,
typ: { art: "ganzzahl" },
hinweis: "Muss im Blatt „Personen“ oder bereits im System stehen.",
beispiel: "2219",
},
{ name: "Datum", ziel: "event_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
{
name: "Ereignis",
ziel: "event_type",
pflicht: true,
typ: { art: "auswahl", werte: EREIGNIS },
hinweis: "",
beispiel: "Eintritt",
},
{ name: "Beschreibung", ziel: "description", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Eintritt als Projektingenieur:in" },
],
};
export const BLATT_ANGEHOERIGE: BlattSchema = {
name: "Angehörige",
zweck: "Angehörige je Person.",
schluessel: null,
spalten: [
{ name: "Personalnummer", ziel: "personnel_number", pflicht: true, typ: { art: "ganzzahl" }, hinweis: "", beispiel: "2219" },
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Julian" },
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
{
name: "Verhältnis",
ziel: "relationship",
pflicht: true,
typ: { art: "auswahl", werte: VERHAELTNIS },
hinweis: "",
beispiel: "Kind",
},
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "04.06.2014" },
{ name: "SV-Nummer", ziel: "sv_nummer", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "" },
],
};
/**
* Reihenfolge = Ladereihenfolge.
*
* Standorte, Organisation und Jobkatalog müssen stehen, bevor Planstellen
* darauf verweisen können; Personen brauchen Planstellen; Historie und
* Angehörige brauchen Personen.
*/
export const BLAETTER: BlattSchema[] = [
BLATT_STANDORTE,
BLATT_ORGANISATION,
BLATT_JOBKATALOG,
BLATT_PLANSTELLEN,
BLATT_PERSONEN,
BLATT_HISTORIE,
BLATT_ANGEHOERIGE,
];
export function blattSchema(name: string): BlattSchema | undefined {
const gesucht = name.trim().toLowerCase();
return BLAETTER.find((b) => b.name.toLowerCase() === gesucht);
}

381
lib/import/validate.ts Normal file
View File

@@ -0,0 +1,381 @@
import { todayIso } from "@/lib/format";
import { normalizeSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
import type { ImportSheet } from "./parse";
import { BLAETTER, blattSchema, type BlattSchema, type Spalte } from "./schema";
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "./werte";
// Prüfung einer eingelesenen Datei.
//
// Zwei Grundsätze, beide bewusst:
//
// 1. **Es wird alles gemeldet, nicht das erste.** Wer eine Datei mit 800
// Zeilen hochlädt, will nicht achthundertmal hochladen. Deshalb sammelt
// jede Prüfung weiter, statt abzubrechen.
//
// 2. **Der Bestand kommt als Parameter, nicht aus der Datenbank.** Damit
// bleibt diese Datei rein und ohne Verbindung testbar — und die
// Abfragen stehen an einer Stelle, wo man sie sieht (load.ts).
export type Befund = {
blatt: string;
/** Zeilennummer wie in Excel; null für Probleme am ganzen Blatt. */
zeile: number | null;
spalte: string | null;
wert?: string;
meldung: string;
};
/** Was bereits in der Datenbank steht — für Verweise und Doppelprüfungen. */
export type Bestand = {
standorte: Map<string, string>;
orgNummern: Map<string, string>;
jobCodes: Map<string, string>;
/** Planstellennummer → { id, heuteBesetzt } */
planstellen: Map<string, { id: string; besetzt: boolean }>;
personalnummern: Map<number, string>;
emails: Set<string>;
svNummern: Set<string>;
};
export const LEERER_BESTAND: Bestand = {
standorte: new Map(),
orgNummern: new Map(),
jobCodes: new Map(),
planstellen: new Map(),
personalnummern: new Map(),
emails: new Set(),
svNummern: new Set(),
};
export type Zeile = { zeile: number; werte: Record<string, unknown> };
export type Datensatz = Record<string, Zeile[]>;
export type Pruefergebnis = {
fehler: Befund[];
hinweise: Befund[];
datensatz: Datensatz;
/** Was angelegt würde, je Blatt. */
anzahl: Record<string, number>;
};
function leseFeld(spalte: Spalte, roh: string): { wert: unknown; fehler: string | null } {
if (roh === "") return { wert: null, fehler: null };
switch (spalte.typ.art) {
case "text":
return { wert: roh, fehler: null };
case "datum": {
const d = alsDatum(roh);
return d ? { wert: d, fehler: null } : { wert: null, fehler: "Kein gültiges Datum. Erwartet: 31.12.2026 oder 2026-12-31." };
}
case "zahl": {
const n = alsZahl(roh);
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine Zahl." };
}
case "ganzzahl": {
const n = alsGanzzahl(roh);
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine ganze Zahl." };
}
case "janein": {
const b = alsJaNein(roh);
return b !== null ? { wert: b, fehler: null } : { wert: null, fehler: "Erwartet: ja oder nein." };
}
case "liste": {
const l = alsListe(roh);
if (spalte.typ.werte) {
const erlaubt = spalte.typ.werte;
const treffer: string[] = [];
for (const t of l) {
const k = alsAufzaehlung(t, erlaubt);
if (!k) return { wert: null, fehler: `${t}“ ist unbekannt. Erlaubt: ${erlaubt.join(", ")}.` };
treffer.push(k);
}
return { wert: treffer, fehler: null };
}
return { wert: l, fehler: null };
}
case "auswahl": {
const w = alsAufzaehlung(roh, spalte.typ.werte);
return w ? { wert: w, fehler: null } : { wert: null, fehler: `Unbekannter Wert. Erlaubt: ${spalte.typ.werte.join(", ")}.` };
}
}
}
function blattLesen(schema: BlattSchema, blatt: ImportSheet, fehler: Befund[]): Zeile[] {
const vorhanden = new Set(blatt.spalten);
for (const s of schema.spalten) {
if (s.pflicht && !vorhanden.has(s.name)) {
fehler.push({ blatt: schema.name, zeile: null, spalte: s.name, meldung: "Pflichtspalte fehlt." });
}
}
for (const s of blatt.spalten) {
if (!schema.spalten.some((x) => x.name === s)) {
fehler.push({ blatt: schema.name, zeile: 1, spalte: s, meldung: "Unbekannte Spalte — wird nicht übernommen." });
}
}
const zeilen: Zeile[] = [];
const schluesselGesehen = new Map<string, number>();
for (const r of blatt.zeilen) {
const werte: Record<string, unknown> = {};
for (const s of schema.spalten) {
const roh = (r.werte[s.name] ?? "").trim();
if (roh === "" && s.pflicht) {
fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, meldung: "Pflichtfeld ist leer." });
continue;
}
const { wert, fehler: f } = leseFeld(s, roh);
if (f) fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, wert: roh, meldung: f });
else werte[s.ziel] = wert;
}
if (schema.schluessel) {
const sp = schema.spalten.find((x) => x.name === schema.schluessel)!;
const k = werte[sp.ziel];
if (k !== undefined && k !== null) {
const schluessel = String(k);
const zuvor = schluesselGesehen.get(schluessel);
if (zuvor !== undefined) {
fehler.push({
blatt: schema.name,
zeile: r.zeile,
spalte: schema.schluessel,
wert: schluessel,
meldung: `Kommt bereits in Zeile ${zuvor} vor. Welche Zeile gälte, hinge an der Reihenfolge in der Datei.`,
});
} else {
schluesselGesehen.set(schluessel, r.zeile);
}
}
}
zeilen.push({ zeile: r.zeile, werte });
}
return zeilen;
}
const s = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
const n = (v: unknown): number | null => (typeof v === "number" ? v : null);
export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAND): Pruefergebnis {
const fehler: Befund[] = [];
const hinweise: Befund[] = [];
const datensatz: Datensatz = {};
for (const blatt of blaetter) {
const schema = blattSchema(blatt.name);
if (!schema) {
hinweise.push({
blatt: blatt.name,
zeile: null,
spalte: null,
meldung: `Unbekanntes Blatt — wird übergangen. Erwartet: ${BLAETTER.map((b) => b.name).join(", ")}.`,
});
continue;
}
datensatz[schema.name] = blattLesen(schema, blatt, fehler);
}
const hole = (name: string) => datensatz[name] ?? [];
const melde = (blatt: string, zeile: number, spalte: string | null, meldung: string, wert?: string) =>
fehler.push({ blatt, zeile, spalte, wert, meldung });
// ── Standorte und Organisation ────────────────────────────────
const standorte = new Set([...bestand.standorte.keys()]);
for (const z of hole("Standorte")) {
const name = s(z.werte.name);
if (name) {
if (bestand.standorte.has(name)) melde("Standorte", z.zeile, "Bezeichnung", "Gibt es bereits.", name);
standorte.add(name);
}
}
const orgNummern = new Set([...bestand.orgNummern.keys()]);
const orgArten = new Map<string, string>();
let wurzeln = 0;
for (const z of hole("Organisation")) {
const nr = s(z.werte.org_number);
const art = s(z.werte.unit_type);
if (nr) {
if (bestand.orgNummern.has(nr)) melde("Organisation", z.zeile, "Orgnummer", "Gibt es bereits.", nr);
orgNummern.add(nr);
if (art) orgArten.set(nr, art);
}
if (art === "Gesellschaft") wurzeln++;
}
for (const z of hole("Organisation")) {
const eltern = s(z.werte.parent_org_number);
const art = s(z.werte.unit_type);
if (art === "Gesellschaft") {
if (eltern) melde("Organisation", z.zeile, "Übergeordnet", "Die Gesellschaft ist die Wurzel und hat nichts über sich.");
} else if (!eltern) {
melde("Organisation", z.zeile, "Übergeordnet", "Pflicht für alles ausser der Gesellschaft.");
} else if (!orgNummern.has(eltern)) {
melde("Organisation", z.zeile, "Übergeordnet", "Steht weder in der Datei noch im System.", eltern);
} else if (eltern === s(z.werte.org_number)) {
melde("Organisation", z.zeile, "Übergeordnet", "Eine Einheit kann sich nicht selbst übergeordnet sein.");
}
}
if (wurzeln > 1) {
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: `Es gibt ${wurzeln} Zeilen „Gesellschaft“; genau eine ist erlaubt.` });
}
if (wurzeln === 0 && bestand.orgNummern.size === 0 && hole("Organisation").length > 0) {
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: "Keine Zeile „Gesellschaft“ — der Aufbau hätte keine Wurzel." });
}
// ── Jobkatalog und Planstellen ────────────────────────────────
const jobCodes = new Set([...bestand.jobCodes.keys()]);
for (const z of hole("Jobkatalog")) {
const code = s(z.werte.code);
if (code) {
if (bestand.jobCodes.has(code)) melde("Jobkatalog", z.zeile, "Jobcode", "Gibt es bereits.", code);
jobCodes.add(code);
}
}
const planstellen = new Set([...bestand.planstellen.keys()]);
const leitungJeEinheit = new Map<string, number>();
for (const z of hole("Planstellen")) {
const nr = s(z.werte.position_number);
const org = s(z.werte.org_number);
const job = s(z.werte.job_code);
if (nr) {
if (bestand.planstellen.has(nr)) melde("Planstellen", z.zeile, "Planstellennummer", "Gibt es bereits.", nr);
planstellen.add(nr);
}
if (org && !orgNummern.has(org)) melde("Planstellen", z.zeile, "Orgnummer", "Steht weder in der Datei noch im System.", org);
if (job && !jobCodes.has(job)) melde("Planstellen", z.zeile, "Jobcode", "Steht weder in der Datei noch im System.", job);
if (z.werte.is_chief === true && org) {
const anzahl = (leitungJeEinheit.get(org) ?? 0) + 1;
leitungJeEinheit.set(org, anzahl);
if (anzahl === 2) {
melde("Planstellen", z.zeile, "Leitung", `Die Einheit ${org} hätte damit zwei Leitungsstellen.`);
}
}
const von = s(z.werte.valid_from);
const bis = s(z.werte.valid_to);
if (von && bis && bis < von) melde("Planstellen", z.zeile, "Gültig bis", "Liegt vor „Gültig ab“.");
}
// ── Personen ──────────────────────────────────────────────────
const heute = todayIso();
const personalnummern = new Set([...bestand.personalnummern.keys()]);
const emails = new Set([...bestand.emails]);
const svNummern = new Set([...bestand.svNummern]);
const belegtePlanstellen = new Map<string, number>();
const eintritte = new Map<number, string>();
for (const z of hole("Personen")) {
const pnr = n(z.werte.personnel_number);
const w = z.werte;
if (pnr !== null) {
if (bestand.personalnummern.has(pnr)) melde("Personen", z.zeile, "Personalnummer", "Gibt es bereits im System.", String(pnr));
personalnummern.add(pnr);
}
const email = s(w.email)?.toLowerCase();
if (email) {
if (emails.has(email)) melde("Personen", z.zeile, "E-Mail", "Kommt bereits vor.", email);
emails.add(email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "E-Mail", "Sieht nicht wie eine Adresse aus.", email);
}
const geburt = s(w.birth_date);
const svRoh = s(w.sv_nummer);
if (svRoh) {
const f = validateSvnr(svRoh, geburt);
if (f) melde("Personen", z.zeile, "SV-Nummer", svnrErrorMessage(f), svRoh);
else {
const norm = normalizeSvnr(svRoh);
if (svNummern.has(norm)) melde("Personen", z.zeile, "SV-Nummer", "Kommt bereits vor.", svRoh);
svNummern.add(norm);
}
}
if (geburt && geburt > heute) melde("Personen", z.zeile, "Geburtsdatum", "Liegt in der Zukunft.", geburt);
const standort = s(w.location);
if (standort && !standorte.has(standort)) melde("Personen", z.zeile, "Standort", "Steht weder in der Datei noch im System.", standort);
const stelle = s(w.position_number);
if (stelle) {
if (!planstellen.has(stelle)) {
melde("Personen", z.zeile, "Planstellennummer", "Steht weder in der Datei noch im System.", stelle);
} else if (bestand.planstellen.get(stelle)?.besetzt) {
melde("Personen", z.zeile, "Planstellennummer", "Diese Planstelle ist bereits besetzt.", stelle);
}
const zuvor = belegtePlanstellen.get(stelle);
// Doppelbesetzung ist in diesem Modell nicht bloss unsauber, sondern
// verboten — die Datenbank hat dafür einen Teilindex.
if (zuvor !== undefined) melde("Personen", z.zeile, "Planstellennummer", `Wird bereits in Zeile ${zuvor} besetzt.`, stelle);
else belegtePlanstellen.set(stelle, z.zeile);
}
const eintritt = s(w.entry_date);
const austritt = s(w.exit_date);
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", eintritt);
if (austritt && eintritt && austritt <= eintritt) melde("Personen", z.zeile, "Austritt", "Liegt nicht nach dem Eintritt.", austritt);
if (austritt && !s(w.exit_reason)) melde("Personen", z.zeile, "Austrittsgrund", "Pflicht, sobald ein Austritt steht.");
if (s(w.contract_type) === "befristet" && !s(w.contract_end_date)) {
melde("Personen", z.zeile, "Befristet bis", "Pflicht bei einem befristeten Vertrag.");
}
if (s(w.contract_type) !== "befristet" && s(w.contract_end_date)) {
melde("Personen", z.zeile, "Befristet bis", "Nur bei „befristet“ erlaubt.");
}
const abVon = s(w.karenz_start_date);
if (abVon) {
if (!s(w.karenz_return_date)) melde("Personen", z.zeile, "Rückkehr geplant", "Pflicht, sobald eine Abwesenheit beginnt.");
if (!s(w.absence_type)) melde("Personen", z.zeile, "Abwesenheitsart", "Pflicht, sobald eine Abwesenheit beginnt.");
if (eintritt && abVon < eintritt) melde("Personen", z.zeile, "Abwesenheit ab", "Liegt vor dem Eintritt.", abVon);
}
const stunden = n(w.weekly_hours);
if (stunden !== null && (stunden <= 0 || stunden > 60)) {
melde("Personen", z.zeile, "Wochenstunden", "Ausserhalb eines plausiblen Bereichs (060).", String(stunden));
}
}
// ── Historie und Angehörige ───────────────────────────────────
for (const z of hole("Historie")) {
const pnr = n(z.werte.personnel_number);
if (pnr === null) continue;
if (!personalnummern.has(pnr)) {
melde("Historie", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
continue;
}
// Nur für Personen aus derselben Datei; für bereits vorhandene kennt
// diese Funktion das Eintrittsdatum nicht, und die Datenbank prüft es
// ohnehin ein zweites Mal.
const eintritt = eintritte.get(pnr) ?? null;
const datum = s(z.werte.event_date);
// Die Datenbank weist das ohnehin ab (trg_history_not_before_entry) —
// aber mitten im Einfügen und mit einer Meldung ohne Zeilennummer.
if (eintritt && datum && datum < eintritt) {
melde("Historie", z.zeile, "Datum", `Liegt vor dem Eintritt am ${eintritt}.`, datum);
}
}
for (const z of hole("Angehörige")) {
const pnr = n(z.werte.personnel_number);
if (pnr === null) continue;
if (!personalnummern.has(pnr)) {
melde("Angehörige", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
continue;
}
const sv = s(z.werte.sv_nummer);
const geb = s(z.werte.birth_date);
if (sv) {
const f = validateSvnr(sv, geb);
if (f) melde("Angehörige", z.zeile, "SV-Nummer", svnrErrorMessage(f), sv);
}
}
const anzahl: Record<string, number> = {};
for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length;
return { fehler, hinweise, datensatz, anzahl };
}

125
lib/import/werte.ts Normal file
View File

@@ -0,0 +1,125 @@
// Aus einer Zelle wird ein Wert.
//
// Jede Funktion hier liefert entweder den Wert oder `null` — nie eine
// Näherung. Was sich nicht eindeutig lesen lässt, ist ein Fehler, den die
// Person in der Datei korrigieren soll. Raten wäre hier besonders teuer: aus
// „03.08.26" könnte 2026-08-03 oder 2003-08-26 werden, und beides sähe im
// Ergebnis unauffällig aus.
/** Kalendertag als „JJJJ-MM-TT". Ohne Zeitzone, weil ein Geburtstag keine hat. */
export type IsoTag = string;
function tagAusTeilen(jahr: number, monat: number, tag: number): IsoTag | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
const d = new Date(Date.UTC(jahr, monat - 1, tag));
// Fängt den 31. Februar: Date rechnet ihn stillschweigend in den 3. März um.
if (d.getUTCFullYear() !== jahr || d.getUTCMonth() !== monat - 1 || d.getUTCDate() !== tag) return null;
const m = String(monat).padStart(2, "0");
const t = String(tag).padStart(2, "0");
return `${jahr}-${m}-${t}`;
}
/**
* Datum aus einer Zelle.
*
* Angenommen werden ISO (2026-08-03), österreichisch (3.8.2026, 03.08.2026)
* und mit Schrägstrich (03/08/2026). Zweistellige Jahre werden **abgelehnt**:
* bei Geburtsdaten liegt das Jahrhundert nicht fest, und eine Regel wie
* „unter 30 heisst 20xx" produziert lautlos Personen, die noch nicht geboren
* sind.
*/
export function alsDatum(roh: string): IsoTag | null {
const s = roh.trim();
if (!s) return null;
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) return tagAusTeilen(Number(iso[1]), Number(iso[2]), Number(iso[3]));
const deutsch = s.match(/^(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})\.?$/);
if (deutsch) return tagAusTeilen(Number(deutsch[3]), Number(deutsch[2]), Number(deutsch[1]));
// Excel-Serienzahl — kommt vor, wenn die Spalte nicht als Datum formatiert
// ist. Tag 1 ist der 01.01.1900, und Excel kennt einen 29.02.1900, den es
// nie gab; ab Serie 60 muss deshalb ein Tag abgezogen werden.
if (/^\d{1,6}$/.test(s)) {
const serie = Number(s);
if (serie >= 1 && serie < 100000) {
const tage = serie >= 60 ? serie - 1 : serie;
const d = new Date(Date.UTC(1899, 11, 31) + tage * 86400000);
return tagAusTeilen(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
}
}
return null;
}
/**
* Zahl aus einer Zelle, mit Komma **oder** Punkt als Dezimaltrenner.
*
* „1.234,5" und „1,234.5" sind beide gebräuchlich und bedeuten dasselbe. Das
* letzte Trennzeichen entscheidet, die übrigen sind Tausenderpunkte.
*/
export function alsZahl(roh: string): number | null {
let s = roh.trim().replace(/\s/g, "");
if (!s) return null;
s = s.replace(/€|EUR/gi, "");
const letztesKomma = s.lastIndexOf(",");
const letzterPunkt = s.lastIndexOf(".");
if (letztesKomma >= 0 && letzterPunkt >= 0) {
const dezimal = letztesKomma > letzterPunkt ? "," : ".";
const tausender = dezimal === "," ? "." : ",";
s = s.split(tausender).join("").replace(dezimal, ".");
} else if (letztesKomma >= 0) {
// Ein einzelnes Komma ist im deutschsprachigen Raum ein Dezimaltrenner,
// auch bei „1,234" — das sind 1,234 und nicht 1234.
s = s.replace(",", ".");
}
if (!/^-?\d*\.?\d+$/.test(s)) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
export function alsGanzzahl(roh: string): number | null {
const n = alsZahl(roh);
return n !== null && Number.isInteger(n) ? n : null;
}
const JA = new Set(["ja", "j", "x", "wahr", "true", "1", "y", "yes"]);
const NEIN = new Set(["nein", "n", "falsch", "false", "0", "no", "-"]);
/** Ja/Nein aus einer Zelle. Alles andere ist ein Fehler, nicht „nein". */
export function alsJaNein(roh: string): boolean | null {
const s = roh.trim().toLowerCase();
if (!s) return null;
if (JA.has(s)) return true;
if (NEIN.has(s)) return false;
return null;
}
/**
* Liste aus einer Zelle — für Titel und Arbeitstage.
*
* Getrennt wird an Semikolon oder Komma. Leere Glieder fallen weg, damit
* „Mo, Di, " nicht in einem leeren Arbeitstag endet.
*/
export function alsListe(roh: string): string[] {
return roh
.split(/[;,]/)
.map((t) => t.trim())
.filter(Boolean);
}
/**
* Einen Wert einer Aufzählung zuordnen, unabhängig von Gross- und
* Kleinschreibung und von Leerzeichen.
*
* Zurück kommt die **kanonische** Schreibweise aus der Datenbank, nicht die
* aus der Datei: „vollzeit" wird zu „Vollzeit", weil die Spalte ein enum ist
* und alles andere die Zeile beim Einfügen abweisen würde.
*/
export function alsAufzaehlung<T extends string>(roh: string, erlaubt: readonly T[]): T | null {
const s = roh.trim().toLowerCase();
if (!s) return null;
return erlaubt.find((w) => w.toLowerCase() === s) ?? null;
}