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