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:
242
lib/import/parse.ts
Normal file
242
lib/import/parse.ts
Normal 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
439
lib/import/schema.ts
Normal 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
381
lib/import/validate.ts
Normal 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 (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<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
125
lib/import/werte.ts
Normal 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;
|
||||
}
|
||||
252
tests/unit/import-validate.test.ts
Normal file
252
tests/unit/import-validate.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
137
tests/unit/import-werte.test.ts
Normal file
137
tests/unit/import-werte.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user