Im Feld Manager stand die Personalnummer der vorgesetzten Person -- also der Wert, der in derselben Zeile als Local System ID gefuehrt wird. Cornerstone verknuepft aber ueber die User ID. Der Verweis lief damit entweder ins Leere oder, schlimmer, auf jemand anderen, dessen User ID zufaellig aussieht wie eine Personalnummer. Leer, wenn die vorgesetzte Person selbst keine Cornerstone-ID traegt. Aus demselben Grund wie bei User ID und Username: eine Kennung der falschen Art ist schlimmer als keine, weil niemand ihr ansieht, dass sie falsch ist. Bei 435 von 785 Personen fehlt die Kennung noch -- das ist eine Luecke in der Zuordnungstabelle, kein Fehler im Export. Die Abbildung im Kontext heisst entsprechend managerKennung und traegt jetzt Zeichenketten, damit der Typ selbst keine Nummer mehr zulaesst.
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
baueCornerstoneZeile,
|
|
benutzername,
|
|
beschaeftigungsStatus,
|
|
CORNERSTONE_CSV,
|
|
CORNERSTONE_SPALTEN,
|
|
cornerstoneSpalten,
|
|
csDatum,
|
|
DIVISION_GUID,
|
|
geschlecht,
|
|
kontoStatus,
|
|
type CornerstoneKontext,
|
|
type CornerstoneQuelle,
|
|
} from "@/lib/cornerstone";
|
|
import { toCsv } from "@/lib/export";
|
|
|
|
// Die Datei geht in ein fremdes System, das sie über eine im Portal
|
|
// eingerichtete Zuordnung einliest. Zwei Sorten Fehler sind hier teuer:
|
|
//
|
|
// * Ein deutscher Anzeigename statt des Systemwerts („Aktiv" statt
|
|
// „Active") weist Cornerstone mit „ungültiger Wert" ab — und zwar die
|
|
// ganze Zeile, gefolgt von „Alle abhängigen Felder müssen gültig sein".
|
|
// * Ein Datum in der falschen Reihenfolge fällt gar nicht auf: 09.03. und
|
|
// 03.09. sind beide gültig.
|
|
//
|
|
// Deshalb steht hier beides fest, Wert für Wert.
|
|
|
|
// Die Kopfzeile aus der Vorlage, Zeichen für Zeichen.
|
|
const VORGABE =
|
|
"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";
|
|
|
|
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
|
|
// Die Kennung, unter der Cornerstone die Person kennt — seit 20260928100000
|
|
// fuehrt Alpenwerk sie mit, und sie steht in User ID und Username.
|
|
const CS_ID = "10000382";
|
|
|
|
const kontext: CornerstoneKontext = {
|
|
managerKennung: new Map([
|
|
["chef", "10000117"],
|
|
// Eine vorgesetzte Person, die selbst keine Cornerstone-ID trägt — bei 435
|
|
// von 785 Personen der Normalfall, nicht der Sonderfall.
|
|
["chef-ohne-kennung", null],
|
|
]),
|
|
kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
|
|
};
|
|
|
|
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
|
|
return {
|
|
id: UUID,
|
|
personnel_number: 9002,
|
|
first_name: "Max",
|
|
last_name: "Alpentest",
|
|
title_prefix: [],
|
|
title_suffix: [],
|
|
gender: "m",
|
|
company_email: "m.test@alpenwerk.test.com",
|
|
cornerstone_id: CS_ID,
|
|
address: "Teststrasse 2",
|
|
postal_code: "1010",
|
|
city: "Wien",
|
|
address_country: "AT",
|
|
entry_date: "2026-10-01",
|
|
exit_date: null,
|
|
exit_reason: null,
|
|
austrittsart: null,
|
|
absence_type: null,
|
|
status: "Aktiv",
|
|
paygrade: "HG15",
|
|
location_id: "wien",
|
|
position_id: "pos-1",
|
|
manager_id: null,
|
|
...teil,
|
|
};
|
|
}
|
|
|
|
const zeile = (teil: Partial<CornerstoneQuelle> = {}) => baueCornerstoneZeile(person(teil), kontext);
|
|
|
|
describe("Spalten", () => {
|
|
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
|
|
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
|
|
});
|
|
|
|
it("sind 57", () => {
|
|
expect(CORNERSTONE_SPALTEN).toHaveLength(57);
|
|
});
|
|
|
|
it("decken sich mit den Feldern, die eine Zeile liefert", () => {
|
|
// Ein Feld, das die Zeile setzt, das aber in keiner Spalte steht, ginge
|
|
// stillschweigend verloren.
|
|
expect(Object.keys(zeile()).sort()).toEqual([...CORNERSTONE_SPALTEN].sort());
|
|
});
|
|
|
|
it("schreibt die Datei mit Komma und ohne BOM", () => {
|
|
// Stünden die Vorgaben von toCsv drin (Semikolon fürs deutsche Excel,
|
|
// BOM gegen Buchstabensalat), käme in Cornerstone eine einspaltige Datei
|
|
// an, deren Spalte auch noch ein unsichtbares Zeichen im Namen trägt.
|
|
expect(CORNERSTONE_CSV).toEqual({ trenner: ",", bom: false });
|
|
});
|
|
});
|
|
|
|
describe("Systemwerte statt Anzeigenamen", () => {
|
|
it("setzt Status auf Active beziehungsweise Inactive", () => {
|
|
expect(kontoStatus("Aktiv")).toBe("Active");
|
|
expect(kontoStatus("Karenz")).toBe("Active");
|
|
expect(kontoStatus("Ausgetreten")).toBe("Inactive");
|
|
});
|
|
|
|
it("setzt Employment Status auf Working, On Leave oder Terminated", () => {
|
|
expect(beschaeftigungsStatus("Aktiv")).toBe("Working");
|
|
expect(beschaeftigungsStatus("Karenz")).toBe("On Leave");
|
|
expect(beschaeftigungsStatus("Ausgetreten")).toBe("Terminated");
|
|
// „Geplant" kennt Cornerstone nicht — die Person bekommt ein Konto.
|
|
expect(beschaeftigungsStatus("Geplant")).toBe("Working");
|
|
});
|
|
|
|
it("schreibt nirgends einen deutschen Anzeigenamen in die Statusfelder", () => {
|
|
// Der häufigste Fehler aus früheren Loads.
|
|
const z = zeile();
|
|
expect([z.Status, z["Employment Status"], z["User Type"]]).toEqual(["Active", "Working", "Employee"]);
|
|
});
|
|
|
|
it("lässt die Zeitzone leer, statt ein Kürzel zu schreiben", () => {
|
|
// Gültig ist allein eine Zeitzonen-ID aus dem Portal; „CET" quittiert
|
|
// der Load mit „Zeitzonencode nicht eindeutig".
|
|
expect(zeile()["Time Zone"]).toBe("");
|
|
});
|
|
|
|
it("übersetzt das Geschlecht in Cornerstones Werte", () => {
|
|
expect(geschlecht("w")).toBe("female");
|
|
expect(geschlecht("m")).toBe("male");
|
|
// Alles andere ist nicht „leer", sondern ein gültiger Wert — sonst weist
|
|
// Cornerstone die ganze Zeile ab.
|
|
expect(geschlecht("x")).toBe("not specified");
|
|
});
|
|
|
|
it("schreibt das Datum mit dem Tag zuerst", () => {
|
|
expect(csDatum("2026-10-01")).toBe("01.10.2026");
|
|
expect(csDatum(null)).toBe("");
|
|
});
|
|
|
|
it("baut den Anmeldenamen ohne Umlaute und Sonderzeichen", () => {
|
|
expect(benutzername("Jörg", "Müller-Groß")).toBe("joerg.muellergross");
|
|
expect(benutzername("Anna Maria", "Berger")).toBe("annamaria.berger");
|
|
});
|
|
});
|
|
|
|
describe("Eine Zeile gegen das Muster vom 27.09.2026", () => {
|
|
it("trägt dieselben Werte wie der Testload", () => {
|
|
expect(zeile()).toMatchObject({
|
|
"Local System ID": "9002",
|
|
"First Name": "Max",
|
|
"Last Name": "Alpentest",
|
|
Username: CS_ID,
|
|
Email: "m.test@alpenwerk.test.com",
|
|
Gender: "male",
|
|
"Compensation Currency": "EUR",
|
|
"Address Line 1": "Teststrasse 2",
|
|
City: "Wien",
|
|
Zip: "1010",
|
|
Country: "AT",
|
|
"Original Hire Date": "01.10.2026",
|
|
"Last Hire Date": "01.10.2026",
|
|
Status: "Active",
|
|
Language: "de-DE",
|
|
"Time Zone": "",
|
|
"User Type": "Employee",
|
|
"Employment Status": "Working",
|
|
"Division ID": DIVISION_GUID,
|
|
"Grade ID": "HG15",
|
|
"Cost Center ID": "41130",
|
|
"Customfield ID AD": "max.alpentest",
|
|
"Customfield ID Doxis": "DOX-9002",
|
|
"Customfield ID Interflex": "9002",
|
|
"Customfield ID LGVplus": "LGV-9002",
|
|
});
|
|
});
|
|
|
|
it("lässt die Felder leer, die das Muster leer lässt", () => {
|
|
const z = zeile();
|
|
for (const feld of [
|
|
"Prefix",
|
|
"Middle Name",
|
|
"Suffix",
|
|
"Approver",
|
|
"Manager",
|
|
"Absent",
|
|
"Allow Reconciliation",
|
|
"Required Training Approvals",
|
|
"Exempt",
|
|
"Ethnicity",
|
|
"Months of Service",
|
|
"Address Line 2",
|
|
"State",
|
|
"Mailstop",
|
|
"Phone",
|
|
"Mobile",
|
|
"Home Phone",
|
|
"Fax",
|
|
"User activation date",
|
|
"User de-activation date",
|
|
"Personal Email",
|
|
"Leave Reason",
|
|
"Termination Type",
|
|
"Termination Reason",
|
|
"Termination Date",
|
|
"Eligible For Rehire",
|
|
"Rehired Employee",
|
|
"Position ID",
|
|
"Location ID",
|
|
"Retention Rules",
|
|
"Organisationsstufe",
|
|
]) {
|
|
expect(z[feld], feld).toBe("");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Abhängige Felder", () => {
|
|
it("füllt die Austrittsfelder nur bei Terminated", () => {
|
|
// Ein Austrittsgrund ohne Austritt ist für den Load ein ungültiger
|
|
// Zustand, nicht eine Zusatzangabe.
|
|
const offen = zeile({ exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" });
|
|
expect(offen["Termination Date"]).toBe("");
|
|
expect(offen["Termination Type"]).toBe("");
|
|
expect(offen["Termination Reason"]).toBe("");
|
|
|
|
const weg = zeile({ status: "Ausgetreten", exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" });
|
|
expect(weg["Termination Date"]).toBe("31.12.2026");
|
|
expect(weg["Termination Type"]).toBe("freiwillig");
|
|
expect(weg["Termination Reason"]).toBe("Kündigung");
|
|
expect(weg.Status).toBe("Inactive");
|
|
expect(weg["Employment Status"]).toBe("Terminated");
|
|
});
|
|
|
|
it("nennt den Abwesenheitsgrund nur bei On Leave", () => {
|
|
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
|
|
const karenz = zeile({ status: "Karenz", absence_type: "Elternkarenz" });
|
|
expect(karenz["Leave Reason"]).toBe("Elternkarenz");
|
|
expect(karenz["Employment Status"]).toBe("On Leave");
|
|
});
|
|
|
|
it("nennt keinen Abwesenheitsgrund an einer ausgetretenen Person", () => {
|
|
const weg = zeile({ status: "Ausgetreten", absence_type: "Elternkarenz", exit_date: "2026-12-31" });
|
|
expect(weg["Leave Reason"]).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("Benutzername und Benutzer-ID", () => {
|
|
it("tragen denselben Wert", () => {
|
|
// In Alpenwerk sind die beiden stets im Gleichklang. Liefen sie
|
|
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
|
|
// jeder Verweis darauf (Manager, Berichte) träfe womöglich die falsche.
|
|
const z = zeile();
|
|
expect(z.Username).toBe(z["User ID"]);
|
|
});
|
|
|
|
it("sind die Cornerstone-ID, nicht die UUID und nicht die Personalnummer", () => {
|
|
// Hier stand die Alpenwerk-UUID, solange Alpenwerk die Kennung des
|
|
// Lernsystems nicht kannte. Sie sagte dort niemandem etwas.
|
|
const z = zeile();
|
|
expect(z["User ID"]).toBe(CS_ID);
|
|
expect(z.Username).toBe(CS_ID);
|
|
expect(z["User ID"]).not.toBe(UUID);
|
|
expect(z["User ID"]).not.toBe("9002");
|
|
});
|
|
|
|
it("bleiben leer, wenn keine Cornerstone-ID hinterlegt ist", () => {
|
|
// Kein Rueckfall auf die UUID: das 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.
|
|
const z = baueCornerstoneZeile(person({ cornerstone_id: null }), kontext);
|
|
expect(z["User ID"]).toBe("");
|
|
expect(z.Username).toBe("");
|
|
expect(JSON.stringify(z)).not.toContain(UUID);
|
|
});
|
|
|
|
it("fuehren die LOGA-Personalnummer in Local System ID", () => {
|
|
expect(zeile()["Local System ID"]).toBe("9002");
|
|
});
|
|
|
|
it("lassen den Anmeldenamen im Customfield AD stehen", () => {
|
|
// Der Verzeichnisdienst kennt die Person unter vorname.nachname; das ist
|
|
// nicht der Username in Cornerstone, sondern eine eigene Angabe.
|
|
const z = zeile();
|
|
expect(z["Customfield ID AD"]).toBe("max.alpentest");
|
|
expect(z["Customfield ID AD"]).not.toBe(z.Username);
|
|
});
|
|
});
|
|
|
|
describe("Kennungen statt Namen", () => {
|
|
it("verweist auf die vorgesetzte Person über deren Cornerstone-ID", () => {
|
|
// Cornerstone verknüpft über die User ID, nicht über den Namen und nicht
|
|
// über die Personalnummer: die ist in dieser Datei die Local System ID.
|
|
expect(zeile({ manager_id: "chef" }).Manager).toBe("10000117");
|
|
expect(zeile({ manager_id: null }).Manager).toBe("");
|
|
});
|
|
|
|
it("lässt den Verweis leer, wenn die vorgesetzte Person keine Kennung trägt", () => {
|
|
// Lieber keine Angabe als eine Kennung der falschen Art: eine
|
|
// Personalnummer im Feld Manager sähe aus wie eine User ID und träfe im
|
|
// Zielsystem womöglich eine andere Person.
|
|
expect(zeile({ manager_id: "chef-ohne-kennung" }).Manager).toBe("");
|
|
});
|
|
|
|
it("nimmt als Kostenstelle den Code der Planstelle", () => {
|
|
expect(zeile()["Cost Center ID"]).toBe("41130");
|
|
expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");
|
|
});
|
|
|
|
it("schreibt die Division als GUID", () => {
|
|
expect(zeile()["Division ID"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
});
|
|
});
|
|
|
|
describe("als CSV", () => {
|
|
it("schreibt die Kopfzeile Zeichen für Zeichen wie die Vorlage", () => {
|
|
const csv = toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV);
|
|
expect(csv.startsWith("User ID,")).toBe(true);
|
|
expect(csv.split(/\r?\n/)[0]).toBe(VORGABE);
|
|
});
|
|
|
|
it("trennt die Zeilen mit CRLF", () => {
|
|
expect(toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV)).toContain("\r\n");
|
|
});
|
|
|
|
it("maskiert ein Komma im Wert, statt die Zeile zu zerreissen", () => {
|
|
const csv = toCsv([zeile({ address: "Teststrasse 2, Stiege 3" })], cornerstoneSpalten(), CORNERSTONE_CSV);
|
|
expect(csv).toContain('"Teststrasse 2, Stiege 3"');
|
|
// Felder zählen, nicht Kommas: das maskierte Komma steht ja in der Zeile.
|
|
const felder = (t: string) => t.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length;
|
|
const [kopf, daten] = csv.split(/\r?\n/);
|
|
expect(felder(daten)).toBe(felder(kopf));
|
|
expect(felder(kopf)).toBe(57);
|
|
});
|
|
|
|
it("lässt die private Adresse nirgends durch", () => {
|
|
const csv = toCsv([zeile({ company_email: null })], cornerstoneSpalten(), CORNERSTONE_CSV);
|
|
expect(csv).not.toContain("@");
|
|
});
|
|
});
|