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

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