Cornerstone fuehrt jede Person unter einer eigenen Kennung. Der Export dorthin trug in "User ID" und "Username" bisher die Alpenwerk-UUID -- richtig, solange es nichts Besseres gab, aber nicht die Kennung, unter der Cornerstone die Person kennt. Jetzt steht dort diese Spalte. Freiwillig, weil die Zuordnungstabelle des Kunden fuer 434 der 784 Personen keine Kennung liefert. Eindeutig, weil eine Kennung genau einer Person gehoert. Als Text, weil fuehrende Nullen in einer numerischen Spalte verlorengingen. Ohne hinterlegte Kennung bleiben User ID und Username **leer**. Ein Rueckfall auf die UUID braechte zwei Kennungsarten in eine Datei, ohne dass es auffiele, und legte in Cornerstone eine zweite Person neben der bestehenden an. Eine fehlende Angabe soll fehlen; dafuer gibt es einen eigenen Test. Fuenf Funktionen mussten mit -- dieselbe Liste und derselbe Grund wie bei der Firmen-E-Mail: hire_employee und rehire_employee teilen sich den Schritt "Person", change_employee_data macht das Feld aenderbar, apply_due_pending_changes sorgt dafuer, dass eine datierte Aenderung nicht verfaellt, app_feld_karte haelt den Eintrag in der Historie richtigstellbar. Die Migration ist wieder erzeugt, nicht abgeschrieben, und prueft jede der fuenf einzeln. Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung und Wiedereintritt sowie ueber den Massenimport; es steht im Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
345 lines
16 KiB
TypeScript
345 lines
16 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", "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) ?? "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;
|
|
}
|