employees.email ist die private Adresse (20260811140000). Sie taugt nicht als Dienstadresse und darf auch nicht als solche benutzt werden: der Honestly-Export geht an einen fremden Anbieter, der damit im Namen des Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich. Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den Index nicht, weil null nie gleich null ist. Geschrieben wird ueber `case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich auch wieder entfernen lassen. Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet: hire_employee und rehire_employee (beide teilen sich den Schritt "Person" -- das Formular haette das Feld gezeigt und den Wert weggeworfen), change_employee_data (sonst nicht aenderbar), apply_due_pending_changes (sonst verfiele eine auf spaeter datierte Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
421 lines
18 KiB
TypeScript
421 lines
18 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>;
|
||
firmenEmails: 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(),
|
||
firmenEmails: 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 firmenEmails = new Set([...bestand.firmenEmails]);
|
||
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, "Private E-Mail", "Kommt bereits vor.", email);
|
||
emails.add(email);
|
||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private E-Mail", "Sieht nicht wie eine Adresse aus.", email);
|
||
}
|
||
|
||
// Eigener Vorrat, nicht derselbe wie bei der privaten Adresse: die beiden
|
||
// Spalten haben je einen eigenen Index, und dieselbe Adresse darf bei
|
||
// einer Person privat und dienstlich stehen.
|
||
const firmenEmail = s(w.company_email)?.toLowerCase();
|
||
if (firmenEmail) {
|
||
if (firmenEmails.has(firmenEmail)) melde("Personen", z.zeile, "Firmen-E-Mail", "Kommt bereits vor.", firmenEmail);
|
||
firmenEmails.add(firmenEmail);
|
||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(firmenEmail)) {
|
||
melde("Personen", z.zeile, "Firmen-E-Mail", "Sieht nicht wie eine Adresse aus.", firmenEmail);
|
||
}
|
||
}
|
||
|
||
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);
|
||
// Gleicher Tag ist erlaubt — jemand, der den Dienst nicht antritt, tritt
|
||
// am selben Tag ein und aus. Die Datenbank sieht das genauso.
|
||
if (austritt && eintritt && austritt < eintritt) melde("Personen", z.zeile, "Austritt", "Liegt vor 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);
|
||
}
|
||
|
||
if (abVon && s(w.karenz_return_date) && s(w.karenz_return_date)! < (eintritt ?? "")) {
|
||
melde("Personen", z.zeile, "Rückkehr geplant", "Liegt vor dem Eintritt.", s(w.karenz_return_date)!);
|
||
}
|
||
|
||
// Vollzeit bedeutet in diesem Kollektivvertrag genau 38,5 Stunden, und
|
||
// Teilzeit alles darunter über null. Die Datenbank hat dafür eine
|
||
// CHECK-Bedingung; ohne diese Prüfung bräche der Import erst beim
|
||
// Schreiben ab — mit einer Meldung ohne Zeilennummer. Genau so ist der
|
||
// erste Durchstich gescheitert.
|
||
const stunden = n(w.weekly_hours);
|
||
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
|
||
if (stunden !== null) {
|
||
if (beschaeftigung === "Vollzeit" && stunden !== 38.5) {
|
||
melde("Personen", z.zeile, "Wochenstunden", "Vollzeit sind genau 38,5 Stunden. Für weniger „Teilzeit“ eintragen.", String(stunden));
|
||
}
|
||
if (beschaeftigung === "Teilzeit" && (stunden <= 0 || stunden >= 38.5)) {
|
||
melde("Personen", z.zeile, "Wochenstunden", "Teilzeit liegt zwischen 0 und 38,5 Stunden.", String(stunden));
|
||
}
|
||
} else if (beschaeftigung === "Teilzeit") {
|
||
melde("Personen", z.zeile, "Wochenstunden", "Pflicht bei Teilzeit — sonst gälten 38,5 und damit Vollzeit.");
|
||
}
|
||
|
||
const tage = w.work_days;
|
||
if (Array.isArray(tage) && tage.length === 0) {
|
||
melde("Personen", z.zeile, "Arbeitstage", "Mindestens ein Tag. Leer lassen für Mo–Fr.");
|
||
}
|
||
}
|
||
|
||
// ── 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 };
|
||
}
|