From 478364e2d03c4cc3d8c8154ba1adfe9bf96f0da1 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Fri, 25 Sep 2026 11:21:25 +0200 Subject: [PATCH] Add the Cornerstone export to Berichte The 57 columns of the Cornerstone user import, in the given order and spelling, offered as CSV and Excel under the Honestly report. Same people and same filters as the full export. Two things about the file itself, both of which would have failed quietly. Cornerstone reads comma-separated, while toCsv writes the semicolons and the BOM that German Excel wants -- the BOM would have hidden inside the name of the first column, so "User ID" would have matched nothing in the mapping. toCsv now takes the form as an argument and keeps its old defaults; the Cornerstone form lives next to the columns so a test can hold both. Quoting follows the delimiter now, otherwise a comma in an address would split the row. Dates go out day-first, matching the import setting. An ISO date is read as a different, equally valid date and nobody notices. Gender maps to Cornerstone's own values; anything unexpected becomes "not specified" rather than empty, because an invalid value makes Cornerstone reject the whole row, not just the field. Status and Employment Status come from separate rules: somebody on Karenz has a working account and is not working, and filling both from one value gets one of the two wrong. Four fields carry visible placeholders because the leading systems do not supply their identifiers yet: Division ID, Doxis, Interflex, LGVplus. Home Phone and Personal Email stay empty on purpose -- what leaves the house is the business data. Not verified against a running Cornerstone import, and not seen in a browser; there is no database reachable here. Co-Authored-By: Claude Opus 5 --- app/api/export/cornerstone/route.ts | 60 +++++ components/reports/ReportsPageClient.tsx | 21 ++ lib/cornerstone.ts | 308 +++++++++++++++++++++++ lib/export-auswahl.ts | 7 +- lib/export.ts | 29 ++- tests/unit/cornerstone.test.ts | 243 ++++++++++++++++++ 6 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 app/api/export/cornerstone/route.ts create mode 100644 lib/cornerstone.ts create mode 100644 tests/unit/cornerstone.test.ts 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}

+
+ + + CSV + + + + Excel + +
+
+ )} + {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("@"); + }); +});