A round of interface corrections from use, plus one schema change behind them. The private email address is now optional. It was NOT NULL — the wrong default for a private detail: someone without one had to invent one, and invented data in a personnel file is worse than missing data. Both fields are relabelled to say whose they are, "Private E-Mail" and "Private Telefonnummer", because the company address does not exist until the person starts. Uniqueness stays; several NULLs coexist in a Postgres unique index, which is exactly what is wanted. The summary step still promised that "Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any more. Removed rather than reworded — the step lists what was entered, and a banner claiming otherwise is worse than no banner. Dependents move into the wizard as step three, optional. They can only be attached after the hire, because add_employee_dependent needs an id that does not exist while the form is open, so they are collected in the draft and written afterwards. That puts them outside the transaction the person is created in: if one fails the person still exists, so the message names who is missing instead of failing silently, and the SV number is checked in the step rather than after. The emergency contact gets its own step, second to last, and its relationship is a dropdown of the common ones rather than free text — otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can be counted. "Sonstige" is there because a closed list would otherwise be presumptuous. On the master-data tab it now sits below the dependents rather than above: both are people around the employee, and this is the one you reach for in a hurry. Returning from a long absence: the choice read "unverändert", which made you open the file to find out what you were agreeing to. It now reads "Wie vor Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is "Reduziert" — whose hours field starts empty on purpose. A number already filled in gets confirmed rather than read off the agreement it comes from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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",
|
|
"Private 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 vor dem Eintritt");
|
|
});
|
|
|
|
it("lässt Ein- und Austritt am selben Tag zu", () => {
|
|
// Wer den Dienst nicht antritt, tritt am selben Tag ein und aus. Die
|
|
// CHECK-Bedingung der Datenbank erlaubt das ausdrücklich; die Prüfung
|
|
// hier war strenger und hätte einen echten Fall abgewiesen.
|
|
const spalten = [...PERSON_SPALTEN, "Austritt", "Austrittsgrund"];
|
|
const r = pruefe(
|
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.03.2015", "Dienstantritt nicht erfolgt"]])],
|
|
BESTAND
|
|
);
|
|
expect(r.fehler).toEqual([]);
|
|
});
|
|
|
|
it("bindet die Wochenstunden an die Beschäftigungsart", () => {
|
|
// Vollzeit sind genau 38,5 — die Datenbank hat dafür eine
|
|
// CHECK-Bedingung. Ohne diese Prüfung bricht der Import erst beim
|
|
// Schreiben ab, und die Meldung nennt keine Zeile.
|
|
const spalten = [...PERSON_SPALTEN, "Beschäftigung", "Wochenstunden"];
|
|
const falsch = pruefe(
|
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Vollzeit", "20,5"]])],
|
|
BESTAND
|
|
);
|
|
expect(meldungen(falsch.fehler).join(" ")).toContain("Vollzeit sind genau 38,5");
|
|
|
|
const teilzeitOhne = pruefe(
|
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Teilzeit", ""]])],
|
|
BESTAND
|
|
);
|
|
expect(meldungen(teilzeitOhne.fehler).join(" ")).toContain("Pflicht bei Teilzeit");
|
|
|
|
const gut = pruefe(
|
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Teilzeit", "20,5"]])],
|
|
BESTAND
|
|
);
|
|
expect(gut.fehler).toEqual([]);
|
|
});
|
|
|
|
it("lässt nur Titel aus der hinterlegten Liste zu", () => {
|
|
const spalten = [...PERSON_SPALTEN, "Titel vorangestellt"];
|
|
const erfunden = pruefe(
|
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Doktor"]])],
|
|
BESTAND
|
|
);
|
|
expect(meldungen(erfunden.fehler).join(" ")).toContain("Doktor");
|
|
|
|
const echt = pruefe([blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "Mag."]])], BESTAND);
|
|
expect(echt.fehler).toEqual([]);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|