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

View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import type { ImportSheet } from "@/lib/import/parse";
import { LEERER_BESTAND, pruefe, type Bestand } from "@/lib/import/validate";
// Die Prüfung entscheidet, ob 800 fremde Zeilen in eine Personaldatenbank
// dürfen. Getestet wird deshalb nicht, dass gute Dateien durchgehen — das
// wäre die leichte Hälfte —, sondern dass jede einzelne Regel eine schlechte
// Datei aufhält, und zwar mit Zeilennummer.
function blatt(name: string, spalten: string[], zeilen: string[][]): ImportSheet {
return {
name,
spalten,
zeilen: zeilen.map((werte, i) => ({
zeile: i + 2,
werte: Object.fromEntries(spalten.map((s, j) => [s, werte[j] ?? ""])),
})),
};
}
const ORG_SPALTEN = ["Orgnummer", "Bezeichnung", "Art", "Übergeordnet"];
const PERSON_SPALTEN = [
"Personalnummer",
"Vorname",
"Nachname",
"Geschlecht",
"Geburtsdatum",
"E-Mail",
"Tätigkeit",
"Standort",
"Planstellennummer",
"Eintritt",
];
/** Ein Bestand, in dem Standort und Planstelle bereits existieren. */
const BESTAND: Bestand = {
...LEERER_BESTAND,
standorte: new Map([["Wien", "l1"]]),
planstellen: new Map([
["60000001", { id: "p1", besetzt: false }],
["60000002", { id: "p2", besetzt: false }],
["60000009", { id: "p9", besetzt: true }],
]),
};
const person = (pnr: string, mail: string, stelle: string, extra: Partial<Record<string, string>> = {}) => [
pnr,
"Sabine",
"Aigner",
"w",
"15.08.1968",
mail,
"Projektingenieur:in",
extra.Standort ?? "Wien",
stelle,
extra.Eintritt ?? "01.03.2015",
];
function meldungen(f: { blatt: string; zeile: number | null; spalte: string | null; meldung: string }[]) {
return f.map((x) => `${x.blatt}/${x.zeile ?? "-"}/${x.spalte ?? "-"}: ${x.meldung}`);
}
describe("Aufbau der Organisation", () => {
it("besteht auf genau einer Wurzel", () => {
const zwei = pruefe(
[blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Gesellschaft", ""]])],
LEERER_BESTAND
);
expect(meldungen(zwei.fehler).join(" ")).toContain("2 Zeilen „Gesellschaft“");
const keine = pruefe([blatt("Organisation", ORG_SPALTEN, [["2", "B", "Bereich", "1"]])], LEERER_BESTAND);
expect(meldungen(keine.fehler).join(" ")).toContain("keine Wurzel");
});
it("weist eine übergeordnete Einheit ab, die es nicht gibt", () => {
const r = pruefe(
[blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "99"]])],
LEERER_BESTAND
);
expect(meldungen(r.fehler)).toContain("Organisation/3/Übergeordnet: Steht weder in der Datei noch im System.");
});
it("weist eine Einheit ab, die sich selbst übergeordnet ist", () => {
const r = pruefe(
[blatt("Organisation", ORG_SPALTEN, [["1", "A", "Gesellschaft", ""], ["2", "B", "Bereich", "2"]])],
LEERER_BESTAND
);
expect(meldungen(r.fehler).join(" ")).toContain("kann sich nicht selbst übergeordnet sein");
});
it("nennt bei einer doppelten Orgnummer die frühere Zeile", () => {
const r = pruefe(
[
blatt("Organisation", ORG_SPALTEN, [
["1", "A", "Gesellschaft", ""],
["2", "B", "Bereich", "1"],
["2", "C", "Bereich", "1"],
]),
],
LEERER_BESTAND
);
expect(meldungen(r.fehler).join(" ")).toContain("Kommt bereits in Zeile 3 vor");
});
});
describe("Personen", () => {
it("lässt eine saubere Zeile durch", () => {
const r = pruefe([blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")])], BESTAND);
expect(r.fehler).toEqual([]);
expect(r.anzahl.Personen).toBe(1);
});
it("verhindert, dass zwei Zeilen dieselbe Planstelle besetzen", () => {
// Die Datenbank hat dafür einen Teilindex — ohne diese Prüfung bräche
// der Import erst beim Schreiben ab, ohne Zeilennummer.
const r = pruefe(
[
blatt("Personen", PERSON_SPALTEN, [
person("1", "a@example.at", "60000001"),
person("2", "b@example.at", "60000001"),
]),
],
BESTAND
);
expect(meldungen(r.fehler)).toContain("Personen/3/Planstellennummer: Wird bereits in Zeile 2 besetzt.");
});
it("weist eine bereits besetzte Planstelle ab", () => {
const r = pruefe([blatt("Personen", PERSON_SPALTEN, [person("1", "a@example.at", "60000009")])], BESTAND);
expect(meldungen(r.fehler)).toContain("Personen/2/Planstellennummer: Diese Planstelle ist bereits besetzt.");
});
it("prüft die SV-Nummer gegen das Geburtsdatum", () => {
const spalten = [...PERSON_SPALTEN, "SV-Nummer"];
const gut = pruefe(
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "7960 150868"]])],
BESTAND
);
expect(gut.fehler).toEqual([]);
const falsch = pruefe(
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "7960 010170"]])],
BESTAND
);
expect(falsch.fehler.some((f) => f.spalte === "SV-Nummer")).toBe(true);
});
it("verlangt einen Grund zum Austritt und einen Austritt nach dem Eintritt", () => {
const spalten = [...PERSON_SPALTEN, "Austritt", "Austrittsgrund"];
const ohneGrund = pruefe(
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2020", ""]])],
BESTAND
);
expect(meldungen(ohneGrund.fehler).join(" ")).toContain("Austrittsgrund");
const zuFrueh = pruefe(
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2010", "Kündigung"]])],
BESTAND
);
expect(meldungen(zuFrueh.fehler).join(" ")).toContain("Liegt nicht nach dem Eintritt");
});
it("verlangt ein Ende nur bei befristeten Verträgen — und dort immer", () => {
const spalten = [...PERSON_SPALTEN, "Vertragsart", "Befristet bis"];
const fehlt = pruefe(
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "befristet", ""]])],
BESTAND
);
expect(meldungen(fehlt.fehler).join(" ")).toContain("Pflicht bei einem befristeten Vertrag");
const zuviel = pruefe(
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "unbefristet", "01.01.2030"]])],
BESTAND
);
expect(meldungen(zuviel.fehler).join(" ")).toContain("Nur bei „befristet“ erlaubt");
});
it("meldet einen unbekannten Standort statt ihn anzulegen", () => {
const r = pruefe(
[blatt("Personen", PERSON_SPALTEN, [person("1", "a@example.at", "60000001", { Standort: "Graz" })])],
BESTAND
);
expect(meldungen(r.fehler)).toContain("Personen/2/Standort: Steht weder in der Datei noch im System.");
});
it("sammelt alle Fehler, statt beim ersten aufzuhören", () => {
// Bei 800 Zeilen ist das der Unterschied zwischen einmal korrigieren und
// achthundertmal hochladen.
const r = pruefe(
[
blatt("Personen", PERSON_SPALTEN, [
person("1", "kaputt", "60000001"),
person("1", "b@example.at", "99999999"),
]),
],
BESTAND
);
const zeilen = new Set(r.fehler.map((f) => f.zeile));
expect(zeilen.has(2)).toBe(true);
expect(zeilen.has(3)).toBe(true);
expect(r.fehler.length).toBeGreaterThanOrEqual(3);
});
});
describe("Historie", () => {
const H = ["Personalnummer", "Datum", "Ereignis", "Beschreibung"];
it("weist ein Ereignis vor dem Eintritt ab", () => {
// Genau der Fall, an dem der Seed einmal stillschweigend gescheitert ist.
const r = pruefe(
[
blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")]),
blatt("Historie", H, [["2219", "01.01.2010", "Eintritt", "Eintritt"]]),
],
BESTAND
);
expect(meldungen(r.fehler)).toContain("Historie/2/Datum: Liegt vor dem Eintritt am 2015-03-01.");
});
it("weist ein Ereignis zu einer unbekannten Person ab", () => {
const r = pruefe([blatt("Historie", H, [["999", "01.01.2020", "Eintritt", "x"]])], BESTAND);
expect(meldungen(r.fehler)).toContain("Historie/2/Personalnummer: Steht weder in der Datei noch im System.");
});
it("weist einen unbekannten Ereignistyp ab und nennt die erlaubten", () => {
const r = pruefe(
[
blatt("Personen", PERSON_SPALTEN, [person("2219", "a@example.at", "60000001")]),
blatt("Historie", H, [["2219", "01.04.2015", "Gehaltserhöhung", "x"]]),
],
BESTAND
);
const m = meldungen(r.fehler).join(" ");
expect(m).toContain("Unbekannter Wert");
expect(m).toContain("Gehaltsanpassung");
});
});
describe("Blätter und Spalten", () => {
it("meldet eine fehlende Pflichtspalte am Blatt statt an jeder Zeile", () => {
const r = pruefe([blatt("Jobkatalog", ["Jobcode"], [["50000001"]])], LEERER_BESTAND);
expect(meldungen(r.fehler)).toContain("Jobkatalog/-/Bezeichnung: Pflichtspalte fehlt.");
});
it("übergeht ein unbekanntes Blatt mit Hinweis statt mit Fehler", () => {
// Mappen enthalten oft ein „Deckblatt“ oder „Tabelle1“. Das ist kein
// Grund, den ganzen Import zu verweigern.
const r = pruefe([blatt("Deckblatt", ["A"], [["x"]])], LEERER_BESTAND);
expect(r.fehler).toEqual([]);
expect(meldungen(r.hinweise).join(" ")).toContain("Unbekanntes Blatt");
});
});

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "@/lib/import/werte";
import { csvZerlegen, trennzeichenErkennen } from "@/lib/import/parse";
// Eine Importdatei kommt aus fremder Hand — aus einem Altsystem, aus Excel,
// von einer Steuerberatung. Die Fälle hier sind keine Randfälle, sondern das,
// was tatsächlich in solchen Dateien steht.
describe("Datum aus einer Zelle", () => {
it("liest ISO und österreichische Schreibweise", () => {
expect(alsDatum("2026-08-03")).toBe("2026-08-03");
expect(alsDatum("3.8.2026")).toBe("2026-08-03");
expect(alsDatum("03.08.2026")).toBe("2026-08-03");
expect(alsDatum("03/08/2026")).toBe("2026-08-03");
});
it("weist zweistellige Jahre ab, statt das Jahrhundert zu raten", () => {
// „15.08.68" ist als Geburtsdatum 1968 und als Vertragsende 2068. Wer
// hier eine Regel erfindet, legt lautlos Personen an, die noch nicht
// geboren sind.
expect(alsDatum("15.08.68")).toBeNull();
});
it("weist einen Tag ab, den es nicht gibt", () => {
// Date rechnet den 31.02. stillschweigend in den 03.03. um — genau die
// Sorte Korrektur, die niemand bemerkt.
expect(alsDatum("31.02.2026")).toBeNull();
expect(alsDatum("2026-13-01")).toBeNull();
});
it("liest die Serienzahl, die Excel in unformatierten Spalten hinterlässt", () => {
// 1 = 01.01.1900. Ab 60 verschiebt Excels erfundener 29.02.1900 alles um
// einen Tag; 61 ist deshalb der 01.03.1900 und nicht der 02.03.
expect(alsDatum("1")).toBe("1900-01-01");
expect(alsDatum("61")).toBe("1900-03-01");
// Zwei allgemein bekannte Anker, damit die Umrechnung nachprüfbar ist
// und nicht nur zu sich selbst passt.
expect(alsDatum("25569")).toBe("1970-01-01");
expect(alsDatum("44927")).toBe("2023-01-01");
expect(alsDatum("46237")).toBe("2026-08-03");
});
it("liefert für Unfug null statt eines Datums", () => {
expect(alsDatum("")).toBeNull();
expect(alsDatum("sofort")).toBeNull();
expect(alsDatum("3.8.")).toBeNull();
});
});
describe("Zahl aus einer Zelle", () => {
it("versteht Komma und Punkt als Dezimaltrenner", () => {
expect(alsZahl("38,5")).toBe(38.5);
expect(alsZahl("38.5")).toBe(38.5);
});
it("versteht beide Tausendertrennungen", () => {
expect(alsZahl("1.234,50")).toBe(1234.5);
expect(alsZahl("1,234.50")).toBe(1234.5);
});
it("liest ein einzelnes Komma deutsch", () => {
// „1,234" sind hierzulande 1,234 — nicht 1234. Andersherum wäre ein
// Monatsgehalt um Faktor 1000 daneben.
expect(alsZahl("1,234")).toBe(1.234);
});
it("verträgt Währungszeichen und Leerzeichen", () => {
expect(alsZahl(" 3 450,00 € ")).toBe(3450);
});
it("weist Text ab", () => {
expect(alsZahl("Vollzeit")).toBeNull();
expect(alsZahl("38,5h")).toBeNull();
expect(alsGanzzahl("38,5")).toBeNull();
expect(alsGanzzahl("2219")).toBe(2219);
});
});
describe("Ja/Nein aus einer Zelle", () => {
it("versteht die üblichen Schreibweisen", () => {
for (const j of ["ja", "JA", "j", "x", "X", "wahr", "true", "1"]) expect(alsJaNein(j)).toBe(true);
for (const n of ["nein", "N", "falsch", "false", "0", "-"]) expect(alsJaNein(n)).toBe(false);
});
it("liefert für Unbekanntes null statt „nein“", () => {
// Stillschweigend „nein“ anzunehmen hiesse, aus einem Tippfehler in der
// Spalte „Betriebsrat“ einen fehlenden Kündigungsschutz zu machen.
expect(alsJaNein("vielleicht")).toBeNull();
expect(alsJaNein("")).toBeNull();
});
});
describe("Liste und Aufzählung", () => {
it("trennt an Semikolon und Komma und wirft Leeres weg", () => {
expect(alsListe("Mo; Di ,Mi;")).toEqual(["Mo", "Di", "Mi"]);
expect(alsListe("")).toEqual([]);
});
it("bringt Aufzählungswerte auf die Schreibweise der Datenbank", () => {
expect(alsAufzaehlung("vollzeit", ["Vollzeit", "Teilzeit"])).toBe("Vollzeit");
expect(alsAufzaehlung(" TEILZEIT ", ["Vollzeit", "Teilzeit"])).toBe("Teilzeit");
expect(alsAufzaehlung("halbtags", ["Vollzeit", "Teilzeit"])).toBeNull();
});
});
describe("CSV", () => {
it("erkennt das Semikolon, das deutsches Excel schreibt", () => {
expect(trennzeichenErkennen("Personalnummer;Vorname;Nachname")).toBe(";");
expect(trennzeichenErkennen("Personalnummer,Vorname,Nachname")).toBe(",");
});
it("lässt ein Trennzeichen innerhalb von Anführungszeichen in Ruhe", () => {
// Ohne das verschiebt eine Adresse mit Semikolon alle folgenden Spalten
// — und die Zeile wird plausibel falsch importiert statt abgewiesen.
const [kopf, zeile] = csvZerlegen('Nr;Adresse;Ort\n1;"Hauptstr. 1; Stiege 2";Wien\n');
expect(kopf).toEqual(["Nr", "Adresse", "Ort"]);
expect(zeile).toEqual(["1", "Hauptstr. 1; Stiege 2", "Wien"]);
});
it("versteht verdoppelte Anführungszeichen und Umbrüche im Feld", () => {
const zeilen = csvZerlegen('A;B\n1;"sagt ""hallo""\nin zwei Zeilen"\n');
expect(zeilen[1]).toEqual(["1", 'sagt "hallo"\nin zwei Zeilen']);
});
it("verträgt CRLF und eine fehlende letzte Zeilenschaltung", () => {
expect(csvZerlegen("A;B\r\n1;2")).toEqual([
["A", "B"],
["1", "2"],
]);
});
it("entfernt die Byte-Reihenfolge-Markierung von Excel", () => {
// Ohne das heisst die erste Spalte „Personalnummer“ und wird als
// fehlend gemeldet — ein Fehler, der beim Ansehen der Datei unsichtbar ist.
expect(csvZerlegen("Personalnummer;Vorname")[0]).toEqual(["Personalnummer", "Vorname"]);
});
});