Files
alpenwerk-hr/lib/import/load.ts
Andrei Laas 57acbfae9d
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
Die Firmen-E-Mail als eigenes, freiwilliges Feld
employees.email ist die private Adresse (20260811140000). Sie taugt nicht
als Dienstadresse und darf auch nicht als solche benutzt werden: der
Honestly-Export geht an einen fremden Anbieter, der damit im Namen des
Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher
leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich.

Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den
Index nicht, weil null nie gleich null ist. Geschrieben wird ueber
`case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich
auch wieder entfernen lassen.

Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze
Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet:
hire_employee und rehire_employee (beide teilen sich den Schritt
"Person" -- das Formular haette das Feld gezeigt und den Wert
weggeworfen), change_employee_data (sonst nicht aenderbar),
apply_due_pending_changes (sonst verfiele eine auf spaeter datierte
Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie
nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
2026-09-23 10:52:26 +02:00

343 lines
15 KiB
TypeScript

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 { sortiereWochentage } from "@/lib/wochentage";
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", "company_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])),
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
// kann sie fehlen.
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
firmenEmails: new Set(personen.filter((e) => e.company_email).map((e) => e.company_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)!,
company_email: txt(w.company_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. Sortiert wird trotzdem: die Reihenfolge in der
// Datei ist die der Datei, und „Di, Mo" ist derselbe Sachverhalt wie
// „Mo, Di". Ungeordnet gespeichert erzeugte die erste Änderung an so
// einer Person eine Vertragsänderung über nichts.
work_days: sortiereWochentage(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),
// Ohne Dienstwagen zwingend null, mit Dienstwagen zwingend gesetzt —
// so verlangt es chk_dienstwagen_art. Fehlt die Angabe in der Datei,
// gilt Verbrenner, wie im übernommenen Bestand.
dienstwagen_art: bool(w.has_dienstwagen, false) ? (txt(w.dienstwagen_art) ?? "Verbrenner") : null,
emergency_contact_name: txt(w.emergency_contact_name),
emergency_contact_phone: txt(w.emergency_contact_phone),
emergency_contact_relation: txt(w.emergency_contact_relation),
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
is_c_level: bool(w.is_c_level, false),
has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false),
// Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst
// ab, und die ganze Datei scheiterte an einer einzelnen Zeile.
kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null,
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
),
};
// Weiterhin von Hand geschrieben, aber ohne OVERRIDING SYSTEM VALUE:
// personnel_number ist seit 20260811100000 keine Identitätsspalte mehr,
// sondern eine gewöhnliche Pflichtangabe — sie muss mit Loga und
// Interflex übereinstimmen und wird deshalb überall eingegeben, nicht
// vergeben. Für eine Identitätsspalte war das Schlüsselwort nötig; für
// eine gewöhnliche wäre es ein Fehler.
const spalten = Object.keys(werte);
const r = await sql<{ id: string; personnel_number: number }>`
insert into employees (${sql.raw(spalten.map((s) => `"${s}"`).join(", "))})
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;
// Kein Fortschreiben eines Zählers mehr: es gibt keinen. Die Nummer wird
// bei jeder Einstellung eingegeben, und die Eindeutigkeit sichert der
// Index — beim Import wie im Assistenten.
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;
}