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.
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import type { Kostenstelle } from "./cost-centers";
|
||
import type { CsvForm, ExportColumn } from "./export";
|
||
import type { EmploymentStatus } from "./types";
|
||
|
||
// Der Export für Cornerstone (CSOD) — 57 Spalten in fester Reihenfolge.
|
||
//
|
||
// Die Reihenfolge und die Schreibweise der Überschriften sind vorgegeben:
|
||
// Cornerstone ordnet die Spalten über eine Konfiguration zu, die im Portal
|
||
// eingerichtet wird. Eine umbenannte oder verschobene Spalte fällt hier nicht
|
||
// auf, sondern dort — als Feld, das leer ankommt.
|
||
//
|
||
// ═══ Was aus Alpenwerk kommt und was nicht ══════════════════════════
|
||
//
|
||
// Ein Teil der Spalten hat in Alpenwerk keine Entsprechung. Sie bleiben leer,
|
||
// so wie in der Beispielzeile, statt mit erfundenen Werten gefüllt zu werden:
|
||
// Approver, Absent, Allow Reconciliation, Required Training Approvals, Exempt,
|
||
// Ethnicity, State, Mailstop, Phone, Mobile, Fax, Retention Rules,
|
||
// User activation/de-activation date, Eligible For Rehire.
|
||
//
|
||
// Vier Spalten tragen ausdrücklich **Platzhalter**, weil die führenden
|
||
// Systeme ihre Kennungen noch nicht liefern: Division ID sowie die drei
|
||
// Customfields für Doxis, Interflex und LGVplus. Sie sind als solche erkennbar
|
||
// (siehe die Konstanten unten) und müssen vor einem echten Lauf ersetzt werden.
|
||
//
|
||
// ═══ Was bewusst NICHT mitgeht ══════════════════════════════════════
|
||
//
|
||
// Private Telefonnummer und private E-Mail-Adresse. Cornerstone hat dafür
|
||
// Spalten (Home Phone, Personal Email), und die Beispielzeile lässt beide
|
||
// leer. Dabei bleibt es: Daten, die das Haus verlassen, sind die dienstlichen.
|
||
|
||
/**
|
||
* Die Form der CSV-Datei, wie Cornerstone sie einliest.
|
||
*
|
||
* Steht hier und nicht in der Route, damit sie zusammen mit den Spalten
|
||
* geprueft werden kann: die Vorgaben von toCsv sind fuer Excel gemacht
|
||
* (Semikolon, BOM) und waeren hier beide falsch. Das BOM haenge unsichtbar am
|
||
* Namen der ersten Spalte, und die kaeme in keiner Zuordnung vor.
|
||
*/
|
||
export const CORNERSTONE_CSV: CsvForm = { trenner: ",", bom: false };
|
||
|
||
export const CORNERSTONE_SPRACHE = "de-DE";
|
||
export const CORNERSTONE_WAEHRUNG = "EUR";
|
||
|
||
/**
|
||
* Der Benutzertyp als **Systemwert**, nicht als Anzeigename.
|
||
*
|
||
* Cornerstone zeigt „Mitarbeiter" und erwartet beim Einlesen „Employee".
|
||
* Der Anzeigename führt zu „User Type ungültiger Wert" und danach zu
|
||
* „Alle abhängigen Felder müssen gültig sein" — einem Folgefehler, der
|
||
* verschwindet, sobald der erste behoben ist.
|
||
*/
|
||
export const CORNERSTONE_USER_TYPE = "Employee";
|
||
|
||
/**
|
||
* Die Zeitzone bleibt leer.
|
||
*
|
||
* Gültig ist allein eine Zeitzonen-ID aus dem Portal; ein Kürzel wie „CET"
|
||
* quittiert der Load mit „Zeitzonencode nicht eindeutig". Leer heisst:
|
||
* Vererbung vom Portal beziehungsweise von der Organisationseinheit.
|
||
*/
|
||
export const CORNERSTONE_ZEITZONE = "";
|
||
|
||
/**
|
||
* Die Division als GUID, übernommen aus dem Testload vom 27.09.2026.
|
||
*
|
||
* Cornerstone verlangt hier die GUID, nicht den Namen. Sie steht in keiner
|
||
* Alpenwerk-Tabelle — sie gehört dem Zielsystem. Vor einem Echtlauf gegen
|
||
* das Portal prüfen.
|
||
*/
|
||
export const DIVISION_GUID = "1f4c00b3-f0e7-4b53-b84e-e1f8d6088262";
|
||
|
||
export const DOXIS_PRAEFIX = "DOX-";
|
||
export const LGV_PRAEFIX = "LGV-";
|
||
|
||
/**
|
||
* Cornerstone kennt zwei Geschlechter nicht als „m"/„w".
|
||
*
|
||
* Die erlaubten Werte sind female, male, not known und not specified. Ein
|
||
* Wert ausserhalb davon lässt Cornerstone die Zeile abweisen — nicht das
|
||
* Feld, die ganze Zeile.
|
||
*/
|
||
export function geschlecht(g: string): string {
|
||
if (g === "m") return "male";
|
||
if (g === "w") return "female";
|
||
return "not specified";
|
||
}
|
||
|
||
/**
|
||
* Tag zuerst: 15.09.2026.
|
||
*
|
||
* Die Einleseeinstellung in Cornerstone ist auf „Tag zuerst" gestellt. Käme
|
||
* ein ISO-Datum an, läse Cornerstone den 09. März statt des 3. September —
|
||
* lautlos, weil beides gültige Daten sind.
|
||
*/
|
||
export function csDatum(iso: string | null | undefined): string {
|
||
if (!iso) return "";
|
||
const [j, m, t] = iso.slice(0, 10).split("-");
|
||
return j && m && t ? `${t}.${m}.${j}` : "";
|
||
}
|
||
|
||
/**
|
||
* Der Kontoname im Verzeichnisdienst: vorname.nachname, klein, ohne Umlaute.
|
||
*
|
||
* Nur für das Customfield AD. Der **Username** in Cornerstone ist etwas
|
||
* anderes: er ist die UUID aus Alpenwerk, dieselbe wie die User ID.
|
||
*
|
||
* Abgeleitet und nicht aus der dienstlichen Adresse geschnitten: die Adresse
|
||
* ist freiwillig, der Name ist es nicht.
|
||
*/
|
||
export function benutzername(vorname: string, nachname: string): string {
|
||
const sauber = (s: string) =>
|
||
s
|
||
.toLowerCase()
|
||
.replace(/ä/g, "ae")
|
||
.replace(/ö/g, "oe")
|
||
.replace(/ü/g, "ue")
|
||
.replace(/ß/g, "ss")
|
||
.normalize("NFD")
|
||
.replace(/[̀-ͯ]/g, "")
|
||
.replace(/[^a-z0-9]+/g, "");
|
||
return [sauber(vorname), sauber(nachname)].filter(Boolean).join(".");
|
||
}
|
||
|
||
/**
|
||
* Zwei Statusspalten, zwei verschiedene Fragen.
|
||
*
|
||
* `Status` ist das Konto in Cornerstone: wer ausgetreten ist, soll sich nicht
|
||
* mehr anmelden. `Employment Status` ist das Beschäftigungsverhältnis. Eine
|
||
* Person in Karenz hat ein aktives Konto und arbeitet trotzdem nicht — würde
|
||
* man beide Spalten aus demselben Wert füllen, verschwände sie entweder aus
|
||
* dem System oder sie gälte als arbeitend.
|
||
*/
|
||
export function kontoStatus(status: EmploymentStatus): string {
|
||
return status === "Ausgetreten" ? "Inactive" : "Active";
|
||
}
|
||
|
||
/**
|
||
* Working, On Leave oder Terminated — die Werte des Zielsystems.
|
||
*
|
||
* „Geplant" kennt Cornerstone nicht. Eine Person, deren Eintritt noch
|
||
* bevorsteht, wird als Working geladen: sie bekommt ein Konto, und das
|
||
* Eintrittsdatum steht ohnehin in der Zeile. Alles andere hiesse, sie
|
||
* entweder wegzulassen oder einen Wert zu erfinden.
|
||
*/
|
||
export function beschaeftigungsStatus(status: EmploymentStatus): string {
|
||
if (status === "Karenz") return "On Leave";
|
||
if (status === "Ausgetreten") return "Terminated";
|
||
return "Working";
|
||
}
|
||
|
||
export type CornerstoneZeile = Record<string, string | number>;
|
||
|
||
/** Die Person, so wie der Export sie braucht. */
|
||
export type CornerstoneQuelle = {
|
||
id: string;
|
||
personnel_number: number;
|
||
first_name: string;
|
||
last_name: string;
|
||
title_prefix: string[];
|
||
title_suffix: string[];
|
||
gender: string;
|
||
company_email: string | null;
|
||
cornerstone_id: string | null;
|
||
address: string | null;
|
||
postal_code: string | null;
|
||
city: string | null;
|
||
address_country: string | null;
|
||
entry_date: string;
|
||
exit_date: string | null;
|
||
exit_reason: string | null;
|
||
austrittsart: string | null;
|
||
absence_type: string | null;
|
||
status: EmploymentStatus;
|
||
paygrade: string;
|
||
location_id: string;
|
||
position_id: string | null;
|
||
manager_id: string | null;
|
||
};
|
||
|
||
export type CornerstoneKontext = {
|
||
/** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */
|
||
managerNummer: Map<string, number>;
|
||
/** Kostenstelle je Planstelle zum Stichtag. */
|
||
kostenstelle: Map<string, Kostenstelle>;
|
||
};
|
||
|
||
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
|
||
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
|
||
// Zwei Kennungen, zwei Herkünfte:
|
||
//
|
||
// User ID + Username die Cornerstone-ID (employees.cornerstone_id)
|
||
// Local System ID die Personalnummer, wie LOGA sie vergibt
|
||
//
|
||
// Benutzername und Benutzer-ID sind stets derselbe Wert — liefen sie
|
||
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
|
||
// jeder Verweis darauf träfe womöglich die falsche.
|
||
//
|
||
// Hier stand die Alpenwerk-UUID, solange es nichts Besseres gab. Seit
|
||
// 20260928100000 führt Alpenwerk die Kennung mit, unter der Cornerstone
|
||
// die Person selbst kennt; die UUID sagte dort niemandem etwas.
|
||
//
|
||
// **Leer, wenn keine hinterlegt ist.** Ein Rückfall auf die UUID brächte
|
||
// zwei Kennungsarten in eine Datei, ohne dass es jemandem auffiele — und
|
||
// legte in Cornerstone eine zweite Person neben der bestehenden an. Eine
|
||
// fehlende Kennung ist eine fehlende Angabe und soll als solche auffallen.
|
||
//
|
||
// Der Anmeldename des Verzeichnisdienstes steht nur im Customfield AD.
|
||
const kennung = p.cornerstone_id ?? "";
|
||
const nummer = String(p.personnel_number);
|
||
const adName = benutzername(p.first_name, p.last_name);
|
||
const kst = p.position_id ? k.kostenstelle.get(p.position_id) : undefined;
|
||
const chef = p.manager_id ? k.managerNummer.get(p.manager_id) : undefined;
|
||
const ausgetreten = p.status === "Ausgetreten";
|
||
|
||
return {
|
||
"User ID": kennung,
|
||
"Local System ID": nummer,
|
||
Prefix: p.title_prefix.join(" "),
|
||
"First Name": p.first_name,
|
||
"Middle Name": "",
|
||
"Last Name": p.last_name,
|
||
Suffix: p.title_suffix.join(" "),
|
||
Username: kennung,
|
||
Approver: "",
|
||
Manager: chef ? String(chef) : "",
|
||
Absent: "",
|
||
"Allow Reconciliation": "",
|
||
Email: p.company_email ?? "",
|
||
"Required Training Approvals": "",
|
||
Gender: geschlecht(p.gender),
|
||
Exempt: "",
|
||
Ethnicity: "",
|
||
// Leer: Cornerstone rechnet die Dienstzeit aus den Eintrittsdaten selbst.
|
||
// Ein mitgeladener Wert wäre ab dem Tag nach dem Einlesen veraltet.
|
||
"Months of Service": "",
|
||
"Compensation Currency": CORNERSTONE_WAEHRUNG,
|
||
"Address Line 1": p.address ?? "",
|
||
"Address Line 2": "",
|
||
City: p.city ?? "",
|
||
State: "",
|
||
Zip: p.postal_code ?? "",
|
||
Mailstop: "",
|
||
Country: p.address_country ?? "",
|
||
"Original Hire Date": csDatum(p.entry_date),
|
||
"Last Hire Date": csDatum(p.entry_date),
|
||
Phone: "",
|
||
Mobile: "",
|
||
// Private Nummer und private Adresse bleiben im Haus — siehe oben.
|
||
"Home Phone": "",
|
||
Fax: "",
|
||
Status: kontoStatus(p.status),
|
||
"User activation date": "",
|
||
"User de-activation date": "",
|
||
"Personal Email": "",
|
||
Language: CORNERSTONE_SPRACHE,
|
||
"Time Zone": CORNERSTONE_ZEITZONE,
|
||
"User Type": CORNERSTONE_USER_TYPE,
|
||
"Employment Status": beschaeftigungsStatus(p.status),
|
||
// Die vier Austrittsfelder und der Abwesenheitsgrund nur, wenn der
|
||
// Beschäftigungsstatus sie trägt. Ein Grund ohne Austritt ist für den
|
||
// Load ein ungültiger Zustand, nicht eine Zusatzangabe.
|
||
"Leave Reason": ausgetreten ? "" : p.status === "Karenz" ? (p.absence_type ?? "") : "",
|
||
"Termination Type": ausgetreten ? (p.austrittsart ?? "") : "",
|
||
"Termination Reason": ausgetreten ? (p.exit_reason ?? "") : "",
|
||
"Termination Date": ausgetreten ? csDatum(p.exit_date) : "",
|
||
// Wiedereinstellbarkeit wird in Alpenwerk nicht erhoben.
|
||
"Eligible For Rehire": "",
|
||
// „Nein" gehört laut Vorgabe nicht in die Datei, und der Wert für „Ja"
|
||
// ist nicht bestätigt. Ein nicht bestätigter Wert liesse den Load die
|
||
// **ganze** Zeile abweisen, nicht nur das Feld — deshalb bleibt sie leer.
|
||
"Rehired Employee": "",
|
||
"Division ID": DIVISION_GUID,
|
||
// Leer: die Planstellennummer aus Alpenwerk (S-0001) ist keine
|
||
// Cornerstone-Position-ID. Sie hier einzutragen hiesse, eine Kennung des
|
||
// Zielsystems zu erfinden.
|
||
"Position ID": "",
|
||
"Grade ID": p.paygrade,
|
||
"Cost Center ID": kst?.code ?? "",
|
||
// Leer: Cornerstone erwartet seine eigene Standortkennung („01"), und
|
||
// die Tabelle locations führt nur id, name und country. Der Name wäre
|
||
// hier keine Kennung, sondern eine, die es im Zielsystem nicht gibt.
|
||
// Solange dafür keine Spalte existiert, bleibt das Feld leer.
|
||
"Location ID": "",
|
||
// Beide Spalten wertet der Load derzeit nicht aus. Sie stehen in der
|
||
// Kopfzeile, weil die Vorlage sie führt.
|
||
"Retention Rules": "",
|
||
Organisationsstufe: "",
|
||
"Customfield ID AD": adName,
|
||
"Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`,
|
||
"Customfield ID Interflex": String(nummer),
|
||
"Customfield ID LGVplus": `${LGV_PRAEFIX}${nummer}`,
|
||
};
|
||
}
|
||
|
||
/** Die Spalten in der Reihenfolge, die Cornerstone erwartet. */
|
||
export const CORNERSTONE_SPALTEN: readonly string[] = [
|
||
"User ID",
|
||
"Local System ID",
|
||
"Prefix",
|
||
"First Name",
|
||
"Middle Name",
|
||
"Last Name",
|
||
"Suffix",
|
||
"Username",
|
||
"Approver",
|
||
"Manager",
|
||
"Absent",
|
||
"Allow Reconciliation",
|
||
"Email",
|
||
"Required Training Approvals",
|
||
"Gender",
|
||
"Exempt",
|
||
"Ethnicity",
|
||
"Months of Service",
|
||
"Compensation Currency",
|
||
"Address Line 1",
|
||
"Address Line 2",
|
||
"City",
|
||
"State",
|
||
"Zip",
|
||
"Mailstop",
|
||
"Country",
|
||
"Original Hire Date",
|
||
"Last Hire Date",
|
||
"Phone",
|
||
"Mobile",
|
||
"Home Phone",
|
||
"Fax",
|
||
"Status",
|
||
"User activation date",
|
||
"User de-activation date",
|
||
"Personal Email",
|
||
"Language",
|
||
"Time Zone",
|
||
"User Type",
|
||
"Employment Status",
|
||
"Leave Reason",
|
||
"Termination Type",
|
||
"Termination Reason",
|
||
"Termination Date",
|
||
"Eligible For Rehire",
|
||
"Rehired Employee",
|
||
"Division ID",
|
||
"Position ID",
|
||
"Grade ID",
|
||
"Cost Center ID",
|
||
"Location ID",
|
||
"Retention Rules",
|
||
"Organisationsstufe",
|
||
"Customfield ID AD",
|
||
"Customfield ID Doxis",
|
||
"Customfield ID Interflex",
|
||
"Customfield ID LGVplus",
|
||
];
|
||
|
||
export function cornerstoneSpalten(): ExportColumn<CornerstoneZeile>[] {
|
||
return CORNERSTONE_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
|
||
}
|