Files
alpenwerk-hr/lib/import/validate.ts
Andrei Laas c3e19606e2
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m58s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m24s
Die Cornerstone-ID als eigenes, freiwilliges Feld
Cornerstone fuehrt jede Person unter einer eigenen Kennung. Der Export
dorthin trug in "User ID" und "Username" bisher die Alpenwerk-UUID --
richtig, solange es nichts Besseres gab, aber nicht die Kennung, unter
der Cornerstone die Person kennt. Jetzt steht dort diese Spalte.

Freiwillig, weil die Zuordnungstabelle des Kunden fuer 434 der 784
Personen keine Kennung liefert. Eindeutig, weil eine Kennung genau einer
Person gehoert. Als Text, weil fuehrende Nullen in einer numerischen
Spalte verlorengingen.

Ohne hinterlegte Kennung bleiben User ID und Username **leer**. Ein
Rueckfall auf die UUID braechte zwei Kennungsarten in eine Datei, ohne
dass es auffiele, und legte in Cornerstone eine zweite Person neben der
bestehenden an. Eine fehlende Angabe soll fehlen; dafuer gibt es einen
eigenen Test.

Fuenf Funktionen mussten mit -- dieselbe Liste und derselbe Grund wie bei
der Firmen-E-Mail: hire_employee und rehire_employee teilen sich den
Schritt "Person", change_employee_data macht das Feld aenderbar,
apply_due_pending_changes sorgt dafuer, dass eine datierte Aenderung
nicht verfaellt, app_feld_karte haelt den Eintrag in der Historie
richtigstellbar. Die Migration ist wieder erzeugt, nicht abgeschrieben,
und prueft jede der fuenf einzeln.

Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung
und Wiedereintritt sowie ueber den Massenimport; es steht im
Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
2026-09-28 10:13:52 +02:00

435 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
cornerstoneIds: 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(),
cornerstoneIds: 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 cornerstoneIds = new Set([...bestand.cornerstoneIds]);
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);
}
}
// Die Kennung gehört genau einer Person; die Spalte trägt einen
// eindeutigen Index. Ohne Prüfung hier bräche erst das Schreiben ab —
// mitten im Import und ohne Zeilennummer.
const cornerstoneId = s(w.cornerstone_id);
if (cornerstoneId) {
if (cornerstoneIds.has(cornerstoneId)) {
melde("Personen", z.zeile, "Cornerstone-ID", "Kommt bereits vor.", cornerstoneId);
}
cornerstoneIds.add(cornerstoneId);
}
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 };
}