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>
382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
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 };
|
||
}
|