Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".
Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.
Zwei Anmerkungen von Max:
* Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
naechsten Speichern von selbst.
* "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
jede Korrektur am Personenkreis scheitern, solange der Grad noch
dransteht.
341 lines
15 KiB
TypeScript
341 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", "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())),
|
|
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. 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;
|
|
}
|