Compare commits

..

2 Commits

Author SHA1 Message Date
410acfe01f Cornerstone Report: system values, not display names
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m34s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Reworked against the load spec and the 27.09. test file. The values I
had guessed were the German display names, which is the first entry on
the list of errors from earlier loads: Cornerstone answers "ungültiger
Wert" and rejects the whole row, followed by "Alle abhängigen Felder
müssen gültig sein" as a follow-on.

  Status            Aktiv/Inaktiv    -> Active/Inactive
  Employment Status Arbeitend/...    -> Working/On Leave/Terminated
  User Type         Mitarbeiter      -> Employee
  Time Zone         CET              -> empty

The time zone is the second entry on that list: only a portal time zone
id is valid, an abbreviation gives "Zeitzonencode nicht eindeutig".
Empty means the portal or the OU decides.

Division ID is the GUID from the test file, not a name. Termination
fields and Leave Reason are filled only when the employment status
carries them -- a reason without a termination is an invalid state for
the load, not extra information.

Four fields now stay empty on purpose, because filling them would mean
inventing an identifier that belongs to the target system: Location ID
(locations has id/name/country and no Cornerstone id), Position ID (our
S-0001 is not a Cornerstone position), Months of Service (Cornerstone
derives it) and Rehired Employee (the value for "yes" is unconfirmed,
and an unconfirmed value costs the whole row). Retention Rules and
Organisationsstufe stay empty because the load ignores them.

Header and row now match the test file byte for byte, except User ID
(no TEST- prefix outside a test load), Required Training Approvals and
Location ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:10:56 +02:00
478364e2d0 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>
2026-09-25 11:21:25 +02:00
6 changed files with 709 additions and 9 deletions

View File

@@ -0,0 +1,46 @@
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, managerNummer } = await ladeExportMitarbeiter(tx, { asOf, filters });
// 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 kontext: CornerstoneKontext = {
managerNummer,
kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
};
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) });
}

View File

@@ -657,6 +657,28 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div> </div>
)} )}
{mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Cornerstone Report</h3>
<p className="mb-1 text-xs text-ink-muted">
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.
</p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
<div className="flex gap-2">
<a href={exportHref("/api/export/cornerstone", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref("/api/export/cornerstone", "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
)}
{savedReports.length > 0 && ( {savedReports.length > 0 && (
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Gespeicherte Berichte</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Gespeicherte Berichte</h3>

334
lib/cornerstone.ts Normal file
View File

@@ -0,0 +1,334 @@
import type { Kostenstelle } from "./cost-centers";
import type { CsvForm, ExportColumn } from "./export";
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_WAEHRUNG = "EUR";
/**
* 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-";
/**
* 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}` : "";
}
/**
* 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" ? "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 "On Leave";
if (status === "Ausgetreten") return "Terminated";
return "Working";
}
export type CornerstoneZeile = Record<string, string | number>;
/** 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;
position_id: string | null;
manager_id: string | null;
};
export type CornerstoneKontext = {
/** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */
managerNummer: Map<string, number>;
/** Kostenstelle je Planstelle zum Stichtag. */
kostenstelle: Map<string, Kostenstelle>;
};
/** 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 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),
"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: "",
// 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": "",
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),
// 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": "",
// „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 ?? "",
// 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": "",
Organisationsstufe: "",
"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<CornerstoneZeile>[] {
return CORNERSTONE_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
}

View File

@@ -19,6 +19,7 @@ import type { Database } from "./types";
* über die Planstelle und die abgeleitete Berichtslinie. */ * über die Planstelle und die abgeleitete Berichtslinie. */
export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & { export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null; org_unit_id: string | null;
position_id: string | null;
position_number: string | null; position_number: string | null;
is_chief: boolean; is_chief: boolean;
manager_id: string | null; manager_id: string | null;
@@ -57,6 +58,8 @@ export async function ladeExportMitarbeiter(
lookups: OrgLookups; lookups: OrgLookups;
orgMaps: OrgMaps; orgMaps: OrgMaps;
managerName: Map<string, string>; managerName: Map<string, string>;
/** Personalnummer je Kennung — für Exporte, die über Nummern verweisen. */
managerNummer: Map<string, number>;
dependentsCounts: Map<string, number>; dependentsCounts: Map<string, number>;
}> { }> {
const statuses = parseStatuses(filters.status); const statuses = parseStatuses(filters.status);
@@ -83,7 +86,7 @@ export async function ladeExportMitarbeiter(
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(), employeeQuery().execute(),
loadOrgLookups(tx), 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), loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }), loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag), loadReportingLineMap(tx, stichtag),
@@ -103,6 +106,7 @@ export async function ladeExportMitarbeiter(
{ {
...e, ...e,
org_unit_id: orgUnitId, org_unit_id: orgUnitId,
position_id: placement?.current ? placement.positionId : null,
position_number: placement?.positionNumber ?? null, position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false, is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null, manager_id: lines.get(e.id)?.acting_manager_id ?? null,
@@ -115,6 +119,7 @@ export async function ladeExportMitarbeiter(
lookups: lookupResult.lookups, lookups: lookupResult.lookups,
orgMaps, orgMaps,
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), 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, dependentsCounts,
}; };
} }

View File

@@ -23,20 +23,33 @@ export function sanitizeForSpreadsheetCell(text: string): string {
return /^[=+\-@]/.test(text) ? `'${text}` : text; 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 ""; if (value === null || value === undefined) return "";
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value)); 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. * Wie die Datei aussehen soll.
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string { *
const lines = [columns.map((c) => csvCell(c.header)).join(";")]; * 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<T>(rows: T[], columns: ExportColumn<T>[], 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) { 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 // Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel

View File

@@ -0,0 +1,280 @@
import { describe, expect, it } from "vitest";
import {
baueCornerstoneZeile,
benutzername,
beschaeftigungsStatus,
CORNERSTONE_CSV,
CORNERSTONE_SPALTEN,
cornerstoneSpalten,
csDatum,
DIVISION_GUID,
geschlecht,
kontoStatus,
type CornerstoneKontext,
type CornerstoneQuelle,
} from "@/lib/cornerstone";
import { toCsv } from "@/lib/export";
// Die Datei geht in ein fremdes System, das sie über eine im Portal
// 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 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 kontext: CornerstoneKontext = {
managerNummer: new Map([["chef", 1000]]),
kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
};
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
return {
id: "e1",
personnel_number: 9002,
first_name: "Max",
last_name: "Alpentest",
title_prefix: [],
title_suffix: [],
gender: "m",
company_email: "m.test@alpenwerk.test.com",
address: "Teststrasse 2",
postal_code: "1010",
city: "Wien",
address_country: "AT",
entry_date: "2026-10-01",
exit_date: null,
exit_reason: null,
austrittsart: null,
absence_type: null,
status: "Aktiv",
paygrade: "HG15",
location_id: "wien",
position_id: "pos-1",
manager_id: null,
...teil,
};
}
const zeile = (teil: Partial<CornerstoneQuelle> = {}) => baueCornerstoneZeile(person(teil), kontext);
describe("Spalten", () => {
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
});
it("sind 57", () => {
expect(CORNERSTONE_SPALTEN).toHaveLength(57);
});
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("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", () => {
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("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");
});
});
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 2",
City: "Wien",
Zip: "1010",
Country: "AT",
"Original Hire Date": "01.10.2026",
"Last Hire Date": "01.10.2026",
Status: "Active",
Language: "de-DE",
"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("lässt die Felder leer, die das Muster leer lässt", () => {
const z = zeile();
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("");
}
});
});
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("Inactive");
expect(weg["Employment Status"]).toBe("Terminated");
});
it("nennt den Abwesenheitsgrund nur bei On Leave", () => {
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
const karenz = zeile({ status: "Karenz", absence_type: "Elternkarenz" });
expect(karenz["Leave Reason"]).toBe("Elternkarenz");
expect(karenz["Employment Status"]).toBe("On Leave");
});
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("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 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 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.
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);
});
it("lässt die private Adresse nirgends durch", () => {
const csv = toCsv([zeile({ company_email: null })], cornerstoneSpalten(), CORNERSTONE_CSV);
expect(csv).not.toContain("@");
});
});