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; orgNummern: Map; jobCodes: Map; /** Planstellennummer → { id, heuteBesetzt } */ planstellen: Map; personalnummern: Map; emails: Set; svNummern: Set; }; 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 }; export type Datensatz = Record; export type Pruefergebnis = { fehler: Befund[]; hinweise: Befund[]; datensatz: Datensatz; /** Was angelegt würde, je Blatt. */ anzahl: Record; }; 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(); for (const r of blatt.zeilen) { const werte: Record = {}; 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(); 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(); 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(); const eintritte = new Map(); 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 = {}; for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length; return { fehler, hinweise, datensatz, anzahl }; }