diff --git a/app/api/export/cornerstone/route.ts b/app/api/export/cornerstone/route.ts
new file mode 100644
index 0000000..86b3875
--- /dev/null
+++ b/app/api/export/cornerstone/route.ts
@@ -0,0 +1,60 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { baueCornerstoneZeile, CORNERSTONE_CSV, cornerstoneSpalten, type CornerstoneKontext } from "@/lib/cornerstone";
+import { loadKontierungen } from "@/lib/cost-centers";
+import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
+import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
+import { todayIso } from "@/lib/format";
+import { requireHrUser } from "@/lib/auth/require-hr";
+import { withUser } from "@/lib/db";
+
+// Der Export für Cornerstone — Spalten und Zuordnung stehen in
+// lib/cornerstone.ts.
+//
+// Dieselben Personen wie im vollständigen Export daneben, mit denselben
+// Filtern (lib/export-auswahl.ts). Wer nur die Aktiven einspielen will,
+// stellt das auf der Berichtsseite ein, bevor er exportiert.
+export async function GET(request: NextRequest) {
+ const gate = await requireHrUser();
+ if ("denied" in gate) return gate.denied;
+
+ const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
+ const stichtag = asOf ?? todayIso();
+
+ const { zeilen } = await withUser(gate.userId, async (tx) => {
+ const { rows, lookups, orgMaps, 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.
+ 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,
+ };
+
+ return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
+ });
+
+ const filename = exportFilename("cornerstone-export", format);
+ // Form und Spalten kommen beide aus lib/cornerstone.ts — siehe dort.
+ const body =
+ format === "xlsx"
+ ? await toXlsx(zeilen, cornerstoneSpalten(), "Worksheet")
+ : toCsv(zeilen, cornerstoneSpalten(), CORNERSTONE_CSV);
+ // Derselbe Umweg über Blob wie in den anderen Exporten (TS#59417).
+ return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
+}
diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx
index 5d5388e..191d3cb 100644
--- a/components/reports/ReportsPageClient.tsx
+++ b/components/reports/ReportsPageClient.tsx
@@ -657,6 +657,27 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
)}
+ {mode === "snapshot" && (
+
+
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.
+
+
Status im Export: {statusExportLabel}
+
+
+ )}
+
{savedReports.length > 0 && (
Gespeicherte Berichte
diff --git a/lib/cornerstone.ts b/lib/cornerstone.ts
new file mode 100644
index 0000000..dd5923e
--- /dev/null
+++ b/lib/cornerstone.ts
@@ -0,0 +1,308 @@
+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.
+//
+// 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_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";
+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}` : "";
+}
+
+/** 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.
+ *
+ * Abgeleitet und nicht aus der dienstlichen Adresse geschnitten: die Adresse
+ * ist freiwillig, der Name ist es nicht. Cornerstone braucht das Feld.
+ */
+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" ? "Inaktiv" : "Aktiv";
+}
+
+export function beschaeftigungsStatus(status: EmploymentStatus): string {
+ if (status === "Karenz") return "Abwesend";
+ if (status === "Ausgetreten") return "Ausgetreten";
+ if (status === "Geplant") return "Eintritt geplant";
+ return "Arbeitend";
+}
+
+export type CornerstoneZeile = Record;
+
+/** 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;
+ 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;
+ 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;
+
+ return {
+ "User ID": String(nummer),
+ "Local System ID": String(nummer),
+ Prefix: p.title_prefix.join(" "),
+ "First Name": p.first_name,
+ "Middle Name": "",
+ "Last Name": p.last_name,
+ Suffix: p.title_suffix.join(" "),
+ Username: name,
+ Approver: "",
+ Manager: chef ? String(chef) : "",
+ Absent: "",
+ "Allow Reconciliation": "",
+ Email: p.company_email ?? "",
+ "Required Training Approvals": "",
+ Gender: geschlecht(p.gender),
+ Exempt: "",
+ Ethnicity: "",
+ "Months of Service": dienstmonate(p.entry_date, k.stichtag),
+ "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),
+ "Leave Reason": p.status === "Karenz" ? (p.absence_type ?? "") : "",
+ "Termination Type": p.austrittsart ?? "",
+ "Termination Reason": p.exit_reason ?? "",
+ "Termination Date": csDatum(p.exit_date),
+ "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 ?? "",
+ "Grade ID": p.paygrade,
+ "Cost Center ID": kst?.code ?? "",
+ "Location ID": k.standortName.get(p.location_id) ?? "",
+ "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 ?? "",
+ "Customfield ID AD": name,
+ "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[] {
+ return CORNERSTONE_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
+}
diff --git a/lib/export-auswahl.ts b/lib/export-auswahl.ts
index 9c07c90..bee9076 100644
--- a/lib/export-auswahl.ts
+++ b/lib/export-auswahl.ts
@@ -19,6 +19,7 @@ import type { Database } from "./types";
* über die Planstelle und die abgeleitete Berichtslinie. */
export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null;
+ position_id: string | null;
position_number: string | null;
is_chief: boolean;
manager_id: string | null;
@@ -57,6 +58,8 @@ export async function ladeExportMitarbeiter(
lookups: OrgLookups;
orgMaps: OrgMaps;
managerName: Map;
+ /** Personalnummer je Kennung — für Exporte, die über Nummern verweisen. */
+ managerNummer: Map;
dependentsCounts: Map;
}> {
const statuses = parseStatuses(filters.status);
@@ -83,7 +86,7 @@ export async function ladeExportMitarbeiter(
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(),
loadOrgLookups(tx),
- tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
+ tx.selectFrom("employees").select(["id", "first_name", "last_name", "personnel_number"]).orderBy("id").execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag),
@@ -103,6 +106,7 @@ export async function ladeExportMitarbeiter(
{
...e,
org_unit_id: orgUnitId,
+ position_id: placement?.current ? placement.positionId : null,
position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
@@ -115,6 +119,7 @@ export async function ladeExportMitarbeiter(
lookups: lookupResult.lookups,
orgMaps,
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])),
+ managerNummer: new Map(allEmployees.map((e) => [e.id, e.personnel_number])),
dependentsCounts,
};
}
diff --git a/lib/export.ts b/lib/export.ts
index d30d191..865c294 100644
--- a/lib/export.ts
+++ b/lib/export.ts
@@ -23,20 +23,33 @@ export function sanitizeForSpreadsheetCell(text: string): string {
return /^[=+\-@]/.test(text) ? `'${text}` : text;
}
-function csvCell(value: string | number | boolean | null): string {
+function csvCell(value: string | number | boolean | null, trenner: string): string {
if (value === null || value === undefined) return "";
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
- return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+ // Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
+ // Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
+ return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
-// Leading BOM + semicolon delimiter: Excel's German locale default, and what
-// makes umlauts render correctly instead of mojibake on open.
-export function toCsv(rows: T[], columns: ExportColumn[]): string {
- const lines = [columns.map((c) => csvCell(c.header)).join(";")];
+/**
+ * Wie die Datei aussehen soll.
+ *
+ * Die Vorgaben sind für Excel in deutscher Einstellung gemacht: Semikolon als
+ * Trenner und ein BOM voran, damit Umlaute nicht als Buchstabensalat
+ * ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
+ * erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
+ * keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
+ */
+export type CsvForm = { trenner?: "," | ";"; bom?: boolean };
+
+export function toCsv(rows: T[], columns: ExportColumn[], form: CsvForm = {}): string {
+ const trenner = form.trenner ?? ";";
+ const bom = form.bom ?? true;
+ const lines = [columns.map((c) => csvCell(c.header, trenner)).join(trenner)];
for (const row of rows) {
- lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
+ lines.push(columns.map((c) => csvCell(c.get(row), trenner)).join(trenner));
}
- return "" + lines.join("\r\n");
+ return (bom ? "" : "") + lines.join("\r\n");
}
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
diff --git a/tests/unit/cornerstone.test.ts b/tests/unit/cornerstone.test.ts
new file mode 100644
index 0000000..a8ebc87
--- /dev/null
+++ b/tests/unit/cornerstone.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it } from "vitest";
+import {
+ baueCornerstoneZeile,
+ benutzername,
+ beschaeftigungsStatus,
+ CORNERSTONE_CSV,
+ CORNERSTONE_SPALTEN,
+ cornerstoneSpalten,
+ csDatum,
+ dienstmonate,
+ 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.
+
+// Die Kopfzeile aus der Vorgabe, 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",
+};
+
+function person(teil: Partial = {}): CornerstoneQuelle {
+ return {
+ id: "e1",
+ personnel_number: 9001,
+ first_name: "Testa",
+ last_name: "Musterfrau",
+ title_prefix: [],
+ title_suffix: [],
+ gender: "w",
+ company_email: "testa.musterfrau@example.test",
+ address: "Teststrasse 1",
+ postal_code: "1010",
+ city: "Wien",
+ address_country: "AT",
+ entry_date: "2026-09-15",
+ exit_date: null,
+ exit_reason: null,
+ austrittsart: null,
+ absence_type: null,
+ status: "Aktiv",
+ paygrade: "B",
+ location_id: "wien",
+ org_unit_id: "t",
+ position_id: "pos-1",
+ position_number: "S-0001",
+ manager_id: "chef",
+ ...teil,
+ };
+}
+
+const zeile = (teil: Partial = {}) => baueCornerstoneZeile(person(teil), kontext);
+
+describe("Spalten", () => {
+ it("stehen genau so und in genau der Reihenfolge wie vorgegeben", () => {
+ expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
+ });
+
+ it("sind 57", () => {
+ 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());
+ });
+});
+
+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("");
+ });
+
+ 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("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",
+ "Compensation Currency": "EUR",
+ "Address Line 1": "Teststrasse 1",
+ City: "Wien",
+ Zip: "1010",
+ Country: "AT",
+ "Original Hire Date": "15.09.2026",
+ "Last Hire Date": "15.09.2026",
+ Status: "Aktiv",
+ 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",
+ });
+ });
+
+ 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", () => {
+ const z = zeile();
+ expect(z.Email).toBe("testa.musterfrau@example.test");
+ expect(z["Personal Email"]).toBe("");
+ expect(z["Home Phone"]).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("");
+
+ 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");
+ });
+
+ it("nennt den Abwesenheitsgrund nur bei Karenz", () => {
+ expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
+ expect(zeile({ status: "Karenz", absence_type: "Elternkarenz" })["Leave Reason"]).toBe("Elternkarenz");
+ });
+
+ it("kennzeichnet Wiedereingestellte", () => {
+ expect(zeile()["Rehired Employee"]).toBe("");
+ expect(zeile({ id: "zurueck" })["Rehired Employee"]).toBe("Ja");
+ });
+
+ 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");
+ });
+});
+
+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.
+ const csv = toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV);
+ expect(csv.startsWith("User ID,")).toBe(true);
+ expect(csv.split(/\r?\n/)[0]).toBe(VORGABE);
+ });
+
+ 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"');
+ // 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 [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("@");
+ });
+});