// 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(roh: string, erlaubt: readonly T[]): T | null { const s = roh.trim().toLowerCase(); if (!s) return null; return erlaubt.find((w) => w.toLowerCase() === s) ?? null; }