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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 11:21:25 +02:00
parent e11544b8da
commit 478364e2d0
6 changed files with 659 additions and 9 deletions

View File

@@ -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> = {}): 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<CornerstoneQuelle> = {}) => 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("@");
});
});