diff --git a/app/api/export/cornerstone/route.ts b/app/api/export/cornerstone/route.ts
index 86b3875..ea73e09 100644
--- a/app/api/export/cornerstone/route.ts
+++ b/app/api/export/cornerstone/route.ts
@@ -21,29 +21,15 @@ export async function GET(request: NextRequest) {
const stichtag = asOf ?? todayIso();
const { zeilen } = await withUser(gate.userId, async (tx) => {
- const { rows, lookups, orgMaps, managerNummer } = await ladeExportMitarbeiter(tx, { asOf, filters });
+ const { rows, managerNummer } = await ladeExportMitarbeiter(tx, { asOf, filters });
- // Die beiden Angaben, die die gemeinsame Auswahl nicht mitbringt: die
- // Kostenstelle hängt an der Planstelle, der Wiedereintritt an der
- // Historie. Beide erst jetzt, weil erst jetzt feststeht, um wen es geht.
+ // Die eine Angabe, die die gemeinsame Auswahl nicht mitbringt: die
+ // Kostenstelle hängt an der Planstelle, nicht an der Person. Erst jetzt,
+ // weil erst jetzt feststeht, um welche Planstellen es geht.
const positionIds = [...new Set(rows.map((r) => r.position_id).filter((id): id is string => Boolean(id)))];
- const [kostenstelle, wiedereintritte] = await Promise.all([
- loadKontierungen(tx, { asOf: stichtag, positionIds }),
- tx
- .selectFrom("employee_history")
- .select("employee_id")
- .where("event_type", "=", "Wiedereintritt")
- .distinct()
- .execute(),
- ]);
-
const kontext: CornerstoneKontext = {
- orgMaps,
- standortName: lookups.locationName,
managerNummer,
- kostenstelle,
- wiedereingestellt: new Set(wiedereintritte.map((w) => w.employee_id)),
- stichtag,
+ kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
};
return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx
index 191d3cb..f467d3b 100644
--- a/components/reports/ReportsPageClient.tsx
+++ b/components/reports/ReportsPageClient.tsx
@@ -659,10 +659,11 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
{mode === "snapshot" && (
-
Cornerstone-Report
+
Cornerstone Report
- Die 57 Spalten des Cornerstone-Einlesens, Datum tagzuerst. Vier Felder tragen noch Platzhalter
- (Division ID, Doxis, Interflex, LGVplus) und sind vor einem echten Lauf zu ersetzen.
+ Load-File für Cornerstone: 57 Spalten in der Reihenfolge der Vorlage, kommagetrennt, Datum
+ TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und
+ Position ID — dafür fehlen die Kennungen des Zielsystems.
Status im Export: {statusExportLabel}
diff --git a/lib/cornerstone.ts b/lib/cornerstone.ts
index dd5923e..18876bd 100644
--- a/lib/cornerstone.ts
+++ b/lib/cornerstone.ts
@@ -1,6 +1,5 @@
import type { Kostenstelle } from "./cost-centers";
import type { CsvForm, ExportColumn } from "./export";
-import { unitOf, type OrgMaps } from "./org";
import type { EmploymentStatus } from "./types";
// Der Export für Cornerstone (CSOD) — 57 Spalten in fester Reihenfolge.
@@ -40,12 +39,36 @@ import type { EmploymentStatus } from "./types";
export const CORNERSTONE_CSV: CsvForm = { trenner: ",", bom: false };
export const CORNERSTONE_SPRACHE = "de-DE";
-export const CORNERSTONE_ZEITZONE = "CET";
export const CORNERSTONE_WAEHRUNG = "EUR";
-export const CORNERSTONE_USER_TYPE = "Mitarbeiter";
-/** Platzhalter, solange das führende System die Kennung nicht liefert. */
-export const DIVISION_PLATZHALTER = "DIVISION-ID-HIER";
+/**
+ * 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-";
@@ -75,16 +98,6 @@ export function csDatum(iso: string | null | undefined): string {
return j && m && t ? `${t}.${m}.${j}` : "";
}
-/** Volle Monate zwischen Eintritt und Stichtag. */
-export function dienstmonate(eintritt: string | null | undefined, stichtag: string): number | "" {
- if (!eintritt) return "";
- const [ej, em, et] = eintritt.slice(0, 10).split("-").map(Number);
- const [sj, sm, st] = stichtag.slice(0, 10).split("-").map(Number);
- if (!ej || !sj) return "";
- const monate = (sj - ej) * 12 + (sm - em) - (st < et ? 1 : 0);
- return Math.max(0, monate);
-}
-
/**
* Der Anmeldename: vorname.nachname, klein, ohne Umlaute und Leerzeichen.
*
@@ -115,14 +128,21 @@ export function benutzername(vorname: string, nachname: string): string {
* dem System oder sie gälte als arbeitend.
*/
export function kontoStatus(status: EmploymentStatus): string {
- return status === "Ausgetreten" ? "Inaktiv" : "Aktiv";
+ 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 "Abwesend";
- if (status === "Ausgetreten") return "Ausgetreten";
- if (status === "Geplant") return "Eintritt geplant";
- return "Arbeitend";
+ if (status === "Karenz") return "On Leave";
+ if (status === "Ausgetreten") return "Terminated";
+ return "Working";
}
export type CornerstoneZeile = Record;
@@ -149,31 +169,24 @@ export type CornerstoneQuelle = {
status: EmploymentStatus;
paygrade: string;
location_id: string;
- org_unit_id: string | null;
position_id: string | null;
- position_number: string | null;
manager_id: string | null;
};
export type CornerstoneKontext = {
- orgMaps: OrgMaps;
- standortName: Map;
/** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */
managerNummer: Map;
/** Kostenstelle je Planstelle zum Stichtag. */
kostenstelle: Map;
- /** Wer schon einmal wiedereingetreten ist. */
- wiedereingestellt: Set;
- stichtag: string;
};
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
const nummer = p.personnel_number;
const name = benutzername(p.first_name, p.last_name);
- const einheit = unitOf(k.orgMaps, p.org_unit_id);
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": String(nummer),
@@ -193,7 +206,9 @@ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext
Gender: geschlecht(p.gender),
Exempt: "",
Ethnicity: "",
- "Months of Service": dienstmonate(p.entry_date, k.stichtag),
+ // 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": "",
@@ -217,24 +232,35 @@ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext
"Time Zone": CORNERSTONE_ZEITZONE,
"User Type": CORNERSTONE_USER_TYPE,
"Employment Status": beschaeftigungsStatus(p.status),
- "Leave Reason": p.status === "Karenz" ? (p.absence_type ?? "") : "",
- "Termination Type": p.austrittsart ?? "",
- "Termination Reason": p.exit_reason ?? "",
- "Termination Date": csDatum(p.exit_date),
+ // 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": "",
- // „Ja" nur, wenn die Historie einen Wiedereintritt trägt. Leer heisst
- // „nie ausgetreten und zurückgekommen", nicht „unbekannt".
- "Rehired Employee": k.wiedereingestellt.has(p.id) ? "Ja" : "",
- "Division ID": DIVISION_PLATZHALTER,
- "Position ID": p.position_number ?? "",
+ // „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 ?? "",
- "Location ID": k.standortName.get(p.location_id) ?? "",
+ // 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": "",
- // Die Ebene, auf der die Person hängt — Gesellschaft, Bereich, Abteilung
- // oder Team. Nicht der Name der Einheit: der steht im Organigramm, und
- // Cornerstone gliedert nach der Stufe.
- Organisationsstufe: einheit?.unit_type ?? "",
+ Organisationsstufe: "",
"Customfield ID AD": name,
"Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`,
"Customfield ID Interflex": String(nummer),
diff --git a/tests/unit/cornerstone.test.ts b/tests/unit/cornerstone.test.ts
index a8ebc87..f0c6126 100644
--- a/tests/unit/cornerstone.test.ts
+++ b/tests/unit/cornerstone.test.ts
@@ -7,66 +7,58 @@ import {
CORNERSTONE_SPALTEN,
cornerstoneSpalten,
csDatum,
- dienstmonate,
+ DIVISION_GUID,
geschlecht,
kontoStatus,
type CornerstoneKontext,
type CornerstoneQuelle,
} from "@/lib/cornerstone";
import { toCsv } from "@/lib/export";
-import { buildOrgMaps, type OrgUnit } from "@/lib/org";
// Die Datei geht in ein fremdes System, das sie über eine im Portal
-// eingerichtete Zuordnung einliest. Eine verschobene Spalte oder ein Datum in
-// der falschen Reihenfolge fällt nicht hier auf, sondern dort — und das
-// Datum fällt womöglich gar nicht auf, weil 09.03. und 03.09. beide gültig
-// sind. Deshalb steht die Form hier fest.
+// 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 Vorgabe, Zeichen für Zeichen.
+// 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 units: OrgUnit[] = [
- { id: "g", org_number: "1", name: "Manner AG", parent_id: null, unit_type: "Gesellschaft" },
- { id: "b", org_number: "2", name: "Produktion", parent_id: "g", unit_type: "Bereich" },
- { id: "t", org_number: "3", name: "Linie 1", parent_id: "b", unit_type: "Team" },
-];
-
const kontext: CornerstoneKontext = {
- orgMaps: buildOrgMaps(units, []),
- standortName: new Map([["wien", "Wien"]]),
managerNummer: new Map([["chef", 1000]]),
- kostenstelle: new Map([["pos-1", { id: "k", code: "4711", name: "Waffelproduktion" }]]),
- wiedereingestellt: new Set(["zurueck"]),
- stichtag: "2026-09-25",
+ kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
};
function person(teil: Partial = {}): CornerstoneQuelle {
return {
id: "e1",
- personnel_number: 9001,
- first_name: "Testa",
- last_name: "Musterfrau",
+ personnel_number: 9002,
+ first_name: "Max",
+ last_name: "Alpentest",
title_prefix: [],
title_suffix: [],
- gender: "w",
- company_email: "testa.musterfrau@example.test",
- address: "Teststrasse 1",
+ gender: "m",
+ company_email: "m.test@alpenwerk.test.com",
+ address: "Teststrasse 2",
postal_code: "1010",
city: "Wien",
address_country: "AT",
- entry_date: "2026-09-15",
+ entry_date: "2026-10-01",
exit_date: null,
exit_reason: null,
austrittsart: null,
absence_type: null,
status: "Aktiv",
- paygrade: "B",
+ paygrade: "HG15",
location_id: "wien",
- org_unit_id: "t",
position_id: "pos-1",
- position_number: "S-0001",
- manager_id: "chef",
+ manager_id: null,
...teil,
};
}
@@ -74,7 +66,7 @@ function person(teil: Partial = {}): CornerstoneQuelle {
const zeile = (teil: Partial = {}) => baueCornerstoneZeile(person(teil), kontext);
describe("Spalten", () => {
- it("stehen genau so und in genau der Reihenfolge wie vorgegeben", () => {
+ it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
});
@@ -82,27 +74,45 @@ describe("Spalten", () => {
expect(CORNERSTONE_SPALTEN).toHaveLength(57);
});
- it("schreibt die Datei mit Komma und ohne BOM", () => {
- // Die Route nimmt genau diese Form. 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 });
- });
-
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("Einzelne Umsetzungen", () => {
- it("schreibt das Datum mit dem Tag zuerst", () => {
- // Die Einleseeinstellung steht auf „Tag zuerst". Ein ISO-Datum würde
- // stumm falsch gelesen.
- expect(csDatum("2026-09-15")).toBe("15.09.2026");
- expect(csDatum(null)).toBe("");
+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", () => {
@@ -113,124 +123,151 @@ describe("Einzelne Umsetzungen", () => {
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");
});
-
- it("zählt volle Dienstmonate", () => {
- expect(dienstmonate("2026-09-15", "2026-09-25")).toBe(0);
- expect(dienstmonate("2025-09-30", "2026-09-25")).toBe(11);
- expect(dienstmonate("2025-09-15", "2026-09-25")).toBe(12);
- });
-
- it("trennt Konto von Beschäftigung", () => {
- // Wer in Karenz ist, hat ein aktives Konto und arbeitet nicht. Aus einem
- // Wert gefüllt wäre entweder das eine oder das andere falsch.
- expect(kontoStatus("Karenz")).toBe("Aktiv");
- expect(beschaeftigungsStatus("Karenz")).toBe("Abwesend");
- expect(kontoStatus("Ausgetreten")).toBe("Inaktiv");
- });
});
-describe("Eine Zeile", () => {
- it("entspricht der Beispielzeile in den tragenden Feldern", () => {
- const z = zeile();
- expect(z).toMatchObject({
- "Local System ID": "9001",
- "First Name": "Testa",
- "Last Name": "Musterfrau",
- Username: "testa.musterfrau",
- Gender: "female",
+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: "max.alpentest",
+ Email: "m.test@alpenwerk.test.com",
+ Gender: "male",
"Compensation Currency": "EUR",
- "Address Line 1": "Teststrasse 1",
+ "Address Line 1": "Teststrasse 2",
City: "Wien",
Zip: "1010",
Country: "AT",
- "Original Hire Date": "15.09.2026",
- "Last Hire Date": "15.09.2026",
- Status: "Aktiv",
+ "Original Hire Date": "01.10.2026",
+ "Last Hire Date": "01.10.2026",
+ Status: "Active",
Language: "de-DE",
- "Time Zone": "CET",
- "User Type": "Mitarbeiter",
- "Employment Status": "Arbeitend",
- "Customfield ID AD": "testa.musterfrau",
- "Customfield ID Doxis": "DOX-9001",
- "Customfield ID Interflex": "9001",
- "Customfield ID LGVplus": "LGV-9001",
+ "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("verweist auf die vorgesetzte Person über deren Personalnummer", () => {
- // Cornerstone verknüpft über die User ID, nicht über den Namen.
- expect(zeile().Manager).toBe("1000");
- expect(zeile({ manager_id: null }).Manager).toBe("");
- });
-
- it("nimmt die dienstliche Adresse und lässt die privaten Felder leer", () => {
+ it("lässt die Felder leer, die das Muster leer lässt", () => {
const z = zeile();
- expect(z.Email).toBe("testa.musterfrau@example.test");
- expect(z["Personal Email"]).toBe("");
- expect(z["Home Phone"]).toBe("");
+ 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("");
+ }
});
+});
- it("setzt die Kostenstelle der Planstelle, nicht der Person", () => {
- expect(zeile()["Cost Center ID"]).toBe("4711");
- expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");
- });
-
- it("nennt als Organisationsstufe die Ebene, nicht den Namen der Einheit", () => {
- expect(zeile().Organisationsstufe).toBe("Team");
- expect(zeile({ org_unit_id: "b" }).Organisationsstufe).toBe("Bereich");
- expect(zeile({ org_unit_id: null }).Organisationsstufe).toBe("");
- });
-
- it("füllt den Austritt nur, wenn es einen gibt", () => {
- const aktiv = zeile();
- expect(aktiv["Termination Date"]).toBe("");
- expect(aktiv["Termination Type"]).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("Inaktiv");
+ expect(weg.Status).toBe("Inactive");
+ expect(weg["Employment Status"]).toBe("Terminated");
});
- it("nennt den Abwesenheitsgrund nur bei Karenz", () => {
+ it("nennt den Abwesenheitsgrund nur bei On Leave", () => {
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
- expect(zeile({ status: "Karenz", absence_type: "Elternkarenz" })["Leave Reason"]).toBe("Elternkarenz");
+ const karenz = zeile({ status: "Karenz", absence_type: "Elternkarenz" });
+ expect(karenz["Leave Reason"]).toBe("Elternkarenz");
+ expect(karenz["Employment Status"]).toBe("On Leave");
});
- it("kennzeichnet Wiedereingestellte", () => {
- expect(zeile()["Rehired Employee"]).toBe("");
- expect(zeile({ id: "zurueck" })["Rehired Employee"]).toBe("Ja");
+ 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("Kennungen statt Namen", () => {
+ it("verweist auf die vorgesetzte Person über deren Personalnummer", () => {
+ // Cornerstone verknüpft über die User ID, nicht über den Namen.
+ expect(zeile({ manager_id: "chef" }).Manager).toBe("1000");
+ expect(zeile({ manager_id: null }).Manager).toBe("");
});
- it("trägt die Platzhalter sichtbar ein", () => {
- // Sie müssen vor einem echten Lauf ersetzt werden — ein leeres Feld
- // sähe aus wie „nichts zu tun".
- expect(zeile()["Division ID"]).toBe("DIVISION-ID-HIER");
+ 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 vorgegeben", () => {
- // Komma statt Semikolon und kein BOM — sonst hiesse die erste Spalte für
- // Cornerstone nicht „User ID", sondern trüge das unsichtbare Zeichen im
- // Namen und käme in keiner Zuordnung vor.
+ 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 1, Stiege 2" })], cornerstoneSpalten(), CORNERSTONE_CSV);
- expect(csv).toContain('"Teststrasse 1, Stiege 2"');
+ 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.
- // Beide Zeilen müssen gleich viele Felder haben, sonst verrutscht ab der
- // Adresse jede weitere Spalte um eins.
- const felder = (zeileText: string) => zeileText.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length;
+ 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);