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:
2026-08-03 14:40:33 +02:00
parent 16b37244c8
commit 3926f1bb80
10 changed files with 921 additions and 8 deletions

131
app/api/import/route.ts Normal file
View 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 }
);
}
}