A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste geschickt -- 13 Stufen plus einen Generic Grade. Dieselbe Spalte fuellt im Cornerstone-Extrakt die Grade ID. Solange dort A--F steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen auffaellt: das Zielsystem kennt diese Kennungen nicht. Der Typ heisst weiter paygrade_type, ist aber jetzt eine Domain ueber text mit CHECK statt eines Aufzaehlungstyps. Ein Enum laesst sich nicht umschreiben -- Werte entfernen geht gar nicht, und add value darf im selben Vorgang, der den neuen Wert schreibt, nicht benutzt werden. Wichtiger: die rund zehn SQL-Funktionen, die den Wert nach paygrade_type umwandeln, bleiben unveraendert gueltig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu haben, aus einer veralteten Vorlage zu kopieren. Zwei Funktionen muessen doch angefasst werden, beide per Punktaenderung an der laufenden Definition statt per Kopie aus einer Datei: der Vorgabewert B in hire_employee und die Beschriftung im Protokoll von promote_employee. Der Bestand bekommt den Generic Grade. Aus A--F liesse sich kein Hay-Grade ableiten: andere Einteilung, andere Anzahl. Geraten saehe im Extrakt genauso aus wie erhoben.
346 lines
16 KiB
TypeScript
346 lines
16 KiB
TypeScript
import "server-only";
|
|
import { sql, type Tx } from "@/lib/db";
|
|
import { todayIso } from "@/lib/format";
|
|
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
|
|
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", "cornerstone_id", "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())),
|
|
cornerstoneIds: new Set(personen.filter((e) => e.cornerstone_id).map((e) => e.cornerstone_id!)),
|
|
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),
|
|
cornerstone_id: txt(w.cornerstone_id),
|
|
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) ?? HAY_GRADE_STANDARD) 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;
|
|
}
|