Load a whole organisation from a file, or none of it
Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.
Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.
Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.
Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:
- weekly_hours is bound to employment type by a CHECK constraint: full time
is exactly 38.5. The import reached the insert and was rolled back. Now
it is a finding with a row number.
- Titles are restricted to a fixed list by another CHECK. Same treatment.
- setval() needs UPDATE on the sequence, which `usage, select` does not
grant. Migration 20260803120000 adds it; until it is applied, an import
containing people will fail at the last step and take itself back.
I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.
Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
16
app/(app)/import/page.tsx
Normal file
16
app/(app)/import/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ImportWorkbench } from "@/components/import/ImportWorkbench";
|
||||||
|
|
||||||
|
export const metadata = { title: "Import" };
|
||||||
|
|
||||||
|
export default function ImportPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="max-w-prose text-sm text-ink-muted">
|
||||||
|
Ü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.
|
||||||
|
</p>
|
||||||
|
<ImportWorkbench />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
app/api/import/route.ts
Normal file
131
app/api/import/route.ts
Normal file
@@ -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<string, number>;
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/api/import/template/route.ts
Normal file
96
app/api/import/template/route.ts
Normal file
@@ -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") });
|
||||||
|
}
|
||||||
210
components/import/ImportWorkbench.tsx
Normal file
210
components/import/ImportWorkbench.tsx
Normal file
@@ -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<string, number>;
|
||||||
|
bericht?: Record<string, number>;
|
||||||
|
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<File[]>([]);
|
||||||
|
const [antwort, setAntwort] = useState<Antwort | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
|
||||||
|
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
|
||||||
|
const eingabe = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<section className={`${CARD_CLASS} p-5`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
|
||||||
|
<p className="mt-1 max-w-prose text-sm text-ink-muted">
|
||||||
|
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
|
||||||
|
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
|
||||||
|
<strong>Historie</strong> und <strong>Angehörige</strong> — oder je Blatt eine CSV-Datei, deren Name dem
|
||||||
|
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/import/template"
|
||||||
|
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
Vorlage herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={eingabe}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".xlsx,.xlsm,.csv"
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
|
||||||
|
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dateien.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-ink-muted">
|
||||||
|
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{fehlschlag && (
|
||||||
|
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||||
|
{fehlschlag}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{antwort?.meldung && (
|
||||||
|
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||||
|
{antwort.meldung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{antwort && !antwort.meldung && (
|
||||||
|
<section className={`${CARD_CLASS} p-5`}>
|
||||||
|
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
|
||||||
|
|
||||||
|
<table className="mt-3 w-full max-w-md text-sm">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
|
||||||
|
.filter(([, n]) => n > 0)
|
||||||
|
.map(([blatt, n]) => (
|
||||||
|
<tr key={blatt} className="border-b border-border-subtle last:border-0">
|
||||||
|
<td className="py-1.5 text-ink-body">{blatt}</td>
|
||||||
|
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{uebernommen ? (
|
||||||
|
<p className="mt-4 text-sm text-ink-muted">
|
||||||
|
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
|
||||||
|
</p>
|
||||||
|
) : antwort.ok ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-4 text-sm text-ink-body">
|
||||||
|
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar
|
||||||
|
nichts.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
|
||||||
|
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm font-semibold text-danger-text">
|
||||||
|
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts
|
||||||
|
geschrieben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{antwort && antwort.fehler.length > 0 && (
|
||||||
|
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
|
||||||
|
)}
|
||||||
|
{antwort && antwort.hinweise.length > 0 && (
|
||||||
|
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
||||||
|
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
||||||
|
return (
|
||||||
|
<section className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
|
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
|
||||||
|
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
|
||||||
|
</h2>
|
||||||
|
<table className="mt-3 w-full min-w-[640px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||||
|
<th className="px-5 py-2">Blatt</th>
|
||||||
|
<th className="px-3 py-2">Zeile</th>
|
||||||
|
<th className="px-3 py-2">Spalte</th>
|
||||||
|
<th className="px-3 py-2">Wert</th>
|
||||||
|
<th className="px-5 py-2">Was zu tun ist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sichtbar.map((b, i) => (
|
||||||
|
<tr key={i} className="border-b border-border-subtle last:border-0">
|
||||||
|
<td className="whitespace-nowrap px-5 py-2 text-ink-body">{b.blatt}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums text-ink-muted">{b.zeile ?? "–"}</td>
|
||||||
|
<td className="px-3 py-2 text-ink-body">{b.spalte ?? "–"}</td>
|
||||||
|
<td className="max-w-[16rem] truncate px-3 py-2 text-ink-muted" title={b.wert}>
|
||||||
|
{b.wert ?? ""}
|
||||||
|
</td>
|
||||||
|
<td className={`px-5 py-2 ${art === "fehler" ? "text-danger-text" : "text-ink-muted"}`}>{b.meldung}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{befunde.length > sichtbar.length && (
|
||||||
|
<p className="px-5 py-3 text-xs text-ink-muted">
|
||||||
|
Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache —
|
||||||
|
nach der Korrektur erneut prüfen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -12,6 +12,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/positions", label: "Positionen", icon: Building2 },
|
{ href: "/positions", label: "Positionen", icon: Building2 },
|
||||||
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
||||||
{ href: "/audit", label: "Audit-Log", icon: History },
|
{ href: "/audit", label: "Audit-Log", icon: History },
|
||||||
|
{ href: "/import", label: "Import", icon: Upload },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isActiveRoute(pathname: string, href: string): boolean {
|
export function isActiveRoute(pathname: string, href: string): boolean {
|
||||||
|
|||||||
335
lib/import/load.ts
Normal file
335
lib/import/load.ts
Normal file
@@ -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<string, number>;
|
||||||
|
|
||||||
|
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<T>(tx: Tx, tabelle: string, zeilen: T[]): Promise<void> {
|
||||||
|
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<Bestand> {
|
||||||
|
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<string>): { 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<Ladebericht> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ABSENCE_TYPES } from "@/lib/absence";
|
import { ABSENCE_TYPES } from "@/lib/absence";
|
||||||
|
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
|
||||||
|
|
||||||
// Was in einer Importdatei stehen darf.
|
// Was in einer Importdatei stehen darf.
|
||||||
//
|
//
|
||||||
@@ -233,11 +234,20 @@ export const BLATT_PERSONEN: BlattSchema = {
|
|||||||
name: "Titel vorangestellt",
|
name: "Titel vorangestellt",
|
||||||
ziel: "title_prefix",
|
ziel: "title_prefix",
|
||||||
pflicht: false,
|
pflicht: false,
|
||||||
typ: { art: "liste" },
|
// Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat.
|
||||||
hinweis: "Mehrere mit Semikolon.",
|
// 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.",
|
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",
|
name: "Tätigkeit",
|
||||||
ziel: "job_title",
|
ziel: "job_title",
|
||||||
|
|||||||
@@ -316,7 +316,9 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
|
|||||||
const austritt = s(w.exit_date);
|
const austritt = s(w.exit_date);
|
||||||
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
|
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
|
||||||
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", 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 (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)) {
|
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 (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 stunden = n(w.weekly_hours);
|
||||||
if (stunden !== null && (stunden <= 0 || stunden > 60)) {
|
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
|
||||||
melde("Personen", z.zeile, "Wochenstunden", "Ausserhalb eines plausiblen Bereichs (0–60).", String(stunden));
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
supabase/migrations/20260803120000_import_sequence_grant.sql
Normal file
42
supabase/migrations/20260803120000_import_sequence_grant.sql
Normal file
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -157,7 +157,55 @@ describe("Personen", () => {
|
|||||||
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2010", "Kündigung"]])],
|
[blatt("Personen", spalten, [[...person("1", "a@example.at", "60000001"), "01.01.2010", "Kündigung"]])],
|
||||||
BESTAND
|
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", () => {
|
it("verlangt ein Ende nur bei befristeten Verträgen — und dort immer", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user