diff --git a/app/(app)/import/page.tsx b/app/(app)/import/page.tsx new file mode 100644 index 0000000..3aa0e4d --- /dev/null +++ b/app/(app)/import/page.tsx @@ -0,0 +1,16 @@ +import { ImportWorkbench } from "@/components/import/ImportWorkbench"; + +export const metadata = { title: "Import" }; + +export default function ImportPage() { + return ( +
+

+ Übernahme aus einer Datei — Organisation, Planstellen, Personen, Historie und Angehörige. Angelegt wird nur; + bestehende Datensätze werden nie überschrieben. Geprüft wird vor dem Schreiben, und geschrieben wird alles + zusammen oder gar nichts. +

+ +
+ ); +} diff --git a/app/api/import/route.ts b/app/api/import/route.ts new file mode 100644 index 0000000..51e198e --- /dev/null +++ b/app/api/import/route.ts @@ -0,0 +1,131 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; +import { bestandLaden, laden, type Ladebericht } from "@/lib/import/load"; +import { dateiLesen, type ImportSheet } from "@/lib/import/parse"; +import { pruefe, type Befund } from "@/lib/import/validate"; + +// Massenimport — Prüflauf und Übernahme über denselben Weg. +// +// Es gibt bewusst **keinen** Zwischenspeicher zwischen beiden Schritten. Die +// Oberfläche schickt die Datei zweimal: einmal mit `pruefen=1`, um den +// Bericht zu zeigen, und nach der Bestätigung noch einmal zum Übernehmen. +// Das kostet eine Übertragung und erspart serverseitigen Zustand, der +// ablaufen, vollaufen oder zwischen zwei Personen verwechselt werden kann. +// +// Beide Läufe sehen denselben Bestand, weil Prüfung und Schreiben in +// derselben Transaktion stattfinden. Zwischen „geprüft" und „geschrieben" +// passt sonst eine fremde Änderung — etwa jemand, der dieselbe Planstelle +// besetzt. + +/** Bricht die Transaktion ab, ohne einen Fehler zu sein. */ +class Rueckabwicklung extends Error { + constructor(readonly nutzlast: unknown) { + super("Prüflauf"); + } +} + +export const maxDuration = 120; + +type Antwort = { + ok: boolean; + geprueft: boolean; + blaetter: string[]; + fehler: Befund[]; + hinweise: Befund[]; + anzahl: Record; + bericht?: Ladebericht; + meldung?: string; +}; + +export async function POST(request: NextRequest) { + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; + + const form = await request.formData(); + const nurPruefen = form.get("pruefen") === "1"; + const dateien = form.getAll("datei").filter((f): f is File => f instanceof File); + + if (dateien.length === 0) { + return NextResponse.json({ ok: false, meldung: "Keine Datei erhalten." }, { status: 400 }); + } + + // Mehrere Dateien werden zusammengesetzt: eine Mappe mit allen Blättern + // oder eine CSV je Blatt sind derselbe Vorgang. + const blaetter: ImportSheet[] = []; + const lesefehler: string[] = []; + for (const datei of dateien) { + const ergebnis = await dateiLesen(datei.name, await datei.arrayBuffer()); + blaetter.push(...ergebnis.blaetter); + lesefehler.push(...ergebnis.fehler); + } + + if (lesefehler.length > 0) { + return NextResponse.json( + { + ok: false, + geprueft: true, + blaetter: blaetter.map((b) => b.name), + fehler: lesefehler.map((m) => ({ blatt: "Datei", zeile: null, spalte: null, meldung: m })), + hinweise: [], + anzahl: {}, + } satisfies Antwort, + { status: 422 } + ); + } + + const profil = await withUser(gate.userId, (tx) => + tx.selectFrom("profiles").select(["full_name", "email"]).where("id", "=", gate.userId).executeTakeFirst() + ); + + try { + const antwort = await withUser(gate.userId, async (tx) => { + const bestand = await bestandLaden(tx); + const geprueft = pruefe(blaetter, bestand); + + const basis: Antwort = { + ok: geprueft.fehler.length === 0, + geprueft: true, + blaetter: blaetter.map((b) => b.name), + fehler: geprueft.fehler, + hinweise: geprueft.hinweise, + anzahl: geprueft.anzahl, + }; + + // Fehler oder Prüflauf: die Transaktion wird zurückgerollt. Beim + // Prüflauf hat sie trotzdem echte Abfragen gemacht — der Bericht + // beruht also auf dem tatsächlichen Bestand, nicht auf einer Kopie. + if (!basis.ok || nurPruefen) throw new Rueckabwicklung({ ...basis, geprueft: nurPruefen || !basis.ok }); + + const bericht = await laden(tx, geprueft.datensatz, bestand, { + userId: gate.userId, + name: profil?.full_name || profil?.email || "Unbekannt", + }); + return { ...basis, geprueft: false, bericht }; + }); + + return NextResponse.json(antwort); + } catch (err) { + if (err instanceof Rueckabwicklung) { + const nutzlast = err.nutzlast as Antwort; + return NextResponse.json(nutzlast, { status: nutzlast.ok ? 200 : 422 }); + } + // Ein echter Fehler beim Schreiben. Die Transaktion ist zurückgerollt, + // es steht also nichts Halbes in der Datenbank. + return NextResponse.json( + { + ok: false, + geprueft: false, + blaetter: blaetter.map((b) => b.name), + fehler: [], + hinweise: [], + anzahl: {}, + meldung: + err instanceof Error + ? `Der Import wurde vollständig zurückgenommen. Grund: ${err.message}` + : "Der Import wurde vollständig zurückgenommen.", + } satisfies Antwort, + { status: 500 } + ); + } +} diff --git a/app/api/import/template/route.ts b/app/api/import/template/route.ts new file mode 100644 index 0000000..f0f1704 --- /dev/null +++ b/app/api/import/template/route.ts @@ -0,0 +1,96 @@ +import ExcelJS from "exceljs"; +import { NextResponse } from "next/server"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { exportFilename, exportResponseHeaders } from "@/lib/export"; +import { BLAETTER } from "@/lib/import/schema"; + +// Die Vorlage entsteht aus demselben Schema wie die Prüfung. +// +// Das ist der Punkt: eine von Hand gepflegte Beispieldatei läuft dem Code +// hinterher, und dann verlangt die Vorlage eine Spalte, die es nicht mehr +// gibt — oder umgekehrt. Hier kann das nicht passieren; kommt in schema.ts +// eine Spalte dazu, steht sie beim nächsten Herunterladen drin. + +export async function GET() { + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; + + const mappe = new ExcelJS.Workbook(); + mappe.creator = "Alpenwerk HR"; + mappe.created = new Date(); + + const hinweise = mappe.addWorksheet("Hinweise"); + hinweise.columns = [ + { header: "Blatt", width: 16 }, + { header: "Spalte", width: 26 }, + { header: "Pflicht", width: 9 }, + { header: "Format", width: 30 }, + { header: "Hinweis", width: 70 }, + ]; + hinweise.getRow(1).font = { bold: true }; + + const formatText = (typ: (typeof BLAETTER)[number]["spalten"][number]["typ"]): string => { + switch (typ.art) { + case "datum": + return "Datum (31.12.2026)"; + case "zahl": + return "Zahl (38,5)"; + case "ganzzahl": + return "Ganze Zahl"; + case "janein": + return "ja / nein"; + case "liste": + return typ.werte ? `Mehrere mit Semikolon aus: ${typ.werte.join(", ")}` : "Mehrere mit Semikolon"; + case "auswahl": + return typ.werte.join(" | "); + default: + return "Text"; + } + }; + + for (const schema of BLAETTER) { + hinweise.addRow([schema.name, "", "", "", schema.zweck]).font = { bold: true }; + for (const s of schema.spalten) { + hinweise.addRow([schema.name, s.name, s.pflicht ? "ja" : "", formatText(s.typ), s.hinweis]); + } + hinweise.addRow([]); + } + + for (const schema of BLAETTER) { + const blatt = mappe.addWorksheet(schema.name); + blatt.columns = schema.spalten.map((s) => ({ + header: s.name, + width: Math.max(12, Math.min(28, s.name.length + 4)), + })); + + const kopf = blatt.getRow(1); + kopf.font = { bold: true }; + kopf.eachCell((zelle, i) => { + const spalte = schema.spalten[i - 1]; + if (!spalte) return; + // Pflichtspalten sichtbar markieren — sonst ist die erste Rückmeldung + // eine Fehlerliste statt eines Hinweises beim Ausfüllen. + if (spalte.pflicht) { + zelle.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFDE7EF" } }; + } + const teile = [spalte.pflicht ? "Pflichtfeld." : "Optional.", formatText(spalte.typ), spalte.hinweis].filter(Boolean); + zelle.note = teile.join("\n"); + }); + + // Eine Beispielzeile. Alles als Text, damit Excel nicht selbst + // interpretiert — der Leser deutet die Werte ohnehin. + const beispiel = schema.spalten.map((s) => s.beispiel); + if (beispiel.some(Boolean)) { + const zeile = blatt.addRow(beispiel); + zeile.font = { italic: true, color: { argb: "FF8A8A8A" } }; + zeile.eachCell((z) => { + z.numFmt = "@"; + }); + } + blatt.views = [{ state: "frozen", ySplit: 1 }]; + } + + const puffer = await mappe.xlsx.writeBuffer(); + const dateiname = exportFilename("import-vorlage", "xlsx"); + return new NextResponse(new Blob([puffer as BlobPart]), { headers: exportResponseHeaders(dateiname, "xlsx") }); +} diff --git a/components/import/ImportWorkbench.tsx b/components/import/ImportWorkbench.tsx new file mode 100644 index 0000000..784e520 --- /dev/null +++ b/components/import/ImportWorkbench.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { CARD_CLASS } from "@/components/ui/Card"; + +// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen. +// +// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll +// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer +// lesen, nicht „Import fehlgeschlagen". + +type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string }; + +type Antwort = { + ok: boolean; + geprueft: boolean; + blaetter: string[]; + fehler: Befund[]; + hinweise: Befund[]; + anzahl: Record; + bericht?: Record; + meldung?: string; +}; + +/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */ +const MAX_ANZEIGE = 200; + +export function ImportWorkbench() { + const [dateien, setDateien] = useState([]); + const [antwort, setAntwort] = useState(null); + const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null); + const [fehlschlag, setFehlschlag] = useState(null); + const eingabe = useRef(null); + + async function senden(nurPruefen: boolean) { + if (dateien.length === 0) return; + setLaeuft(nurPruefen ? "pruefen" : "uebernehmen"); + setFehlschlag(null); + try { + const daten = new FormData(); + for (const d of dateien) daten.append("datei", d); + if (nurPruefen) daten.append("pruefen", "1"); + const antwort = await fetch("/api/import", { method: "POST", body: daten }); + const inhalt = (await antwort.json()) as Antwort; + setAntwort(inhalt); + } catch { + setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen."); + } finally { + setLaeuft(null); + } + } + + function neueAuswahl(liste: FileList | null) { + setDateien(liste ? Array.from(liste) : []); + // Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner. + setAntwort(null); + setFehlschlag(null); + } + + const uebernommen = antwort?.bericht !== undefined; + const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen; + + return ( +
+
+
+
+

Datei wählen

+

+ Eine Excel-Mappe mit den Blättern Standorte, Organisation,{" "} + Jobkatalog, Planstellen, Personen,{" "} + Historie und Angehörige — oder je Blatt eine CSV-Datei, deren Name dem + Blatt entspricht. Nicht jedes Blatt muss dabei sein. +

+
+ + Vorlage herunterladen + +
+ +
+ neueAuswahl(e.target.files)} + className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border + file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50" + /> + +
+ {dateien.length > 0 && ( +

+ {dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt +

+ )} +
+ + {fehlschlag && ( +

+ {fehlschlag} +

+ )} + + {antwort?.meldung && ( +

+ {antwort.meldung} +

+ )} + + {antwort && !antwort.meldung && ( +
+

{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}

+ + + + {Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl) + .filter(([, n]) => n > 0) + .map(([blatt, n]) => ( + + + + + ))} + +
{blatt}{n}
+ + {uebernommen ? ( +

+ Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt. +

+ ) : antwort.ok ? ( + <> +

+ Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar + nichts. +

+
+ +
+ + ) : ( +

+ {antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts + geschrieben. +

+ )} +
+ )} + + {antwort && antwort.fehler.length > 0 && ( + + )} + {antwort && antwort.hinweise.length > 0 && ( + + )} +
+ ); +} + +function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) { + const sichtbar = befunde.slice(0, MAX_ANZEIGE); + return ( +
+

+ {titel} ({befunde.length}) +

+ + + + + + + + + + + + {sichtbar.map((b, i) => ( + + + + + + + + ))} + +
BlattZeileSpalteWertWas zu tun ist
{b.blatt}{b.zeile ?? "–"}{b.spalte ?? "–"} + {b.wert ?? ""} + {b.meldung}
+ {befunde.length > sichtbar.length && ( +

+ Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache — + nach der Korrektur erneut prüfen. +

+ )} +
+ ); +} diff --git a/components/shell/Sidebar.tsx b/components/shell/Sidebar.tsx index 3c934a3..2fa0b50 100644 --- a/components/shell/Sidebar.tsx +++ b/components/shell/Sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react"; +import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect } from "react"; @@ -12,6 +12,7 @@ const NAV_ITEMS = [ { href: "/positions", label: "Positionen", icon: Building2 }, { href: "/reports", label: "Berichte", icon: BarChart3 }, { href: "/audit", label: "Audit-Log", icon: History }, + { href: "/import", label: "Import", icon: Upload }, ] as const; export function isActiveRoute(pathname: string, href: string): boolean { diff --git a/lib/import/load.ts b/lib/import/load.ts new file mode 100644 index 0000000..7d3f124 --- /dev/null +++ b/lib/import/load.ts @@ -0,0 +1,335 @@ +import "server-only"; +import { sql, type Tx } from "@/lib/db"; +import { todayIso } from "@/lib/format"; +import { deriveStatusAsOf } from "@/lib/reports"; +import { normalizeSvnr } from "@/lib/svnr"; +import type { Bestand, Datensatz, Zeile } from "./validate"; + +// Schreiben einer geprüften Datei. +// +// Der Aufruf steckt in **einer** Transaktion (siehe die Route). Das ist keine +// Vorsicht, sondern die Bedingung: eine halb geladene Organisation — Bereiche +// ohne Abteilungen, Planstellen ohne Personen — ist schlimmer als gar keine, +// weil sie nach Daten aussieht. Bricht irgendetwas ab, war nichts. +// +// Angelegt wird nur; aktualisiert wird nie. Was es schon gibt, hat die +// Prüfung vorher abgewiesen. Damit kann ein Tippfehler in einer Datei keine +// bestehenden Personaldaten überschreiben. + +/** Wie viele Zeilen je INSERT. Postgres verträgt 65535 Parameter je Anweisung. */ +const PORTION = 200; + +export type Ladebericht = Record; + +const txt = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); +const zahl = (v: unknown): number | null => (typeof v === "number" ? v : null); +const bool = (v: unknown, ersatz: boolean): boolean => (typeof v === "boolean" ? v : ersatz); +const liste = (v: unknown): string[] | null => (Array.isArray(v) ? (v as string[]) : null); + +async function einfuegen(tx: Tx, tabelle: string, zeilen: T[]): Promise { + for (let i = 0; i < zeilen.length; i += PORTION) { + await tx + // Die Tabellennamen stammen aus einer geschlossenen Aufzählung in + // diesem Modul, nie aus der Datei. + .insertInto(tabelle as never) + .values(zeilen.slice(i, i + PORTION) as never) + .execute(); + } +} + +/** + * Was bereits im System steht. + * + * Läuft in derselben Transaktion wie das Schreiben. Zwischen Prüfung und + * Schreiben könnte sonst jemand dieselbe Planstelle besetzen, und der Import + * liefe in den Teilindex statt in eine verständliche Meldung. + */ +export async function bestandLaden(tx: Tx): Promise { + const heute = todayIso(); + + const [standorte, einheiten, jobs, stellen, besetzungen, personen] = await Promise.all([ + tx.selectFrom("locations").select(["id", "name"]).execute(), + tx.selectFrom("org_units").select(["id", "org_number"]).execute(), + tx.selectFrom("jobs").select(["id", "code"]).execute(), + tx.selectFrom("om_positions").select(["id", "position_number"]).execute(), + tx + .selectFrom("position_assignments") + .select(["position_id"]) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)])) + .execute(), + tx.selectFrom("employees").select(["id", "personnel_number", "email", "sv_nummer"]).execute(), + ]); + + const besetzt = new Set(besetzungen.map((b) => b.position_id)); + + return { + standorte: new Map(standorte.map((l) => [l.name, l.id])), + orgNummern: new Map(einheiten.map((o) => [o.org_number, o.id])), + jobCodes: new Map(jobs.map((j) => [j.code, j.id])), + planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])), + personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])), + emails: new Set(personen.map((e) => e.email.toLowerCase())), + svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))), + }; +} + +/** + * Ordnet Einheiten so, dass jede nach ihrer übergeordneten kommt. + * + * Der Fremdschlüssel auf parent_id wird je Zeile geprüft, und eine Datei + * darf ihre Zeilen in beliebiger Reihenfolge führen — eine Mappe, die nach + * Bezeichnung sortiert ist, hätte sonst Pech. + */ +function elternZuerst(zeilen: Zeile[], bekannt: Set): { sortiert: Zeile[]; ungeloest: Zeile[] } { + const offen = [...zeilen]; + const sortiert: Zeile[] = []; + const erledigt = new Set(bekannt); + + let fortschritt = true; + while (offen.length > 0 && fortschritt) { + fortschritt = false; + for (let i = offen.length - 1; i >= 0; i--) { + const eltern = txt(offen[i].werte.parent_org_number); + if (!eltern || erledigt.has(eltern)) { + const nummer = txt(offen[i].werte.org_number); + if (nummer) erledigt.add(nummer); + sortiert.push(offen[i]); + offen.splice(i, 1); + fortschritt = true; + } + } + } + // Was übrig bleibt, hängt in einem Kreis — die Prüfung fängt den einfachen + // Fall (sich selbst übergeordnet), längere Ketten fallen hier auf. + return { sortiert, ungeloest: offen }; +} + +export async function laden( + tx: Tx, + datensatz: Datensatz, + bestand: Bestand, + akteur: { userId: string; name: string } +): Promise { + const hole = (name: string) => datensatz[name] ?? []; + const bericht: Ladebericht = {}; + const heute = todayIso(); + + // ── Standorte ───────────────────────────────────────────────── + const standortId = new Map(bestand.standorte); + const neueStandorte = hole("Standorte").map((z) => ({ + name: txt(z.werte.name)!, + country: txt(z.werte.country)!, + })); + if (neueStandorte.length) { + const zurueck = await tx.insertInto("locations").values(neueStandorte).returning(["id", "name"]).execute(); + for (const r of zurueck) standortId.set(r.name, r.id); + } + bericht.Standorte = neueStandorte.length; + + // ── Organisation ────────────────────────────────────────────── + const orgId = new Map(bestand.orgNummern); + const { sortiert, ungeloest } = elternZuerst(hole("Organisation"), new Set(orgId.keys())); + if (ungeloest.length) { + throw new Error( + `Die übergeordneten Einheiten von ${ungeloest.length} Zeile(n) lassen sich nicht auflösen — vermutlich ein Kreis in der Spalte „Übergeordnet“.` + ); + } + for (const z of sortiert) { + const eltern = txt(z.werte.parent_org_number); + const r = await tx + .insertInto("org_units") + .values({ + org_number: txt(z.werte.org_number)!, + name: txt(z.werte.name)!, + unit_type: txt(z.werte.unit_type) as never, + parent_id: eltern ? orgId.get(eltern)! : null, + valid_from: txt(z.werte.valid_from) ?? heute, + valid_to: txt(z.werte.valid_to), + }) + .returning(["id", "org_number"]) + .execute(); + orgId.set(r[0].org_number, r[0].id); + } + bericht.Organisation = sortiert.length; + + // ── Jobkatalog ──────────────────────────────────────────────── + const jobId = new Map(bestand.jobCodes); + const neueJobs = hole("Jobkatalog").map((z) => ({ code: txt(z.werte.code)!, title: txt(z.werte.title)! })); + if (neueJobs.length) { + const zurueck = await tx.insertInto("jobs").values(neueJobs).returning(["id", "code"]).execute(); + for (const r of zurueck) jobId.set(r.code, r.id); + } + bericht.Jobkatalog = neueJobs.length; + + // ── Planstellen ─────────────────────────────────────────────── + const stellenId = new Map([...bestand.planstellen].map(([nr, p]) => [nr, p.id])); + const neueStellen = hole("Planstellen").map((z) => ({ + position_number: txt(z.werte.position_number)!, + org_unit_id: orgId.get(txt(z.werte.org_number)!)!, + job_id: jobId.get(txt(z.werte.job_code)!)!, + is_chief: bool(z.werte.is_chief, false), + valid_from: txt(z.werte.valid_from) ?? heute, + valid_to: txt(z.werte.valid_to), + })); + if (neueStellen.length) { + const zurueck = await tx + .insertInto("om_positions") + .values(neueStellen) + .returning(["id", "position_number"]) + .execute(); + for (const r of zurueck) stellenId.set(r.position_number, r.id); + } + bericht.Planstellen = neueStellen.length; + + // ── Personen ────────────────────────────────────────────────── + const personId = new Map(bestand.personalnummern); + const personenZeilen = hole("Personen"); + const besetzungen: { position_id: string; employee_id: string; valid_from: string; valid_to: string | null }[] = []; + + for (const z of personenZeilen) { + const w = z.werte; + const eintritt = txt(w.entry_date)!; + const austritt = txt(w.exit_date); + const karenzVon = txt(w.karenz_start_date); + const karenzBis = txt(w.karenz_return_date); + + const werte = { + personnel_number: zahl(w.personnel_number)!, + first_name: txt(w.first_name)!, + last_name: txt(w.last_name)!, + gender: txt(w.gender) as never, + birth_date: txt(w.birth_date)!, + sv_nummer: txt(w.sv_nummer) ? normalizeSvnr(txt(w.sv_nummer)!) : null, + nationality: txt(w.nationality) ?? "Österreich", + address: txt(w.address), + postal_code: txt(w.postal_code), + city: txt(w.city), + address_country: txt(w.address_country), + email: txt(w.email)!, + phone: txt(w.phone), + title_prefix: liste(w.title_prefix) ?? [], + title_suffix: liste(w.title_suffix) ?? [], + job_title: txt(w.job_title)!, + location_id: standortId.get(txt(w.location)!)!, + employment_type: (txt(w.employment_type) ?? "Vollzeit") as never, + weekly_hours: zahl(w.weekly_hours) ?? 38.5, + // Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und + // auf die Schreibweise der Datenbank gebracht; hier steht deshalb + // sicher nur Mo…So. + work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never, + contract_type: (txt(w.contract_type) ?? "unbefristet") as never, + contract_end_date: txt(w.contract_end_date), + paygrade: (txt(w.paygrade) ?? "B") as never, + collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never, + worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never, + monthly_salary_gross: zahl(w.monthly_salary_gross), + source: (txt(w.source) ?? "Extern") as never, + is_betriebsrat: bool(w.is_betriebsrat, false), + has_dienstwagen: bool(w.has_dienstwagen, false), + is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false), + is_c_level: bool(w.is_c_level, false), + entry_date: eintritt, + exit_date: austritt, + exit_reason: txt(w.exit_reason), + karenz_start_date: karenzVon, + karenz_return_date: karenzBis, + absence_type: txt(w.absence_type), + // Der Status wird **abgeleitet**, nicht importiert. Stünde er in der + // Datei, könnte er den Daten widersprechen — jemand mit Austritt und + // Status „Aktiv“ —, und die Anwendung leitet ihn ohnehin überall aus + // denselben Datumsangaben ab. + status: deriveStatusAsOf( + { entry_date: eintritt, exit_date: austritt, karenz_start_date: karenzVon, karenz_return_date: karenzBis }, + heute + ), + }; + + // Von Hand geschrieben statt über den Abfragebauer, wegen genau eines + // Wortes: OVERRIDING SYSTEM VALUE. + // + // personnel_number ist GENERATED ALWAYS AS IDENTITY — die Datenbank + // vergibt sie und weist einen eigenen Wert sonst ab. Für eine Übernahme + // aus einem Altsystem ist das die falsche Richtung: die Nummer steht auf + // Lohnzetteln, in Akten und auf Ausweisen. Ein Import, der sie neu + // würfelt, ist keine Übernahme. + const spalten = Object.keys(werte); + const r = await sql<{ id: string; personnel_number: number }>` + insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))}) + overriding system value + values (${sql.join(Object.values(werte).map((v) => sql.val(v)))}) + returning id, personnel_number + `.execute(tx); + + personId.set(r.rows[0].personnel_number, r.rows[0].id); + + const stelle = txt(w.position_number); + if (stelle) { + besetzungen.push({ + position_id: stellenId.get(stelle)!, + employee_id: r.rows[0].id, + valid_from: eintritt, + // Beim Austritt endet die Besetzung — sonst gälte die Planstelle als + // belegt und liesse sich nicht nachbesetzen. + valid_to: austritt, + }); + } + } + bericht.Personen = personenZeilen.length; + + // Den Zähler nachziehen. Ohne das vergibt die Datenbank für die nächste + // Neueinstellung eine Nummer, die der Import bereits verbraucht hat — und + // der eindeutige Index weist sie ab. Der Fehler träte erst Wochen später + // auf, beim ersten Eintritt nach der Übernahme. + if (personenZeilen.length > 0) { + await sql` + select setval( + pg_get_serial_sequence('employees', 'personnel_number'), + (select max(personnel_number) from employees) + ) + `.execute(tx); + } + + await einfuegen(tx, "position_assignments", besetzungen); + + // ── Historie und Angehörige ─────────────────────────────────── + const historie = hole("Historie").map((z) => ({ + employee_id: personId.get(zahl(z.werte.personnel_number)!)!, + event_date: txt(z.werte.event_date)!, + event_type: txt(z.werte.event_type)! as never, + description: txt(z.werte.description)!, + })); + await einfuegen(tx, "employee_history", historie); + bericht.Historie = historie.length; + + const angehoerige = hole("Angehörige").map((z) => ({ + employee_id: personId.get(zahl(z.werte.personnel_number)!)!, + first_name: txt(z.werte.first_name)!, + last_name: txt(z.werte.last_name)!, + relationship: txt(z.werte.relationship)!, + birth_date: txt(z.werte.birth_date)!, + sv_nummer: txt(z.werte.sv_nummer) ? normalizeSvnr(txt(z.werte.sv_nummer)!) : null, + })); + await einfuegen(tx, "employee_dependents", angehoerige); + bericht.Angehörige = angehoerige.length; + + // ── Protokoll ───────────────────────────────────────────────── + // Ein Eintrag für den ganzen Vorgang, nicht einer je Zeile: 800 Zeilen + // würden das Protokoll unlesbar machen, und der Vorgang ist ohnehin + // untrennbar — er ist eine Transaktion. + const zusammenfassung = Object.entries(bericht) + .filter(([, n]) => n > 0) + .map(([blatt, n]) => `${n} ${blatt}`) + .join(", "); + await tx + .insertInto("audit_log") + .values({ + actor_user_id: akteur.userId, + actor_name: akteur.name, + action: "Import", + target_label: "Massenimport", + details: zusammenfassung || "nichts angelegt", + }) + .execute(); + + return bericht; +} diff --git a/lib/import/schema.ts b/lib/import/schema.ts index 6912bc8..2621688 100644 --- a/lib/import/schema.ts +++ b/lib/import/schema.ts @@ -1,4 +1,5 @@ import { ABSENCE_TYPES } from "@/lib/absence"; +import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles"; // Was in einer Importdatei stehen darf. // @@ -233,11 +234,20 @@ export const BLATT_PERSONEN: BlattSchema = { name: "Titel vorangestellt", ziel: "title_prefix", pflicht: false, - typ: { art: "liste" }, - hinweis: "Mehrere mit Semikolon.", + // Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat. + // Ohne die Aufzählung hier bräche der Import erst beim Schreiben ab. + typ: { art: "liste", werte: TITLE_PREFIXES }, + hinweis: "Mehrere mit Semikolon. In einer CSV die Zelle in Anführungszeichen setzen.", beispiel: "Mag.", }, - { name: "Titel nachgestellt", ziel: "title_suffix", pflicht: false, typ: { art: "liste" }, hinweis: "", beispiel: "MSc" }, + { + name: "Titel nachgestellt", + ziel: "title_suffix", + pflicht: false, + typ: { art: "liste", werte: TITLE_SUFFIXES }, + hinweis: "Mehrere mit Semikolon.", + beispiel: "MSc", + }, { name: "Tätigkeit", ziel: "job_title", diff --git a/lib/import/validate.ts b/lib/import/validate.ts index 5945e8d..0399d87 100644 --- a/lib/import/validate.ts +++ b/lib/import/validate.ts @@ -316,7 +316,9 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN 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); + // 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)) { @@ -333,9 +335,31 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN 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); - if (stunden !== null && (stunden <= 0 || stunden > 60)) { - melde("Personen", z.zeile, "Wochenstunden", "Ausserhalb eines plausiblen Bereichs (0–60).", String(stunden)); + 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."); } } diff --git a/supabase/migrations/20260803120000_import_sequence_grant.sql b/supabase/migrations/20260803120000_import_sequence_grant.sql new file mode 100644 index 0000000..2f1d123 --- /dev/null +++ b/supabase/migrations/20260803120000_import_sequence_grant.sql @@ -0,0 +1,42 @@ +-- Die Anwendungsrolle muss die Personalnummern-Sequenz fortschreiben dürfen. +-- +-- Hintergrund: employees.personnel_number ist GENERATED ALWAYS AS IDENTITY. +-- Der Massenimport übernimmt die Nummern aus der Quelldatei — sie stehen auf +-- Lohnzetteln, in Akten und auf Ausweisen, ein Import darf sie nicht neu +-- vergeben — und schreibt danach den Zähler auf das neue Maximum. +-- +-- Ohne diesen Schritt vergäbe die Datenbank bei der nächsten Neueinstellung +-- eine Nummer, die der Import bereits verbraucht hat. Der eindeutige Index +-- weist sie ab, und zwar erst Wochen später beim ersten Eintritt nach der +-- Übernahme — weit weg von der Ursache. +-- +-- `setval()` verlangt UPDATE auf der Sequenz. `usage, select` reicht nicht; +-- genau daran ist der erste Durchstich gescheitert. +do $$ +declare + v_sequenz text := pg_get_serial_sequence('public.employees', 'personnel_number'); + r text; +begin + if v_sequenz is null then + raise exception 'Sequenz zu employees.personnel_number nicht gefunden.'; + end if; + + -- Nur an Rollen, die es gibt: dieselbe Datei soll auf einem gewöhnlichen + -- PostgreSQL ohne die Supabase-Rollen laufen. + foreach r in array array['alpenwerk_app', 'authenticated', 'service_role'] loop + if exists (select 1 from pg_roles where rolname = r) then + execute format('grant usage, select, update on sequence %s to %I', v_sequenz, r); + end if; + end loop; +end; +$$; + +-- Damit künftige Sequenzen in diesem Schema dieselben Rechte bekommen und +-- der nächste Import nicht an derselben Stelle stehenbleibt. +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then + execute 'alter default privileges in schema public grant usage, select, update on sequences to alpenwerk_app'; + end if; +end; +$$; diff --git a/tests/unit/import-validate.test.ts b/tests/unit/import-validate.test.ts index 451eb07..c39cbe7 100644 --- a/tests/unit/import-validate.test.ts +++ b/tests/unit/import-validate.test.ts @@ -157,7 +157,55 @@ describe("Personen", () => { [blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2010", "Kündigung"]])], BESTAND ); - expect(meldungen(zuFrueh.fehler).join(" ")).toContain("Liegt nicht nach dem Eintritt"); + 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", () => {