diff --git a/lib/import/parse.ts b/lib/import/parse.ts new file mode 100644 index 0000000..774d53e --- /dev/null +++ b/lib/import/parse.ts @@ -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; +}; + +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(); + 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 = {}; + 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 { + 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 { + 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.`], + }; +} diff --git a/lib/import/schema.ts b/lib/import/schema.ts new file mode 100644 index 0000000..6912bc8 --- /dev/null +++ b/lib/import/schema.ts @@ -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); +} diff --git a/lib/import/validate.ts b/lib/import/validate.ts new file mode 100644 index 0000000..5945e8d --- /dev/null +++ b/lib/import/validate.ts @@ -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; + orgNummern: Map; + jobCodes: Map; + /** Planstellennummer → { id, heuteBesetzt } */ + planstellen: Map; + personalnummern: Map; + emails: Set; + svNummern: Set; +}; + +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 }; +export type Datensatz = Record; + +export type Pruefergebnis = { + fehler: Befund[]; + hinweise: Befund[]; + datensatz: Datensatz; + /** Was angelegt würde, je Blatt. */ + anzahl: Record; +}; + +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(); + + for (const r of blatt.zeilen) { + const werte: Record = {}; + 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(); + 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(); + 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(); + const eintritte = new Map(); + + 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 (0–60).", 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 = {}; + for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length; + + return { fehler, hinweise, datensatz, anzahl }; +} diff --git a/lib/import/werte.ts b/lib/import/werte.ts new file mode 100644 index 0000000..593b533 --- /dev/null +++ b/lib/import/werte.ts @@ -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(roh: string, erlaubt: readonly T[]): T | null { + const s = roh.trim().toLowerCase(); + if (!s) return null; + return erlaubt.find((w) => w.toLowerCase() === s) ?? null; +} diff --git a/tests/unit/import-validate.test.ts b/tests/unit/import-validate.test.ts new file mode 100644 index 0000000..451eb07 --- /dev/null +++ b/tests/unit/import-validate.test.ts @@ -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> = {}) => [ + 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"); + }); +}); diff --git a/tests/unit/import-werte.test.ts b/tests/unit/import-werte.test.ts new file mode 100644 index 0000000..b5d0eca --- /dev/null +++ b/tests/unit/import-werte.test.ts @@ -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"]); + }); +});