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);